Skip to content

feat: continue runs from blocking stop hooks - #482

Merged
danny-avila merged 4 commits into
mainfrom
danny-avila/warm-terminal-steer-continuation
Aug 30, 2026
Merged

feat: continue runs from blocking stop hooks#482
danny-avila merged 4 commits into
mainfrom
danny-avila/warm-terminal-steer-continuation

Conversation

@danny-avila

@danny-avila danny-avila commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • complete the existing StopDecision: block contract by admitting injected messages into another graph segment inside the same Run.processStream lifecycle
  • preserve one public run, trace scope, handlers, graph sidecars, content accumulator, and hook session across terminal continuation
  • add a serialized StopFinalize admission phase after parallel Stop hooks are folded, so durable hosts can atomically claim or seal with full knowledge of other continuation sources
  • expose a bounded continuation budget plus an SDK capability probe
  • support both checkpointed (delta input) and non-checkpointed (live transcript) execution without replaying RunStart or UserPromptSubmit
  • bypass warm continuation for HITL pauses, hook halts, preemption-incomplete turns, and output truncation

Why

LibreChat needs an atomic terminal steer handoff: a steer accepted immediately before natural completion should either continue the already-warm run or remain an ordinary follow-up after terminal admission is sealed. The SDK must own the in-run continuation lifecycle and serialize final admission after notification/plugin Stop hooks before the host can implement that storage transition safely.

Validation

  • tsc --noEmit
  • scoped ESLint and import-order checks
  • 83 hook/continuation tests
  • preemption and output-truncation regression tests
  • circular dependency check
  • full package build

@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-30T13:16:33.506092Z c08ea9c Manual request
ℹ️ 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.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

Please review the current PR head 8a81123. Confirm that this exact commit is the reviewed commit and ignore findings that apply only to earlier heads.

@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: 8a81123610

ℹ️ 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 thread src/run.ts
Comment on lines +1437 to +1439
messages: this.hasCheckpointer
? injected
: [...graph.messages, ...injected],

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 Advance pinned checkpoints before continuing

When a run was started or resumed with configurable.checkpoint_id, this continuation submits only the injected delta but reuses the original pinned checkpoint configuration on the next streamEvents call. LangGraph therefore applies the steer to the historical/interrupted checkpoint rather than the state completed by the preceding segment, losing that segment's answer and tool state or replaying the interrupt. Remove or replace the pinned checkpoint ID after the first segment, or seed the continuation from the complete current state.

Useful? React with 👍 / 👎.

Comment thread src/run.ts Outdated
Comment on lines +1288 to +1290
const stream = graphRunnable.streamEvents(
streamInputs as t.IState,
{ ...config, runName: graph.runName },

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 Keep stop continuations under one trace root

When a Stop hook admits a continuation, each loop iteration calls streamEvents again with the same top-level callbacks, producing another detached AgentGraph root rather than one root for the processStream lifecycle. In Langfuse this fragments the conversation across sibling roots, and with a checkpointer the later root's input is only the injected delta rather than the user's conversation. Parent continuation segments beneath the original root or otherwise aggregate them into one correctly shaped root observation.

AGENTS.md reference: AGENTS.md:L143-L145

Useful? React with 👍 / 👎.

Comment thread src/run.ts Outdated
Comment on lines +1288 to +1290
const stream = graphRunnable.streamEvents(
streamInputs as t.IState,
{ ...config, runName: graph.runName },

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 Rotate event keys for each continuation segment

When no checkpointer is configured, the second streamEvents invocation resets LangGraph's langgraph_step numbering while the retained graph sidecars still contain the first segment's stepKeyIds and messageIdsByStepKey. Because getStepKey is derived from the run, thread, node, and step number, a direct-answer continuation reuses the first segment's key; getMessageId then declines to create a new message and subsequent deltas are sent to the prior closed step. Clients consequently receive no new message step and can append the continued answer to the pre-steer answer, so add a segment identity to event keys or rotate the per-segment mappings.

Useful? React with 👍 / 👎.

Comment thread src/run.ts Outdated
Comment on lines +1409 to +1410
}).catch((): undefined => undefined);
stopResult = mergeAggregatedHookResults(stopResult, finalized);

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 Fail closed when terminal admission cannot be confirmed

When a durable StopFinalize callback throws or times out after atomically claiming queued input, executeHooks records the failure in errors rather than throwing, while call-level failures are also converted to undefined; this merge ignores both cases and lets processStream terminate normally. The claimed message is then neither injected nor available for a later run, causing durable input loss. Treat a finalizer error as an indeterminate terminal-admission failure that propagates or otherwise requires host reconciliation instead of silently sealing the run.

Useful? React with 👍 / 👎.

Comment thread src/run.ts Outdated
Comment on lines +1278 to +1279
let streamInputs: t.IState | Command = inputs;
let stopContinuationCount = 0;

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 the continuation budget across HITL resumes

When a continued segment pauses for a human-in-the-loop interrupt, the next Run.resume() enters a new processStream call and resets this counter to zero even though it is resuming the same run execution. The next Stop/StopFinalize inputs therefore report stopHookActive: false, an incorrect count, and a replenished budget; repeated continuation-to-interrupt cycles can bypass maxStopContinuations indefinitely. Store the admitted count in run/checkpoint state that survives a clean interrupt and reset it only for a genuinely fresh execution.

Useful? React with 👍 / 👎.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

