-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(core): persist Goal turn endings outside model history #11924
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1dd94e7
139ae5d
c13af5f
9d585ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -649,6 +649,7 @@ interface AcpGoalTurn extends GoalContinuationTurn { | |
| controller: AbortController; | ||
| origin: 'runtime' | 'user'; | ||
| modelStarted: boolean; | ||
| endingToolCallId?: string; | ||
| } | ||
|
|
||
| function sameGoalPermit( | ||
|
|
@@ -2787,6 +2788,29 @@ export class Session implements SessionContext { | |
| const cancelledByUser = | ||
| result?.stopReason === 'cancelled' && | ||
| turn.controller.signal.reason === USER_CANCEL_ABORT_REASON; | ||
| if ( | ||
| turn.endingToolCallId && | ||
| result?.stopReason === 'end_turn' && | ||
| failureMessage === undefined && | ||
| !turn.controller.signal.aborted | ||
| ) { | ||
| const recorder = this.config.getChatRecordingService(); | ||
| if (recorder) { | ||
| try { | ||
| await recorder.recordGoalTurnEnd( | ||
| turn.endingToolCallId, | ||
| turn.permit, | ||
| ); | ||
|
Comment on lines
+2800
to
+2803
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-1: This strict append runs before For scale: this widens a hazard the file already documents rather than introducing a new failure mode — the merge base carries the same latched-write fallback for Witness: Transcript ordering stays valid after the move: the intervening The fix must not assume the recorder is healthy at settlement time: 中文说明这个 strict append 位于 关于严重程度:这是在扩大文件中已记录的风险,而非引入新的失败模式——merge base 对 移动后记录顺序依然成立:中间插入的 修复时不能假定结算那一刻记录器是健康的: — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| const chat = this.#getCurrentChat(); | ||
| chat.setCompletedToolCallIds([ | ||
| ...chat.getCompletedToolCallIds(), | ||
| turn.endingToolCallId, | ||
| ]); | ||
| } catch (error) { | ||
| debugLogger.warn('Failed to record ACP Goal turn end:', error); | ||
| } | ||
| } | ||
| } | ||
| // A turn preempted by a newly arrived user prompt is a handoff, not a | ||
| // failure. `this.pendingPrompt` is the goal turn's own controller while | ||
| // a goal turn is in flight, so a new prompt aborts it with | ||
|
|
@@ -5437,6 +5461,7 @@ export class Session implements SessionContext { | |
| : undefined); | ||
| return buildSessionRecoveryPlanFromApiHistory({ | ||
| sessionId: this.sessionId, | ||
| completedToolCallIds: chat.getCompletedToolCallIds?.(), | ||
| apiHistory: fullHistory | ||
| ? chat.getHistory() | ||
| : (chat.getHistoryTailShallow?.(TURN_INTERRUPTION_HISTORY_TAIL_COUNT) ?? | ||
|
|
@@ -8694,6 +8719,9 @@ export class Session implements SessionContext { | |
| true, | ||
| ); | ||
| await this.messageRewriter?.waitForPendingRewrites(); | ||
| goalTurn.endingToolCallId = toolRun.parts.findLast( | ||
| (part) => part.functionResponse?.id, | ||
| )?.functionResponse?.id; | ||
| return true; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |
| import type { Content } from '@google/genai'; | ||
| import { closeSync, openSync, readSync, statSync } from 'node:fs'; | ||
| import { | ||
| buildApiHistoryFromConversation, | ||
| buildSessionHistoryFromConversation, | ||
| detectTurnInterruption, | ||
| SessionService, | ||
| TURN_INTERRUPTION_HISTORY_TAIL_COUNT, | ||
|
|
@@ -258,9 +258,10 @@ export async function reconcileDanglingPromptTerminals( | |
| ) { | ||
| return; | ||
| } | ||
| const apiHistory = buildApiHistoryFromConversation(resumed.conversation); | ||
| const { apiHistory, completedToolCallIds } = | ||
| buildSessionHistoryFromConversation(resumed.conversation); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-6: One of six sites where the new Daemon restart while a Goal turn's prompt terminal is still dangling, with a transcript ending in a recorded Witness: Add a ledger case whose transcript is assistant The accumulator only honours a boundary when the immediately preceding material record is the 中文说明这是六处“新增 场景:守护进程重启时某个 Goal 回合的 prompt terminal 仍悬空,且记录尾部是一条已写入的 请补一个账本用例,其记录序列为:assistant 注意累加器只有在紧邻的上一条实质记录是携带同一许可的 — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| const historyTail = apiHistory.slice(-TURN_INTERRUPTION_HISTORY_TAIL_COUNT); | ||
| const verdict = detectTurnInterruption(historyTail); | ||
| const verdict = detectTurnInterruption(historyTail, completedToolCallIds); | ||
| // Id-less tool-call guard: `detectTurnInterruption` ignores functionCalls | ||
| // without an id (they cannot be paired on the wire), but reconciliation | ||
| // needs no wire pairing — a model tail holding ANY functionCall means the | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4516,7 +4516,10 @@ export const useLlmStream = ( | |
| }; | ||
| const orphanedEntries: Part[][] = []; | ||
| try { | ||
| const history = llmClient?.getHistoryShallow?.() ?? []; | ||
| const history = | ||
| llmClient?.getChat?.()?.getHistoryForRecovery?.() ?? | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-3: One of six sites where the new Because the call is optional-chained, a rename or removal of the core method degrades silently to the old full-history scan with nothing failing. The cost is that this hunk's whole purpose — making the CLI's orphaned-envelope scan stop at the same completed-tool-call boundary that core's Witness: Add one case to the existing retry-debt describe whose fake client exposes The scan matches by byte equality ( 中文说明这是六处“新增 由于该调用使用了可选链,核心方法一旦被重命名或移除,就会静默退回到旧的全历史扫描而不会有任何失败。代价是本 hunk 的全部意图——让 CLI 的孤儿 envelope 扫描停在 core 的 请在已有的 retry-debt describe 中补一个用例:让 fake client 暴露 注意该扫描按字节相等匹配( — qwen3.8-max via Qwen Code /review (v0.23.3) |
||
| llmClient?.getHistoryShallow?.() ?? | ||
| []; | ||
| for (let i = history.length - 1; i >= 0; i--) { | ||
| const entry = history[i]; | ||
| if (!entry || entry.role !== 'user') break; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] R1-2: The new cancellation test never makes
!turn.controller.signal.abortedthe deciding clause, so this guard can be deleted with the whole suite still green. In that test the abort lands before the terminating-tool exit computes its stop reason, sogetAbortAwareEndTurnStopReasonreturns'cancelled'and the precedingresult?.stopReason === 'end_turn'clause already blocks the recording. The clause is load-bearing only for the narrower race it was presumably written for — a cancel landing after the stop reason was computed, for example during the settlement path'sawait ...flush()just above — and nothing covers that window.The remap hazard is not hypothetical: this file holds 21 bare
return { stopReason: 'end_turn' }exits against 14 abort-aware ones, so a future remap of the terminating-tool exit onto a bare one would persist agoal_turn_endboundary for a turn the user cancelled. On resumegetRecoveryStatus()would reportclean/canContinue: false, and the cancelled-but-unanswered prompt would be silently dropped instead of offered for continuation.Witness:
Add a case that lets the terminating tool exit normally (so
getAbortAwareEndTurnStopReasonreturns'end_turn') and aborts the turn controller while the settlement's recording flush is in flight, then asserts that neitherrecordGoalTurnEndnorsetCompletedToolCallIdswas called.getAbortAwareEndTurnStopReasonalready folds an abort into the stop reason (return signal.aborted ? 'cancelled' : 'end_turn';,Session.ts:580), so the new test must abort after that call rather than before it, or it re-pins thestopReasonclause and leaves this one still uncovered. That new case must go red when!turn.controller.signal.abortedis removed — the mutation that survives today.中文说明
新增的取消测试从未让
!turn.controller.signal.aborted成为决定性条件,因此删掉这个守卫,整个测试套件仍然是绿的。在该测试中,中止发生在终止型工具退出计算 stop reason 之前,所以getAbortAwareEndTurnStopReason返回'cancelled',前一个result?.stopReason === 'end_turn'条件已经拦住了记录写入。这个子句真正起作用的只有一个更窄的竞态——中止发生在 stop reason 计算之后,例如就在上方的结算路径await ...flush()期间——而这个窗口没有任何覆盖。“被改写”的风险并非假设:本文件中有 21 处裸
return { stopReason: 'end_turn' }退出,对应 14 处感知中止的退出。因此将来若把终止型工具退出改接到某个裸退出上,就会为一个用户已取消的回合持久化goal_turn_end边界。恢复时getRecoveryStatus()会报告clean/canContinue: false,那条已取消但未获答复的输入会被静默丢弃,而不再被提供“继续执行”。请补一个用例:让终止型工具正常退出(使
getAbortAwareEndTurnStopReason返回'end_turn'),并在结算的记录 flush 进行中中止 turn controller,然后断言recordGoalTurnEnd与setCompletedToolCallIds都未被调用。注意
getAbortAwareEndTurnStopReason已经把中止折进 stop reason(return signal.aborted ? 'cancelled' : 'end_turn';,Session.ts:580),所以新用例必须在该调用之后才中止,否则只是重新钉住了stopReason条件,本子句仍未被覆盖。该新用例在移除!turn.controller.signal.aborted时必须变红——也就是今天能够存活的那个变异。— qwen3.8-max via Qwen Code /review (v0.23.3)