Skip to content

Orphaned bash_pattern_watches replay forever: GC deletes the task row, the terminalized watch is never cleared, and the pending match is injected into every new session in every project #308

Description

@rohan-patra

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

  1. 2026-09-09 02:08:19Z — session 01a08312-… registers regex watch ^EVENT on background task bash-3522494ec5bcac05. Persisted to bash_pattern_watches.
  2. 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.
  3. 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'.
  4. Nothing ever clears it. pending_match was still 1 more than 24h and dozens of sessions later.
  5. 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.
  6. 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

  1. Register a bash_watch with a pattern on a background task and let the task reach a terminal state without the pattern matching.
  2. Let the persisted-task GC delete that bash_tasks row (persisted_gc_delivered_terminal).
  3. Start any new pi session, in any project. The [BG BASH NOTIFY] reminder is injected at configure time.
  4. 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:

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions