Skip to content

fix(procevent): apply remote replies during capture - #1831

Merged
kunchenguid merged 14 commits into
mainfrom
fm/fm-remote-reply-pending-resolve-r1
Aug 7, 2026
Merged

fix(procevent): apply remote replies during capture#1831
kunchenguid merged 14 commits into
mainfrom
fm/fm-remote-reply-pending-resolve-r1

Conversation

@kunchenguid

@kunchenguid kunchenguid commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Intent

Fix a correctness bug in firstmate's remote-secondmate reply relay, and integrate it onto a new base. A remote secondmate's reply did not settle the parent's pending-reply records, so completed remote work escalated as false 'pending-reply-missed' alarms and kept re-surfacing in the OPEN DECISIONS fold across sessions. Evidence: secondmate eddies-wallet-e1, procevent seq 25 (2026-08-06), correlation ids e0ee2bec0478140f and e3a050e490cc5abf escalated even though the secondmate had reported the release chain done and gone idle. Systemic across every remote secondmate.

ROOT CAUSE (this SUPERSEDES an earlier framing that said the adapter was missing reconciliation logic; that framing was explicitly withdrawn by the user and must not be reintroduced). The adapter's handle/ingest ALREADY mirrored each reply line into the local status stream and already resolved every corr in the payload. The real defect was that handle was never INVOKED on a 'check: procevent remote-reply ' wake: nothing dispatched to it, and the process-event-sources instruction told the handler to run only the GENERIC acknowledgement. The wake was acked as seen while everything it carried was dropped. The user then sharpened the fix preference: an instruction-only fix cannot be proven to fire, so the code-guaranteed path is the PRIMARY guarantee and the instruction fix must not be the sole one.

MANDATORY DIAGNOSIS GATE the user imposed: before writing any fix, build a standalone repro confirming the diagnosis, and stop and report if it did not reproduce as understood. Done first against the pristine base: 7 failed observations, confirming it exactly. It also exposed a consequence not in the stated diagnosis - each capture is terminal for its registration and only the adapter's own handling re-arms the next one, so with handling never running the relay was left retired after every reply and only revived by session start.

FIX. bin/fm-procevent.sh now calls 'bin/fm-procevent-.sh autohandle ' right after publishing, mirroring the existing adapter-owned 'terminal' seam, so a result whose application carries no judgement is applied and acknowledged by code. It runs STRICTLY AFTER terminal retirement, because a handling adapter re-arms its own next source and retiring afterwards would drop that fresh registration. A missing command, an error, or any other exit is deliberately not a capture failure: the result stays unacknowledged and therefore still announced, so a handler receives it exactly as before, and an adapter without the command (Lavish) needs no change. bin/fm-procevent-remote-reply.sh gained the matching 'autohandle' entry keyed by canonical source id. bin/fm-pending-reply-lib.sh closes the durable keyed decision its own escalation opens, since resolving the record alone left a settled request surfacing in every later fold.

USER DECISIONS ALREADY MADE ON REVIEW FINDINGS, all still in force: (1) legacy unkeyed escalations - the user authorized the guarded containment and asked ONLY for wording accuracy, explicitly rejecting an unconditional default-key force-close because it would clear an unrelated decision that has taken over the shared default key; (2) the user then required RESTORING the guarded legacy close after an auto-fix round had replaced it with an unconditional refusal, keeping the invariant 'a different decision taking over the key cannot be cleared' correct; (3) the escalation lookup was restricted by construction to lines this library itself writes, so a foreign line merely mentioning a request id can never be mistaken for its escalation.

THIS INTEGRATION'S REWORK, sequenced by the user after PR #1846 (remote status-stream mirroring) landed on main. #1846 established that the remote reply channel is a MIRROR of the mate's status stream and deleted the adapter's line validator, because gating the stream made a remote mate's uncorrelated progress lines unrepresentable and one bad line failed a whole delta and wedged the channel. The reserved-key guard this branch had added inside that validator was therefore in the wrong place twice: it was batch-fatal, and it protected only the remote path while a local mate appends into the same status stream unchecked. Per the user's remote-vs-local principle the guard MOVED OUT of the adapter and INTO the shared consumer both writers flow through - the open-decision fold in bin/fm-classify-lib.sh. A key like 'pending-reply-' names a decision one library raises and is the only writer that closes it, so the fold now allows a reserved key to be opened or closed only by a line whose note speaks that namespace's own vocabulary; any other line naming the key folds as ordinary status, so it can neither squat the key and block the owner's close forever nor clear the owner's decision. The rule is generic, so the fold needs no knowledge of any particular owner, and being consumer-side it can never fail a delta or wedge a stream.

