Summary
persisted_gc_delivered_terminal deletes a bash_tasks row without deleting that task's bash_pattern_watches rows. The orphaned watch is then terminalized into a permanent pending_match=1, which is replayed at every subsequent aft binary start. Because the replay is filtered by neither session_id nor project root, a single orphaned watch injects a [BG BASH NOTIFY] system reminder into every new session, in every project, forever.
I hit this with a watch registered on 2026-09-09 that was still firing into brand-new sessions on 2026-09-10, in two unrelated repositories, until I deleted the row by hand.
- aft binary:
0.55.1 (darwin-arm64)
@cortexkit/aft-pi: 0.55.1
- harness:
pi
Symptom
A freshly created session receives this as its first message, ~3s after session creation and before any user input:
<system-reminder>
[BG BASH NOTIFY]
- task bash-3522494ec5bcac05 exited:
> watch target erased: the background task row was erased before the watch reached a normal terminal result
</system-reminder>
The task id is meaningless to that session. It never ran that command.
The stuck row
sqlite> select * from bash_pattern_watches where pending_match=1;
harness = pi
session_id = 01a08312-c64a-7478-ad56-ad116ca6f281
task_id = bash-3522494ec5bcac05
watch_id = watch-00000003
pattern_kind = regex
pattern = ^EVENT
once = 1
created_at = 1788919699387 -- 2026-09-09 02:08:19Z
stdout_offset = 0
stderr_offset = 0
pty_offset = 0
scanning = 0
pending_match = 1
match_text = watch target erased
match_offset = 0
match_context = watch target erased: the background task row was erased before the watch reached a normal terminal result
Chain of events
- 2026-09-09 02:08:19Z — session
01a08312-… registers regex watch ^EVENT on background task bash-3522494ec5bcac05. Persisted to bash_pattern_watches.
- 2026-09-10 12:05:28Z — the persisted-task GC deletes the task row and leaves the watch row behind:
2026-09-10T12:05:28Z [aft] bash task row deleted: task_id=bash-3522494ec5bcac05 reason=persisted_gc_delivered_terminal
This was part of a bulk sweep that deleted several hundred rows in ~2s.
- The now-targetless watch is terminalized (
failed to terminalize erased bash watch / failed to inspect bash watch target in crates/aft/src/bash_background/watches.rs), and the synthetic result is written back into the row as pending_match=1, match_text='watch target erased'.
- Nothing ever clears it.
pending_match was still 1 more than 24h and dozens of sessions later.
- Every aft binary start restores persisted watches for
harness='pi', sees the pending match, and pushes a bash_pattern_match frame. The restore is scoped by neither session_id nor project_key.
aft-pi receives the frame in onBashPatternMatch. It keys its bg state by frame.session_id, but delivery is unconditional:
// dist/index.js:17339, triggerWakeIfPending
drainContext.runtime.sendUserMessage(reminder, { deliverAs: "steer" });
There is no check that the frame's session is the live session, so a foreign session's match is steered into whatever session the process happens to be running.
Reproduction
- Register a
bash_watch with a pattern on a background task and let the task reach a terminal state without the pattern matching.
- Let the persisted-task GC delete that
bash_tasks row (persisted_gc_delivered_terminal).
- Start any new pi session, in any project. The
[BG BASH NOTIFY] reminder is injected at configure time.
- Repeat step 3 indefinitely.
Observed blast radius
The identical reminder landed in five unrelated session transcripts across two project roots within 90 minutes:
| Session start |
Project root |
| 18:16:18 |
…/GitHub/brookwell |
| 18:51:30 |
…/GitHub/brookwell |
| 19:40:39 |
…/GitHub/claude-code-proxy |
| 19:43:55 |
…/GitHub/claude-code-proxy |
| 19:45:38 |
…/GitHub/claude-code-proxy |
Why this is invisible in the logs
Pattern-match-only deliveries carry zero completions, and logPerTaskDeliveryHop iterates the task-id list:
// dist/index.js:17293
function logPerTaskDeliveryHop(drainContext, kind, message, taskIDs, data, level = "info") {
for (const taskID of taskIDs) { logDeliveryHop(...); }
}
With an empty list it logs nothing, so aft-plugin.log contains no trace of the injection at all. I had to diff the session transcript against the DB to find it.
Suggested fixes
Three independent layers, any one of which would have contained this:
- Delete the watches with the task.
persisted_gc_delivered_terminal should cascade delete from bash_pattern_watches where harness=? and task_id=?. A FOREIGN KEY … ON DELETE CASCADE on (harness, session_id, task_id) would make it structural.
- Make terminal watch results one-shot. After a pending match is pushed, delete the row (or mark it delivered). A
pending_match that survives delivery is an infinite loop by construction; once=1 is already set on this row and was not honored across restarts.
- Scope the replay. Restore/replay should filter to the sessions actually attached to the process, or at minimum to the current
project_key. Independently, aft-pi should drop frames whose session_id is not the live session rather than steering them into an unrelated conversation — the session-keyed sessionBgStates map already implies this invariant, but sendUserMessage bypasses it.
I would also suggest logging pattern-match deliveries even when taskIDs is empty; the current silence makes this class of bug very hard to trace.
Workaround
delete from bash_pattern_watches
where not exists (select 1 from bash_tasks t
where t.harness = bash_pattern_watches.harness
and t.task_id = bash_pattern_watches.task_id);
In my install this removed 103 rows, of which 2 were stuck pending_match=1. Back up aft.db with sqlite3 aft.db ".backup …" first.
Summary
persisted_gc_delivered_terminaldeletes abash_tasksrow without deleting that task'sbash_pattern_watchesrows. The orphaned watch is then terminalized into a permanentpending_match=1, which is replayed at every subsequent aft binary start. Because the replay is filtered by neithersession_idnor project root, a single orphaned watch injects a[BG BASH NOTIFY]system reminder into every new session, in every project, forever.I hit this with a watch registered on 2026-09-09 that was still firing into brand-new sessions on 2026-09-10, in two unrelated repositories, until I deleted the row by hand.
0.55.1(darwin-arm64)@cortexkit/aft-pi:0.55.1piSymptom
A freshly created session receives this as its first message, ~3s after session creation and before any user input:
The task id is meaningless to that session. It never ran that command.
The stuck row
Chain of events
01a08312-…registers regex watch^EVENTon background taskbash-3522494ec5bcac05. Persisted tobash_pattern_watches.failed to terminalize erased bash watch/failed to inspect bash watch targetincrates/aft/src/bash_background/watches.rs), and the synthetic result is written back into the row aspending_match=1,match_text='watch target erased'.pending_matchwas still1more than 24h and dozens of sessions later.harness='pi', sees the pending match, and pushes abash_pattern_matchframe. The restore is scoped by neithersession_idnorproject_key.aft-pireceives the frame inonBashPatternMatch. It keys its bg state byframe.session_id, but delivery is unconditional:Reproduction
bash_watchwith a pattern on a background task and let the task reach a terminal state without the pattern matching.bash_tasksrow (persisted_gc_delivered_terminal).[BG BASH NOTIFY]reminder is injected at configure time.Observed blast radius
The identical reminder landed in five unrelated session transcripts across two project roots within 90 minutes:
…/GitHub/brookwell…/GitHub/brookwell…/GitHub/claude-code-proxy…/GitHub/claude-code-proxy…/GitHub/claude-code-proxyWhy this is invisible in the logs
Pattern-match-only deliveries carry zero completions, and
logPerTaskDeliveryHopiterates the task-id list:With an empty list it logs nothing, so
aft-plugin.logcontains no trace of the injection at all. I had to diff the session transcript against the DB to find it.Suggested fixes
Three independent layers, any one of which would have contained this:
persisted_gc_delivered_terminalshould cascadedelete from bash_pattern_watches where harness=? and task_id=?. AFOREIGN KEY … ON DELETE CASCADEon(harness, session_id, task_id)would make it structural.pending_matchthat survives delivery is an infinite loop by construction;once=1is already set on this row and was not honored across restarts.project_key. Independently,aft-pishould drop frames whosesession_idis not the live session rather than steering them into an unrelated conversation — the session-keyedsessionBgStatesmap already implies this invariant, butsendUserMessagebypasses it.I would also suggest logging pattern-match deliveries even when
taskIDsis empty; the current silence makes this class of bug very hard to trace.Workaround
In my install this removed 103 rows, of which 2 were stuck
pending_match=1. Back upaft.dbwithsqlite3 aft.db ".backup …"first.