Skip to content

fix(coding-agent): keep retried agent messages on one supervisor journal key - #892

Open
Hotragn wants to merge 1 commit into
PrimeIntellect-ai:mainfrom
Hotragn:fix/821-agent-message-retry-idempotency
Open

fix(coding-agent): keep retried agent messages on one supervisor journal key#892
Hotragn wants to merge 1 commit into
PrimeIntellect-ai:mainfrom
Hotragn:fix/821-agent-message-retry-idempotency

Conversation

@Hotragn

@Hotragn Hotragn commented Aug 7, 2026

Copy link
Copy Markdown

Fixes #821.

The supervisor already has durable idempotency for mutating daemon commands: CommandRecoveryJournal keys completed results on createCommandIdempotencyKey(clientId, commandId) and replays the stored response instead of re-executing. send_message is mutating, so it is covered in principle.

DaemonMode.sendRemoteAgentSessionMessage — the one loop in the tree that re-sends send_message across the accept window — steps outside it. It constructs a new DaemonClient on every attempt, and both halves of the journal key are per-instance:

private requestId = 0;
private readonly protocolClientId = `daemon-client:${randomUUID()}`;

So each retry presented a different key for the same logical message. The journal could not recognize the duplicate, acceptAgentSessionMessage performs no dedupe of its own, and the target received N independent steering prompts under N distinct agentmsg_ ids.

The window is real rather than theoretical. DaemonClient.request times out locally and sends no cancellation, so a timeout does not mean the supervisor declined the message — it may already have accepted and enqueued it. A socket drop mid-response or a supervisor restart has the same shape. The existing receivedResponse guard correctly refuses to retry once a response was parsed, but that is precisely the case this window excludes.

Change

Let a caller pin the identity, and have the one loop that needs it do so:

  • DaemonClient takes an optional clientId, used in place of the per-instance uuid.
  • DaemonClientRequestOptions takes an optional commandId, used in place of daemon_${++this.requestId}.
  • sendRemoteAgentSessionMessage mints one clientId + commandId per logical send, before the retry loop, and passes them to every attempt.

Behaviour after the change:

  • Retry after the supervisor completed the command: the journal reports complete and replays the recorded receipt. Exactly one message is enqueued and the caller gets the real receipt rather than an error.
  • Retry that races an attempt still in flight: the journal reports pending and the supervisor answers command_result_uncertain. That is a definite answer instead of a silent duplicate, which is what the journal was built to provide.
  • Retry where nothing reached the supervisor (connect/waitForHello failed, the common restart case): no journal entry exists, so the attempt proceeds normally. Unchanged.
  • The journal file is append-only and reloaded on construction, so the key also survives a supervisor restart.

Callers that pass neither option keep a fresh identity per client, so every other DaemonClient consumer is unchanged — this is opt-in, not a global behaviour change. Reusing a command id while it is still in flight on the same client would clobber that client's pending-request map, so it is rejected with a clear error.

No protocol or schema change: clientId and id are existing envelope fields, and this only controls what is put in them.

Tests

packages/coding-agent/test/suite/regressions/821-agent-message-retry-idempotency.test.ts (4 cases), using the same mocked-socket harness as daemon-client.test.ts so it needs no real daemon:

  • Two separate DaemonClient instances with a pinned identity write envelopes whose clientId and id are identical, and createCommandIdempotencyKey over the two envelopes produces the same key — the defect stated directly.
  • Two unpinned clients still produce different keys, proving the default is untouched.
  • Driving the real CommandRecoveryJournal with the two captured identities: the first begin is new, the recorded receipt is stored, and the retry's begin returns complete with that same receipt instead of admitting a second command. The same test then shows an unpinned identity would have been admitted as new — the duplicate steering prompt.
  • Reusing an in-flight command id on one client rejects.

Verified: npm run check clean; the new suite plus daemon-client.test.ts pass (33). command-recovery-journal.test.ts has one pre-existing Windows-only failure (EPERM on directory fsync, tracked as #666) that does not touch this path.

Note

Fix duplicate steering prompts on retried agent messages by pinning supervisor journal keys

  • When a send_message transport call fails after the supervisor has already accepted the message, retries previously enqueued duplicate steering prompts. This fix pins a stable clientId and commandId for the duration of each send_message operation so all retry attempts share the same supervisor journal key and replay the recorded receipt instead.
  • DaemonClient constructor now accepts an optional clientId to pin the protocol client identity, and DaemonClient.request accepts an optional commandId to pin the wire command id.
  • The client rejects a request if the chosen commandId is already in-flight on the same client instance, preventing accidental duplicate submissions.
  • A regression test suite in 821-agent-message-retry-idempotency.test.ts covers pinned and default identity behavior, supervisor journal replay, and in-flight duplicate rejection.

Macroscope summarized 11e6916.

…nal key

The supervisor journals mutating daemon commands under
[clientId, commandId] and replays a completed result instead of
re-executing. DaemonMode.sendRemoteAgentSessionMessage, the one loop that
re-sends send_message across the accept window, builds a new DaemonClient per
attempt. Both halves of that key are per-instance: protocolClientId is a fresh
uuid and the request counter restarts at 0. Every retry therefore presented a
different key for the same logical message, the journal could not recognize
the duplicate, and acceptAgentSessionMessage has no dedupe of its own, so the
target received N independent steering prompts under N distinct agentmsg ids.

The window is real: request() times out locally without sending any
cancellation, so a timeout does not mean the supervisor declined the message.
A socket drop mid-response or a supervisor restart has the same shape.

Let a caller pin the identity: DaemonClient accepts a clientId, and request()
accepts a commandId. sendRemoteAgentSessionMessage mints both once per logical
send and reuses them for every attempt, so a retry after the supervisor
already accepted replays the recorded receipt, and a retry that races an
in-flight attempt gets command_result_uncertain rather than a duplicate.
Callers that pass neither keep a fresh identity per client, unchanged. A
command id reused while still in flight on one client is rejected.

fixes PrimeIntellect-ai#821
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.

sendRemoteAgentSessionMessage retries with a fresh DaemonClient per attempt, bypassing the supervisor command journal and duplicating steering prompts

1 participant