Skip to content

fix(triggers): stop reporting observed activity as a confirmed submission - #168

Merged
devsuitup merged 5 commits into
mainfrom
fix/submitted-semantics
Sep 4, 2026
Merged

devsuitup merged 5 commits into
mainfrom
fix/submitted-semantics

Conversation

@devsuitup

Copy link
Copy Markdown
Owner

What was wrong

A remote machine reported an auto-compaction trigger that returned ok=true,
submitted=confirmed, 2/2 steps — with no compaction, and no /compact
anywhere in the transcript. Neither the command nor a message.

pollForBusyRise was never a rising-edge detector. It is a level probe that
answers on its first tick, with no reference sample taken before the write. A
session that was already mid-turn therefore satisfied it immediately, and its
pre-existing turn was reported as a confirmation of our own submission.

Why it is not fixed by making the probe smarter

Measured on 13 real /compact runs: in the window where the transport writes
its result, no discriminator exists. At the moment Enter is pressed, the
executed case and the non-executed case write a strictly identical line. The
separating signal — compact_boundary, isCompactSummary, the XML replay under
the same promptId — arrives 96 to 271 seconds later. A compaction stays in the
same .jsonl, and the session state file reads busy exactly as for an
ordinary turn.

The transport cannot know. So it stops claiming to.

What changed

confirmed is no longer emitted. A fourth value activity says what is
actually known: activity was observed after the write. The order becomes
no < assumed < activity < confirmed, and confirmed stays reserved for a
transport that re-reads the effect rather than the symptom.

A reader that tested === 'confirmed' now falls into its retry branch: strictly
more cautious, never less.

Field evidence collected since

A local chain ran /compact then a resume prompt. Result: submitted=confirmed,
submit_retries: 0, step 1 waited 212 ms. The resume prompt was never submitted
— it sat in the composer until it was submitted by hand, four minutes later.
Step 0 was still executing when step 1 was written: the chain waits for the
composer to be free, which it is while the previous command runs.

That second defect is not addressed here. It is a chain-sequencing problem,
not a reporting one, and it deserves its own change.

Tests

824 pass, 0 fail. A pre-existing test pinned the false confirmation and was
rewritten rather than deleted: it now asserts the value the transport can
justify.

…sion

submitted: "confirmed" was emitted whenever the session read busy in the window
after a write. That observation is a level, not an edge measured against a
pre-write baseline, so a session already mid-turn satisfies it with activity the
write did not cause. And even a turn we did cause proves nothing about how the
CLI read the text: a slash command that misses the completion menu is submitted
as an ordinary message and produces a turn just the same. A caller that
debounces on "confirmed" therefore believes it acted, and does not retry.

That case now reports the new value "activity", which claims only what was seen.
"confirmed" stays in the strength order, strictly above "activity", reserved for
a transport that reads the intended effect back; no path in this repository
emits it. Order: no < assumed < activity < confirmed.

Readers testing submitted === 'confirmed' stop matching and fall through to
their retry or escalation path, which is the safe direction. Readers testing
against 'no' are unaffected.

No discriminator was wired in. Measured on 13 real /compact occurrences across
four session transcripts, an executed command and a non-executed one write an
identical user record at Enter + a few seconds; the record that separates them
(the compact_boundary system record) is only flushed 96-271 s later, once the
compaction has finished. That readback is a caller's job, not the watcher's.
The chain path folds each step's verify.sawBusy into chainSubmitted via
weakestSubmitted, but no chain test ever asserted result.submitted at the
top level for a step that is legitimately submitted without ever being
observed as busy. Two mutations stayed green as a result: dropping
verify.sawBusy entirely (always folding in "activity"), and forcing the
retry's own sawBusy to false.

Add two chain tests reproducing the exact incident shape (a "/compact" step
that IS observed, followed by a resume prompt that sits unobserved): one
pins the fold at "assumed" when a step never sees busy, the other pins it
at "activity" when only the retry Enter wakes the session. Each kills its
mutation individually, confirmed by running the mutated line in isolation.
…rvation