Main is integrated as a real merge onto the existing branch head rather than a rebase, deliberately: every prior pipeline fix commit stays present in the ancestry and the branch only moves forward, as this gate requires. An earlier attempt at this integration planted a precomputed tree on the merge commit, which silently reverted changes that had landed on main in the meantime; review caught it and it was redone as an ordinary merge with each conflict resolved individually. The three conflicting paths - the remote reply adapter, its suite, and the remote secondmate document - are ones this branch had already integrated against the status-stream mirror, and main has not touched them since, so their integrated versions carry forward unchanged. The result was then verified rather than assumed: every file main changed since this branch's base is byte-identical to main on this branch, there are no deletions relative to main, and the only paths that differ from main are this branch's own intended ones. In particular the NUL-safe durable parent binding, the inherited secondmate domain intake procedure, and the network-free session start are all preserved intact.

LINT CORRECTION made in this rework: earlier review rounds had put three pending-reply functions in subshell bodies, which assigned the wake library's globals inside a subshell and made every later use of them across the repo read as a lost subshell write, failing the repo's own lint gate (bin/fm-lint.sh). Main lints clean, so this was a regression from this branch, not pre-existing. Those functions are now plain function bodies with the sourced globals declared local and the lock released explicitly instead of from an EXIT trap; the per-correlation serialization is unchanged and both concurrency regressions still pass. bin/fm-lint.sh now exits 0 with no diagnostics.

TWO ITEMS THE USER REQUIRES IN THE PR BODY.
(1) E2E REPRODUCTION EVIDENCE. All of it ran in a throwaway ISOLATED home with its own FM_HOME and state, never a live home, driving the real path end to end: a real armed source, the real blocking cursor-anchored delta reader over the real remote entrypoint, the real process-event runner, and the real wake queue, with NO stubbing of the ingest. The repro deliberately runs only the GENERIC acknowledgement on the wake, so every observation that passes afterwards comes from code rather than handler discipline. Two scenarios: a reply arriving while the request is still open, and a reply arriving after the request already escalated, which is the incident's own shape. BEFORE, on the pristine base: 7 failed observations - the reply never reached the local status mirror, both requests escalated as missed reports for work the remote had completed, the fold kept surfacing them, and the relay was left unarmed. AFTER: 0 failed observations, and it still passes on the integrated branch. Each half of the fix was verified independently load-bearing by reverting one file at a time: without the runner's adapter-owned application the reply never reaches the mirror, and without the escalation close the settled request still surfaces as an open decision.
(2) DELIBERATELY ACCEPTED RESIDUAL, decided by the user: adapter-owned application is hooked into CAPTURE only, NOT into the watcher's reconcile, because reconcile runs synchronously on the watcher's cycle and a remote call there could stall supervision for the whole fleet. The residual is the narrow window where the process crashes after a result is durably captured but before it is applied. In that window the result simply stays unacknowledged, so it remains eligible for re-announcement and returns to the handler on a later drain or restart - the existing announce-until-handled backstop plus the corrected handling instruction. This is deliberate and accepted, not an oversight: the normal path is guaranteed by code and the crash path degrades to the behavior that existed before this change rather than to silent loss.

CONSTRAINTS. The adapter's non-destructive read, cursor continuity, deduplication, and identity guarantees are preserved, as is #1846's contract that no single line can stop or wedge the stream. This is firstmate's own shared tracked material, so firstmate-coding-guidelines applies: the one-owner rule for contracts, colocated tests in tests/ extending the existing runner, tests that exercise behavior through an executable interface and never assert implementation source text, one sentence per line in tracked Markdown, plain dash never an em dash, no agent name as a commit co-author, and shellcheck-clean via bin/fm-lint.sh.

TESTS. tests/fm-remote-reply.test.sh is #1846's suite adapted: it now asserts capture-time application directly, the local-document-storage-failure case obstructs storage BEFORE capture so the automatic application fails for real and covers the unapplied-capture fallback, and the obsolete adapter-side reserved-key case is replaced by one driving a real capture in which a forged reserved-key line mirrors normally and advances the cursor while the fold refuses to let it take the key over, after which a genuine reply resolves the request and clears the fold. tests/fm-wake-drain-open-decisions.test.sh gained a focused regression for the reserved-key rule where that fold contract lives. Both new regressions were verified to fail without their fix. Full changed-test sweep on the integrated tree: 72 scripts, 0 failed; bin/fm-lint.sh and bin/fm-doc-audience-check.sh clean.

