Skip to content

fix(cli): settle acknowledged steers when their target turn stops - #668

Closed
yetuge wants to merge 4 commits into
LodyAI:mainfrom
yetuge:fix/cli-steer-settles-with-stopped-turn
Closed

yetuge wants to merge 4 commits into
LodyAI:mainfrom
yetuge:fix/cli-steer-settles-with-stopped-turn

Conversation

@yetuge

@yetuge yetuge commented Sep 13, 2026

Copy link
Copy Markdown

Related issue

Closes #666

Problem / pressure

A steer submitted while a turn is running must not outlive its target turn, but steerSessionLocked waited for the agent's application verdict with no bound tied to that turn:

  • Request-transport runtimes settle the verdict only when the agent answers the held extension request or the connection closes — stopping the turn aborts neither — so after a Stop the await hung forever: the steer response never resolved, the guide stayed in pending_apply (a status ordinary dispatch deliberately skips), and the per-session steer mutation queue stayed blocked behind the hung await.
  • Prompt-transport runtimes reject the verdict directly when the stopped prompt rejects, which fell into the generic error disposition that also left the history entry untouched.

Either way the user's message was stranded exactly as described in #666: absent from the provider conversation, absent from ordinary dispatch, with no terminal state.

Summary

steerSessionLocked now races steerRun.applied against the target turn's prompt-run settlement (the prompt run is the cancellation owner, so its settlement is the structural bound — no arbitrary timeout):

  • When the turn settles first, the guide is settled to a terminal history status via the existing setTerminalUserTurnStatus (canceled when a stop was requested, failed when the turn ended without an answer) and the response is stale-turn. The catch path applies the same settlement when a post-submission stop rejects the verdict, so both transports converge.
  • Confirmed refusals (AgentSteerNotDeliveredError) and pre-submission rejections keep the existing requeue-to-dispatch behavior unchanged; the terminal path only covers the unknown post-submission window.
  • Unknown delivery results are deliberately terminal rather than re-queued: the provider may still take the held request, and replaying could duplicate it. The application waiter stays registered in the agent client until the agent answers or the connection closes, so a late verdict cannot resurrect the stopped turn.
  • A bilingual Agent Note (implemented/bug-fix/2026-09-13-steer-settles-with-stopped-turn + .zh.md) records the decision per AGENTS.md.

Visual explanation

flowchart TD
    A["steerSessionLocked<br/>submits steerPrompt"] --> B{"Promise.race"}
    B -- "verdict resolves" --> C["existing application path<br/>(ownership handoff, unchanged)"]
    B -- "verdict rejects:<br/>AgentSteerNotDeliveredError" --> D["requeue to ordinary dispatch<br/>(confirmed refusal, unchanged)"]
    B -- "prompt run settles first<br/>(stop or end)" --> E["terminal settlement:<br/>canceled if stop requested, else failed;<br/>response stale-turn"]
    B -- "verdict rejects otherwise<br/>+ stop requested" --> E
    B -- "verdict rejects otherwise,<br/>no stop" --> F["error disposition<br/>(unchanged)"]
Loading

Simple-change justification: the diff is 347 changed lines but they are one control-flow change in a single function plus its regressions; the Mermaid view above is the complete decision surface.

Before / after

Before After
Stop with a held steer request: steerSession never returns, guide stays pending_apply, steer mutation queue blocked Response resolves stale-turn, guide settled canceled, queue unblocked
Prompt-transport stop rejects the verdict: error disposition, entry untouched Same terminal settlement as above
Confirmed refusal / pre-submission rejection Unchanged (requeue to dispatch)
Late application after stop (verdict arrives later) Waiter resolves unconsumed; no resurrection

Test plan

  • npx vitest run tests/session-execution-service.test.ts (apps/cli): 111 passed, including two new regressions that reproduce [Bug] Timed-out steer remains pending_apply after its target turn is stopped #666 — a held verdict plus a stopped turn now resolves stale-turn and writes the canceled status (previously the test timed out at 30s with the response pending), and a rejected verdict on a stopped turn converges to the same settlement (previously error with no history write). Both new tests fail on the parent commit.
  • Full apps/cli suite with and without the change: identical failure set (88 FAIL lines, name-by-name; 61 failing tests are pre-existing on this Windows machine — real-process login flows, native binary downloads, better-sqlite3-adjacent suites), 0 new / 0 disappeared; the with-change run additionally passes the two new tests.
  • oxlint (repo gate is --quiet): 0 errors on both changed files; prettier --check clean; pnpm run docs status / docs check output for the two new notes shows status: implemented, translation: current, missingTranslation: false. pnpm run typecheck in apps/cli reports the same three pre-existing cross-package module-resolution errors as the parent commit (untouched packages: @lody/shared/platform-kind subpath, better-sqlite3 types).

