Skip to content

ref(chat): unify turn lifecycle - #1567

Open
dcramer wants to merge 18 commits into
mainfrom
codex/unify-turn-lifecycle
Open

ref(chat): unify turn lifecycle#1567
dcramer wants to merge 18 commits into
mainfrom
codex/unify-turn-lifecycle

Conversation

@dcramer

@dcramer dcramer commented Aug 14, 2026

Copy link
Copy Markdown
Member

Conversation Turns now use one shared executeTurn boundary for AgentRunner completion, failure fallback, result commit, and terminal lifecycle across Slack, web, local, dispatch, and agent invocation paths. Source-specific code still owns input durability, Actor, Source, Destination, authorization, delivery, and persistence, and starts each Turn only after its input is durable.

The old api-turns loop is removed. Web ingress and authorization now live with API conversations, web mailbox work delegates to the shared runtime, and the former reply executor is a Slack-owned Turn boundary. Fresh mailbox work routes from its exact Source; empty resume wakes use durable dispatch, invocation, or Turn ownership instead of a fallback chain.

This intentionally makes a hard cutover from the internal api_turn and api-run names to web_message and web-run; there are no compatibility aliases. The main review focus is Turn start/terminal commit ordering and the accepted-output path, where persistence failure must not cause duplicate delivery.

Validated with the full Junior suite (2,657 tests), workspace typechecking, the Junior build, eval harness tests, dependency checks, architecture checks, and the complete pre-push lint suite.

Fixes #1563

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
junior-docs Ready Ready Preview Aug 16, 2026 7:31pm

Request Review

Comment thread packages/junior/src/chat/task-execution/web-work.ts
Comment thread packages/junior/src/chat/agent-invocations/work.ts
Comment thread packages/junior/src/chat/runtime/slack-resume.ts Outdated
Comment thread packages/junior/src/chat/runtime/turn-execution.ts Outdated
@dcramer
dcramer marked this pull request as ready for review August 14, 2026 18:51
@github-actions github-actions Bot added the risk: high PR risk score: high label Aug 14, 2026
@dcramer
dcramer force-pushed the codex/unify-turn-lifecycle branch from a7c647a to aa1a216 Compare August 14, 2026 19:08
Comment thread packages/junior/src/chat/task-execution/web-work.ts Outdated
Comment thread packages/junior/src/chat/task-execution/web-work.ts
Comment thread packages/junior/src/chat/agent-invocations/work.ts
Comment thread packages/junior/src/chat/task-execution/web-work.ts
Comment thread packages/junior/src/chat/local/runner.ts
Comment thread packages/junior/src/api/conversations/web.ts Outdated
Comment thread packages/junior/src/chat/runtime/slack-resume.ts
Comment thread packages/junior/src/chat/task-execution/web-work.ts
Comment thread packages/junior/src/api/conversations/pending-messages.ts
});
await args.lifecycle.complete({
conversationId: args.conversationId,
createdAtMs: Date.now(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancelled web turn leaks active cancellation entry if cleanup throws

Removing the try/finally around turn cleanup allows finish() to be skipped when any step throws, leaving the conversation blocked in the active cancellation map and preventing future turns.

Evidence
  • createWebTurnCancellation stores entries in a local Map; finish() is the only code path that removes them.
  • begin() returns undefined when an entry already exists, permanently blocking new turns for that conversation.
  • The old code wrapped cleanup in try/finally so finish() always ran; the refactored code removed the try/finally entirely.
  • If abandonTurnRecord, deleteWebAuthorization, persistThreadStateById, or lifecycle.complete throws, the function propagates the error without ever reaching finish(), leaking the active entry.
  • In the old code, finish() ran inside finally; in the new code it also runs after acknowledge(), so an acknowledge() throw also skips cleanup.

Identified by Warden · code-review · ZR5-CNH

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fix attempt detected (commit 88b347d)

The commit clearly attempts to release failed cancellations by finishing in the cleanup catch, but an error from acknowledge() still occurs before the final finish() call and can leak the active cancellation entry.

The original issue appears unresolved. Please review and try again.

Evaluated by Warden

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fix attempt detected (commit 6f01b93)

The commit directly refactors cancellation cleanup but still calls finish() only after acknowledge() and without a try/finally, so any cleanup or acknowledgement error can still leak the active cancellation entry.

The original issue appears unresolved. Please review and try again.

Evaluated by Warden

Comment thread packages/junior/src/chat/task-execution/web-cancellation.ts Outdated
Comment thread packages/junior/src/chat/local/runner.ts
Comment thread packages/junior/src/chat/task-execution/web-work.ts
Comment thread packages/junior/src/chat/task-execution/web-work.ts
Comment on lines +496 to +498
// The reply and completed conversation state are already durable. A later
// lifecycle write failure must not rewrite them as a failed Turn.
if (completedStateCommitted) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

completedStateCommitted set too late allows committed state overwrite on checkpoint failure

commitResult persists the completed conversation state before saveTurnCheckpoint, but completedStateCommitted is only set to true after the checkpoint succeeds. If checkpointing fails and lifecycle.fail() also throws, the catch block overwrites the already-durable completed state with a failed turn because the guard evaluates to false.

Evidence
  • commitResult calls persistThreadStateById to make the completed conversation state durable (~line 324), then proceeds to saveTurnCheckpoint (~line 329).
  • completedStateCommitted = true is assigned only after saveTurnCheckpoint returns successfully (~line 338).
  • If saveTurnCheckpoint throws, executeTurn sets commitFailure, calls lifecycle.fail(), and if that call also throws, executeTurn throws into the local catch block.
  • At that point completedStateCommitted is still false even though persistThreadStateById already wrote the completed state.
  • The if (completedStateCommitted) guard at line 496 is intended to prevent rewriting a durable completed state, but it fails here because the flag is set too late than the persistence point.
  • The catch block then calls markTurnFailed and persistThreadStateById, overwriting the committed completed state with a failed turn state.

Identified by Warden · code-review · 5A6-NWZ

Comment on lines +670 to +678
} catch (persistenceError) {
captureWebBoundaryFailure({
conversationId: context.conversationId,
error: persistenceError,
failureCode: "persistence_failed",
runId: currentRunId,
turnId,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Web failure path writes terminal lifecycle event even when turn-state persistence fails

If persistThreadStateById throws after markTurnFailed mutates the conversation, the catch block swallows the error and lifecycle.fail is still called. The task then acknowledges and returns "completed", leaving the DB snapshot stale while the event log records a terminal failure.

Evidence
  • markTurnFailed clears activeTurnId and sets lastCompletedAtMs on the in-memory conversation object.
  • persistThreadStateById is the DB call that makes those mutations durable.
  • When it throws, the catch at line 670 logs the secondary error but does not rethrow or return.
  • Execution falls through to lifecycle.fail at line 680, which appends a terminal turn_failed event.
  • The worker then returns { status: "completed" }, so the task framework will not retry.
  • Callers such as chat/runtime/slack-turn.ts:614 and chat/task-execution/paused-turn.ts:451 read activeTurnId from the persisted DB snapshot, not the event log, so they will observe stale active-turn state.

Identified by Warden · code-review · WMP-5SV

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9490a09. Configure here.

Comment thread packages/junior/src/chat/task-execution/web-work.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: high PR risk score: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unify turn lifecycle outside api-turns

1 participant