What Changed

  • Automatically apply published remote-reply captures through the adapter after terminal retirement, mirroring replies locally, resolving correlations, re-arming the relay, and leaving failed applications unacknowledged for retry.
  • Close keyed pending-reply escalations when requests resolve, serialize escalation and resolution, and protect reserved decision keys in the shared open-decision fold while preserving guarded legacy closure.
  • End-to-end isolated-home reproduction went from 7 failed observations on the base to 0 for both open and already-escalated requests. Automatic application deliberately remains capture-only: a crash before application leaves the result unacknowledged for re-announcement and later handler or restart recovery.

Risk Assessment

✅ Low: The durable reply-relay fix and follow-up cache/publication safeguards satisfy the stated invariants without a substantiated remaining source defect.

Testing

After correcting rejected manual fixture setups whose temporary homes violated remote-home safety checks, all five targeted suites passed and a fresh isolated real-path E2E run demonstrated capture-time application without handler action, durable request resolution, decision-fold closure, acknowledgement, and relay re-arming; no UI surface was involved, so the reviewer evidence is a direct state and CLI transcript rather than a screenshot.

Evidence: Remote reply relay E2E transcript
Remote secondmate reply relay E2E evidence
==========================================
Scenario: a remote reply arrives after its request already escalated.
Handler action after the procevent wake: none.

Before remote reply, parent OPEN DECISIONS fold:
pending-reply-a8b56c05395ede40	blocked	pending-reply-missed: task=ios pending-reply-id=a8b56c05395ede40 request=confirm the release chain

Remote mate appended:
done [corr=a8b56c05395ede40]: release chain complete

Parent status mirror after capture:
done [corr=a8b56c05395ede40]: release chain complete

Durable pending record after capture:
phase=resolved
resolved_via=status
escalation_closed_epoch=1786074210

After remote reply, parent OPEN DECISIONS fold:
(empty)

Capture lifecycle after automatic application:
handled marker: present
next relay source: armed

Runner output:
retired: remote-reply-ios (adapter classified the captured result terminal)
autohandled: remote-reply-ios
captured: /private/var/folders/0k/bf8mwt2n5qddzk24r20gfk0c0000gn/T/no-mistakes-evidence/01KZD3ZYDDGQWF3DAJXGB39V9E/.remote-reply-case.oRRksm/parent/state/procevent-inbox/remote-reply-ios.1.result

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • 🚨 bin/fm-classify-lib.sh:261 - The required rule says any foreign line using a reserved key must fold as ordinary status, but existing .open-decisions-cursor files retain open sets computed under the old semantics. Since the cursor format has no fold-version check, an already-consumed foreign blocked [key=pending-reply-…] line continues surfacing indefinitely after upgrade while the whole-file fold correctly ignores it. Version or invalidate the incremental cache when fold semantics change, and cover an upgrade from a pre-existing cursor.

🔧 Fix: Version fold cache and gate autohandle on publication
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • git diff 4b6b89d995285accdc77e743f5dd76cfdc12629c..fb4c8f0abf3d4e2aa75eeecaa5e5f84cdf474ba5 to identify the affected executable paths and focused regressions
  • tests/fm-procevent.test.sh
  • tests/fm-remote-reply.test.sh
  • tests/fm-pending-reply.test.sh
  • tests/fm-wake-drain-open-decisions.test.sh
  • tests/fm-wake-drain-open-decisions-cursor.test.sh
  • Isolated bash -s E2E fixture using the real remote entrypoint, blocking delta reader, process-event runner, remote-reply adapter, pending-reply store, status mirror, and open-decision fold; captured the resulting state in remote-reply-e2e.txt
  • git status --short and targeted temporary-directory checks confirmed no testing artifacts remained in the worktree
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…uction

A remote secondmate's reply was captured and announced, but never applied.
Nothing dispatched the reply adapter's `handle` on a `procevent remote-reply`
wake, and the handling instruction named only the generic acknowledgement, so
the wake was retired while everything it carried was dropped: the reply never
reached the secondmate's local status mirror, the request it answered kept
escalating as a missed report, and the relay - whose registration each capture
retires, and which only that same handling re-arms - was left dead until the
next session start armed it again.