pollForBusyObserved is a level probe: a session already mid-turn when a
trigger fires satisfies it in milliseconds, with a turn the write did not
cause. That gap was already closed for "activity" (never confirmed from
busy alone), but removing "confirmed" outright broke the harness side:
DeliveryConfirmed is the only ordinary release of the one-chain-in-flight
guard, so every chain fell back to the two-hour ceiling.

Reintroduce "confirmed", on stricter footing: the composer is read back
immediately after our own Enter (unconditionally, never skipped because
activity was observed), and the result only counts when the session was
not already busy the instant we wrote and a turn was still independently
observed. Neither condition alone is enough -- proven by the "chain
confirmed fold" tests, where a composer reading that nobody ever
disturbed reads identically whether or not anything downstream reacted to
it. A retry never earns "confirmed": needing one already means the first
Enter did not visibly register.

Also: each chain step's own politeness wait now counts toward that step's
own waited_ms, not only toward the chain-level total_waited_ms -- the gap
that let a real run report total_waited_ms far ahead of the sum of its
steps, with no definition on file to contradict it. total_waited_ms's
definition is now written down in docs/automation.md.
…ed_ms

The preBusy-before-write ordering had no test that could fall on the
mutated (after-write) ordering: it survived untouched, and that line is
the one that separates the field incident from every other case. Added a
mock with a deterministic order (busy true until our own write lands,
false at that instant, true again once a genuine turn starts) rather than
a timing window -- correct code samples busy still true and refuses
"confirmed"; sampling after the write reads it as idle and would grant it,
the overestimating direction that costs.

Also: the single-command path's waited_ms left out the submit-verification
poll (and its retry) entirely, the same shape of gap total_waited_ms had.
Folded in, with its own test, and docs/automation.md now defines waited_ms
and total_waited_ms side by side so the two paths can't drift apart again.
Two triggers for one session could reach its composer at the same time: the
in-flight set is keyed on the trigger filename, never on the session, so
their writes could interleave into a single line of input. They now queue
behind one another per session; different sessions are unaffected.

The same window is why a submission could be reported as confirmed on a
turn it did not cause. That part is documented rather than fixed: this
channel carries nothing that ties an observed turn to the write that
would have caused it, so confirmed says the session was idle before the
write and a turn followed within the window, and no more than that.

Also folds a non-final step's turn-fall wait and a failed recovery
keystroke's polled wait into total_waited_ms, both of which the written
definition already claimed and the code did not deliver.
@devsuitup
devsuitup force-pushed the fix/submitted-semantics branch from 8d8bf30 to f3d5474 Compare September 4, 2026 12:30
@devsuitup
devsuitup merged commit a42f2ef into main Sep 4, 2026
7 checks passed
@devsuitup
devsuitup deleted the fix/submitted-semantics branch September 4, 2026 12:46
devsuitup added a commit that referenced this pull request Sep 4, 2026
…cleanly

Rebased onto main, which now carries the per-session FIFO lock (#168): the
lock is acquired right after sessionId validation, before the target guard
runs, so a guard refusal is briefly a lock holder. Proved by execution (and
by mutating the release call) that this is safe -- the refusal's early
return still runs inside the same try/finally that releases the lock, so a
legitimate trigger queued behind a refused one still goes through.

Also, from review:
- normalizeCwd's bare-root exemption now checks n !== path.sep instead of a
  length threshold, which stopped protecting a one-character path.sep the
  moment someone shortened it; added the missing test.
- translated a stray French heading in the (English) context doc.
- corrected a test comment that named the wrong refusal branch.
@devsuitup devsuitup mentioned this pull request Sep 4, 2026
devsuitup added a commit that referenced this pull request Sep 4, 2026
Ships #166, #168, #169, #170, #179, #180 and #182.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant