fix: log dispatch-marker degradation once and quarantine stale bundles#117
fix: log dispatch-marker degradation once and quarantine stale bundles#117tbrownio wants to merge 3 commits into
Conversation
Both dispatch_marker_ingest_degraded emit sites fired on every ~5s scan for the same marker (~100k journald lines/day on the production host), and bundles owned by ended sessions were rescanned forever. Degraded logs are now emitted at most once per marker per daemon process, and an age-based sweep (default 24h, DISPATCH_QUARANTINE_AGE_MS) moves already-ingested or unresumable bundles - every file sharing the marker basename, .done last, never overwriting - into a quarantine directory beside the event database (DISPATCH_QUARANTINE_DIR). Closes #115 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings: the sweep crossed several awaits between the open-turn check and the file moves, so a turn opened mid-sweep could consume a bundle while the scanner moved it; quarantine now re-checks hasOpenTurn immediately before moving and skips the marker for that cycle. A successfully quarantined marker's degraded-log memo key is cleared so the process-local set stays bounded to markers still in the scan path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A turn opened between the sweep's awaited renames could consume a bundle mid-move; the open-turn predicate (synchronous SQLite) is now re-checked before every file move and the sweep aborts the bundle when one opens, leaving .done in place so a later scan completes it. Scans also prune degraded-log memo keys whose marker left the owner directory, keeping the per-process set bounded to markers still in the scan path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QA evidence — automated pass on final HEAD
|
type: wrap-up-report
|
Postmortem — daemon-log-hygiene (ops-only)Run operations (always)Wall-clock 0:49 (2026-07-23 22:48:24 → 23:37:26 UTC), agent-active ≈ 100%, human-idle 0:00 (0%) — the only genuine human message in the transcript is the initial Per-step timing (scripted from the session transcript; tokens from each dispatch's
Aggregates: Load 5% · Plan 21% · Implement+Verify 25% · PR 3% · Review+QA 42% · Wrap 4%. Summed overseer turnaround between dispatches ≈ 6 min total across 14 dispatches (fast pickup; the in-turn poll loops kept every gap under ~30s). Overseer main-loop usage: output 95,221 / cache-read 13.41M tokens across 89 messages. The one defect worth recording: during QA publication the Overseer edited the PR body through an unquoted bash heredoc; the markdown's backticked strings executed as command substitution, running the full daemon test suite twice concurrently (two of those incidental runs flaked on timeouts, forcing a serial re-run to prove they were load-flakes) and aborting the intended edit. Cost: ~3.5 minutes + one redundant suite run. The fix that removes the biggest (only) stall-shaped loss: always use quoted heredocs ( What we asked forStop the dispatch-marker scanner re-logging the same stale markers every ~5s (~100k journald lines/day), quarantine stale handled bundles (move-never-delete, ~24h age), and capture the two host config gaps (APP_ACTOR_ID vars, Linear Outcome vs intendedOn-target — no outcome gap known at wrap-up. AC1–AC4 verified with regression tests plus rubric blockers; AC5/AC6 are post-deploy operator checks by design. The outcome half of this postmortem runs after the human's PR review. Why the gap happenedOmitted — operational-only postmortem. What to change so it doesn't recur
Both proposals are recorded for human review — nothing applied. Dial record & right-sizingzone: 1
lanes: single-codex
passes: {plan: 3/3, post_pr: 3/3}
findings: {plan: {pass1: {codex: 4, claude: 0}, later: {codex: 4, claude: 0}},
post_pr: {pass1: {codex: 2, claude: 0}, later: {codex: 2, claude: 0}}}
verifiers: {frontend: skipped, qa_pass: ran}
qa_findings: 0
wall_clock: 0:49
deviations: none
pr_size: {files_changed: 9, additions: 526, deletions: 20}
tokens:
codex: {total: 801583, by_role: {code_researcher: 87894, plan_reviewer: 133997,
implementer: 263615, backend_verifier: 136503, code_reviewer: 179574}}
claude_subagents: 0
overseer: 13748203 # deduped by message.id; output 95221, cache_read 13411420
total: 14549786
spend_ratio: 26648.9 # cache-read-dominated; Codex-only ratio: 1468.1
agents:
- {role: code-researcher, model: gpt-5.6-sol, effort: low, dispatches: 1, wall_clock: "2:00", tokens: 87894}
- {role: plan-reviewer, model: gpt-5.6-sol, effort: low, dispatches: 3, wall_clock: "2:43", tokens: 133997}
- {role: implementer, model: gpt-5.6-sol, effort: medium, dispatches: 4, wall_clock: "12:43", tokens: 263615}
- {role: backend-verifier, model: gpt-5.6-sol, effort: low, dispatches: 3, wall_clock: "4:54", tokens: 136503}
- {role: code-reviewer, model: gpt-5.6-sol, effort: low, dispatches: 3, wall_clock: "7:29", tokens: 179574}Right-sizing: right-sized. Every pass was trigger-driven and every fix round answered a concrete Must Fix; the two lowest-yield spends were plan pass 3 (45,101 tokens → one trivial wording finding, but it was the confirmation pass after a materially revised plan) and post-PR pass 3 (49,738 tokens → 0 findings, but it was the mandatory re-review that closed the loop). No dial change would have cut spend without giving up a required confirmation; the zone-1 single-Codex lane at cap 3/3 fit this item. AcceptanceStill awaiting review (operations-only postmortem; the outcome half runs when the human returns from PR review). System changesComment URLs: recorded after posting (see the postmortem comments on issue #115 and PR #117). Proposal verdicts: pending human review — this auto-run does not wait. |

Summary
What — The daemon's dispatch-marker scanner now logs
dispatch_marker_ingest_degradedat most once per marker per daemon process (both reasons:invalid_sidecarandno_parent_turn), and an age-based sweep quarantines stale, already-handled dispatch bundles out of the scan path — moving every file that shares the marker basename, never deleting anything.Why — Six stale marker bundles under ended sessions were re-logged on every ~5-second scan cycle, forever: ~100k junk journald lines/day on the sole production daemon host, drowning real signals (this obscured live debugging during the observability smoke test). The degraded log fired before the ingest dedup, and nothing ever removed bundles whose owning session would never run another turn.
How — D1: both emit sites are gated by a process-local per-marker memo (
<sessionId>:<file>, reason-independent), andinvalid_sidecarrescans are additionally suppressed by the durableagent_invocations.source_keylookup (new publicEventLog.hasCodexInvocation), so suppression survives restarts. D2: inside the same scan loop, a bundle older thanDISPATCH_QUARANTINE_AGE_MS(default 24h — far above any live consume window) that is already ingested or unresumable (no_parent_turn) is moved toDISPATCH_QUARANTINE_DIR(default<db-dir>/dispatch-quarantine/<sessionId>/, outside every scan path and outside the worktree so worktree cleanup can't destroy it). Siblings move first and.donemoves last, so a partial failure self-heals on the next scan; destination collisions allocate a fresh suffixed name instead of overwriting. D3 (host env vars + Linearadminscope) is deliberately ops-only — no daemon code; see Deploy notes.Done means:a degraded marker produces exactly one log line per daemon process; stale handled bundles leave.codex-dispatches/for the quarantine directory with the event log's invocation record intact; young bundles are untouched; the operator has exact commands for the two host config gaps.Visual overview
Before the scanner re-logs every stale marker on each 5-second cycle forever; after, gate 1 (per-process memo + durable SQLite
source_key) limits each marker to one degraded line, and gate 2 moves handled bundles older than 24h — every basename sibling,.donelast, never overwriting — todispatch-quarantine/beside the event DB, outside every scan path. (.excalidrawsource in./tmp/daemon-log-hygiene/refs/.)User journeys
No user-facing surface — the "user" is the daemon operator. Operator journey:
journalctl -u linear-agent-daemonis currently ~100k degraded-marker lines/day; after deploy, each stale marker contributes at most one line per daemon start, and within 24h the stale bundles move to<db-dir>/dispatch-quarantine/where they remain inspectable. Reconcile safety nets switch on only after the operator applies the Deploy notes.Verification
logs each degraded dispatch marker once across rescans and reason transitionsdrives twoingestDispatches()cycles over fabricatedinvalid_sidecar,no_parent_turn, and reason-transition markers; exactly onedispatch_marker_ingest_degradedper marker.cd daemon && pnpm test: 364 passed | 1 skipped (final HEAD).quarantines every stale ingested sibling while retaining its invocation: bundle incl..shsibling aged past threshold → every basename file moved to the quarantine dir, source dir empty,dispatch_marker_quarantinedlogged.leaves a young ingested dispatch bundle in the live consume path: below-threshold bundle → all files remain, no quarantine dir created.log.invocations(owner)still returns the record withsourceKey: dispatch:<owner>:<basename>.doneafter the move.logs a quarantine failure and completes the bundle on the next scan: a real filesystem obstruction forces the move to fail →dispatch_marker_quarantine_failedlogged, bundle intact; obstruction removed → next scan completes the quarantine.pnpm typecheck,pnpm build,bash -nover the seven ops scripts: all exit 0. Plan review: 3 Codex passes, all Must Fix resolved. Post-PR code review: 3 Codex passes ending Approve (0 Must Fix, 0 Should Fix open). Verification: independent backend-verifier passes at the verify stage and again as the final QA drive on HEADaf2841e.Manual tests
Important (operator, production host, post-deploy):
PLANNER_APP_ACTOR_ID/IMPLEMENTER_APP_ACTOR_IDin/etc/linear-agent-daemon/env, restart, thenjournalctl -u linear-agent-daemon --since -2min | grep -c reconcile_sessions_skipped→0journalctl -u linear-agent-daemon --since -2min | grep reconcile_webhook→ success events only, zeroreconcile_webhook_failed. If Linear rejects the grant (the runbook records a live 2026-07 rejection): file follow-up itemwebhook-reconcile-fallbackand record AC6 blocked, not failed/var/lib/linear-agent-daemon/dispatch-quarantine/within 24hAreas not affected: webhook ingestion, turn execution, Linear activity posting, worktree lifecycle, operations CLI — no code paths outside
scanDispatchMarkers(), config loading, and one new read-only EventLog accessor.QA results
Automated QA executed all command-shaped items against final HEAD
af2841e: full suite 364 passed | 1 skipped, including the seven dispatch-scan regressions (AC1–AC4, failure path, mid-loop race abort, memo prune); typecheck, build, andbash -nall exit 0. 0 of 3 Manual-test items were machine-runnable — all three are red-tier production-host operator checks, left to the human. Post-PR review found and fixed two issues on the PR: a quarantine/turn-open race (the open-turn predicate is now re-checked before every file move) and an unbounded log-once memo (now pruned). Proof in the QA evidence comment.Deploy notes
Nothing blocks verification (AC1–AC4 are repo-local tests). Ordered for deploy on the production host
linear-agent(GCE, projectbloom-agents) — all items are operator-run; this pipeline touched no production resource:daemon/ops/runbook.md(daemonctl flow). No schema change: the newhasCodexInvocationis a read-only accessor; SQLite DDL untouched.adminscope (red: Linear workspace admin action): grantadminto both apps (bloom-planner,bloom-implementer) perdaemon/ops/runbook.md§"Register the two OAuth apps", then run the deploy-gate confirmation that client_credentials tokens honoradminforwebhooks/updateWebhook. On rejection (the runbook documents Linear rejecting this live in 2026-07): filewebhook-reconcile-fallback— "Decide and implement the webhook re-enable path when Linear rejects theadminscope on client_credentials tokens" — and record AC6 blocked.DISPATCH_QUARANTINE_AGE_MS(default86400000) andDISPATCH_QUARANTINE_DIR(default<db-dir>/dispatch-quarantine) exist for tuning; no action needed.Residual risks
no_parent_turnbundle older than 24h is quarantined even though a parent turn could theoretically appear later — locked decision (no event-log coupling); the bundle remains recoverable from quarantine by hand.no_parent_turnis per-process: a daemon restart re-logs each such marker once (bounded by restarts, and the 24h sweep removes the source)..donemoves last so the remainder self-heals on a later scan, and every sweepable bundle's prompt/report already lives durably in SQLite.Closes #115