Applying such a result carries no judgement, so it belongs in code. After
publishing, the runner now calls
`bin/fm-procevent-<adapter>.sh autohandle <source-id> <sequence> <result-file>`
and lets the adapter apply and acknowledge its own result, through the same kind
of seam that already owns the terminal verdict. It runs strictly after terminal
retirement, because a handling adapter re-arms its own next source and retiring
afterwards would drop that fresh registration. An adapter with no such command,
or one whose pass does not complete, leaves the result unacknowledged and
therefore still announced, so a handler receives it exactly as before.

Resolving the request was not enough on its own either. An escalation opens a
durable keyed decision in the parent status log, and nothing ever closed it, so
a request the remote had answered kept surfacing in every later open-decisions
fold. The pending-reply library now owns both ends of that decision: it opens
one under a per-request key rather than the shared default key, and closes it
once the record resolves, appending the closing line only while that exact
decision is still open in the fold so it can neither double-close nor clear an
unrelated decision that has since taken the same key.

The handling instruction still routes a wake to its adapter, now as the
idempotent confirmation of what the runner already did rather than as the
guarantee.

Verified end to end in a throwaway isolated home driving the real armed source,
blocking delta reader, runner, and wake queue, with the handler doing only the
generic acknowledgement and no part of the ingest stubbed: before, seven failed
observations reproducing the incident; after, none. Each half is independently
load-bearing - without the runner change the reply never reaches the mirror,
without the escalation close the settled request still surfaces as an open
decision.
@kunchenguid

Copy link
Copy Markdown
Owner Author

Owed record: E2E reproduction evidence, and one deliberately accepted residual

Two items the captain asked to have on this PR. They are recorded here so they survive
body regeneration on re-validation; they will also be folded into the intent when this
branch is re-validated.


1. End-to-end reproduction, before and after

Run in a throwaway isolated firstmate home (its own FM_HOME, state, and registry -
never the main home or a live one), driving the real path end to end: a real armed source,
the real blocking cursor-anchored delta reader over the real remote entrypoint, the real
process-event runner, and the real wake queue. Nothing about the ingest is stubbed.

The reproduction acts exactly as firstmate acted in the incident: on the published wake it
runs only the generic runner acknowledgement, and nothing else. That is deliberate -
it means every observation that passes after the fix comes from code, not from a handler
remembering to do the right thing.