Context handoff

Instructions for reviewing agents

  • Review focus: steerSessionLocked in apps/cli/src/session/session-execution-service.ts — the Promise.race bound and the two settlement branches (post-race and catch); confirm the requeue path for AgentSteerNotDeliveredError and the pre-submission rejections are untouched.
  • Decisions to challenge: terminal-instead-of-requeue for unknown post-submission results (duplicate-delivery risk vs. visibility); marking a non-stop settlement failed; leaving the agent-client waiter registered until answer/connection close.
  • Plausible failures / evidence gaps: verified against the service contract and mocked agent clients only — no live Codex/Claude adapter run; the failed status for a non-stop settlement is a judgment call, not a documented product decision.

Authoring context

  • User goal / directives: automated contribution workflow: pick an unclaimed agent-core issue, verify the root cause at current HEAD, fix minimally with regression tests, and open a focused PR.
  • Constraints / non-goals: no behavior change for confirmed refusals, pre-submission rejections, or the application path; no timeout introduced; no agent-client protocol changes.
  • Risk-bearing decisions: terminal statuses replace an infinite wait; if a provider later applies a steer after its turn was stopped, the history shows canceled while the agent may execute — matching the report's requirement that a late result must not resurrect stopped state.
  • Destructive or irreversible behavior: none; history entries only move from pending_apply to a terminal status.
  • Deliberately not done or tested: live adapter verification (no provider credentials on the authoring machine); no changes to agentClient.steerPrompt abort wiring.
  • Unknowns / confidence: high confidence in the response bound and settlement; medium on the failed-vs-canceled distinction for non-stop settlements.

Original user prompt

Show original prompt
按 G:\agent学习资料\pr\定时任务提示词.md 完整执行一轮