Please review the current PR head 54832a1863f6d0fe21307c82a4c20f00cf73fe7e. Confirm that this exact commit is the reviewed commit and ignore findings that apply only to earlier heads. Focus especially on the terminal-continuation lifecycle invariant across pinned checkpoints, Langfuse trace rooting, per-segment event identity, indeterminate StopFinalize admission, and continuation budget preservation across rebuilt HITL resumes.

@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: 54832a1863

ℹ️ 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 thread src/run.ts Outdated
await this.resolveInterruptResumeConfig(config);
}
if (this._interrupt != null) {
await this.resolveInterruptResumeConfig(config);

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 Use the advanced cursor when recording warm interrupts

When a run starts from a pinned checkpoint_id, admits a terminal continuation, and that continued segment pauses for HITL, this passes the original pinned config to interrupt resolution rather than the advanced streamConfig. A rebuilt Run.resume() that retains the caller's pin can consequently inspect and resume the pre-continuation checkpoint, losing the warm segment and its persisted continuation budget. Fresh evidence at this head is that the cursor is advanced only for the next streamEvents call, while this interrupt-resolution path still uses the old configuration.

Useful? React with 👍 / 👎.

Comment thread src/langfuse.ts
Comment on lines +598 to +600
if (runId === this.deferredRootRunId && parentRunId == null) {
this.deferredRootOutcome = { type: 'end', output };
return Promise.resolve();

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 Propagate terminal-hook failures to the deferred trace root

When StopFinalize throws or times out after a graph segment ends, this records the segment's successful chain outcome, and disposeLangfuseHandler() later replays that success even though processStream() rejects and fires StopFailure. Because post-stream hook errors never invoke this handler's handleChainError, Langfuse reports a successful agent root for an indeterminate terminal-admission failure; the deferred root outcome needs to reflect the enclosing process lifecycle, not only the last graph callback.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

Comment thread src/graphs/Graph.ts
Comment on lines +1865 to +1866
this.stopContinuationCount = state.stopContinuationCount ?? 0;
this.streamSegment = state.streamSegment ?? 0;

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 Reset persisted continuation counts for fresh turns

When a checkpointer is reused for the next ordinary user turn, its latest runStepState still contains the prior turn's terminal-continuation count. Although resetValues() clears the in-memory counter for the fresh processStream() call, the first graph node restores that persisted value here, so stopHookActive is immediately true and the remaining budget shrinks cumulatively across unrelated turns; after enough turns, valid continuations are rejected as exhausted. Restore these lifecycle fields only for HITL resume, or overwrite them in graph state when starting a genuinely fresh execution.

Useful? React with 👍 / 👎.

Comment thread src/run.ts
Comment on lines +173 to +174
delete configurable.checkpoint_id;
delete configurable.checkpoint_map;

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 Keep continuation pinned to the completed branch

When two executions can write checkpoints for the same thread, removing the pinned checkpoint cursor makes the warm segment load whichever checkpoint is globally latest, not necessarily the checkpoint produced by its preceding segment. A concurrent turn or sibling fork writing between the two streamEvents calls can therefore splice that other branch's messages and tool state into this continuation. Fresh evidence in this head is that advanceCheckpointCursor() deletes the branch identifiers rather than replacing them with the completed segment's exact checkpoint configuration.

Useful? React with 👍 / 👎.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

Please review the current PR head 63cc950fef2bf6f824f02aa6cfb282b2f0e49bb8. Confirm that this exact commit is the reviewed commit and ignore findings that apply only to earlier heads. This is the post-invariant-audit round after two actionable passes. Focus especially on exact checkpoint lineage under concurrent siblings, fresh-turn admission reset, pinned HITL interrupt recovery, continuation-budget persistence, and deferred Langfuse root outcomes when terminal admission fails.

@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: 63cc950fef

ℹ️ 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 thread src/run.ts
Comment on lines +336 to +337
runStepState?.stopContinuationExecutionId !== executionId ||
runStepState.streamSegment !== streamSegment

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 Accept legacy checkpoints without execution IDs

When a HITL checkpoint created before this commit is resumed after an upgrade, its optional stopContinuationExecutionId is absent, while the restored graph uses ''; the resumed segment consequently persists the field as absent again. If a Stop hook then admits a continuation, this strict comparison rejects every checkpoint (undefined !== '') and throws Cannot identify the checkpoint committed by the completed graph segment. Normalize the legacy missing value or stamp a new execution ID during resume so in-flight HITL runs survive rolling upgrades.

Useful? React with 👍 / 👎.

Comment thread src/run.ts
sessionId: this.id,
signal: config.signal,
});
assertFinalAdmissionSucceeded(finalized);

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 Fail closed for internal finalizer errors

When a StopFinalize matcher is registered with internal: true and its hook throws or times out after atomically claiming input, executeHooks deliberately omits that failure from AggregatedHookResult.errors, so this assertion treats admission as successful and the run terminates normally while the claimed message is lost. Fresh evidence at this head is that terminal admission now relies exclusively on the filtered errors array; preserve finalizer success/failure independently of the matcher's diagnostic-suppression flag.

Useful? React with 👍 / 👎.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

Please review the current PR head c08ea9c5c27a5e828e32a0d0cca574264af0bc5c. Confirm that this exact commit is the reviewed commit and ignore findings that apply only to earlier heads. Focus especially on rolling-upgrade compatibility for legacy HITL checkpoints without continuation execution IDs, reducer-safe resume overwrites, and fail-closed StopFinalize behavior when internal matcher diagnostics are suppressed.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: c08ea9c5c2

ℹ️ 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".

@danny-avila
danny-avila merged commit 24e105e into main Aug 30, 2026
13 checks passed
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