@sema-agent/client-core 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/abortableSleep.d.ts +29 -0
- package/dist/abortableSleep.js +43 -0
- package/dist/adapt/arms.d.ts +58 -0
- package/dist/adapt/arms.js +613 -0
- package/dist/adapt/ids.d.ts +22 -0
- package/dist/adapt/ids.js +34 -0
- package/dist/adapt/instanceLedger.d.ts +36 -0
- package/dist/adapt/instanceLedger.js +50 -0
- package/dist/adapt/panelTasks.d.ts +54 -0
- package/dist/adapt/panelTasks.js +193 -0
- package/dist/adapt/textStream.d.ts +49 -0
- package/dist/adapt/textStream.js +141 -0
- package/dist/adapt/toolCards.d.ts +65 -0
- package/dist/adapt/toolCards.js +100 -0
- package/dist/adapt/turnFlags.d.ts +33 -0
- package/dist/adapt/turnFlags.js +55 -0
- package/dist/adapt/wireShapes.d.ts +93 -0
- package/dist/adapt/wireShapes.js +167 -0
- package/dist/adapt.d.ts +32 -59
- package/dist/adapt.js +78 -1206
- package/dist/adapter/downstream/eventToSdkMessage.d.ts +55 -13
- package/dist/adapter/downstream/eventToSdkMessage.js +166 -106
- package/dist/adapter/downstream/terminalToSdkResult.js +149 -160
- package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +36 -0
- package/dist/adapter/downstream/turnUsageToModelUsage.js +34 -6
- package/dist/adapter/runStream.d.ts +29 -4
- package/dist/adapter/runStream.js +129 -13
- package/dist/adapter/types.d.ts +2 -2
- package/dist/agentSession/backgroundView.js +4 -15
- package/dist/agentsWireCaps.d.ts +10 -6
- package/dist/agentsWireCaps.js +21 -7
- package/dist/argvFlagValue.d.ts +41 -0
- package/dist/argvFlagValue.js +69 -0
- package/dist/attachmentsWireCaps.d.ts +3 -2
- package/dist/attachmentsWireCaps.js +5 -3
- package/dist/classifierVerdictWire.d.ts +8 -0
- package/dist/classifierVerdictWire.js +8 -0
- package/dist/cloudConfigWireCaps.d.ts +28 -1
- package/dist/cloudConfigWireCaps.js +51 -11
- package/dist/controlRouter.d.ts +14 -8
- package/dist/controlRouter.js +15 -21
- package/dist/detachWire.d.ts +15 -5
- package/dist/detachWire.js +17 -7
- package/dist/effortWire.d.ts +0 -21
- package/dist/effortWire.js +6 -20
- package/dist/engineInlineTaskStats.d.ts +12 -6
- package/dist/engineWireSdk.d.ts +12 -0
- package/dist/env/localeGeo.js +2 -1
- package/dist/envFlag.d.ts +39 -0
- package/dist/envFlag.js +51 -0
- package/dist/finalVerifyWire.d.ts +7 -5
- package/dist/finalVerifyWire.js +6 -5
- package/dist/fleet/fleetLedger.d.ts +32 -9
- package/dist/fleet/fleetLedger.js +119 -34
- package/dist/fleet/fleetProjection.d.ts +44 -6
- package/dist/fleet/fleetProjection.js +52 -10
- package/dist/fleetAgentPanelProjection.d.ts +18 -1
- package/dist/fleetAgentPanelProjection.js +61 -14
- package/dist/forkWireCaps.d.ts +2 -1
- package/dist/forkWireCaps.js +5 -11
- package/dist/goalStopHook.d.ts +142 -0
- package/dist/goalStopHook.js +258 -0
- package/dist/headlessPermissionModeWire.d.ts +10 -0
- package/dist/headlessPermissionModeWire.js +24 -21
- package/dist/headlessReconnectWire.d.ts +7 -1
- package/dist/headlessReconnectWire.js +20 -2
- package/dist/hitl/approvalsFeed.js +31 -8
- package/dist/hitl/askGateWire.d.ts +27 -96
- package/dist/hitl/askGateWire.js +69 -618
- package/dist/hitl/frameRouter.d.ts +86 -0
- package/dist/hitl/frameRouter.js +342 -0
- package/dist/hitl/gateLedger.d.ts +107 -0
- package/dist/hitl/gateLedger.js +113 -0
- package/dist/hitl/hitlBridge.d.ts +75 -15
- package/dist/hitl/hitlBridge.js +94 -22
- package/dist/hitl/hitlHostSurface.d.ts +49 -0
- package/dist/hitl/hitlHostSurface.js +155 -0
- package/dist/hitl/parkResolver.d.ts +74 -0
- package/dist/hitl/parkResolver.js +241 -0
- package/dist/hitl/planReviewWire.d.ts +60 -2
- package/dist/hitl/planReviewWire.js +152 -74
- package/dist/hitl/toolApprovalWire.d.ts +67 -4
- package/dist/hitl/toolApprovalWire.js +119 -31
- package/dist/hooksWireCaps.d.ts +1 -82
- package/dist/hooksWireCaps.js +34 -235
- package/dist/host.d.ts +16 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -1
- package/dist/interactiveToolsWire.d.ts +15 -4
- package/dist/interactiveToolsWire.js +24 -26
- package/dist/limitsWire.js +7 -40
- package/dist/liveInitToolFace.d.ts +51 -6
- package/dist/liveQuestionStore.d.ts +5 -6
- package/dist/model/providerPresets.js +11 -1
- package/dist/notifications.d.ts +48 -2
- package/dist/notifications.js +223 -46
- package/dist/retainBackgroundWireCaps.d.ts +3 -2
- package/dist/retainBackgroundWireCaps.js +5 -9
- package/dist/sandboxWire.d.ts +9 -31
- package/dist/sandboxWire.js +51 -50
- package/dist/scenarioWire.d.ts +1 -1
- package/dist/scenarioWire.js +25 -36
- package/dist/seam.d.ts +23 -6
- package/dist/seam.js +40 -30
- package/dist/seatContract.d.ts +369 -83
- package/dist/seatContract.js +585 -198
- package/dist/selfOrchestrationWireCaps.d.ts +6 -5
- package/dist/selfOrchestrationWireCaps.js +8 -12
- package/dist/sessionSlot.d.ts +8 -0
- package/dist/sessionSlot.js +1 -0
- package/dist/steering.js +2 -2
- package/dist/subagent/engineTaskHandleWire.d.ts +3 -0
- package/dist/subagent/engineTaskHandleWire.js +17 -2
- package/dist/subagentContentStore.d.ts +5 -5
- package/dist/toolResult.d.ts +89 -8
- package/dist/toolResult.js +99 -30
- package/dist/typePins.d.ts +17 -0
- package/dist/typePins.js +1 -0
- package/dist/ultracodeWireCaps.js +5 -6
- package/dist/unrefTimer.d.ts +19 -0
- package/dist/unrefTimer.js +5 -0
- package/dist/workflow.d.ts +3 -2
- package/dist/workflow.js +3 -2
- package/dist/workflowClient.d.ts +7 -0
- package/dist/workflowClient.js +47 -12
- package/package.json +3 -3
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js';
|
|
2
|
+
import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
|
|
3
|
+
import { hostLog } from '../host.js';
|
|
4
|
+
import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
|
|
5
|
+
import { observeCancelByDeny } from './hitlHostSurface.js';
|
|
6
|
+
import { isAskTool } from './frameRouter.js';
|
|
7
|
+
/** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
|
|
8
|
+
const MAX_GATE_HOPS = 24;
|
|
9
|
+
function isGateFailureCode(v) {
|
|
10
|
+
return v === 'no_pending' || v === 'binding_mismatch' || v === 'wrong_gate' || v === 'bad_plan_edit';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* decide 的 `failed` 原因是不是「这个 gate 早就被解决了」(#110 缺陷② c 的判据)。
|
|
14
|
+
*
|
|
15
|
+
* REF-CC-033:此前判据**只**锚在文案词(`no pending checkpoint` / `resolved` / `already` 三者
|
|
16
|
+
* 任一在场)——server 原文改一个字,整条命中当场失效(FIX-13 / 域词表-01)。现在消费点(见下方
|
|
17
|
+
* `isAlreadyResolvedFailure`)优先判 `GateOutcome.code`(闭集判别位,见 `GateFailureCode`);
|
|
18
|
+
* 这个函数降级为**文案兜底臂**,只在 outcome 没带 code 的时候才被调用(典型:`FsApprovalOutcome`
|
|
19
|
+
* 来自 `toolApprovalWire.ts`,那份类型今天没有 code 字段——C-bridge 卡的文件域,本卡不改)。
|
|
20
|
+
* 🔴 方向没变:这条判**错**的后果不对称 —— 误判成「已解决」最多让流多读一轮(hop 预算兜底),
|
|
21
|
+
* 误判成「真失败」会把一个已经 completed 的 run 谎报成失败并烧掉整条会话转录。所以宁可宽。
|
|
22
|
+
*/
|
|
23
|
+
/** #114/[C68] 单源化提升:desktop 照抄件(durable 双实现审计 P0-1 修)到货换包导入删抄件。 */
|
|
24
|
+
export function isAlreadyResolvedGateReason(reason) {
|
|
25
|
+
if (typeof reason !== 'string')
|
|
26
|
+
return false;
|
|
27
|
+
const r = reason.toLowerCase();
|
|
28
|
+
return r.includes('no pending checkpoint') || r.includes('resolved') || r.includes('already');
|
|
29
|
+
}
|
|
30
|
+
/** 已解决判据(REF-CC-033):code 在场就只信 code,不在场才落回文案兜底(fs 审批腿的
|
|
31
|
+
* `FsApprovalOutcome` 今天没有 code 字段——C-bridge 卡域,退役条件 = 那边也补上 code)。
|
|
32
|
+
* 🔴 入参**已经**是窄化到 `{kind:'failed'}` 的那半:一个「true ⇒ 是 failed」的判定不能反过来
|
|
33
|
+
* 当「false ⇒ 不是 failed」的类型谓词用(TS 的 `outcome is Extract<…>` 负向窄化会把整个
|
|
34
|
+
* `'failed'` 分支从联合类型里排除,而这个函数对未解决的真失败照样返回 false——那不是「它不是
|
|
35
|
+
* failed」,是「它是 failed 但没解决」。调用点先用 `outcome.kind === 'failed'` 做真正的判别式
|
|
36
|
+
* 窄化,再把窄化后的值交给这里,本函数只回答布尔,不再兼职类型谓词。 */
|
|
37
|
+
function isAlreadyResolvedFailure(outcome) {
|
|
38
|
+
const code = 'code' in outcome && isGateFailureCode(outcome.code) ? outcome.code : undefined;
|
|
39
|
+
return code !== undefined ? code === 'no_pending' : isAlreadyResolvedGateReason(outcome.reason);
|
|
40
|
+
}
|
|
41
|
+
/** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
|
|
42
|
+
* (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
|
|
43
|
+
export function toAnsweredOutput(questions, answer) {
|
|
44
|
+
const answers = {};
|
|
45
|
+
const annotations = {};
|
|
46
|
+
for (const entry of answer.answers ?? []) {
|
|
47
|
+
const q = questions.find(qq => qq.header === entry.header);
|
|
48
|
+
const key = typeof q?.question === 'string' ? q.question : entry.header;
|
|
49
|
+
answers[key] = (entry.selected ?? []).join(', ');
|
|
50
|
+
if (entry.note)
|
|
51
|
+
annotations[key] = { notes: entry.note };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
type: 'ask-user-question',
|
|
55
|
+
questions,
|
|
56
|
+
answers,
|
|
57
|
+
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 弹既有 AskUserQuestion overlay → 等作答 → 经 HitlBridge decide(答案/拒答)。
|
|
62
|
+
* decide 成功即引擎已把 run 驱到下一状态(sync);调用方随后 attach runs.events 续流。
|
|
63
|
+
*/
|
|
64
|
+
async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
|
|
65
|
+
if (!hasQuestionOverlay()) {
|
|
66
|
+
return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
|
|
67
|
+
}
|
|
68
|
+
// pending 行 = 问题 payload 的权威源 + D-1 绑定的唯一 surface(PendingCheckpoint.input)。
|
|
69
|
+
// REF-CC-029(单源取件)+ REF-CC-033(no_pending 判别码):findPendingForTask 是唯一取件口
|
|
70
|
+
// (三份近似复刻已收敛),其失败形带 code 判别位,本处原样透传给 GateOutcome ——
|
|
71
|
+
// 「没有待决 gate」(良性,park 重放常见)与「approvals.list 真失败」自此结构可分。
|
|
72
|
+
const found = await findPendingForTask(deps.client, taskId, isAskTool, signal ? { signal } : undefined);
|
|
73
|
+
if (!found.ok)
|
|
74
|
+
return { kind: 'failed', reason: found.reason, ...(found.code !== undefined ? { code: found.code } : {}) };
|
|
75
|
+
const { pending, gatedCallId } = found;
|
|
76
|
+
// 问题 payload:优先流上 tool_start.args(零额外语义),缺则 pending.input(service 已 redact + 限长)。
|
|
77
|
+
const fromArgs = gatedCallId
|
|
78
|
+
? askArgsByCall.get(gatedCallId)?.questions
|
|
79
|
+
: undefined;
|
|
80
|
+
const fromPending = pending.input?.questions;
|
|
81
|
+
const questions = Array.isArray(fromArgs) && fromArgs.length > 0 ? fromArgs : fromPending;
|
|
82
|
+
if (!Array.isArray(questions) || questions.length === 0) {
|
|
83
|
+
// [1543]②:input 超引擎入参帽(MAX_TOOL_INPUT_CHARS,preview 富文本题易中)时 server 降级为
|
|
84
|
+
// {truncated:true} ——无题可渲不是引擎代差,是 payload 被截。分形出诚实理由(fail-soft 链
|
|
85
|
+
// 会把 reason 记入 SEMA_DEBUG,别再误导成 pre-CHANNEL[56] 老引擎)。
|
|
86
|
+
const truncated = pending.input?.truncated === true;
|
|
87
|
+
return {
|
|
88
|
+
kind: 'failed',
|
|
89
|
+
gatedCallId,
|
|
90
|
+
reason: truncated
|
|
91
|
+
? 'question payload exceeded the engine input cap (input={truncated:true}) — dialog cannot be re-rendered; run stays suspended for out-of-band decide'
|
|
92
|
+
: 'gate has no question payload (pre-CHANNEL[56] engine?)',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// overlay 往返:合成 frame(id 独占命名空间 hitl-ask:)→ local responder 一次性收答。
|
|
96
|
+
const questionId = `hitl-ask:${gatedCallId ?? taskId}`;
|
|
97
|
+
const answer = await new Promise(resolve => {
|
|
98
|
+
const unregister = registerLocalQuestionResponder(questionId, async (_id, a) => {
|
|
99
|
+
cleanup();
|
|
100
|
+
resolve(a);
|
|
101
|
+
return { ok: true };
|
|
102
|
+
});
|
|
103
|
+
const onAbort = () => {
|
|
104
|
+
cleanup();
|
|
105
|
+
// 撤下还开着的对话框(question_complete = overlay 的既有 dismiss 语义)
|
|
106
|
+
publishQuestionFrame({ type: 'question_complete', questionId });
|
|
107
|
+
resolve(null);
|
|
108
|
+
};
|
|
109
|
+
const cleanup = () => {
|
|
110
|
+
unregister();
|
|
111
|
+
signal?.removeEventListener('abort', onAbort);
|
|
112
|
+
};
|
|
113
|
+
if (signal?.aborted) {
|
|
114
|
+
onAbort();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
118
|
+
publishQuestionFrame({ type: 'question', questionId, questions });
|
|
119
|
+
});
|
|
120
|
+
const bridge = new HitlBridge(deps.client, taskId);
|
|
121
|
+
if (answer === null) {
|
|
122
|
+
// turn 被中断(Esc/Ctrl+C):cancel-by-deny(contract/04 §2.4 —— suspended run 不 runs.cancel)。
|
|
123
|
+
// 件3(中断事故修复批 G,2026-07-15,症状1 壳侧配套):此前 .catch(()=>{}) 全吞 = deny 丢失时
|
|
124
|
+
// run 永卡 suspended,session 锁死,用户下一条消息撞 409「active run」还全无线索。改为有界观察
|
|
125
|
+
// (observeCancelByDeny,2s 预算):abort 仍立即返回用户控制(不 await,交互时序不变),后台
|
|
126
|
+
// settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。引擎侧解锁腿([866] server:cancel
|
|
127
|
+
// suspended 改语义 + reapSuspended TTL)到货前,这是壳能做的最诚实半场。
|
|
128
|
+
observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
|
|
129
|
+
return { kind: 'aborted', gatedCallId };
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
// REF-CC-038(2026-08-02):`answer.answers` 已经是 `AskAnswer[]`(liveQuestionStore 的
|
|
133
|
+
// `QuestionAnswer` 直用 hitlBridge 的 `AskAnswer` 为唯一源)—— 不再需要靠 cast 把两个
|
|
134
|
+
// 名不同的等价形状缝合。
|
|
135
|
+
const entries = answer.answers ?? [];
|
|
136
|
+
const hasContent = entries.some(a => (a.selected?.length ?? 0) > 0 || (a.note?.length ?? 0) > 0);
|
|
137
|
+
if (hasContent) {
|
|
138
|
+
await bridge.answerQuestion(entries, gatedCallId, signal ? { signal } : undefined, pending);
|
|
139
|
+
return { kind: 'decided', gatedCallId, answered: toAnsweredOutput(questions, answer) };
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// 用户拒答(overlay reject/dismiss 送空 answers)= 诚实 deny;模型收 denied 结果自续。
|
|
143
|
+
await bridge.decideTool({ decision: 'deny', reason: 'User declined to answer' }, gatedCallId, signal ? { signal } : undefined, pending);
|
|
144
|
+
}
|
|
145
|
+
return { kind: 'decided', gatedCallId };
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
// REF-CC-033:HitlSafetyError.code 是 hitlBridge.ts 自己的闭集契约(见其类型头注),
|
|
149
|
+
// 这里 instanceof 窄化后再过 isGateFailureCode 白名单——不认得的码(将来 hitlBridge 那边
|
|
150
|
+
// 万一多加一种)一律降级成 undefined,退回文案兜底,不让开集腐蚀这个新判别位。
|
|
151
|
+
const code = e instanceof HitlSafetyError && isGateFailureCode(e.code) ? e.code : undefined;
|
|
152
|
+
return { kind: 'failed', gatedCallId, reason: `decide failed: ${String(e)}`, ...(code !== undefined ? { code } : {}) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** 台账里那批 gated `tool_start` 当拍 args 快照 → 决断腿要的 `toolCallId → args` 表(UNTRUSTED 原样搬运)。 */
|
|
156
|
+
function argsByCallOf(led) {
|
|
157
|
+
return new Map(led.gatedStartArgs());
|
|
158
|
+
}
|
|
159
|
+
export async function resolvePark(park, ctx) {
|
|
160
|
+
const { deps, led, taskId, hops } = ctx;
|
|
161
|
+
let outcome;
|
|
162
|
+
// REF-CC-034:这次 park 的候选 callId(仅 fs 分支填,已决断身份匹配用)——ask 分支不填,
|
|
163
|
+
// 因为 `surfaceGateAndDecide` 的 `!pending` 早退已经自带 `code:'no_pending'`(REF-CC-033),
|
|
164
|
+
// 不需要这条第二判据。用 `lastFsOrShellGatedCallId()` 而非「未收口 fs call 栈」:durable
|
|
165
|
+
// re-attach 重放的 `suspended` park 没有配套的新 tool_start/tool_end,那个栈在这次重放之前
|
|
166
|
+
// 早被 tool_end 的 drop 清空了(见台账声明处注),取不到候选。
|
|
167
|
+
let candidateGatedCallId;
|
|
168
|
+
if (hops > MAX_GATE_HOPS) {
|
|
169
|
+
outcome = { kind: 'failed', reason: `gate hop limit (${MAX_GATE_HOPS}) exceeded` };
|
|
170
|
+
}
|
|
171
|
+
else if (park.gate === 'fs') {
|
|
172
|
+
candidateGatedCallId = led.lastFsOrShellGatedCallId();
|
|
173
|
+
// [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
|
|
174
|
+
outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal);
|
|
175
|
+
// #51: `outcome`'s declared type is the wider `GateOutcome |
|
|
176
|
+
// FsApprovalOutcome`; both unions share a 'decided' kind with
|
|
177
|
+
// different optional fields (`answered` vs `denied`), so a plain
|
|
178
|
+
// `.denied` access doesn't hold for every 'decided' member even
|
|
179
|
+
// though this branch only ever assigns the FsApprovalOutcome shape.
|
|
180
|
+
// 'in' narrowing sidesteps it.
|
|
181
|
+
const outcomeDenied = 'denied' in outcome ? outcome.denied : undefined;
|
|
182
|
+
if (outcome.kind === 'decided' && outcomeDenied === true && outcome.gatedCallId) {
|
|
183
|
+
led.markDenied(outcome.gatedCallId); // 重放的报错帧渲 `User rejected …`(见 tool_end 有序臂 ③)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal);
|
|
188
|
+
}
|
|
189
|
+
// #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
|
|
190
|
+
// durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
|
|
191
|
+
// 重放窗口里)。这时 approval 行早已 resolved,`surfaceFsApprovalAndDecide` 取不到 pending,
|
|
192
|
+
// 返回 `failed: no pending checkpoint for this run (resolved/expired?)` —— 那句话的语义恰恰是
|
|
193
|
+
// **「这个 gate 已经被解决了」**,正确处置是**继续读流**(引擎那边 run 正常往下跑,实测
|
|
194
|
+
// `run.json status="completed"`),而不是宣布 turn 失败。旧行为的代价不是一句错文案:
|
|
195
|
+
// 终帧被投影成 `error_during_execution` ⇒ 壳走失败路径 ⇒ CC 转录**一行都不写** ⇒
|
|
196
|
+
// 同 session `--resume` 报 `No conversation found` ⇒ **整个会话废掉**。
|
|
197
|
+
// 无限循环由 `hops > MAX_GATE_HOPS` 兜底(本 reattach 照常吃 hop 预算,不绕过它)。
|
|
198
|
+
//
|
|
199
|
+
// REF-CC-034:`isAlreadyResolvedFailure` 是首选判据(code/文案双臂),但 fs 腿的
|
|
200
|
+
// `FsApprovalOutcome` 今天没有 `code`(toolApprovalWire.ts,C-bridge 域),失败原因若不是
|
|
201
|
+
// 那三个文案子串(例如 `approvals.list` 自身网络失败)就会漏判。已决断身份匹配是独立于
|
|
202
|
+
// 文案的第二判据 —— 两臂任一命中都按「已解决」处置,方向偏宽(见台账 `markDecided` 处注)。
|
|
203
|
+
const alreadyDecidedById = candidateGatedCallId !== undefined && led.isDecided(candidateGatedCallId);
|
|
204
|
+
if (outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
|
|
205
|
+
const seq = led.lastSeq();
|
|
206
|
+
hostLog('debug', `liveHitlAskWire: gate already resolved (${outcome.reason}) — replayed park, re-attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn` +
|
|
207
|
+
(alreadyDecidedById ? ` [decidedGates id match: ${candidateGatedCallId}]` : '') +
|
|
208
|
+
(led.decidedCount() > 0 ? ` [decided so far: ${led.decidedCount()}]` : ''));
|
|
209
|
+
return { kind: 'reattach' };
|
|
210
|
+
}
|
|
211
|
+
if (outcome.kind !== 'decided') {
|
|
212
|
+
hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
|
|
213
|
+
const events = [...led.flushHeld()]; // 回退:毒化帧照旧渲染(= 修复前的诚实红)
|
|
214
|
+
if (park.pendingDone) {
|
|
215
|
+
events.push(park.pendingDone);
|
|
216
|
+
}
|
|
217
|
+
else if (outcome.kind === 'failed') {
|
|
218
|
+
// 记案(#87 评审② minor,不修):durable re-attach 的第二问(park.pendingDone 无)走 `aborted`
|
|
219
|
+
// 时(仅用户主动 Esc/Ctrl+C 中断触发)不吐终帧——下游正在拆流,合成终帧也没人渲;flushHeld
|
|
220
|
+
// 已把毒化帧诚实吐出。真正的 failed 才合成下面的可见终帧。
|
|
221
|
+
// durable-leg park 无 done 可回吐 —— 合成 failed 让用户看得见为什么停了
|
|
222
|
+
events.push({
|
|
223
|
+
type: 'failed',
|
|
224
|
+
errorCode: 'hitl_unanswered',
|
|
225
|
+
errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${outcome.reason}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return { kind: 'failsoft', events };
|
|
229
|
+
}
|
|
230
|
+
// decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
|
|
231
|
+
// 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
|
|
232
|
+
if (outcome.gatedCallId) {
|
|
233
|
+
led.dropHeld(outcome.gatedCallId);
|
|
234
|
+
led.markDecided(outcome.gatedCallId); // #110 缺陷② b / REF-CC-034:身份留痕,上方候选匹配消费
|
|
235
|
+
if ('answered' in outcome && outcome.answered)
|
|
236
|
+
led.rememberAnswer(outcome.gatedCallId, outcome.answered);
|
|
237
|
+
}
|
|
238
|
+
const seq = led.lastSeq();
|
|
239
|
+
hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''}`);
|
|
240
|
+
return { kind: 'reattach' };
|
|
241
|
+
}
|
|
@@ -1,12 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⇄ B7 批搬迁(2026-07-27,设计稿 §3 B7):cli `src/sema/planReviewWire.ts`(142 行)**整条进包**。
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **`wireConfig()` 第七份收编闭合**:B6 的 `engineWireTarget.ts` 头注记着「本批搬入的是前六份;
|
|
5
|
+
* 第七份 `planReviewWire` 不在 B6 范围,仍留壳内 —— 它是下一批的收编候选,别以为已经收干净了」。
|
|
6
|
+
* 本批就是那一批:这里改用 `engineWireTarget()`,壳树 `grep -rn 'function wireConfig' src/sema/`
|
|
7
|
+
* 的七份手抄至此**归零**(pure 门 B7 段有断言:壳树在场时命中数 = 0;缺席 ⇒ DEGRADED)。
|
|
8
|
+
*
|
|
9
|
+
* 🔴 其余搬迁差分(零行为变化):
|
|
10
|
+
* 1. `logForDebugging` → `hostLog`。
|
|
11
|
+
* 2. `await import('../utils/messageQueueManager.js').enqueuePendingNotification({...})`
|
|
12
|
+
* → `NotificationQueuePort.enqueueMetaPrompt`(B7 新增的**可选**方法 —— 既有宿主实现不破)。
|
|
13
|
+
* 🔴 缺席后果不是「少个装饰」:模型永远不知道 plan 被批准/驳回后引擎跑出了什么结果,
|
|
14
|
+
* 于是它在 approve 之后**原地不动**。所以缺席计 miss(`notificationQueuePortMisses()`),
|
|
15
|
+
* 并保留壳原文的 try/catch fail-soft(投递失败绝不炸流)。
|
|
16
|
+
* 3. `registerLocalQuestionResponder`/`publishQuestionFrame` → 包内 `liveQuestionStore`
|
|
17
|
+
* (B1 已搬,**同一个 module 台账**;两份实例 ⇒ 卡弹出来没人收答)。
|
|
18
|
+
*
|
|
19
|
+
* ── 以下为原文件的领域说明(逐字保留)────────────────────────────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* planReviewWire — plan_review park 的壳侧审批闭环(core [508]③ 形状拍板 + [511]② 实证)。
|
|
22
|
+
*
|
|
23
|
+
* WIRE 全链(server 1.138 × core 1.257.4 探针逐字实证,probe-plan-final-frame.mjs):
|
|
24
|
+
* 1. plan-mode turn 里模型调 ExitPlanMode → 引擎 park:done 帧 `status:"needs_review"` +
|
|
25
|
+
* `checkpointGate:{kind:"plan_review",reason:"ExitPlanMode"}` + `errorCode:"review.pending"`;
|
|
26
|
+
* plan 文本已由 ExitPlanMode tool_end 渲染在转录(fence 块),checkpoint 落 FileCheckpointStore。
|
|
27
|
+
* 2. 壳(本模块,runStream done 分支调用)合成一个 QuestionFrame 借 AskUserQuestion overlay 弹
|
|
28
|
+
* 审批卡(core 拍板:不新造 live question 帧,壳自绘)——合成 id `plan-review:<taskId>` 经
|
|
29
|
+
* liveQuestionStore 的 LOCAL responder 拦截,绝不会打到引擎的 /v1/questions(那里 404)。
|
|
30
|
+
* 3. 决断 → POST /v1/assistant/tasks/:taskId/plan_review `{decision:"approve"|"reject"}`——
|
|
31
|
+
* resume 是 SYNC 驱动到终态的(实证 200 `{taskId,sessionId,status:"completed"}`,可能分钟级),
|
|
32
|
+
* 所以 fire 后卡片即关,完成后经 enqueuePendingNotification 入队一个 isMeta turn:模型收到
|
|
33
|
+
* resume 终态,自然接续汇报(approve 后"继续干活"的 CC 体验由引擎侧 resume 已完成,这里是
|
|
34
|
+
* 结果呈现)。reject 同构(引擎丢 plan 保持 plan 语境)。
|
|
35
|
+
*
|
|
36
|
+
* 挂账(诚实边界):approve 后壳本地的 plan-mode footer 徽章不自动退(CC 会退)——退 mode 的
|
|
37
|
+
* app-state seam 不在本模块可达面,记 rc.47 接 REPL 层;engine 侧 handsReadOnly 已由 resume 处理。
|
|
38
|
+
*/
|
|
39
|
+
import { type QuestionAnswer } from '../liveQuestionStore.js';
|
|
40
|
+
/**
|
|
41
|
+
* REF-CC-026:此前是 module-private 常量,唯一另一个消费者(cli `planReviewReopen.ts`)只能靠
|
|
42
|
+
* 人眼手抄同步(头注写着「与 client-core planReviewWire 的原卡逐字一致」)——漂一个字节,判决就
|
|
43
|
+
* 落到危险的默认臂(见下方 `planReviewDecisionFromAnswer` 的头注)。现在导出成唯一真源;cli 侧改
|
|
44
|
+
* import 是后续跟车工单(壳仓文件域,不在本卡范围)。
|
|
45
|
+
*/
|
|
46
|
+
export declare const PLAN_REVIEW_APPROVE_LABEL = "Yes, approve and run the plan";
|
|
47
|
+
export declare const PLAN_REVIEW_REJECT_LABEL = "No, reject it (keep planning)";
|
|
48
|
+
/**
|
|
49
|
+
* REF-CC-026:三值判决(此前 `selected === APPROVE_LABEL ? 'approve' : 'reject'` 把「没读懂/没
|
|
50
|
+
* 选」的第三种输入静默塌缩进危险的 reject 臂——overlay 的 dismiss/拒答语义是「送空 answers」,
|
|
51
|
+
* 与用户真的点了 REJECT_LABEL 长得一模一样)。两个标签都做**显式**等值比对,任何第三种输入
|
|
52
|
+
* (含 undefined)落 `'dismissed'`,由调用方决定是保持 park 还是显式当 reject 处理——本函数只
|
|
53
|
+
* 负责如实分类,不替调用方做选择。
|
|
54
|
+
*/
|
|
55
|
+
export declare function planReviewDecisionFromAnswer(answer: QuestionAnswer | null | undefined): 'approve' | 'reject' | 'dismissed';
|
|
1
56
|
/** done 帧的 plan_review park 形状(结构性读,别的终态一律 false)。 */
|
|
2
57
|
export declare function isPlanReviewPark(result: unknown): result is {
|
|
3
58
|
taskId: string;
|
|
4
59
|
sessionId?: string;
|
|
5
60
|
};
|
|
61
|
+
/** 测试钩:清掉武装态(不动 liveQuestionStore 本身,那边有自己的 reset)。 */
|
|
62
|
+
export declare function _resetArmedPlanReviewsForTest(): void;
|
|
6
63
|
/**
|
|
7
64
|
* Arm the approval card for a parked plan_review (call with the RAW done.result). Returns true when
|
|
8
|
-
* armed (live path + shape matched
|
|
9
|
-
*
|
|
65
|
+
* armed (live path + shape matched, INCLUDING the idempotent "already armed" short-circuit — see
|
|
66
|
+
* REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
|
|
67
|
+
* never break the stream drain.
|
|
10
68
|
*/
|
|
11
69
|
export declare function armPlanReviewApproval(result: unknown): boolean;
|
|
12
70
|
/** POST the decision; on settle enqueue an isMeta turn so the model reports the resume outcome.
|
|
@@ -44,8 +44,31 @@ import { enqueuePlanReviewOutcome } from '../notifications.js';
|
|
|
44
44
|
/** resume 是 SYNC 驱动到终态的(可能分钟级)——原 raw fetch 无超时;SDK Transport 恒有每请求
|
|
45
45
|
* 超时,取 6h 宽上界(诚实预算:超过它基本是引擎僵死,超时文案与网络失败同走 outcome 通知)。 */
|
|
46
46
|
const PLAN_REVIEW_RESUME_TIMEOUT_MS = 6 * 60 * 60_000;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* REF-CC-026:此前是 module-private 常量,唯一另一个消费者(cli `planReviewReopen.ts`)只能靠
|
|
49
|
+
* 人眼手抄同步(头注写着「与 client-core planReviewWire 的原卡逐字一致」)——漂一个字节,判决就
|
|
50
|
+
* 落到危险的默认臂(见下方 `planReviewDecisionFromAnswer` 的头注)。现在导出成唯一真源;cli 侧改
|
|
51
|
+
* import 是后续跟车工单(壳仓文件域,不在本卡范围)。
|
|
52
|
+
*/
|
|
53
|
+
export const PLAN_REVIEW_APPROVE_LABEL = 'Yes, approve and run the plan';
|
|
54
|
+
export const PLAN_REVIEW_REJECT_LABEL = 'No, reject it (keep planning)';
|
|
55
|
+
const APPROVE_LABEL = PLAN_REVIEW_APPROVE_LABEL;
|
|
56
|
+
const REJECT_LABEL = PLAN_REVIEW_REJECT_LABEL;
|
|
57
|
+
/**
|
|
58
|
+
* REF-CC-026:三值判决(此前 `selected === APPROVE_LABEL ? 'approve' : 'reject'` 把「没读懂/没
|
|
59
|
+
* 选」的第三种输入静默塌缩进危险的 reject 臂——overlay 的 dismiss/拒答语义是「送空 answers」,
|
|
60
|
+
* 与用户真的点了 REJECT_LABEL 长得一模一样)。两个标签都做**显式**等值比对,任何第三种输入
|
|
61
|
+
* (含 undefined)落 `'dismissed'`,由调用方决定是保持 park 还是显式当 reject 处理——本函数只
|
|
62
|
+
* 负责如实分类,不替调用方做选择。
|
|
63
|
+
*/
|
|
64
|
+
export function planReviewDecisionFromAnswer(answer) {
|
|
65
|
+
const selected = answer?.answers?.[0]?.selected?.[0];
|
|
66
|
+
if (selected === APPROVE_LABEL)
|
|
67
|
+
return 'approve';
|
|
68
|
+
if (selected === REJECT_LABEL)
|
|
69
|
+
return 'reject';
|
|
70
|
+
return 'dismissed';
|
|
71
|
+
}
|
|
49
72
|
/** done 帧的 plan_review park 形状(结构性读,别的终态一律 false)。 */
|
|
50
73
|
export function isPlanReviewPark(result) {
|
|
51
74
|
const r = result;
|
|
@@ -55,24 +78,56 @@ export function isPlanReviewPark(result) {
|
|
|
55
78
|
typeof r.taskId === 'string' &&
|
|
56
79
|
r.taskId.length > 0);
|
|
57
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* REF-CC-027:已武装未消解的 taskId 去重集(与 `HitlBridge`/`liveQuestionStore` 同款「进程内单例
|
|
83
|
+
* 承重不变量」——见 `docs/refactor/p1-scan/singleton-manifest.json` 登记)。durable re-attach 会把
|
|
84
|
+
* 同一个 `done{needs_review}` 帧再送一遍(与 askGateWire 的 `decidedGates` 同一族问题);此前
|
|
85
|
+
* `armPlanReviewApproval` 对同一 taskId 的重复调用会**再注册一次** responder(旧的注销函数被整个
|
|
86
|
+
* 丢掉),第二次注册会覆盖 `localResponders` 里的条目——若第一张卡此刻还没答,它的 unregister
|
|
87
|
+
* 闭包会变成一个指向"已被覆盖"槽位的悬空引用。这个集合让重复 arm 直接幂等短路,不再产生第二次
|
|
88
|
+
* `registerLocalQuestionResponder` 调用。
|
|
89
|
+
*/
|
|
90
|
+
const armedPlanReviewTaskIds = new Set();
|
|
91
|
+
/** 测试钩:清掉武装态(不动 liveQuestionStore 本身,那边有自己的 reset)。 */
|
|
92
|
+
export function _resetArmedPlanReviewsForTest() {
|
|
93
|
+
armedPlanReviewTaskIds.clear();
|
|
94
|
+
}
|
|
58
95
|
/**
|
|
59
96
|
* Arm the approval card for a parked plan_review (call with the RAW done.result). Returns true when
|
|
60
|
-
* armed (live path + shape matched
|
|
61
|
-
*
|
|
97
|
+
* armed (live path + shape matched, INCLUDING the idempotent "already armed" short-circuit — see
|
|
98
|
+
* REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
|
|
99
|
+
* never break the stream drain.
|
|
62
100
|
*/
|
|
63
101
|
export function armPlanReviewApproval(result) {
|
|
102
|
+
let armedTaskId;
|
|
64
103
|
try {
|
|
65
104
|
if (!isPlanReviewPark(result))
|
|
66
105
|
return false;
|
|
67
106
|
if (!engineWireTarget())
|
|
68
107
|
return false;
|
|
69
108
|
const { taskId } = result;
|
|
109
|
+
if (armedPlanReviewTaskIds.has(taskId)) {
|
|
110
|
+
// REF-CC-027:同一张卡还没消解就被重放的 arm 撞上——幂等,不二次弹卡/二次注册。
|
|
111
|
+
hostLog('debug', `planReviewWire: arm skipped — task ${taskId} is already armed (durable replay, idempotent)`);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
armedPlanReviewTaskIds.add(taskId);
|
|
115
|
+
armedTaskId = taskId;
|
|
70
116
|
const questionId = `plan-review:${taskId}`;
|
|
71
|
-
registerLocalQuestionResponder(questionId, async (_id, answer) => {
|
|
72
|
-
|
|
73
|
-
|
|
117
|
+
const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
|
|
118
|
+
// REF-CC-027:一次性 —— 首句就注销 + 清武装态(照 askGateWire 的 cleanup() 形)。
|
|
119
|
+
// liveQuestionStore.respondToQuestion 自身也是一次性取件(delete-before-invoke),这里是
|
|
120
|
+
// 第二道钉:武装态一清,同 taskId 才能重新正常 arm(证明 responder 真的落定了)。
|
|
121
|
+
unregister();
|
|
122
|
+
armedPlanReviewTaskIds.delete(taskId);
|
|
123
|
+
const decided = planReviewDecisionFromAnswer(answer);
|
|
124
|
+
if (decided === 'dismissed') {
|
|
125
|
+
// dismiss/无法判读:诚实缺席优先于编造默认值(REF-CC-026)——不静默驱动 decidePlanReview,
|
|
126
|
+
// 卡的重开路径把决定权还给用户。
|
|
127
|
+
return { ok: true };
|
|
128
|
+
}
|
|
74
129
|
// fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay;结果经 queue 通知回来
|
|
75
|
-
void decidePlanReview(taskId,
|
|
130
|
+
void decidePlanReview(taskId, decided);
|
|
76
131
|
return { ok: true };
|
|
77
132
|
});
|
|
78
133
|
publishQuestionFrame({
|
|
@@ -94,6 +149,10 @@ export function armPlanReviewApproval(result) {
|
|
|
94
149
|
return true;
|
|
95
150
|
}
|
|
96
151
|
catch (e) {
|
|
152
|
+
// REF-CC-027:arm 本身失败(register/publish 抛)绝不能把 taskId 永久锁在武装态里
|
|
153
|
+
// ——那样这张卡再也无法被正常 arm 一次。
|
|
154
|
+
if (armedTaskId !== undefined)
|
|
155
|
+
armedPlanReviewTaskIds.delete(armedTaskId);
|
|
97
156
|
hostLog('debug', `planReviewWire: arm failed (fail-soft): ${String(e)}`);
|
|
98
157
|
return false;
|
|
99
158
|
}
|
|
@@ -104,80 +163,99 @@ export function armPlanReviewApproval(result) {
|
|
|
104
163
|
* server body.error 原文)→ 同款「HTTP <status> <error>」outcome 文案;网络失败走原「could not
|
|
105
164
|
* reach the engine」臂。 */
|
|
106
165
|
export async function decidePlanReview(taskId, decision) {
|
|
166
|
+
// REF-CC-025:此前 `cfg`/`client` 缺席各自 `return` 静默——卡已经对用户回了「收到」
|
|
167
|
+
// (armPlanReviewApproval 的 responder 早就 `return {ok:true}` 过了),但决断本身连一次网络
|
|
168
|
+
// 请求都没发出去,而模型/用户没有任何回程信号。两条早退现在都汇进同一条 outcome 管道(下方
|
|
169
|
+
// 唯一的 enqueue 调用点),不再是两条独立的静默 return。
|
|
170
|
+
let outcome;
|
|
107
171
|
const cfg = engineWireTarget();
|
|
108
|
-
if (!cfg)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
baseUrl: cfg.baseUrl,
|
|
112
|
-
...(cfg.token ? { token: cfg.token } : {}),
|
|
113
|
-
principal: cfg.principal,
|
|
114
|
-
timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS,
|
|
115
|
-
});
|
|
116
|
-
if (!client)
|
|
117
|
-
return;
|
|
118
|
-
let outcome = '';
|
|
119
|
-
try {
|
|
120
|
-
const body = await client.assistant.planReview(taskId, { decision });
|
|
121
|
-
hostLog('debug', `planReviewWire: ${decision} → ok ${JSON.stringify(body).slice(0, 200)}`);
|
|
122
|
-
// [2315]/[2316](#109,2026-08-02):decide 的 **2xx 不当终态** —— test 黑盒实测 reject 9/9
|
|
123
|
-
// 返回 200 而会话仍锁在同一 gate(core RB-471:重开兜底对 reject 腿恒真误触发)。这里回拉
|
|
124
|
-
// 一次任务状态,按真形分三路措辞;根因归 core/server,本腿是「对外动作回读验证」在产品面的
|
|
125
|
-
// 同款,上游修后仍有价值(approve 合法推进新 gate 的形也靠它说真话)。
|
|
126
|
-
// 回拉用**短超时**独立 client(15s):verify 腿挂住不该占用 decide 的 6h 宽预算。
|
|
127
|
-
let postStatus;
|
|
128
|
-
try {
|
|
129
|
-
const probe = makeEngineWireClient({
|
|
130
|
-
baseUrl: cfg.baseUrl,
|
|
131
|
-
...(cfg.token ? { token: cfg.token } : {}),
|
|
132
|
-
principal: cfg.principal,
|
|
133
|
-
timeoutMs: 15_000,
|
|
134
|
-
});
|
|
135
|
-
const post = probe ? (await probe.runs.get(taskId)) : null;
|
|
136
|
-
postStatus = typeof post?.status === 'string' ? post.status : undefined;
|
|
137
|
-
}
|
|
138
|
-
catch (e) {
|
|
139
|
-
hostLog('debug', `planReviewWire: post-decide status re-pull failed (fail-soft): ${String(e)}`);
|
|
140
|
-
}
|
|
141
|
-
// 回拉失败时回落 decide 200 体自带的 status(次级来源;两者都缺=unverified,措辞如实降级)。
|
|
142
|
-
const effective = postStatus ?? (typeof body?.status === 'string' ? body.status : undefined);
|
|
143
|
-
if (effective === 'needs_review') {
|
|
144
|
-
// 决定没生效,仍锁原 gate。不渲「已处理」;真出路只有 approve 或 cancel(下一次提交撞 409
|
|
145
|
-
// 时 activeRunSelfHeal 会把审批卡重开——这里不自动重弹卡,避免「刚拒绝又弹卡」的突袭感,
|
|
146
|
-
// 决定权经卡的重开路径还给用户)。
|
|
147
|
-
outcome =
|
|
148
|
-
`The plan_review ${decision} returned HTTP 200 but the session is STILL locked on the same review gate ` +
|
|
149
|
-
`(post-decide status: needs_review). The decision did NOT take effect (known engine issue, RB-471 family). ` +
|
|
150
|
-
`Tell the user plainly that the ${decision} did not go through; the reliable exits today are approving the plan or cancelling the task.`;
|
|
151
|
-
}
|
|
152
|
-
else if (effective === 'suspended') {
|
|
153
|
-
// 合法推进到新 gate(test [2315] ②形):不是「完成」,如实说下一张审批卡会跟上。
|
|
154
|
-
outcome =
|
|
155
|
-
`The plan was ${decision === 'approve' ? 'approved' : 'rejected'} and the task advanced to a NEW approval gate ` +
|
|
156
|
-
`(post-decide status: suspended) — the next approval card will surface it; this is not a completion yet.`;
|
|
157
|
-
}
|
|
158
|
-
else if (effective !== undefined) {
|
|
159
|
-
outcome = `The plan was ${decision === 'approve' ? 'approved and the parked task resumed to completion' : 'rejected (plan discarded)'} — final status: ${effective} (post-decide re-checked: task left the review gate).`;
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
outcome = `The plan was ${decision === 'approve' ? 'approved and the parked task resumed to completion' : 'rejected (plan discarded)'} — final status: unknown (post-decide verification unavailable; treat as unconfirmed).`;
|
|
163
|
-
}
|
|
172
|
+
if (!cfg) {
|
|
173
|
+
hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
|
|
174
|
+
outcome = `The plan_review ${decision} could NOT be sent: no engine connection is configured on this host. Tell the user plainly that the decision did not go through.`;
|
|
164
175
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
176
|
+
else {
|
|
177
|
+
const client = makeEngineWireClient({
|
|
178
|
+
baseUrl: cfg.baseUrl,
|
|
179
|
+
...(cfg.token ? { token: cfg.token } : {}),
|
|
180
|
+
principal: cfg.principal,
|
|
181
|
+
timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS,
|
|
182
|
+
});
|
|
183
|
+
if (!client) {
|
|
184
|
+
hostLog('error', `planReviewWire: ${decision} NOT sent — makeEngineWireClient() returned null`);
|
|
185
|
+
outcome = `The plan_review ${decision} could NOT be sent: the engine client could not be constructed. Tell the user plainly that the decision did not go through.`;
|
|
172
186
|
}
|
|
173
187
|
else {
|
|
174
|
-
|
|
188
|
+
try {
|
|
189
|
+
const body = await client.assistant.planReview(taskId, { decision });
|
|
190
|
+
hostLog('debug', `planReviewWire: ${decision} → ok ${JSON.stringify(body).slice(0, 200)}`);
|
|
191
|
+
// [2315]/[2316](#109,2026-08-02):decide 的 **2xx 不当终态** —— test 黑盒实测 reject 9/9
|
|
192
|
+
// 返回 200 而会话仍锁在同一 gate(core RB-471:重开兜底对 reject 腿恒真误触发)。这里回拉
|
|
193
|
+
// 一次任务状态,按真形分三路措辞;根因归 core/server,本腿是「对外动作回读验证」在产品面的
|
|
194
|
+
// 同款,上游修后仍有价值(approve 合法推进新 gate 的形也靠它说真话)。
|
|
195
|
+
// 回拉用**短超时**独立 client(15s):verify 腿挂住不该占用 decide 的 6h 宽预算。
|
|
196
|
+
let postStatus;
|
|
197
|
+
try {
|
|
198
|
+
const probe = makeEngineWireClient({
|
|
199
|
+
baseUrl: cfg.baseUrl,
|
|
200
|
+
...(cfg.token ? { token: cfg.token } : {}),
|
|
201
|
+
principal: cfg.principal,
|
|
202
|
+
timeoutMs: 15_000,
|
|
203
|
+
});
|
|
204
|
+
const post = probe ? (await probe.runs.get(taskId)) : null;
|
|
205
|
+
postStatus = typeof post?.status === 'string' ? post.status : undefined;
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
hostLog('debug', `planReviewWire: post-decide status re-pull failed (fail-soft): ${String(e)}`);
|
|
209
|
+
}
|
|
210
|
+
// 回拉失败时回落 decide 200 体自带的 status(次级来源;两者都缺=unverified,措辞如实降级)。
|
|
211
|
+
const effective = postStatus ?? (typeof body?.status === 'string' ? body.status : undefined);
|
|
212
|
+
if (effective === 'needs_review') {
|
|
213
|
+
// 决定没生效,仍锁原 gate。不渲「已处理」;真出路只有 approve 或 cancel(下一次提交撞 409
|
|
214
|
+
// 时 activeRunSelfHeal 会把审批卡重开——这里不自动重弹卡,避免「刚拒绝又弹卡」的突袭感,
|
|
215
|
+
// 决定权经卡的重开路径还给用户)。
|
|
216
|
+
outcome =
|
|
217
|
+
`The plan_review ${decision} returned HTTP 200 but the session is STILL locked on the same review gate ` +
|
|
218
|
+
`(post-decide status: needs_review). The decision did NOT take effect (known engine issue, RB-471 family). ` +
|
|
219
|
+
`Tell the user plainly that the ${decision} did not go through; the reliable exits today are approving the plan or cancelling the task.`;
|
|
220
|
+
}
|
|
221
|
+
else if (effective === 'suspended') {
|
|
222
|
+
// 合法推进到新 gate(test [2315] ②形):不是「完成」,如实说下一张审批卡会跟上。
|
|
223
|
+
outcome =
|
|
224
|
+
`The plan was ${decision === 'approve' ? 'approved' : 'rejected'} and the task advanced to a NEW approval gate ` +
|
|
225
|
+
`(post-decide status: suspended) — the next approval card will surface it; this is not a completion yet.`;
|
|
226
|
+
}
|
|
227
|
+
else if (effective !== undefined) {
|
|
228
|
+
outcome = `The plan was ${decision === 'approve' ? 'approved and the parked task resumed to completion' : 'rejected (plan discarded)'} — final status: ${effective} (post-decide re-checked: task left the review gate).`;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
outcome = `The plan was ${decision === 'approve' ? 'approved and the parked task resumed to completion' : 'rejected (plan discarded)'} — final status: unknown (post-decide verification unavailable; treat as unconfirmed).`;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
// APIError 判型走 status duck-check(sseIdleTriage 同款纪律:双包时 instanceof 会分叉)。
|
|
236
|
+
const status = e?.status;
|
|
237
|
+
if (typeof status === 'number') {
|
|
238
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
239
|
+
hostLog('debug', `planReviewWire: ${decision} → ${status} ${msg.slice(0, 200)}`);
|
|
240
|
+
outcome = `The plan_review decision failed: HTTP ${status} ${msg}`.trim();
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
outcome = `The plan_review decision could not reach the engine: ${String(e)}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
175
246
|
}
|
|
176
247
|
}
|
|
248
|
+
// REF-CC-025:唯一的 enqueue 调用点(三条上游路径——cfg 缺席 / client 缺席 / 正常决断—— 都汇到
|
|
249
|
+
// 这里)。返回值此前被丢弃:`enqueuePlanReviewOutcome` 返回 `false` 意味着队列口没装或没实现
|
|
250
|
+
// `enqueueMetaPrompt`(notifications.ts 头注:「模型永远不知道 plan 被批准/驳回后引擎跑出了
|
|
251
|
+
// 什么结果」),这是与 HTTP 失败同等重量的静默丢失,必须留痕。
|
|
177
252
|
try {
|
|
178
|
-
enqueuePlanReviewOutcome(`<plan-review-outcome>\n${outcome}${decision === 'approve' ? `\nUse TaskOutput("${taskId}") if you need the resumed task's full output, then give the user a brief report of what was done.` : '\nBriefly acknowledge to the user that the plan was rejected and continue planning.'}\n</plan-review-outcome>`);
|
|
253
|
+
const delivered = enqueuePlanReviewOutcome(`<plan-review-outcome>\n${outcome}${decision === 'approve' ? `\nUse TaskOutput("${taskId}") if you need the resumed task's full output, then give the user a brief report of what was done.` : '\nBriefly acknowledge to the user that the plan was rejected and continue planning.'}\n</plan-review-outcome>`);
|
|
254
|
+
if (!delivered) {
|
|
255
|
+
hostLog('error', `planReviewWire: outcome enqueue MISSED (queue port not installed or lacks enqueueMetaPrompt) for task ${taskId} — model will not learn the ${decision} result`);
|
|
256
|
+
}
|
|
179
257
|
}
|
|
180
258
|
catch (e) {
|
|
181
|
-
hostLog('debug', `planReviewWire: outcome enqueue
|
|
259
|
+
hostLog('debug', `planReviewWire: outcome enqueue threw: ${String(e)}`);
|
|
182
260
|
}
|
|
183
261
|
}
|