ref(chat): unify turn lifecycle - #1567
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
a7c647a to
aa1a216
Compare
aa1a216 to
dccb17d
Compare
dccb17d to
c7f8406
Compare
a67b1f9 to
d561e31
Compare
88b347d to
41d06f7
Compare
| @@ -905,17 +911,6 @@ async function resumeSlackTurnInContext( | |||
| } | |||
|
|
|||
| if (postDeliveryCommitError) { | |||
There was a problem hiding this comment.
Removed turnLifecycle.fail fallback leaves thrown commit errors unterminated
When the commitResult callback throws before returning a failure — for example if status.clear() or onPostDeliveryCommitFailure throws — executeTurn does not write a terminal event, and the removed fallback no longer ensures the turn reaches a failed state. This leaves the turn incomplete in the event store with no terminal lifecycle.
Evidence
commitResultcallback setsrunResultHandled = truefirst, then callsawait status.clear()outside the try/catch at line ~701, so a Slack status-write error propagates out of the callback.- Inside the callback catch block,
await runArgs.onPostDeliveryCommitFailure?.(error)at line 762 is also unguarded and can throw. executeTurncontract explicitly states: "Throw when the source still owns recovery; this helper does not write a terminal event for a thrown commit error."- When the callback throws, outer catch at line ~879 sees
runResultHandled === trueand setspostDeliveryCommitError, then the function throws at line 913 without callingturnLifecycle.fail. ConversationTurnLifecycleService.failuses an idempotency key, so the old duplicate call was harmless; removing it entirely breaks the thrown-error path.
Identified by Warden · code-review · 7PG-PYS
| if (mailboxBatch) { | ||
| const firstMessageId = mailboxBatch[0]!.metadata.messageId; | ||
| const terminalInputMessageIds = await getTerminalTurnInputMessageIds( |
There was a problem hiding this comment.
Empty mailbox batch crashes on truthy array guard
if (mailboxBatch) treats an empty array as truthy, allowing a subsequent mailboxBatch[0]!.metadata.messageId access to throw at runtime when the batch is empty.
Evidence
mailboxBatchis initialized fromresolved.batchand can be an empty array whenresolved.kind === "mailbox".if (mailboxBatch)evaluates totruefor[], so the block executes despite there being no messages.mailboxBatch[0]!evaluates toundefinedat runtime (the non-null assertion is erased), and accessing.metadata.messageIdthrows.- The same hunk later uses
mailboxBatch?.lengthat line 390 to explicitly guard against empty batches, demonstrating the codebase expects them.
Identified by Warden · code-review · P2R-URT
|
|
||
| if (cancellationSignal?.aborted && outcome.status !== "completed") { |
There was a problem hiding this comment.
Cancellation after accepted output still rewrites non-completed turn state
The post-executeTurn cancellation check calls completeCancelledTurn for awaiting_auth and suspended outcomes even when assistant output was already accepted, violating the accepted-output boundary established in the agentRunner wrapper.
Evidence
- The
agentRunner.runwrapper inside theexecuteTurnoptions guards cancellation withif (!assistantMessageAccepted)and documents: "Active cancellation wins until assistant output is accepted. After that point, cancellation must not rewrite visible work." - The agent can return
status: "awaiting_auth"orstatus: "suspended"after already callingdeliveryand settingassistantMessageAccepted = true. - The outer code at line 638 checks only
cancellationSignal?.aborted && outcome.status !== "completed", ignoring whetherassistantMessageAcceptedis true. - When this condition fires for a non-completed outcome after accepted output,
completeCancelledTurn()is called, which invokescompleteCancelledWebTurnto abandon the turn record, clear pending auth, mark the user message as skipped, and write aturn_completedevent withoutcome: "cancelled"— rewriting the visible turn state that should have been protected.
Identified by Warden · code-review · M2C-Z6Q
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c9d1014. Configure here.
c9d1014 to
695be31
Compare

Conversation Turns now use one shared
executeTurnboundary 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-turnsloop 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_turnandapi-runnames toweb_messageandweb-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