feat(agent): session persistence, restart-resume, fire-and-return - #416
Conversation
clcollins
left a comment
There was a problem hiding this comment.
Security Review — PR #416: Session Persistence (pkg/agent/)
Adversarial security review of the persistent Claude Code CLI subprocess sessions added in this PR. Reviewed by reading the diff and source; no code was modified.
Finding 1 — Legacy spawn paths bypass flag denylist (MEDIUM)
File: pkg/tui/claude.go:108 (agentQuery) and pkg/tui/claude.go:502 (streamAgentCmd)
The new session path in pkg/agent/session.go:42-55 correctly validates agent_cli_command tokens against deniedFlags via validateUserFlags. However, the two legacy paths — agentQuery (blocking, line 108) and streamAgentCmd (streaming, line 502) — parse agent_cli_command with strings.Fields and pass the result directly to exec.CommandContext with no validateUserFlags call.
Exploit scenario: Set agent_cli_command: "claude --dangerously-skip-permissions --print" in config. When session mode is disabled (agent_session_enabled: false) or the session path is unreachable, the legacy path spawns Claude with --dangerously-skip-permissions, giving the agent full host access without operator approval.
Verified: Read both functions end-to-end; neither calls validateUserFlags or any equivalent. The session path at session.go:200 does call it.
Suggested fix: Apply validateUserFlags to the legacy agentQuery and streamAgentCmd functions, or refactor to share a single validated command-building function.
Finding 2 — Write goroutine leak on Send() context cancellation (MEDIUM)
File: pkg/agent/session.go:178-192
Send() spawns a goroutine to write to stdin (line 179). If the context is cancelled while the write is blocked (pipe full because child stopped reading), Send() returns at line 190-191, but the goroutine remains blocked on s.stdin.Write(data) until Close() is eventually called and breaks the pipe.
Exploit scenario: A hung child process stops reading stdin. The user (or the TUI) retries sending messages. Each timed-out Send() leaks one goroutine pinned on a blocked pipe write. Between the timeout and the eventual Close(), goroutines accumulate proportionally to retry attempts.
Verified: The goroutine at line 179-182 has no cancellation path — io.Writer does not accept a context. Close() at line 296+ does close s.stdin, which would unblock the writes, but there is no guarantee Close() is called promptly after a timeout.
Suggested fix: After the context-cancelled branch fires, close s.stdin (or the entire session) to unblock the leaked goroutine. Alternatively, set the session into a "draining" state that prevents further Send() calls and triggers Close().
Finding 3 — Index file: two Write calls not atomic (LOW)
File: pkg/agent/index.go:132-133
_, _ = f.Write(data)
_, _ = f.Write([]byte("\n"))The data and newline are two separate Write calls. Although O_APPEND makes each individual write atomic on Linux (for sizes under PIPE_BUF), a crash or SIGKILL between the two calls produces a partial line without a trailing newline. The next append concatenates with this partial line, corrupting two entries.
Verified: The load function (index.go:60-82) tolerates corrupt lines by logging a warning and skipping them, so this is self-healing. However, combining both writes into one call is trivial and eliminates the window entirely.
Suggested fix: f.Write(append(data, '\n')) — single atomic append.
Finding 4 — Full parent environment inherited by child process (LOW)
File: pkg/tui/claude.go:118, claude.go:445, claude.go:523
All three spawn paths call os.Environ() and append incident metadata. The parent's full environment — including PAGERDUTY_TOKEN, ANTHROPIC_API_KEY, and any other credentials — is inherited by the child Claude CLI process and visible via /proc/<pid>/environ.
Mitigating factors: The child is a trusted binary (Claude CLI), and this is consistent with the existing codebase pattern (commands.go:815). No secrets appear in argv (visible in ps). Incident data passed via env vars is sanitized by sanitizeEnvValue.
Suggested fix: Consider filtering os.Environ() to exclude known sensitive vars (e.g., *TOKEN*, *SECRET*, *API_KEY*) before passing to the child, or document this as an accepted design decision. The Claude CLI likely needs ANTHROPIC_API_KEY, so at minimum document which vars are intentionally inherited.
Finding 5 — Full prompt logged at debug level (LOW)
File: pkg/tui/claude.go:120
log.Debug("tui.agentQuery()", "command", agentCLICommand, "prompt", prompt)The full prompt (including system prompt and incident context) is written to the debug log at ~/.config/srepd/debug.log. This could include incident details, cluster names, and alert content. The info-level log at line 177 correctly truncates to 80 chars; the debug path does not.
Suggested fix: Truncate the prompt in the debug log the same way the info-level log does, or omit it.
Clean areas (verified, no issues)
-
No shell involvement. All three spawn sites use
exec.CommandContextwith argv slices. Nosh -c/bash -c/ string concatenation into shell commands. Verified atsession.go:67,claude.go:33,claude.go:517. -
--barenever in spawn args.BuildSpawnArgs(agent.go:215-238) never constructs--bare. The denylist atsession.go:23-37blocks it viaagent_cli_command. TestTestBuildSpawnArgs_NeverBarecovers 5 combinations;TestSpawn_RejectsDeniedFlagscovers the injection path including--bareand--bare=truevariants. -
AllowedTools/PermissionModeinjection impossible. Both are passed as single argv elements (agent.go:230-236), not split. A value like"Bash --dangerously-skip-permissions"is passed as one argument to--allowedTools— the CLI treats it as a (likely invalid) tool name, not separate flags. Both flags are also indeniedFlags, preventing injection viaagent_cli_command. -
SessionIDFor is safe. UUIDv5 from SHA-1 (
agent.go:43-45) produces a well-formed UUID regardless of input. Path separators, NUL bytes, newlines, and very long strings all hash to a valid UUID. No path traversal possible. -
Index file permissions correct. Directory created with
0700(index.go:103), file with0600(index.go:122). No world-readable state. -
JSON injection in index impossible.
json.Marshalproperly escapes special characters (index.go:117). Malicious incident IDs cannot inject fields. -
NDJSON line length bounded. Both session (
session.go:253-254) and legacy (claude.go:561-562) paths setscanner.Buffer(make([]byte, 256*1024), 256*1024). A 300KB-line test (session_test.go:523-559) verifies the error surfaces correctly. -
LRU enforcement works.
GetOrCreate(session.go:383-389) uses aforloop to evict down tomaxLivebefore creating. Mutex-serialized. Cannot open unbounded subprocesses. -
Process reaping correct.
WaitDelay = 2*time.Secondset atsession.go:71. Deferredwait()inreadLoopreaps the child.Close()cancels context (SIGKILL) and closes stdin. Pipe cleanup on spawn failure covers all three error paths (session.go:67-88). -
Test fixtures clean. All UUIDs use
fakemarkers (00000000-fake-0000-0000-000000000000). No real domains (openshiftapps.com,devshift.net, etc.), no tokens, no customer data.make test-fixturesglob (Makefile:247) covers*.ndjsonfiles.fakeclaude/main.gocontains only synthetic data. -
Error strings do not leak secrets. All
fmt.Errorfcalls in new code emit structural descriptions only. The body-stripping pattern fromai/classify.gois preserved atclaude.go:303. Stderr from child is logged on error but contains only CLI diagnostics. -
TUI Update loop not blocked. All subprocess I/O runs in
tea.Cmdgoroutines, not inUpdate.
Summary
| # | Finding | Severity | File:Line |
|---|---|---|---|
| 1 | Legacy spawn paths bypass validateUserFlags denylist |
MEDIUM | claude.go:108, claude.go:502 |
| 2 | Write goroutine leaked on Send() context cancellation |
MEDIUM | session.go:178-192 |
| 3 | Index write: data + newline in two calls (crash window) | LOW | index.go:132-133 |
| 4 | Full parent env (incl. tokens) inherited by child | LOW | claude.go:118,445,523 |
| 5 | Full prompt logged at debug level | LOW | claude.go:120 |
No critical-severity findings. The two medium findings are actionable — especially #1, which creates a config-driven path to --dangerously-skip-permissions in the legacy spawn functions.
clcollins
left a comment
There was a problem hiding this comment.
PR #416 Intent/Acceptance Review — Plan 412 (Session Persistence)
Reviewer mandate: verify every criterion by tracing the call path from user action to effect. "The function exists and has a test" is not evidence.
Verdict Table
| # | Criterion | Verdict | Evidence |
|---|---|---|---|
| H1a | Populated index -> --resume |
MET | GetOrCreate (session.go:394) checks m.index.has(incidentID) -> sets s.resumed = true -> BuildSpawnArgs(resume=true) (agent.go:224-225) -> --resume. Index loaded from disk by newSessionIndex -> idx.load() (index.go:42-83) using os.Open + json.Unmarshal. TestIntegration_RestartResume asserts argv[1] has --resume after a fresh manager loads the same sessionDir. |
| H1b | Absent index -> --session-id |
MET | m.index.has() returns false -> s.resumed stays false -> BuildSpawnArgs(resume=false) (agent.go:227) -> --session-id. TestIntegration_AbsentIndex_SessionID confirms. |
| H2 | Per-incident isolation | MET | Sessions keyed by incidentID in SessionManager.sessions map (session.go:323). Each Session has its own events chan, stdin pipe, done chan. No shared buffer or global. TestIntegration_PerIncidentIsolation asserts distinct --session-id values for two incidents. |
| L1 | Index entry only after system/init |
MET | onEstablished callback set in GetOrCreate (session.go:400-402), fired only when ev.Kind == Init in readLoop (session.go:264-267). TestIntegration_CrashBeforeInit uses crash_before_init script (fake exits before init), verifies no index file, then fresh manager uses --session-id. |
| L2 | startAgentSession uses context.WithTimeout |
MET | claude.go:456: ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second). TestIntegration_SendHonorsTimeout verifies a cancelled context returns error. |
| V1 | Harness reproduces duplicate --session-id semantics |
MET | fakeclaude/main.go:70-78: checks state dir for existing ID, writes stderr, exits 1, NO result line. TestIntegration_DuplicateSessionID pre-writes state file, asserts Error event surfaces without hang. |
| F1 | agent_session_enabled defaults true, gated on revert check |
PARTIAL | Default is true in DefaultOptionalKeys (config.go:42) and resolveAgentSessionEnabled() (model.go:318-323). BUT: the revert check is missing. ForTestStubIndexWrite() exists (index.go:149) but is never called from any test. Plan 412 explicitly requires: "Gate the flip on the revert check passing for both headline tests (1 and 2)." No test stubs the index write and verifies the headline test would fail without it. The gate is absent. |
| B1 | :agent bare -> chat; :agent <query> -> fire-and-return |
MET | msgHandlers.go:831-838: bare -> enterChatMode(); with query -> claudePromptMsg dispatched, no enterChatModeState(). Input blurred and table focused at lines 828-829 (before the isAgentCommand check). TestChatMode_AgentWithQueryFiresAndReturns asserts chatMode == false and cmd != nil. |
Carried-forward Plan 411 checks
| # | Criterion | Verdict | Evidence |
|---|---|---|---|
| 411-1 | Two consecutive :agent messages share one conversation |
MET | handleClaudePrompt -> startAgentSession -> mgr.GetOrCreate(incidentID, env). On second call for same incident, GetOrCreate returns existing *Session (session.go:362-369), same subprocess. |
| 411-2 | Restart resumes prior conversation | MET | NewSessionManager -> newSessionIndex(cfg.SessionDir) -> idx.load() reads disk -> GetOrCreate -> index.has() returns true -> s.resumed = true -> --resume. Disk write: idx.record() calls os.MkdirAll(dir, 0700) (index.go:103) + `os.OpenFile(idx.path, O_CREATE |
| 411-3 | Per-incident isolation | MET | See H2 above. |
| 411-4 | Tool-use lines render; final markdown-rendered | MET | handleAgentSessionEvent (claude.go:221-292): ToolUse -> "⚙ <tool> <input>" line appended to watcher buffer (claude.go:254-256). Result -> glamour render via m.markdownRenderer.Render(ev.Text) (claude.go:272). ParseStreamEvent handles tool_use blocks in assistant messages (agent.go:148-152) — the headline bug fix from plan 411. |
| 411-5 | Non-claude agent_cli_command unchanged |
MET | handleClaudePrompt gates session path on isClaudeCLI(agentCmd) (claude.go:192). Non-claude falls to legacy streaming (claude.go:202) or blocking fallback (claude.go:211). isClaudeCLI checks filepath.Base(field) == "claude" (claude.go:349). |
| 411-6 | Config keys exist, consumed, documented | MET | All four keys in DefaultOptionalKeys + OptionalKeys (config.go:42-43,58-61). Consumed: resolveAgentSessionEnabled(), resolveAgentMaxSessions(), resolveAgentAllowedTools(), viper.GetString("agent_permission_mode"). Documented in README table and docs/ai-agents.md. |
Deadcode check
deadcode ./... (whole-project, all main packages): zero findings in pkg/agent. The ./cmd/... variant flags everything as unreachable due to Bubble Tea interface dispatch, which is expected. Production wiring confirmed: InitialModel (model.go:470) and InitialModelWithConfig (model.go:606) both call agent.NewSessionManager; handleClaudePrompt (claude.go:192-198) calls startAgentSession -> mgr.GetOrCreate. CloseAgentSessions (tui.go:2698) calls CloseAll.
The UUID-equality trap
All 10 integration tests assert on the --resume vs --session-id argv flag decision, never on UUID equality. Verified by reading every test: they use hasFlag(argv[n].Args, "--resume") and hasFlag(argv[n].Args, "--session-id"). No test compares session IDs for equality across managers. No false-green defect found on this axis.
Gaps (prioritized)
1. MISSING: Revert check (F1 incomplete) — HIGH
ForTestStubIndexWrite() is defined (index.go:149) but never called. Plan 412 requires: "Gate the flip on the revert check passing for both headline tests (1 and 2)." The expected pattern:
func TestIntegration_RevertCheck_RestartResume(t *testing.T) {
// Same setup as TestIntegration_RestartResume, but:
mgr1.ForTestStubIndexWrite()
// ... run same flow ...
// Assert second spawn does NOT use --resume (proving the index is load-bearing)
}Without this, the agent_session_enabled: true default is ungated. The whole point of 410a was that dead persistence code passed CI; the revert check is the mechanism that prevents a regression. This is the exact failure class that caused the original rejection.
2. STALE DOCS: docs/ai-agents.md configuration table — MEDIUM
Line 157: | agent_session_enabled | false | ... Default false — session persistence not yet implemented; flips to true when per-incident resume lands. |
Line 165 example: agent_session_enabled: false
These directly contradict the code, which defaults to true (config.go:42, model.go:318-323). The description is copy-pasted from the PR (i) salvage and was never updated for the flag flip. A user reading the docs would think sessions are off by default.
3. MISSING: PR Deviations section — MEDIUM
Plan 412 spec requires: "Note the deviation from plan 411 in this plan's post-mortem and in the PR's ## Deviations section." B1 reverses plan 411's decision that :agent <query> opens chat mode. The PR description has no ## Deviations section. The plan doc docs/plans/412-session-persistence.md has no post-mortem or deviations record either.
4. MISSING: Plan doc post-mortem and UNVERIFIED resolutions — LOW
docs/plans/412-session-persistence.md is 99 lines with no post-mortem section. The plan spec records that --resume <unknown> behaviour is UNVERIFIED against the real CLI. This finding should be recorded in the plan doc per repo convention. The fakeclaude binary's comment at line 9 acknowledges it's unverified, but the plan doc should too.
5. B1 test coverage gap — LOW
TestChatMode_AgentWithQueryFiresAndReturns asserts chatMode == false and cmd != nil but does not assert m.input.Focused() == false or table focused. The code does blur/focus correctly (msgHandlers.go:828-829), but the test doesn't verify it. Minor — the assertions on chatMode and cmd are the load-bearing ones.
PR Description Claims vs Reality
| Claim | Verified? |
|---|---|
| "All assert on spawn-flag decision, never UUID equality" | TRUE — confirmed by reading all 10 integration tests |
| "10 integration tests" | TRUE — counted 10 test functions in integration_test.go |
"deadcode ./... — no dead code in pkg/agent" |
TRUE — confirmed, zero findings from full-project deadcode |
"L2 fix: context.WithTimeout(30s) instead of context.Background()" |
TRUE — claude.go:456 |
| "B1 fix: fire-and-return" | TRUE — msgHandlers.go:831-838, enterChatModeState() removed from query path |
| "Flag flip: default changed from false to true" | TRUE in code — FALSE in docs/ai-agents.md config table |
Scope Discipline
No pkg/delta/, pkg/ai/tools/, pkg/ai/policy/, pkg/mcpserver/ in the diff. Clean. Only plan 411/412 scope.
Privacy Documentation
README (line 88) documents the session metadata storage path. docs/ai-agents.md (lines 216-223) has a substantive privacy note covering both srepd's index and Claude Code's own session storage. Adequate.
Summary
The PR genuinely delivers session persistence — the index is written to disk via os.MkdirAll/os.OpenFile, read back on manager construction, and the --resume vs --session-id decision is correctly driven by the on-disk state. This is NOT the false-green pattern from the prior rejection. The fake claude harness is well-designed and the integration tests are structurally sound.
One structural gap remains: the revert check (ForTestStubIndexWrite) that plan 412 and 410a require as the gate for the agent_session_enabled: true default is defined but never exercised. This is ironic given the PR's history — the revert check is the specific mechanism designed to prevent the exact failure class that caused the original rejection. The docs inconsistency in ai-agents.md (still says default false) compounds this: neither the test gate nor the documentation reflects the flag flip.
Recommendation: Add the revert check tests and fix the stale docs before merge. Everything else is solid.
clcollins
left a comment
There was a problem hiding this comment.
PR #416 Code Review — Session Persistence, Restart-Resume, Fire-and-Return
Must-Fix
1. CRITICAL: Context cancellation kills the child process after first Send
pkg/tui/claude.go:456-457 → pkg/agent/session.go:214
startAgentSession creates a 30-second timeout context and passes it to s.Send():
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Send(ctx, fullPrompt); err != nil { ... }
return agentSessionEventMsg{...}On the first Send, spawn(ctx) derives the subprocess's lifetime context from this caller context:
spawnCtx, cancel := context.WithCancel(ctx) // session.go:214 — child of the 30s ctx
s.ctx = spawnCtx
stdin, stdout, wait, err := s.exec.Start(spawnCtx, binPath, args, s.env)When the tea.Cmd closure returns (immediately after Send writes to stdin), defer cancel() fires, cancelling the 30-second context. Go context propagation cancels spawnCtx. exec.CommandContext then kills the child process.
Failure scenario: Every session-based agent interaction kills the Claude process within milliseconds of the first message being sent. The readLoop sees s.ctx.Done(), exits, the events channel closes, the user sees an immediate done/error event. Multi-turn conversation is impossible.
Why tests miss this: All integration tests call s.Send(context.Background(), ...) directly, never going through startAgentSession. The production-only code path is untested.
Fix: spawn should derive spawnCtx from context.Background() (not from the caller's Send context), since the process lifetime must be independent of any single Send call:
spawnCtx, cancel := context.WithCancel(context.Background())The Send context should only gate the stdin write (which it already does separately in lines 178-192).
2. Dead exported functions — test-only API surface (CONVENTIONS.md violation)
pkg/agent/index.go:138 — SessionManager.IndexEntryCount()
pkg/agent/index.go:149 — SessionManager.ForTestStubIndexWrite()
Both are exported but have zero production callers — only called from tests. This is the same dead-code pattern flagged in the PR's predecessors. Convention: test-only helpers should be in _test.go files (same package) or use unexported names.
Additionally:
Session.ID()(session.go:134) — exported, zero callers anywhere (not even tests)Session.IncidentID()(session.go:139) — exported, zero callers anywhereSessionIDFor()(agent.go:43) — exported, only called internally + tests
Fix: Move IndexEntryCount and ForTestStubIndexWrite to a *_test.go file (exported test helpers in _test.go within the same package work fine). Remove ID() and IncidentID() if they have no planned consumer.
Nice-to-Have
3. Swallowed write errors in record() — silent data loss
pkg/agent/index.go:132-133
_, _ = f.Write(data)
_, _ = f.Write([]byte("\n"))Both write errors are discarded. If the write fails (disk full), the in-memory map was already updated (line 96) but nothing persisted. On restart, the index misses this session → next spawn uses --session-id instead of --resume → duplicate session error.
The two separate Write calls also create a crash-atomicity window: a crash between them produces a line without a trailing newline. load() handles this via corrupt-line tolerance, but the concatenation of two entries into one line corrupts both.
Fix: Log the write errors. Combine into a single write: f.Write(append(data, '\n')).
4. Missing scanner.Err() check in load()
pkg/agent/index.go:75 (after the for scanner.Scan() loop)
If the scanner stops due to a read error (not EOF), the error is silently lost. The index is partially loaded, and the system proceeds with incomplete data.
Fix: Add if err := scanner.Err(); err != nil { charlog.Warn("agent.index.load", "error", err) } after the loop.
5. record() holds idx.mu during file I/O
pkg/agent/index.go:93-134
The lock is held through MkdirAll, OpenFile, Write, and Close. has() (called by GetOrCreate on the TUI thread) will block until the I/O completes. In practice, appending one line to a local file is sub-millisecond, but on a degraded filesystem this blocks the entire SessionManager (because GetOrCreate holds m.mu → calls has() → blocks on idx.mu).
Fix (if warranted): Update the in-memory map under the lock, release the lock, then do file I/O outside it.
6. ForTestStubIndexWrite writes idx.path without holding idx.mu
pkg/agent/index.go:149-153
Writes idx.path unsynchronized while record() reads it under idx.mu. Race detector would flag this if a test called it while a session's readLoop is processing an Init event.
Fix: Acquire idx.mu before writing idx.path.
Clean Areas
- go.mod hygiene: No changes to go.mod. No
replacedirectives.google/uuidwas already direct on main. Clean. - No new lint suppressions: Zero
//nolintdirectives in new code.golangci-lintpasses with 0 issues. - Build:
make buildsucceeds. - Race detector:
pkg/agentpasses with-race. (cmdfailure is pre-existing on main; unrelated.) - Type assertions: No bare
.(T)assertions in the diff — comma-ok pattern used throughout. context.Contextfirst parameter: Followed in all new functions.- Commit hygiene: Genuine TDD history — failing tests committed first (61534f5), implementation second (725a2f7), remaining integration third (aa9868a).
- Plan document: Present at
docs/plans/412-session-persistence.mdwith Context, Solution, Files Modified sections. - Fire-and-return behavior (B1): Correct.
enterChatModeState()properly removed from the:agent <query>path. The query dispatches viahandleClaudePromptwhich expands the watcher pane. Test updated to assertchatMode == false. - Chat viewport ownership: Clean separation.
m.chatViewportis independent fromm.watcherViewport. No state bleed. - Key dispatch ordering: Chat mode dispatched before chord/global keybindings — correct and documented.
renderChatPanereceiver: Value receiver, read-only — no mutations discarded.enterChatModeState()not dead: Still called byenterChatMode()and tests.- Concurrency: Lock hierarchy (
m.mu→s.mu/idx.mu) is consistent.onEstablishedhas a valid happens-before chain via thegostatement inspawn().donechannel closed viasync.Once. All shared fields properly synchronized. - Goroutine/pipe leaks:
WaitDelayset atsession.go:71.readLoopterminates onClose()via context cancellation. Stdin/stdout closed inClose()with nil-guards. - Error wrapping: New errors use
%wconsistently.
clcollins
left a comment
There was a problem hiding this comment.
PR #416 Test Quality & Coverage Review
Reviewer goal: Find every remaining test that would still pass if its feature were deleted.
Context: This PR previously shipped tests (#414) that passed while the feature did not exist. The core sin was testing in-memory encode/decode with no production caller. This review repeats that audit empirically.
1. Mandatory Gates (Plan 410a)
Gate 2b — Traceability Matrix: MISSING ❌
The PR body contains a "Test plan" checklist of commands (go test, golangci-lint, etc.) but no acceptance-criterion → test-function → file mapping table. Plan 412 lists test labels (H1a, H1b, H2, L1, V1, etc.) but the PR description does not include the required table. The plan doc is not a substitute for the PR body requirement.
Gate 2c — Revert Check Statement: MISSING ❌
The PR body does not mention performing a revert check. ForTestStubIndexWrite() in index.go:147 exists as a hook for the revert check, but it is never called by any test (0% coverage, zero grep hits in *_test.go). It is dead code.
Gate 2d — Deadcode Gate: PASS ✅
go run golang.org/x/tools/cmd/deadcode@latest ./... reports no dead code in pkg/agent/. ForTestStubIndexWrite is a public method on SessionManager, so the tool cannot flag it (it's reachable from external callers), but it is effectively dead since no test or production code calls it.
Gate 2e — Flag Defaults to False: FAIL ❌
agent_session_enabled defaults to true, not false:
pkg/config/config.go:41—"agent_session_enabled": "true"pkg/tui/model.go:320—resolveAgentSessionEnabled()returnstruewhen not set
The plan doc (412) explicitly states "flag flipped from false to true." The 410a requirement says this salvage PR must default to false because session persistence is deferred. This is a rejection criterion.
2. Mutation Matrix
| # | Mutation | Caught? | By which test(s) |
|---|---|---|---|
| M1 | record() → no-op (index persistence disabled) |
YES | TestIntegration_RestartResume (file existence + --resume flag) |
| M2 | BuildSpawnArgs always uses --session-id (never --resume) |
YES (4/5) | TestBuildSpawnArgs_Resume, TestIntegration_RestartResume, TestIntegration_LRUEvictionResume, TestSessionManager_CrashedSessionReplaced. Only TestSessionManager_LRUEviction survives (it tests eviction, not resume). |
| M3 | BuildSpawnArgs always uses --resume (never --session-id) |
YES (3/3) | TestBuildSpawnArgs_NewSession, TestIntegration_AbsentIndex_SessionID, TestIntegration_CrashBeforeInit |
| M4 | Record index on spawn instead of after system/init |
YES | TestIntegration_CrashBeforeInit (index non-empty after crash) |
| M5 | SessionIDFor returns same UUID for all incidents |
YES (2/2) | TestSessionIDFor_DistinctPerIncident, TestIntegration_PerIncidentIsolation |
| M6 | Remove 30s timeout in startAgentSession (restore context.Background()) |
NO ❌ | No test in pkg/tui/ covers startAgentSession (7.7% coverage). The L2 fix is untested at the TUI integration level. |
| M7 | Fake claude accepts duplicate --session-id (no exit 1) |
YES | TestIntegration_DuplicateSessionID (timeout catches missing error event) |
| M8 | Re-add enterChatModeState() to :agent <query> path |
YES | TestChatMode_AgentWithQueryFiresAndReturns in model_test.go:1831 (asserts chatMode == false) |
Summary: 7 of 8 mutations killed. M6 survives — the L2 timeout fix has no test.
3. Coverage
Per-package
| Package | Coverage |
|---|---|
pkg/agent |
86.2% |
pkg/tui |
78.7% |
| Total (all packages) | 80.6% (threshold: 55%) |
Notable uncovered branches in pkg/agent
| Function | Coverage | Gap |
|---|---|---|
ForTestStubIndexWrite |
0% | Dead code — never called |
record() |
64% | Error paths for MkdirAll and OpenFile failures |
Session.ID() |
0% | Trivial getter, not a concern |
Session.IncidentID() |
0% | Trivial getter, not a concern |
summarizeToolInput |
60% | Fallback path (no description/command/file_path) |
Notable uncovered branches in pkg/tui (session-related)
| Function | Coverage | Gap |
|---|---|---|
startAgentSession |
7.7% | The L2 fix (30s timeout) is here — essentially untested |
readAgentSessionCmd |
12.5% | Session event reader loop barely covered |
handleAgentSessionEvent |
47.6% | Event handling partially covered |
resolveSessionDir |
0% | XDG_CONFIG_HOME resolution untested |
resolveAgentMaxSessions |
0% | Max sessions config resolution untested |
4. Test Quality Audit
UUID-Equality Trap: CLEAR ✅
All persistence tests assert on the --resume vs --session-id flag decision from the fake's recorded argv. No persistence test asserts UUID equality. The TestSessionIDFor_Deterministic test asserts UUID equality but tests the SessionIDFor pure function, not persistence — this is correct.
Missing Tests
| Gap | Severity | What to add |
|---|---|---|
No concurrent GetOrCreate/Close test |
Medium | SessionManager uses a mutex-guarded map and LRU order slice. No test hammers GetOrCreate and Close from multiple goroutines simultaneously. TestSessionManager_EvictionNoRace runs sequentially. Add a test that spawns 10 goroutines calling GetOrCreate with random incident IDs while another goroutine calls CloseAll. |
startAgentSession timeout untested (M6) |
High | The L2 fix (30s timeout) lives at claude.go:456 but startAgentSession has 7.7% coverage. A regression removing the timeout would go undetected. |
ForTestStubIndexWrite dead code |
Low | Either delete it or add the revert-check test that uses it. Currently it's a public API with zero callers. |
resolveSessionDir untested |
Medium | XDG_CONFIG_HOME resolution is at 0% coverage. Add table-driven test with t.Setenv. |
No test for resolveAgentMaxSessions |
Low | 0% coverage on config resolution for max sessions. |
Tests Asserting on Pure Helpers Without Production Callers
This was the #414 failure mode. I checked every function in pkg/agent/ for production callers:
| Function | Has production caller? | Status |
|---|---|---|
ParseStreamEvent |
Yes (session.go:readLoop) |
OK |
EncodeUserTurn |
Yes (session.go:Send) |
OK |
BuildSpawnArgs |
Yes (session.go:spawn) |
OK |
SessionIDFor |
Yes (session.go:NewSession) |
OK |
newSessionIndex / load / has / record |
Yes (session.go:NewSessionManager, GetOrCreate) |
OK |
ForTestStubIndexWrite |
NO | Dead code ❌ |
IndexEntryCount |
Only from tests (integration_test.go:634) |
Test-only accessor, acceptable but should be documented |
ForTestStubIndexWrite is the only function reachable ONLY from tests (and even then, it's not called). IndexEntryCount is used in TestIntegration_IndexRobustness — it's a test accessor, which is acceptable.
Fixture Integrity
raw-capture.ndjson: Sanitized (all UUIDs contain "fake", no real domains). Real capture structure.partial-capture.ndjson: Same sanitization. Containsstream_eventdeltas for double-render testing.fakeclaude/main.go: Not a fixture but a test harness. Documents which behaviors are verified vs unverified against real CLI.make test-fixtures: PASSES — globs both.jsonand.ndjsonundertestdata/.
Golden Snapshots
TestGolden_ChatMode passes. Box-drawing borders (╭╰│) close correctly. The 🤖 emoji renders properly in ASCII profile mode.
Race Detection
go test -race ./pkg/agent/... PASSES. However, as noted above, no test exercises concurrent GetOrCreate/Close, so the race detector has limited opportunity to find issues in the session manager's mutex-guarded state.
Flakiness Risk
TestIntegration_RestartResumeusestime.Sleep(500 * time.Millisecond)(line 156) to wait for the second process to start. This is a timing dependency that could flake under load.TestSession_CLICommandArgsPreservedusestime.Sleep(100 * time.Millisecond)(line 257). Same concern.- No test spawns a real
claudebinary — all use the fake harness or mock executors. Good.
5. Suite Status
| Check | Result |
|---|---|
go test ./pkg/agent/... |
PASS (all tests) |
go test ./pkg/tui/... |
PASS (all tests) |
go test -race ./pkg/agent/... |
PASS |
gofmt -s -l |
PASS (no formatting issues) |
go vet ./... |
PASS |
make test-fixtures |
PASS |
TestGolden (all modes) |
PASS |
| Coverage threshold (55%) | PASS (80.6%) |
Note: go test ./cmd/... fails due to pre-existing config validation issues unrelated to this PR.
6. Verdict
Blockers (rejection criteria from 410a)
-
Gate 2e:
agent_session_enableddefaultstrue, must befalse. The plan says session persistence is being shipped but the 410a requirement says this salvage PR must default to false. The PR body says "Flag flip" and the plan doc confirms it. This contradicts 410a §2e. -
Gate 2b: No traceability matrix in PR body. The plan doc lists test labels but the PR description lacks the required acceptance-criterion → test → file table.
-
Gate 2c: No revert check performed or stated.
ForTestStubIndexWriteexists but is never called. The PR body does not mention performing the revert check.
High-Priority Findings
-
M6: L2 timeout fix untested.
startAgentSessionatclaude.go:456has 7.7% coverage. Removing the 30s timeout is undetectable by the test suite. -
ForTestStubIndexWriteis dead code (0% coverage, 0 callers). Either delete it or implement the revert-check test that uses it.
Medium-Priority Findings
-
No concurrent
GetOrCreate/Closestress test. The session manager is mutex-guarded but concurrent access is never tested. -
time.Sleepin integration tests (RestartResume:156,CLICommandArgsPreserved:257) creates flakiness risk. Consider polling on a condition instead. -
resolveSessionDirandresolveAgentMaxSessionsat 0% coverage. These config resolvers are untested.
clcollins
left a comment
There was a problem hiding this comment.
Adversarial re-review of PR #416 — post-fix commits aa9868a..5b9b720
Reviewer: automated adversarial audit
Scope: 13 commits since last review, full feature coherence check
TL;DR
The five flagged fixes (F1–F5) all hold under adversarial testing. Three mutation tests confirmed that deleting each fix's implementation causes the corresponding tests to fail. No must-fix defects found. Two nice-to-have items below.
F1 — Critical bug (subprocess lifetime)
Verdict: FIXED. Verified by running.
spawn() now derives spawnCtx from s.lifecycleCtx (manager-scoped, cancelled by CloseAll), not the caller's Send context. session.go:231
Verified:
- (a) Mutation test: changed
context.WithCancel(s.lifecycleCtx)→context.WithCancel(ctx)and ranTestSession_CallerCancelDoesNotKillProcess— failed immediately with "session must still be alive after caller context cancel." Restored; test passes. - (b) Reaping:
TestSessionManager_CloseAllReapsChildrenconfirms thatCloseAll()still kills children (two sessions both reachDone()within 3s). - (c) L2 timeout:
TestIntegration_SendHonorsTimeoutandTestSession_SendHonorsContextboth pass —Sendwith a cancelled/timed-out context returns promptly. The 30s timeout instartAgentSession(claude.go:459) bounds the write wait. - (d) The
contextAwareExecutor(session_test.go:897) was purpose-built for this fix — it respects context cancellation, unlike the older mocks that discarded it. The regression test genuinely exercises the bug's failure mode.
F2 — --bare denylist bypass
Verdict: FIXED. Verified by running.
ValidateUserFlags(ClaudeArgs(fields)) is called once in handleClaudePrompt (claude.go:172) before the three-way dispatch, and redundantly in spawn() (session.go:222).
Bypass attack results (18 vectors tested):
- All direct
--bare,--bare=true,--bare=1,--session-id=abc,-c,-rforms: BLOCKED --separator then--bare: BLOCKED (overly strict, but correct for security)- Wrapper commands (
toolbox run claude --bare,flatpak-spawn --host claude --bare): BLOCKED —ClaudeArgsscans backwards for lastclaudebasename - Non-claude binary (
/usr/bin/claude-wrapper --bare): BLOCKED — basename is not "claude" soClaudeArgsfalls back tofields[1:] - Double
claudetoken (claude --bare claude --print): PASS-THROUGH — correct by design,--bareis a wrapper arg - Unicode lookalike (cyrillic "а"): binary wouldn't match
claudeorisClaudeCLI, so it takes the blocking fallback path. No bypass. - Mutation test: set
ValidateUserFlagsto always return nil — bothTestSpawn_RejectsDeniedFlags(22 subtests) andTestHandleClaudePrompt_RejectsDeniedFlagsAllPaths(21 subtests across all 3 paths) failed. Tests have teeth.
F3 — Write goroutine leak → writeLoop
Verdict: FIXED. Verified by reading + race detector.
Per-Send goroutines replaced with a single writeLoop per session (session.go:253-257). Concurrency analysis:
- Send on closed channel: Impossible.
Sendholdss.muand checkss.closedbefore touchings.writeCh.Closeholdss.muwhen closings.writeChand setss.closed = true. Mutual exclusion prevents racing. - Close racing writeLoop: Safe.
writeLoopcapturesstdinby value (parameter, not field access —session.go:253), soClosesettings.stdin = nildoesn't affect the writeLoop's reference.close(s.writeCh)causesrange s.writeChto exit cleanly. - Deadlock if consumer stops draining: Not possible.
writeLoopwrites to the child's stdin, not to the events channel. If stdin blocks (pipe full),writeLoopblocks, butSend's timeout context unblocks theselectons.writeCh.CloseAllcancelslifecycleCtx, which kills the child viaexec.CommandContext, eventually closing the pipe and unblockingwriteLoop. - Goroutine leak on eviction:
Closecallsclose(s.writeCh), which unblockswriteLoop.s.cancel()kills the child, which unblocksreadLoop. Both exit cleanly. - Double-close:
Closenil-guardss.writeChbefore closing and sets it to nil after. Idempotent under mutex. -racedetector: passes cleanly.TestSession_WriteGoroutineDoesNotLeak: confirms goroutine count stays flat across 5 timed-out sends.
F4 — Index hygiene
Verdict: FIXED. Verified by reading.
- Atomic write:
f.Write(append(data, '\n'))— single call, well under PIPE_BUF (4096), O_APPEND ensures atomicity.index.go:142 scanner.Err()checked:index.go:76. Confirmed byTestIndex_ScannerErrChecked.- Narrowed lock — no TOCTOU:
record()updates the in-memory map underidx.mu, releases lock, then does file I/O. Two concurrentrecord()calls both update the map atomically (last writer wins in memory) and both append to the file (last entry wins on reload atindex.go:73). Consistent. has()cannot observe torn state:has()reads the in-memory map underidx.mu. The map is always updated atomically before the lock is released byrecord().index.go:88-93- Mutation test: making
record()a no-op causesTestIntegration_RestartResumeto fail. The persistence path has teeth.
F5 — Revert-check gate
Verdict: FIXED. Verified by running.
ForTestStubIndexWrite is in index_test_helpers_test.go (test-only, not compiled into production). TestRevertCheck_StubIndexWrite (integration_test.go:710) stubs index writes, then asserts that the second manager uses --session-id (not --resume). This is the inverse of TestIntegration_RestartResume, proving that test has teeth.
410a gates
deadcode:go run golang.org/x/tools/cmd/deadcode@latest ./...— no reachable-only-from-tests code inpkg/agent.- Test names: all test names cited in the PR body confirmed present via
grep -rn 'func Test' --include='*_test.go'. agent_session_enableddefaulting totrue: CORRECT per 410a rule 3f. Not re-reported.go test ./pkg/agent/... ./pkg/tui/...: all pass.go test -race ./pkg/agent/... ./pkg/tui/...: all pass.golangci-lint run: 0 issues.
Systemic test weakness — mock executors that discard context
This was the root cause of the original critical bug surviving: mockStreamExecutor.Start(_ context.Context, ...), argCapturingExecutor.Start(_ context.Context, ...), and customStdinExecutor.Start(_ context.Context, ...) all discard context. These mocks still exist and are still used by many tests.
The contextAwareExecutor was correctly added for the F1 regression test, but the older mocks remain. Any future test that uses mockStreamExecutor or argCapturingExecutor will be structurally blind to context-lifetime bugs.
Not a must-fix (the critical regression test uses the right executor), but worth being aware of for future test development.
Mutation test results summary
| Fix | Mutation | Test that caught it | Result |
|---|---|---|---|
| F1 (lifecycleCtx) | Revert to context.WithCancel(ctx) |
TestSession_CallerCancelDoesNotKillProcess |
FAILED as expected |
| F2 (ValidateUserFlags) | Return nil always | TestSpawn_RejectsDeniedFlags (22 subtests), TestHandleClaudePrompt_RejectsDeniedFlagsAllPaths (21 subtests) |
FAILED as expected |
| F4 (index.record) | Make record() a no-op | TestIntegration_RestartResume |
FAILED as expected |
All three mutations were detected. No persistence test uses UUID equality — they all assert on --resume vs --session-id flag decisions.
Nice-to-have findings
N1: Double Init event in TUI (cosmetic)
startAgentSession (claude.go:464) returns a synthetic agent.Event{Kind: agent.Init} immediately on successful Send. The real Init event from the subprocess arrives later via readAgentSessionCmd. handleAgentSessionEvent processes Init by appending an empty marker line to the watcher buffer (claude.go:246). Result: two marker lines per session start — a cosmetic double-blank.
file: pkg/tui/claude.go:464
Suggested fix: either don't return a synthetic Init (return a different event type like a "send succeeded" msg), or deduplicate Init events in handleAgentSessionEvent.
N2: Send holds mutex across blocking channel operations
Send (session.go:171) holds s.mu for its entire duration, including the select on s.writeCh (line 197-201) and errCh (line 203-211). If writeLoop blocks on stdin.Write (child stdin pipe full), Send blocks on s.writeCh <- req while holding s.mu, preventing Close() from acquiring the lock.
This is not a deadlock because:
- The caller (startAgentSession) uses a 30-second timeout context
CloseAllcancels the manager context before callingClose, which kills the child viaexec.CommandContext, eventually unblocking the pipe
But calling Send(context.Background(), ...) on a session whose child has stopped reading stdin would block Close() indefinitely. Since the only current caller uses a timeout, this is theoretical.
file: session.go:171-212
Suggested fix: release s.mu before the channel selects (capture writeCh by value under the lock, then release). Or document the context.Background() hazard.
Must-fix findings
None.
Conclusion
All five fixes hold. The test suite has teeth — three mutation tests independently confirmed that deleting each implementation causes the corresponding tests to fail. No test uses UUID equality for persistence assertions. The denylist blocks all 18 bypass vectors attempted. Race detector and linter are clean. The PR is ready to merge.
The fake claude binary (pkg/agent/testdata/fakeclaude/main.go) reproduces the verified session-ID semantics of claude 2.1.220: duplicate --session-id exits 1 with stderr and no result line, --resume with a known ID succeeds. TestIntegration_RestartResume (H1a) FAILS: without index persistence, the second SessionManager instance uses --session-id instead of --resume. This is the exact false-green shape from PR #414 — the test catches it. TestIntegration_AbsentIndex_SessionID (H1b) PASSES: fresh manager with no index correctly uses --session-id. The onEstablished callback is added to Session but not yet wired — the index implementation follows in the next commit. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The session index at ~/.config/srepd/sessions/index.jsonl (XDG-aware) records which sessions have been established (system/init received). On construction, SessionManager loads the index; GetOrCreate checks it to decide --session-id (fresh) vs --resume (established). L1: entries are written only after system/init arrives via the onEstablished callback — a crash between spawn and init leaves no entry, so the next spawn correctly uses --session-id. Robustness: corrupt trailing line is tolerated (truncated with warning); I/O failures log once and continue in-memory; directory created 0700. TestIntegration_RestartResume (H1a) now passes: the second manager instance reads the index and uses --resume. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- L2: Replace context.Background() with 30s timeout in startAgentSession to prevent hung child processes from blocking indefinitely - B1: :agent <query> now dispatches and returns to queue (fire-and-return) instead of entering chat mode, matching :watcher precedent - Flip agent_session_enabled default to true now that session persistence is implemented - Add integration tests: double-render prevention, crash mid-stream recovery, index robustness (corrupt line tolerance, unwritable dir) - Remove dead code (SessionIndexDir, SessionIndexPath) - Fix errcheck lint issues in index.go and integration_test.go - Update docs, README, quickstart, plan doc Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add contextAwareExecutor that respects context cancellation, unlike existing mocks that discard the context. This executor mimics exec.CommandContext behavior: when ctx is cancelled, stdout closes and wait() returns. TestSession_CallerCancelDoesNotKillProcess: the headline regression test for C1. Sends with a cancellable context, cancels it, asserts the session survives and a second Send succeeds. FAILS before the fix because spawn derives spawnCtx from the caller's context. TestSessionManager_CloseAllReapsChildren: proves CloseAll still terminates children (passes before and after the fix). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The Send context must bound the WRITE, not the PROCESS. Previously, spawn derived spawnCtx from the caller's context; when startAgentSession's defer cancel() fired, the child was killed milliseconds after the first message — breaking multi-turn sessions. spawn now derives spawnCtx from a manager-scoped lifecycle context created by NewSessionManager and cancelled by CloseAll. Sessions created directly (without a manager) default to context.Background(). Close still cancels the per-session spawnCtx, and CloseAll cancels the manager context as a belt-and-suspenders reap. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
handleClaudePrompt routes to three spawn paths (session, streaming, blocking) but only the session path validates flags via validateUserFlags. These tests verify that denied flags like --bare are rejected on ALL three paths. They FAIL because the validation check is missing. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…spawn paths Export ValidateUserFlags and call it in handleClaudePrompt before dispatching to any of the three spawn paths (session, streaming, blocking). This closes the --bare bypass where legacy paths parsed agent_cli_command without flag validation. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When Send's context times out while the child isn't draining stdin, the write goroutine stays pinned on the blocked pipe write. Repeated sends accumulate leaked goroutines. This test verifies goroutine count does not grow per timed-out Send attempt, and that Close releases everything. It FAILS because each Send spawns a new unbounded goroutine. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Each Send previously spawned a new goroutine for the pipe write. When the child stopped draining stdin and the caller's context timed out, the goroutine stayed pinned on the blocked write, leaking one per timed-out Send. Replace with a single writeLoop goroutine started at spawn time that drains a channel of write requests, so goroutine count stays constant regardless of how many Sends time out. Close() closes the channel and stdin, allowing writeLoop to exit. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add tests for record/has lock contention and scanner.Err coverage. These extend the existing TestIntegration_IndexRobustness tests with guards for the FIX 3 improvements. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Three improvements to pkg/agent/index.go: - Combine entry and newline into a single f.Write(append(data, '\n')) to prevent a corrupt trailing line on crash between the two calls. Log write errors instead of swallowing them. - Add scanner.Err() check after the load loop (identical omission was fixed in readLoop during PR #414). - Narrow record()'s critical section: update in-memory map under the lock, then release it before file I/O, so has() on the TUI thread is never blocked by slow filesystem operations. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ClaudeArgs extracts the tokens after the last "claude" basename in the command, so wrapper flags like toolbox's -c are not falsely rejected as Claude's -c (--continue). Applied in both handleClaudePrompt and spawn to fix false positives in wrapped commands like "toolbox run -c devtools claude --print". Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
writeLoop accessed s.stdin directly, racing with Close setting s.stdin=nil. Pass stdin as a parameter to writeLoop so it captures the value at creation time, avoiding the race. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add TestRevertCheck_StubIndexWrite — stubs index writes and asserts the restart-resume scenario falls back to --session-id, proving H1a/H1b tests have teeth (410a §2c). Move ForTestStubIndexWrite and IndexEntryCount into _test.go so no test-only helper remains in production code. Delete Session.ID() and Session.IncidentID() — both had zero callers anywhere. deadcode ./... reports nothing in pkg/agent. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Update docs/ai-agents.md: correct the config table from false to true, expand privacy section with srepd's on-disk session index details. Add post-mortem to plan 412: --resume <unknown-id> unverified note, context-cascade bug lesson, revert-check gate lesson, dead accessor cleanup. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ClaudeArgs scans backwards for the last token whose basename is "claude" so wrapper commands like "toolbox run -c devtools claude ..." work without their own flags colliding with the denied-flags list. Document the known limitation where a trailing path token ending in "claude" can hide earlier flags from validation. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
TestIndex_NoDuplicateOnReEstablish verifies that establishing a session for the same incident twice does not append a duplicate line to index.jsonl. Currently fails because record() always appends. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
record() now checks if the incident already has an established entry and returns early, preventing unbounded index.jsonl growth. SessionIDFor is deterministic, so re-appending the same entry adds nothing. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
TestHandleAgentSessionEvent_InitOnce verifies that two Init events (synthetic from startAgentSession + real from subprocess) produce only one marker line. Currently fails because both Init events append. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
startAgentSession returns a synthetic Init event immediately for responsive UI feedback, and the real Init later arrives from the subprocess. Track agentSessionInitSeen to emit only one marker line per session start, preventing a cosmetic double blank. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
TestSession_SendDoesNotBlockClose verifies that a Send(context.Background()) blocked on a non-draining child does not prevent Close() from returning. Currently fails because Send holds s.mu across blocking channel selects. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Send previously held the session mutex across blocking channel selects, which would prevent Close() from returning if a Send(context.Background()) was blocked on a non-draining child. Now the mutex is released after capturing writeCh and lifecycleDone, and the selects also listen on the lifecycle context so Close() can unblock a pending Send. writeLoop now exits via context cancellation instead of channel close, avoiding a data race between Send's select and Close's close(writeCh). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Three new integration tests for the session-ID-already-in-use recovery path, committed FAILING before the fix (TDD per 410a §2a): 1. TestIntegration_SessionIDInUse_RetryResume (headline): pre-populate fake state so the deterministic session ID is used, start with empty index, Send. Expects two spawns (--session-id then --resume), Send success, and an index entry. 2. TestIntegration_SessionIDInUse_NoInfiniteRetry: both --session-id and --resume fail (reject_resume script option). Expects exactly two spawn attempts and a surfaced error. 3. TestIntegration_OtherFailure_NoRetry: crash_before_init (no "already in use" stderr). Expects exactly one spawn — no retry triggered. Also adds reject_resume option to the fake claude binary for test 2. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…-resume When the index is empty but Claude Code's session store already has the deterministic session ID (index/store divergence from deletion, config change, or write failure), spawn now detects the "Session ID already in use" rejection and retries once with --resume. The fix adds a brief synchronous check after exec.Start for non-resumed spawns: if the child exits within 100ms with "already in use" on stderr, spawn sets resumed=true and recurses. On success the index records the establishment so subsequent spawns go straight to --resume. If the retry also fails, the error is surfaced as before (no infinite loop). Interface change: StreamCommandExecutor.Start now returns stderr as *bytes.Buffer (captured via io.MultiWriter alongside os.Stderr) so spawn can match the specific error message. All mock executors in tests updated to match. TestIntegration_DuplicateSessionID updated deliberately: it previously asserted that duplicate session-id surfaced an Error event. With recovery, the session self-heals and Send succeeds. The no-recovery case is covered by TestIntegration_SessionIDInUse_NoInfiniteRetry. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add post-mortem entry documenting the index/store divergence bug found via live testing, the recovery fix, and the lesson: a faithful fixture plus an honest test can still lock in wrong behaviour when the test asserts graceful failure without questioning whether failure is correct. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The real claude CLI takes 352–411ms to reject a duplicate session ID. The fake exited instantly, so the 100ms detection timer always won the race and the tests passed — a fourth distinct false-green shape where a fixture is faithful in content but wrong in timing. FAKECLAUDE_REJECT_DELAY_MS (default 400ms) makes the fake match real CLI timing. TestIntegration_HappyPathNotDelayed guards against "just raise the timeout" fixes by asserting Send returns in < 200ms on the success path. EXPECTED FAILURES with this commit: - TestIntegration_SessionIDInUse_RetryResume - TestIntegration_DuplicateSessionID These fail because the 100ms timer fires before the 400ms rejection, exactly reproducing the production bug. The next commit fixes this. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…D detection The 100ms timer in spawn() never fired in production: the real claude CLI takes 352–411ms to reject a duplicate session ID, so the timer always won the race and the rejection surfaced later as a raw error. Replace the fixed timer with an event-driven approach: race the first stdout byte against process exit. On the happy path, the child writes system/init immediately — no delay at all. On duplicate-session-ID rejection, the child exits with no stdout, and the exit is caught regardless of how long the CLI takes to produce it. The prefixedReadCloser type reconstructs the full stdout stream after peeking the first byte, so readLoop receives the complete output. TestSession_CloseNoSpuriousError's mock updated: wait() now blocks until context cancellation, matching real exec.CommandContext behaviour. The previous mock returned immediately, which was invisible to the timer-based code but exposed by the event-driven detection (which correctly interprets an instant exit as "process died"). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When a mock's wait() returns nil and stdout has data simultaneously, the select could receive from exitCh first. The original code closed stdout unconditionally, discarding buffered data. Now only close stdout when exitErr is non-nil (actual failure). When exitErr is nil, wait for the peek result and wrap stdout if data was available. Fixes a race visible under -race with mocks whose wait() returns immediately while stdout still has data. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Record the fourth distinct false-green shape: the fake claude harness was faithful in content (exit code, stderr, absence of result line) but wrong in timing (0ms vs ~400ms measured against the real CLI). The 100ms timer always won the race in production. Fixtures must be faithful in timing, not only in content. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
c1f7656 to
0183fe2
Compare
Summary
~/.config/srepd/sessions/index.jsonl(XDG-aware) records established sessions so restart-resume works across srepd restarts. Sessions are recorded only aftersystem/initarrives — crash-before-init leaves no stale entry.pkg/agent/testdata/fakeclaude/main.go) reproducing verified claude 2.1.220 session-ID semantics for integration testing.--resumevs--session-id), never UUID equality. Covers restart-resume, absent index, per-incident isolation, crash-before-init, duplicate session-id, LRU eviction, send timeout, double-render prevention, crash mid-stream, and index robustness.startAgentSessionnow usescontext.WithTimeout(30s)instead ofcontext.Background()— a hung child returns an error instead of blocking forever.:agent <query>dispatches and returns to the queue immediately (fire-and-return), matching:watcher <query>precedent. Bare:agentstill opens chat mode.agent_session_enableddefault changed fromfalsetotrue.TestRevertCheck_StubIndexWriteproves H1a/H1b tests have teeth.Session.ID(),Session.IncidentID()(zero callers) deleted.ForTestStubIndexWriteandIndexEntryCountmoved into_test.go.docs/ai-agents.mdtable row corrected (true, notfalse), privacy section updated with srepd's on-disk index details.ClaudeArgslast-token heuristic, why it exists, and the known limitation where a trailing path token can hide earlier flags.record()now skips appending when the incident already has an established entry, bounding index growth.handleAgentSessionEventnow tracksagentSessionInitSeen.Sendreleasess.mubefore blocking channel selects and listens on the lifecycle context, preventingClose()from blocking.writeLoopexits via context cancellation instead of channel close to avoid data races.Test plan
go test ./pkg/agent/...— all tests passgo test ./pkg/tui/...— all tests passCGO_ENABLED=1 go test -race ./pkg/...— no racesgolangci-lint run— 0 issuesgofmt -s -l cmd pkg— no formatting issuesgo vet ./...— cleandeadcode ./...— no dead code in pkg/agentmake test-fixtures— all fixture data sanitizedmake generate-quickstart— quickstart regenerateddocs/plans/412-session-persistence.mdAcceptance criteria traceability
--resumeTestIntegration_RestartResumepkg/agent/integration_test.go--session-idTestIntegration_AbsentIndex_SessionIDpkg/agent/integration_test.goTestIntegration_PerIncidentIsolationpkg/agent/integration_test.goinitTestIntegration_CrashBeforeInitpkg/agent/integration_test.goSendhonours a timeoutTestIntegration_SendHonorsTimeoutpkg/agent/integration_test.goTestIntegration_DuplicateSessionIDpkg/agent/integration_test.goTestIntegration_SessionIDInUse_RetryResumepkg/agent/integration_test.goTestIntegration_HappyPathNotDelayedpkg/agent/integration_test.goTestIntegration_SessionIDInUse_NoInfiniteRetrypkg/agent/integration_test.goTestIntegration_OtherFailure_NoRetrypkg/agent/integration_test.goTestRevertCheck_StubIndexWrite+TestResolveAgentSessionEnabled_DefaultTruepkg/agent/integration_test.go,pkg/tui/model_test.go:agent <query>fire-and-returnTestChatMode_AgentWithQueryFiresAndReturns+TestChatMode_BareAgentEntersChatModepkg/tui/model_test.goTestIndex_NoDuplicateOnReEstablishpkg/agent/integration_test.goTestHandleAgentSessionEvent_InitOncepkg/tui/claude_test.goSenddoes not blockClosevia held mutexTestSession_SendDoesNotBlockClosepkg/agent/session_test.goRevert checks
Timing-fidelity revert check (V2 — the headline of this fix)
Temporarily restored the 100ms timer in
spawn(), ran the timing-faithfultests. Both FAIL because the 400ms rejection delay defeats the timer:
Restored the event-driven fix, both tests pass again.
Index-write revert check (F1, H1a, H1b)
TestRevertCheck_StubIndexWritecallsForTestStubIndexWrite()to disableindex file writes, then runs the restart-resume scenario. With writes stubbed,
the second manager has no on-disk index and falls back to
--session-idinstead of
--resume.Send-blocks-Close revert check (N4)
Reintroduced
defer s.mu.Unlock()(lock held across blocking select) andconfirmed
TestSession_SendDoesNotBlockCloseFAILS:Restored the fix, test passes again.
Dead code gate
TestSession_CloseNoSpuriousError mock update
The
TestSession_CloseNoSpuriousErrormock'swait()was updated to blockuntil context cancellation, matching real
exec.CommandContextbehaviour.The original mock returned immediately, which was invisible to the timer-based
code but incorrectly triggered the event-driven "process exited before stdout"
path. The test's intent is unchanged — it still verifies that
Close()doesnot produce a spurious Error event.
Deviations
B1 reverses plan 411. Plan 411 said
:agent <query>"opens chat mode andsends." This PR makes it fire-and-return instead — the query is dispatched and
control returns to the incident queue immediately. This is a deliberate,
maintainer-requested reversal based on live usage: a one-shot question should
not hijack the view. The implementation follows the
:watcher <query>precedentin the same file.
Testing this live
What will NOT work in
--devmode: PagerDuty and OCM API calls returnmock data, so incident enrichment (cluster info, service logs) is simulated.
The agent subprocess (
claude) requires a real Claude Code install andAnthropic API key.
Sources
claude 2.1.220session-ID behaviour (captured 2026-07-28)🤖 Generated with Claude Code