Two scenarios:

  • A - the reply arrives while the request is still open.
  • B - the reply arrives after the request already escalated (the incident's own shape).

Before (base commit 8387039)

== Scenario A: reply arrives while the request is still open
retired: remote-reply-ios (adapter classified the captured result terminal)
captured: .../home/state/procevent-inbox/remote-reply-ios.1.result
  (the wake was published as expected)
  FAIL  the relay is still armed after the wake was handled
  FAIL  the reply reached the local status mirror
  FAIL  the answered request resolved instead of escalating
  PASS  no false missed-report escalation was raised
  FAIL  the open-decisions fold is clear
== Scenario B: reply arrives after the request already escalated
  (the request escalated as a missed report, as the incident did)
retired: remote-reply-ios (adapter classified the captured result terminal)
captured: .../home/state/procevent-inbox/remote-reply-ios.2.result
  FAIL  the late reply reached the local status mirror
  FAIL  the late reply resolved the escalated request
  FAIL  the settled request stopped surfacing in the open-decisions fold

RESULT: FAILURE REPRODUCED (7 failed observations)
--- local status mirror ---
blocked: pending-reply-recovery-delivery-failed: task=ios pending-reply-id=7ee03e6a8aaf42e9 request=publish the release chain
blocked: pending-reply-missed: task=ios pending-reply-id=ff9c14d41d5043cc request=confirm the notarization
--- open decisions fold ---
default	blocked	pending-reply-missed: task=ios pending-reply-id=ff9c14d41d5043cc request=confirm the notarization
--- pending-reply records ---
7ee03e6a8aaf42e9 phase=escalated
ff9c14d41d5043cc phase=escalated

This confirmed the diagnosis before any fix was written: the delta is captured and
announced, the generic acknowledgement retires the wake, and the reply is never applied.
Both requests then escalate as missed reports for work the remote had actually completed,
and the fold keeps surfacing them.

It also exposed a consequence that was not part of the stated diagnosis: each capture
is terminal for its registration, and only the adapter's own handling re-arms the next one.
With that handling never running, the relay was left retired after every reply and only
came back because session start re-arms it.

After (this branch, at c5f608b)

== Scenario A: reply arrives while the request is still open
retired: remote-reply-ios (adapter classified the captured result terminal)
autohandled: remote-reply-ios
captured: .../home/state/procevent-inbox/remote-reply-ios.1.result
  (the wake was published as expected)
  PASS  the relay is still armed after the wake was handled
  PASS  the reply reached the local status mirror
  PASS  the answered request resolved instead of escalating
  PASS  no false missed-report escalation was raised
  PASS  the open-decisions fold is clear
== Scenario B: reply arrives after the request already escalated
  (the request escalated as a missed report, as the incident did)
retired: remote-reply-ios (adapter classified the captured result terminal)
autohandled: remote-reply-ios
captured: .../home/state/procevent-inbox/remote-reply-ios.2.result
  PASS  the late reply reached the local status mirror
  PASS  the late reply resolved the escalated request
  PASS  the settled request stopped surfacing in the open-decisions fold

RESULT: relay behaved correctly end to end (0 failed observations)

Both halves of the fix were verified independently load-bearing by reverting one file at a
time and re-running the colocated regression:

  • without the runner's adapter-owned application (bin/fm-procevent.sh):
    not ok - the captured reply never reached the secondmate's local status mirror
  • without the escalation close (bin/fm-pending-reply-lib.sh):
    not ok - the settled request still surfaces as an open decision

Scope note for accuracy: the transcripts above are from that isolated-home reproduction at
c5f608b. The pipeline subsequently added further hardening on this branch - per-request
serialization of resolution against escalation, the guarded legacy close, the reserved key
namespace, and by-construction escalation matching. That later work is covered by the test
transcripts already in the PR body, not by the two transcripts above.


2. Deliberately accepted residual: the capture-to-apply crash sliver

Adapter-owned application is hooked into capture only, not into the watcher's
reconcile.

reconcile runs synchronously on the watcher's cycle, and putting a remote call there
could stall supervision for the whole fleet. That tradeoff was raised explicitly and the
captain accepted it.

The residual is therefore the narrow window in which the process crashes after a result
is durably captured but before it is applied. In that window the result is simply left
unacknowledged, which means it stays eligible for re-announcement and comes back to the
handler on a later drain or restart - the existing announce-until-handled backstop, plus
the corrected handling instruction in .agents/skills/process-event-sources/SKILL.md.

This is a deliberate, accepted residual rather than an oversight: the normal path is
guaranteed by code, and the crash path degrades to exactly the behavior that existed before
this change rather than to silent loss.

Integrates PR #1846's remote status-stream mirroring, which deleted this
adapter's line validator: gating the stream made a remote mate's uncorrelated
progress lines unrepresentable, and rejecting one line failed a whole delta and
wedged the channel.

The reserved-key guard this branch had put inside that validator was therefore
in the wrong place twice over. It was batch-fatal, and it protected only the
remote path while a local mate appends into the very same status stream
unchecked. Both writers meet at the open-decision fold, so the rule now lives
there: a key like `pending-reply-<id>` names a decision one library raises and
is the only writer that ever closes it, and the fold lets a reserved key be
opened or closed only by a line whose note speaks that namespace's own
vocabulary. Any other line naming the key folds as ordinary status, so it can
neither take the key over and block the owner's close forever, nor clear the
owner's decision. The rule is generic, so the fold needs no knowledge of any
particular owner, and being consumer-side it can never fail a delta or wedge a
stream.

Also converts the serialized pending-reply lifecycle off subshell function
bodies. Sourcing the wake library inside a subshell assigned its globals there,
which read as a lost subshell write at every later use in every script that
sources this library and failed the repo's own lint gate; main lints clean, so
that was this branch's regression rather than a pre-existing one. The globals
are declared local and the lock is released explicitly instead, leaving the
per-correlation serialization unchanged with its concurrency regressions
passing.
…nt main

The previous integration commit planted a precomputed tree that had been built
against an older main, so it silently reverted work that had landed since: the
NUL-safe durable parent binding, the inherited secondmate domain intake
procedure, and the network-free session start. Review caught it.

This merges current main and carries the corrected tree, in which every file
main changed is byte-identical to main and the only paths that differ are this
branch's own. The relay fix itself is unchanged.
@kunchenguid kunchenguid changed the title fix(bin): settle pending replies from remote relay fix(procevent): apply remote replies during capture Aug 7, 2026
@kunchenguid
kunchenguid merged commit d8bb074 into main Aug 7, 2026
14 checks passed
@kunchenguid
kunchenguid deleted the fm/fm-remote-reply-pending-resolve-r1 branch August 7, 2026 04:03
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