(Verbatim triggering instruction. It references a local scheduled-task prompt file that directs an
automated run: follow up on tracked pull requests, then — when quality gates pass — claim one
unclaimed issue in an agent-core TypeScript repository, fix it with regression tests, and open a
pull request. This PR is that run's output for issue #666.)

A steer waited for the agent's application verdict with no bound tied to
the turn: request-transport runtimes only settle that verdict when the
agent answers the held extension request or the connection closes, so
stopping the target turn left steerSessionLocked awaiting forever - the
guide stayed in pending_apply (which ordinary dispatch deliberately
skips) and the per-session steer mutation queue stayed blocked. A
prompt-transport stop instead rejected the verdict into the generic
error disposition, which also left the entry untouched.

steerSessionLocked now races the verdict against the target turn's
prompt-run settlement. When the turn settles first, the guide gets a
terminal history status (canceled on stop, failed when the turn ended
without an answer) and the response is stale-turn; the catch path
applies the same settlement for rejected verdicts. Confirmed refusals
and pre-submission rejections keep the existing requeue behavior, and
unknown delivery results are deliberately terminal rather than
re-queued - the provider may still take the held request, and replaying
could duplicate it. The application waiter stays registered until the
agent answers or the connection closes, so a late verdict cannot
resurrect the stopped turn.

Fixes LodyAI#666

@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: 855eced8e4

ℹ️ 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 +1523 to +1528
const application = await Promise.race([
steerRun.applied,
ownedPromptRun.promptOutcome.then(
() => 'prompt-run-settled' as const,
() => 'prompt-run-settled' as const
),

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 Preserve provable refusals after the settlement race

When a request-transport steer’s target prompt settles before its _session/steering request answers, this race selects prompt-run-settled even if the later answer is AgentSteerNotDeliveredError. AgentClient.steerPrompt explicitly allows this ordering because an upstream turn response can arrive before the steer request’s refusal; terminalizing the guide here loses a provably unaccepted message instead of requeueing it for ordinary dispatch.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L60-L61

Useful? React with 👍 / 👎.

Comment on lines +1540 to +1545
return reject(
'stale-turn',
runtime.cancelRequested
? 'The target turn was stopped before the agent answered the steer'
: 'The target turn ended before the agent answered the steer'
);

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 Release the lease from a late steer acknowledgement

When an application notification arrives after this branch returns, AgentClient.handleSteerApplied resolves steerRun.applied with a lease, installs waiter.released as steerApplicationBarrier, and waits for lease.release(). Because the losing race result is no longer consumed, nothing releases that lease, so the barrier remains pending and every subsequent sessionUpdate for this session blocks indefinitely; the late lease must be consumed and released without transferring ownership.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L58-L59

Useful? React with 👍 / 👎.

The settlement race answers the steer RPC and terminalizes the guide
without waiting for the held request's verdict, but it also dropped that
verdict entirely. Two consequences flagged in review:

- An agent-issued refusal (`AgentSteerNotDeliveredError` is only produced
  by the agent's own invalid-request answer) arrived after the terminal
  write and was lost, even though a provably undelivered guide belongs
  back in ordinary dispatch.
- A late acceptance resolved `steerRun.applied` with a lease nobody
  consumed: `handleSteerApplied` had already installed `waiter.released`
  as the session's application barrier and blocks every later
  `sessionUpdate` until that lease is released, so the session's update
  stream wedged.

The race branch now attaches a non-blocking verdict consumer after the
terminal write: a late acceptance is released immediately, and a late
refusal requeues the guide by flipping back only the terminal status that
same branch wrote, behind the ordinary requeue guards (not active, not
already handled). Ambiguous rejections such as a closed connection keep
the terminal status, and a taken steer is never replayed.
@yetuge

yetuge commented Sep 13, 2026

Copy link
Copy Markdown
Author

Both findings confirmed as real — thank you for the precise analysis. Fixed in 3aa4fbc.

1. Preserve provable refusals after the settlement race

The race branch now attaches a non-blocking consumer to the pending verdict right after the terminal write (the response itself still never waits on the held request, so the original stranding stays fixed). When the verdict later rejects with AgentSteerNotDeliveredError — which the agent client only produces from the agent's own invalid-request answer, so it is a proof of non-delivery — a new requeueSteerAfterLateRefusal returns the guide to ordinary dispatch: it flips back the terminal status that this same race branch wrote (no other path writes canceled/failed over a steer entry whose verdict is still pending), then re-walks the ordinary requeue guards (not the active turn, not already handled) and rewrites the latestUserMsgId pointer. Ambiguous rejections (closed connection, dead process) and late acceptances keep the terminal status, so nothing that may have been taken is ever replayed.

2. Release the lease from a late steer acknowledgement

The same consumer releases a late acceptance's lease immediately: steerRun.applied.then((lease) => lease.release()). handleSteerApplied installs waiter.released as steerApplicationBarrier before the consumer runs, so the window in which later sessionUpdates block is bounded to microtask ordering — the barrier is cleared as soon as the release resolves, and ownership is not transferred (the guide stays terminal; no replay of a taken steer).

Regression tests (new, in tests/session-execution-service.test.ts):

  • requeues a settled steer whose late verdict proves the agent refused it — fails on 855eced (entry stays canceled, no pointer write), passes now (entry back to pending + latestUserMsgId rewritten).
  • releases the lease of a steer acknowledgement arriving after settlement — fails on 855eced (release never called → the barrier would wedge every later sessionUpdate), passes now.

Full apps/cli suite: 2477 tests, 61 failures — all identical to the pre-existing Windows environment failures recorded at the parent commit (list in CI logs matches by name; zero new failures). oxlint 0 errors, prettier clean, docs check reports no new findings (the pre-existing apps/cli/src/session/AGENTS.md size-gate warning is unchanged), and both language versions of the implementation note were updated to replace the "late verdict resolves into a promise nobody consumes" assumption this review correctly refuted.

@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: 3aa4fbc3d3

ℹ️ 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 +1795 to +1796
if (!queueable) {
return;

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 late refusals before history synchronization

When the steer’s history entry has not yet reached this daemon as the late AgentSteerNotDeliveredError arrives—the supported RPC-before-history ordering—queueable remains false and this return leaves the terminal-without-entry record intact without publishing latestUserMsgId. Although the refusal proves that the agent never accepted the message, the watcher subsequently treats the late entry as terminal instead of dispatching it, permanently stranding the guide; requeue via its pointer even while the entry is absent, as the ordinary refusal path does.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L58-L61

Useful? React with 👍 / 👎.

Comment on lines +1799 to +1802
await this.upsertSessionMeta(sessionId, {
latestUserMsgId: userTurnId,
lastMissingHistoryUserMsgId: undefined,
});

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 Preserve newer activations when requeueing a late refusal

If another user turn is published while the held steer request remains unanswered, this late callback unconditionally replaces that newer turn’s latestUserMsgId with the older refused steer. After the chronological history scan dispatches the older steer, lastHandledUserMsgId can equal this overwritten pointer, making hasPendingUserTurnActivation false and allowing the watcher to unload while the newer entry remains pending and never runs; the requeue must avoid replacing an activation published after this steer.

Useful? React with 👍 / 👎.

Two review findings on the late-refusal requeue path:

- When the steer's history entry has not synced to this daemon (the
  supported RPC-before-history ordering), `queueable` stayed false and
  the early return kept the race branch's terminal-without-entry record
  without publishing the pointer, so the late-syncing entry was repaired
  back to terminal and the guide stranded. The refusal proves
  non-delivery, so the entry-absent case now requeues through the
  pointer like the ordinary refusal path does and drops the stale
  record; the late-syncing entry dispatches via the `pending_apply`
  pointer match in `findNextDispatchableUserTurn`.
- Publishing the pointer unconditionally could replace a newer
  activation published while the steer request was held. After the
  chronological scan dispatched the requeued steer,
  `lastHandledUserMsgId` equaled the overwritten pointer, the watcher
  unloaded, and the newer turn never ran. The pointer write now treats
  the slot as occupied while `latestUserMsgId` names a live activation
  (the same suppression slots dispatch uses), and leaves the scan that
  activation keeps running reach the requeued steer.
@yetuge

yetuge commented Sep 13, 2026

Copy link
Copy Markdown
Author

Both findings verified and fixed in 78f90ed:

1. Requeue before history synchronization — correct. When the steer's history entry has not synced to this daemon, queueable stayed false and the early return left the race branch's terminal-without-entry record in place without publishing the pointer, so the late-syncing entry was repaired back to terminal and the guide stranded. The requeue now covers the entry-absent case: it requeues through the pointer exactly like the ordinary refusal path treats a missing entry, and drops the stale record so the late-syncing entry dispatches via the pending_apply pointer match in findNextDispatchableUserTurn instead of being repaired to terminal.

2. Preserve newer activations — correct, and it is the same rule setDispatchProcessing already documents for execution-side writes (execution claims only its own processingUserMsgId slot). The pointer publish now treats the slot as occupied while latestUserMsgId names a live activation — anything that is not this steer itself and is not retired by lastHandledUserMsgId, lastMissingHistoryUserMsgId, or settledActivationUserMsgId — and leaves it alone. The flipped pending entry is then picked up by the chronological scan that the newer activation keeps running, and a not-yet-synced entry keeps steer intent instead of destroying the newer turn's only activation signal.

Tests: five new cases — entry absent → pointer requeue + stale record cleared; entry absent while a newer activation holds the pointer → no pointer write, record still cleared; entry present under a live newer activation → flip to pending without a pointer rewrite; pointer naming an already-handled turn (consumed activation) → rewrite still happens; pointer retired by settledActivationUserMsgId → rewrite still happens. The first three fail on the previous head.

@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: 78f90edb61

ℹ️ 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 +1825 to +1829
if (pointerHoldsNewerActivation) {
this.deps.logger.info(
`[${sessionId}] Steer ${userTurnId} refused after its turn ended; left for the dispatch scan while activation ${pointer} holds the pointer`
);
return;

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 Retain activation for an unsynced refused steer

When the refused steer entry is still absent and latestUserMsgId names a newer activation, this branch clears the terminal-without-entry record and returns without giving the refused steer any durable activation. When its pending_apply entry later syncs, findNextDispatchableUserTurn skips it because the pointer still names the newer turn; after that turn is handled, the watcher can unload and the proven-undelivered guide remains stranded. Preserve a durable way to activate this steer after the newer activation instead of relying on the dispatch scan.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L58-L61

Useful? React with 👍 / 👎.

Comment on lines +1831 to +1834
await this.upsertSessionMeta(sessionId, {
latestUserMsgId: userTurnId,
lastMissingHistoryUserMsgId: undefined,
});

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 Recheck the dispatch pointer after awaited history work

A user can publish a newer turn after the meta snapshot at line 1775 but while getOrCreateSessionDoc or updateHistory is awaited; this write then replaces that newer latestUserMsgId with the older refused steer. After the chronological scan runs the older entry, lastHandledUserMsgId equals the overwritten pointer and the watcher may unload with the newer turn still pending. Fresh evidence in this revision is that the newer-activation guard is computed before two awaited operations, so it does not address publications during the read-to-write window.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L53-L57

Useful? React with 👍 / 👎.

Comment on lines +1831 to +1834
await this.upsertSessionMeta(sessionId, {
latestUserMsgId: userTurnId,
lastMissingHistoryUserMsgId: undefined,
});

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 Preserve unrelated missing-history tombstones

When lastMissingHistoryUserMsgId names an older turn rather than this refused steer, this patch clears that permanent negative acknowledgement unconditionally. If the older payload subsequently syncs with a pending status, findNextDispatchableUserTurn no longer excludes it and can execute a turn whose delivery failure was already surfaced, potentially duplicating a user resend. Only clear the tombstone when it names userTurnId; otherwise carry it through the pointer update.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L33-L37

Useful? React with 👍 / 👎.

…tles

Three review findings on the late-refusal requeue:

- The pointer guard ran on the meta snapshot taken before the awaited
  history work, so a newer send publishing its activation while the flip
  to `pending` was awaited still got clobbered. The decision now runs on
  a meta re-read after the history work — the session contract forbids
  read-await-rewrite of producer-owned slots.
- The requeue dropped the race branch's terminal-without-entry record
  before checking the pointer, so an entry that had not synced with a
  live activation owning the pointer kept nothing durable: the
  late-syncing entry stranded as undispatchable `pending_apply`. That
  corner now keeps the record so the late entry is repaired to its
  terminal status — visible non-delivery, the same terminal settlement
  the race branch already reported — instead of relying on a dispatch
  scan that can never see it.
- The pointer write cleared `lastMissingHistoryUserMsgId`
  unconditionally, destroying the permanent one-shot ack for an older
  turn and re-admitting it once its payload synced. The tombstone is
  now cleared only when it names the requeued steer itself.

The bilingual agent note describes the revised requeue semantics.
@github-actions github-actions Bot added the status:needs-pr-attention External PR needs contributor attention before review label Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@yetuge, this pull request needs updates before review.

It is marked status:needs-pr-attention. Address the findings below by 2026-09-20 20:55:52 UTC. The label and this comment are removed automatically after the PR passes validation.

If the PR remains invalid for 7 days, it will be closed and marked status:pr-policy-expired. Continue afterward by opening a new pull request with the current template.

Policy findings
PR does not meet Lody contribution requirements:

- PR changes 1146 lines; community PRs over 1000 lines require a maintainer assignment on the linked Issue before review.

See `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md`.

@yetuge

yetuge commented Sep 13, 2026

Copy link
Copy Markdown
Author

All three findings verified and fixed in 7fc09af:

1. Retain activation for an unsynced refused steer — true. With the pointer owned by a live activation and the entry still absent, dropping the record left nothing durable, and a late-syncing pending_apply entry dispatches only through a pointer match, so the proven-undelivered guide stranded. That corner now keeps the terminal-without-entry record: the late entry is repaired to its terminal status — visible non-delivery, the same terminal settlement the race branch already reported — instead of stranding as steer intent nothing dispatches. Auto-activating it instead would require a queued-activation slot in session meta (schema + hasPendingUserTurnActivation + watcher-unload coordination); we deliberately did not introduce that unilaterally and are happy to follow up if you consider it warranted.

2. Recheck the dispatch pointer after awaited history work — true, and the guard was worse than merely late: computed before two awaits, it was exactly the read-await-rewrite the session contract forbids. The pointer decision now runs on a meta re-read after updateHistory settles (with a fresh lastHandledUserMsgId === userTurnId re-check), so publications during the awaited history work are observed. The residual window between that re-read and the upsertSessionMeta patch is the same one requeueUndeliveredSteer — the ordinary refusal path — already operates within; eliminating it entirely needs a compare-and-swap in the meta repo layer, which we did not unilaterally add.

3. Preserve unrelated missing-history tombstones — true. The patch now includes lastMissingHistoryUserMsgId: undefined only when it names the requeued steer itself; otherwise the key is omitted and the older turn's permanent one-shot ack survives (the meta store deletes on an explicit undefined and no-ops on an omitted key).

Regression tests (the first three fail on the previous head, all four pass now): absent entry under a live activation keeps the record; an activation published inside the awaited history write is observed by the pointer decision; a tombstone naming an older turn survives the pointer write; a tombstone naming the requeued steer itself is still cleared. The file's suite is 121/121; the full apps/cli run shows no new failures beyond the known Windows-environment flake family (the CodeCollabV2Service temp-dir EBUSY/better-sqlite3 set drifts run-to-run and is untouched by this commit); tsgo/oxlint/prettier are clean on the touched files. The bilingual agent note now describes the revised requeue semantics.

One scope note, consistent with the previous reply: requeueUndeliveredSteer shares both the unconditional tombstone clear and the read-await-rewrite shape. As before, we kept it untouched as pre-existing ordinary-refusal semantics and leave promoting the fix there to you.

@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: 7fc09af51a

ℹ️ 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 +1534 to +1538
await this.setTerminalUserTurnStatus(
options.sessionId,
sessionDoc,
options.userTurnId,
runtime.cancelRequested ? 'canceled' : 'failed'

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 Persist terminal settlement before returning stale-turn

When the supported RPC-before-history ordering leaves this steer entry absent, setTerminalUserTurnStatus records the cancellation only in the in-memory terminalTurnStatusWithoutEntry map. If the daemon restarts before history synchronizes, that record disappears; the later pending_apply entry still matches latestUserMsgId, so findNextDispatchableUserTurn dispatches it normally, potentially duplicating a steer the provider accepted after the stop. Persist a durable terminal/activation marker before returning.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L58-L59

Useful? React with 👍 / 👎.

Comment on lines +1854 to +1857
this.deps.logger.info(
`[${sessionId}] Steer ${userTurnId} refused after its turn ended; activation ${pointer} holds the pointer, keeping the recorded terminal status for the late entry`
);
return;

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 the refused steer behind the newer activation

When the steer entry is still unsynced and another turn owns latestUserMsgId, this branch keeps the guide terminal even though AgentSteerNotDeliveredError proves the agent rejected it. The late entry is therefore repaired to canceled and the user's guide is never executed; a durable queued activation is needed rather than replacing the newer pointer. Fresh evidence in this revision is that this new branch explicitly preserves the terminal record and returns for that case.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L60-L61

Useful? React with 👍 / 👎.

@yetuge

yetuge commented Sep 13, 2026

Copy link
Copy Markdown
Author

Heads-up after pushing 7fc09af: the reconcile-policy check now reports the PR at 1146 changed lines, which per CONTRIBUTING.md needs a maintainer assignment on the linked issue (#666) before review. Could a maintainer assign #666 (or grant an exception) so this can proceed through review? The three review rounds so far are all addressed in the latest commits.

@yetuge

yetuge commented Sep 14, 2026

Copy link
Copy Markdown
Author

Both findings verified against 7fc09af. I have deliberately not pushed a fix yet, and I would rather explain why than leave them unaddressed.

1. Persist terminal settlement before returning stale-turn — confirmed real. The race branch's canceled/failed write lands in terminalTurnStatusWithoutEntry, an in-memory per-daemon map. If the daemon restarts before the entry syncs while latestUserMsgId still names the steer, the late pending_apply entry matches the pointer, so findNextDispatchableUserTurn dispatches it again — a second submission of a guide the provider may already have taken before the stop. That is a durability gap, not a style point.

2. Requeue the refused steer behind the newer activation — also confirmed, and it is the mirror image of the previous round's finding #1. Dropping the record stranded the guide (previous round); keeping it leaves the guide terminal forever whenever another activation owns the pointer (this round). Both behaviours the single latestUserMsgId slot can express are now shipped, and each leaves a gap — because a pending_apply entry is dispatchable only through a pointer match.

Closing either finding therefore needs a second, durable channel: a queued-activation slot for a refused steer, or a durable terminal marker for the race branch. Either way it is a new field in the shared SessionMeta (packages/shared/src/schema.ts), its getPendingUserTurnActivationId / hasPendingUserTurnActivation predicate — documented as the ONLY pending-turn predicate — and the corresponding watcher-unload coordination. That is a contract change, not a bug fix contained in session-execution-service.ts, so I would rather the maintainers choose the direction than pick one unilaterally.

There is also a practical constraint: the PR is at 1146 lines and reconcile-policy requires a maintainer assignment on #666 before review, so every additional commit deepens that violation.

If a maintainer confirms the direction, I can prepare the durable queued-activation slot (schema + predicate + watcher lifecycle + regression tests) on top of this branch. Alternatively, if you would rather keep the slot ownership exactly as documented in apps/cli/src/session/AGENTS.md, I can persist the race branch's terminal marker instead and leave the pointer contract untouched. Happy to follow whichever you prefer.

@yetuge yetuge closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: cli status:needs-pr-attention External PR needs contributor attention before review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Timed-out steer remains pending_apply after its target turn is stopped

1 participant