Skip to content

fix(daemon): complete delivery at consumption; stop re-injecting consumed rows on model switch - #3420

Closed
lsm wants to merge 1 commit into
devfrom
space/fix-model-switch-re-injection-of-the-last-already-delivered
Closed

lsm wants to merge 1 commit into
devfrom
space/fix-model-switch-re-injection-of-the-last-already-delivered

Conversation

@lsm

@lsm lsm commented Aug 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes the model-switch re-injection loop (#1686): switching models on a running session re-injected the last already-delivered message and retried forever.

Root cause: the message_delivery job treated turn end as its completion boundary. A torn-down turn (model switch, reset) hit classifyTurnCompletion with reopenForRetry: true, reopenDeliveryForRetry flipped the consumed row back to enqueued, and the retried job re-fed it.

Changes

  • driveDeliveryTurn is delivery-only now (agent-session.ts): non-ACP turns complete at the SDK admission ack (markDeliveryBatchConsumed + signalDeliveryConsumed, batch members included); ACP turns complete at markMessageAccepted → onDeliveryTurnAccepted. The turn-end wait loop, spurious-rearm grace, and the recovery-intercepted reclaim that reopened consumed rows are gone. The failure tail (query ended before consumption, invalidated acknowledgment) still classifies and throws recoverable, but never reopens a row that was already acknowledged.
  • QueryLifecycleManager.restart(): cancels in-flight message_delivery jobs, durably requeues unconsumed yielded prompts (MessageQueue.requeueAllYielded), and re-feeds the last consumed message only when the replacement query does not preserve the provider transcript (no sdkSessionId / acpSessionId) — so cross-provider/ACP state drops still re-feed, same-generation restarts do not.
  • QueryLifecycleManager.reset(): same cancel + conditional re-feed for restartAfter: true (ACP reset clears acpSessionId, so the transcript really is dropped there).
  • ACP prompt-loop breaks acknowledge an already-consumed prompt instead of requeueing it.

Tests

  • query-lifecycle-manager.test.ts: restart/reset re-injection cleanup matrix (cancel called; re-feed only when transcript dropped; ACP/SDK preservation).
  • message-queue.test.ts: requeueAllYielded durable requeue, empty-set, and re-yield behavior.
  • Updated pinned suites to the new contract: agent-session (consumption completes delivery; already-consumed reclaims complete without reopening), livelock convergence, transcript harness (no db:recordTurnEnd on the delivery path), admission pipeline.

bun run check green; all 6 daemon shards green (1486 tests, 0 failures).

Note: the delivery-turn stall watchdog now only covers the ACP pre-acceptance window; turn-level wedged-query self-healing for non-ACP sessions was previously a side effect of the delivery job waiting for turn end and is intentionally decoupled along with it.


Devin Review

…ecting consumed rows on model switch (#1686)

Delivery jobs are delivery-only: non-ACP turns complete at the SDK
admission ack and ACP turns at markMessageAccepted, instead of waiting
for turn end and reclassifying. An interrupted turn no longer reopens a
consumed row, which was the re-injection retry loop.

restart()/reset() now cancel in-flight message_delivery jobs, durably
requeue unconsumed yielded prompts, and re-feed the last consumed
message only when the replacement query does not preserve the provider
transcript (no sdkSessionId / acpSessionId). ACP prompt-loop breaks
acknowledge already-consumed prompts instead of requeueing them.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-08-30T05:29:39.976890Z 2d0adb4 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

Comment on lines +459 to +462
const jobQueue = db.getJobQueueRepo?.();
if (jobQueue) {
jobQueue.cancelForSession(session.id);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Reset drops batched user messages

cancelForSession deletes batch metadata before buildRequeueContentFor reads it. A transcript-dropping reset then omits accepted batch members from the replacement conversation.

Prompt for agents
In packages/daemon/src/lib/agent/query-lifecycle-manager.ts, QueryLifecycleManager.reset cancels message_delivery jobs before resolving the last consumed UUID and rebuilding its batch content. cancelForSession deletes the only active job payload containing batchUuids, so buildRequeueContentFor can no longer reconstruct consumed batch members when restartAfter is true and the provider transcript was dropped. Capture the consumed UUID and complete reconstructed content before canceling jobs, as restart already does, then conditionally enqueue that snapshot after cancellation. Add a reset test with a multi-member active batch and no preserved transcript that verifies every admitted member appears in the replay content.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +2493 to +2497
if (alreadyConsumed) {
this.logger.debug(
`delivery-turn: message already accepted (uuid=${messageUuid}); delivery job is complete`
);
started.turnEnd.cancel();
if (started.responseObserver && this.deliveryResponseObserver === started.responseObserver) {
this.deliveryResponseObserver = null;
}
if (!claimGuard || claimGuard()) {
this.reopenDeliveryForRetry(messageUuid);
}
throw new MessageDeliveryRecoverableTurnError('Turn ended without a response');
return { outcome: 'completed' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Completed reclaims retain stale observers

When alreadyConsumed is true, the return bypasses cleanup for the admission's idle waiter and response observer. Later activity can trigger this stale delivery state.

Suggested change
if (alreadyConsumed) {
this.logger.debug(
`delivery-turn: message already accepted (uuid=${messageUuid}); delivery job is complete`
);
started.turnEnd.cancel();
if (started.responseObserver && this.deliveryResponseObserver === started.responseObserver) {
this.deliveryResponseObserver = null;
}
if (!claimGuard || claimGuard()) {
this.reopenDeliveryForRetry(messageUuid);
}
throw new MessageDeliveryRecoverableTurnError('Turn ended without a response');
return { outcome: 'completed' };
if (alreadyConsumed) {
this.logger.debug(
`delivery-turn: message already accepted (uuid=${messageUuid}); delivery job is complete`
);
started.turnEnd.cancel();
if (started.responseObserver && this.deliveryResponseObserver === started.responseObserver) {
this.deliveryResponseObserver = null;
}
return { outcome: 'completed' };
}
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 358 to +375

await this.stop();

const lastConsumedUuid = this.resolveLastConsumedUuid();
const lastConsumedContent = lastConsumedUuid
? this.buildRequeueContentFor(lastConsumedUuid)
: null;
const jobQueue = this.ctx.db.getJobQueueRepo?.();
if (jobQueue) {
const cancelledUuids = jobQueue.cancelForSessionWithMessages(session.id);
if (cancelledUuids.length > 0) {
this.logger.debug(
`cancelled ${cancelledUuids.length} in-flight message_delivery job(s) for session ${session.id}`
);
}
}
this.ctx.messageQueue.requeueAllYielded({ durable: true });
this.reenqueueLastConsumedIfTranscriptDropped(lastConsumedUuid, lastConsumedContent);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Lifecycle cleanup bypasses pipeline convention

restart and reset add imperative transcript-replay flows. Repository guidance requires new daemon business paths to use one direct superpipe pipeline where applicable.

(Refers to this code)

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d0adb4ded

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +124 to +126
const { messages } = repo.getUserMessagesByStatus(session.id, 'consumed', 1, 'desc');
const last = messages[0];
return last?.uuid ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve the active batch from any consumed member

When a transcript-dropping restart follows a batched delivery, resolveLastConsumedUuid() can select the newest batch member rather than the kickoff because markDeliveryConsumedByUuids() gives the members a shared timestamp and the query breaks ties by descending rowid. However, getActiveDeliveryBatchUuids() only matches the job's kickoff messageUuid, so this lookup returns no batch for a selected member and the replacement query receives only that member's content, silently omitting the rest of the accepted batch. The same loss occurs if the now-consumption-complete job is removed before this lookup; preserve the batch before completion/cancellation or resolve it by membership.

Useful? React with 👍 / 👎.

Comment on lines +602 to +604
for (const message of Array.from(this.yielded)) {
if (this.requeueYielded(message.id, options)) {
requeued.push(message.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve FIFO order when requeueing yielded messages

When more than one message has been yielded but not acknowledged during a restart, iterating the Set in yield order while requeueYielded() prepends each entry reverses them (A, B becomes B, A). The replacement provider therefore receives pending user messages in the opposite order, which can change their meaning; iterate the snapshot in reverse or otherwise prepend the group while retaining FIFO order.

Useful? React with 👍 / 👎.

Comment on lines +459 to +462
const jobQueue = db.getJobQueueRepo?.();
if (jobQueue) {
jobQueue.cancelForSession(session.id);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Requeue unaccepted deliveries before canceling reset jobs

When a config-triggered reset({ restartAfter: true }) races a prompt that has been yielded or submitted but not accepted by the provider, messageQueue.clear() has already removed its in-memory admission and this new cancellation deletes its durable delivery job. Unlike restart(), this path never calls requeueAllYielded; a pre-yield callback may also have marked the row consumed even though the aborted SDK never committed it, causing transcript-preservation checks to skip re-enqueue and leaving no reconciler-visible enqueued row. The replacement query can therefore silently omit the user's current prompt.

Useful? React with 👍 / 👎.

Comment on lines +2493 to +2497
if (alreadyConsumed) {
this.logger.debug(
`delivery-turn: message already accepted (uuid=${messageUuid}); delivery job is complete`
);
started.turnEnd.cancel();
if (started.responseObserver && this.deliveryResponseObserver === started.responseObserver) {
this.deliveryResponseObserver = null;
}
if (!claimGuard || claimGuard()) {
this.reopenDeliveryForRetry(messageUuid);
}
throw new MessageDeliveryRecoverableTurnError('Turn ended without a response');
return { outcome: 'completed' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release admission resources before completing consumed reclaims

For an already-consumed reclaim, runDeliveryTurnAdmission() has installed a turn-end waiter and may have armed deliveryResponseObserver, but this return occurs before the common finally that cancels and disarms them. Each reclaimed job can consequently leave an idle-transition waiter behind and attribute a later SDK response to an already-completed delivery observer; explicitly release started.turnEnd and started.responseObserver before returning, as the removed reclaim branch previously did.

Useful? React with 👍 / 👎.

Comment on lines +374 to +375
this.ctx.messageQueue.requeueAllYielded({ durable: true });
this.reenqueueLastConsumedIfTranscriptDropped(lastConsumedUuid, lastConsumedContent);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict transcript-drop replay to the interrupted delivery

When a persistent query is idle after a completed turn and the user switches across the ACP/non-ACP boundary, the provider session ID is cleared before restart(), so this unconditional global-last-consumed lookup re-enqueues the previous prompt even though no delivery was interrupted. The new provider then answers an already-completed user message again and may repeat its tool side effects. Use the canceled active-job UUIDs or the yielded/pending queue snapshot to decide whether a consumed row actually needs recovery instead of replaying the latest historical row whenever the transcript is absent.

Useful? React with 👍 / 👎.

@lsm

lsm commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

Closing as superseded: the same fix landed re-sliced as MDR 3/N (#3436). Branch retained.

@lsm lsm closed this Sep 15, 2026
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