@sema-agent/client-core 0.12.1 → 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 +15 -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 -546
- 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
|
@@ -129,18 +129,55 @@ export interface PlanReviewOutcome {
|
|
|
129
129
|
* The caller MUST re-present to the human or surface the error — NEVER silently retry or auto-decide
|
|
130
130
|
* (contract/04 §9.1 fail-closed law). */
|
|
131
131
|
export declare class HitlSafetyError extends Error {
|
|
132
|
-
/** A stable code the shell can branch on (
|
|
133
|
-
|
|
132
|
+
/** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string —
|
|
133
|
+
* 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
|
|
134
|
+
readonly code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit';
|
|
134
135
|
constructor(message: string,
|
|
135
|
-
/** A stable code the shell can branch on (
|
|
136
|
-
|
|
136
|
+
/** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string —
|
|
137
|
+
* 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
|
|
138
|
+
code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit');
|
|
137
139
|
}
|
|
138
140
|
/** The active gate the downstream stream most recently suspended on (null when running). */
|
|
139
141
|
export interface ActiveGate {
|
|
140
142
|
gate: CheckpointGate;
|
|
141
143
|
/** The durable seq of the `suspended` event, for rewind/dedup correlation (types.ts:48 eventSeq). */
|
|
142
|
-
seq?: string;
|
|
144
|
+
seq?: string | undefined;
|
|
143
145
|
}
|
|
146
|
+
/** Outcome of {@link findPendingForTask}. */
|
|
147
|
+
export type FindPendingOutcome = {
|
|
148
|
+
ok: true;
|
|
149
|
+
pending: PendingCheckpoint;
|
|
150
|
+
gatedCallId: string | undefined;
|
|
151
|
+
}
|
|
152
|
+
/** `code`:结构化判别位(REF-CC-033)。'no_pending'=良性缺席(park 重放常见);缺席=真失败(list 抛)。 */
|
|
153
|
+
| {
|
|
154
|
+
ok: false;
|
|
155
|
+
reason: string;
|
|
156
|
+
code?: 'no_pending';
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
|
|
160
|
+
* decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
|
|
161
|
+
* BEFORE they know which specific call/question they're presenting — that is the FIRST fetch, the one
|
|
162
|
+
* that decides what gets rendered on the card/overlay.
|
|
163
|
+
*
|
|
164
|
+
* REF-CC-029(2026-08-02,消 hitl2-08/hitl2-01 双病根):this used to be three near-identical copies
|
|
165
|
+
* (this file's own `HitlBridge.findPending` plus one each in `toolApprovalWire.ts` / `askGateWire.ts`),
|
|
166
|
+
* and only `HitlBridge.findPending`'s copy had a `?? pending[0]` last-resort fallback (REF-CC-022) that
|
|
167
|
+
* could resolve a DIFFERENT session's or a background subagent lane's pending row once this taskId's own
|
|
168
|
+
* rows were gone — they all share the same `/v1/approvals` queue. Single-sourcing this lookup here means
|
|
169
|
+
* that divergence can't recur, and passing the resolved row straight into `decideTool`/`answerQuestion`'s
|
|
170
|
+
* `preResolvedPending` param closes the TOCTOU window between "the row the human saw" (this fetch) and
|
|
171
|
+
* "the row decide resolves" (previously a SECOND independent fetch inside the bridge).
|
|
172
|
+
*
|
|
173
|
+
* Match by `taskId` + a caller-supplied predicate over the row's `toolName` (the tool family this wire
|
|
174
|
+
* cares about, e.g. fs-write vs AskUserQuestion) first; fall back to ANY row for this `taskId` (a gate
|
|
175
|
+
* whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
|
|
176
|
+
* — a typed failure when nothing for this taskId is queued.
|
|
177
|
+
*/
|
|
178
|
+
export declare function findPendingForTask(client: HitlClientLike, taskId: string, matches: (toolName: string | undefined) => boolean, opts?: {
|
|
179
|
+
signal?: AbortSignal;
|
|
180
|
+
}): Promise<FindPendingOutcome>;
|
|
144
181
|
/**
|
|
145
182
|
* The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
|
|
146
183
|
* outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
|
|
@@ -161,13 +198,22 @@ export declare class HitlBridge {
|
|
|
161
198
|
* Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
|
|
162
199
|
* `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
|
|
163
200
|
* the queue is empty (e.g. resolved/expired under the human → the caller must refetch + re-present).
|
|
201
|
+
*
|
|
202
|
+
* Used only as the fallback when a caller does NOT already have the pending row in hand (see
|
|
203
|
+
* `decideTool`/`answerQuestion`'s `preResolvedPending` param) — the wire modules that surface a card/
|
|
204
|
+
* overlay BEFORE calling decide/answer fetch the row themselves via `findPendingForTask` (below) and
|
|
205
|
+
* pass it straight through, closing the TOCTOU window between "the row the human saw" and "the row
|
|
206
|
+
* decide resolves" (REF-CC-029/hitl2-08).
|
|
164
207
|
*/
|
|
165
208
|
private findPending;
|
|
166
209
|
/**
|
|
167
210
|
* Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
|
|
168
|
-
* (contract/04 §2.2).
|
|
169
|
-
* back to resolve-by-sessionId. `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
|
|
211
|
+
* (contract/04 §2.2). `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
|
|
170
212
|
* compliant clients send ONLY the two-tuple, approvals.ts:23-27).
|
|
213
|
+
*
|
|
214
|
+
* pre-D-1 的 `{}` 回落臂已删(2026-08-03,#117 提货 / [2356]#2):server 5.x 的 decide 三键必填、
|
|
215
|
+
* 缺键 400,「发一个注定 400 的请求」不如在本地响亮失败——pending 行缺绑定键只可能来自
|
|
216
|
+
* 不再支持的旧 worker 或坏数据,fail-loud 带坐标好过静默烧一轮 HTTP。
|
|
171
217
|
*/
|
|
172
218
|
private bindingOf;
|
|
173
219
|
/**
|
|
@@ -185,13 +231,20 @@ export declare class HitlBridge {
|
|
|
185
231
|
decideTool(outcome: {
|
|
186
232
|
decision: 'approve';
|
|
187
233
|
updatedInput?: unknown;
|
|
188
|
-
remember?: 'session';
|
|
234
|
+
remember?: 'session' | undefined;
|
|
189
235
|
} | {
|
|
190
236
|
decision: 'deny';
|
|
191
|
-
reason?: string;
|
|
237
|
+
reason?: string | undefined;
|
|
192
238
|
}, toolUseID?: string, opts?: {
|
|
193
239
|
signal?: AbortSignal;
|
|
194
|
-
}
|
|
240
|
+
},
|
|
241
|
+
/** REF-CC-029(2026-08-02):the pending row the caller ALREADY fetched (via `findPendingForTask`,
|
|
242
|
+
* below) to render the card/overlay the human just decided on. When supplied, this method skips
|
|
243
|
+
* its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
|
|
244
|
+
* row decide resolves" (two independent fetches could otherwise land on different rows if the
|
|
245
|
+
* first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
|
|
246
|
+
* such row in hand (falls back to `findPending`). */
|
|
247
|
+
preResolvedPending?: PendingCheckpoint): Promise<unknown>;
|
|
195
248
|
/**
|
|
196
249
|
* Answer an `AskUserQuestion` gate (contract/04 §4; 08 CS-16). Same suspended → decide loop as a
|
|
197
250
|
* permission gate, but the answer rides `ApprovalDecision.answer` as the worker-validated
|
|
@@ -200,12 +253,16 @@ export declare class HitlBridge {
|
|
|
200
253
|
*/
|
|
201
254
|
answerQuestion(answers: AskAnswer[], toolUseID?: string, opts?: {
|
|
202
255
|
signal?: AbortSignal;
|
|
203
|
-
}
|
|
256
|
+
},
|
|
257
|
+
/** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
|
|
258
|
+
* fetched via `findPendingForTask`. */
|
|
259
|
+
preResolvedPending?: PendingCheckpoint): Promise<unknown>;
|
|
204
260
|
/**
|
|
205
261
|
* Resolve a plan-mode gate (contract/04 §3; 08 IH-6). Routed to `assistant.planReview`, NOT `decide`/
|
|
206
|
-
* `resume`.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
262
|
+
* `resume`. Also guards the `editedPlan` invariant (REQUIRED iff `decision==="edit"`, FORBIDDEN
|
|
263
|
+
* otherwise → 400) so the bad call never leaves the shell — that check is unconditional (no
|
|
264
|
+
* `observe()` dependency). The gate-kind guard below is host-optional (see its own comment); the
|
|
265
|
+
* server's 409 `gate_not_plan_review` is the real backstop either way.
|
|
209
266
|
*/
|
|
210
267
|
reviewPlan(outcome: PlanReviewOutcome, opts?: {
|
|
211
268
|
signal?: AbortSignal;
|
|
@@ -213,7 +270,10 @@ export declare class HitlBridge {
|
|
|
213
270
|
/**
|
|
214
271
|
* Continue a `resource_limit` slice pause (contract/04 §6.1; 08 IH-9). Bodyless `assistant.resume` — NOT
|
|
215
272
|
* `decide`. A `human`/`irreversible_ask` gate routed here would 409 `gate_not_resumable` (a "continue"
|
|
216
|
-
* must NEVER bypass a human approval)
|
|
273
|
+
* must NEVER bypass a human approval). REF-CC-028(2026-08-02,评注现势):the local guard below is
|
|
274
|
+
* host-optional — it only fires when the host drives `observe(ev)` (neither production call site does
|
|
275
|
+
* today, see `reviewPlan`'s comment); the server's 409 is the fail-closed backstop that actually holds
|
|
276
|
+
* this invariant.
|
|
217
277
|
*/
|
|
218
278
|
resumeResourceLimit(opts?: {
|
|
219
279
|
signal?: AbortSignal;
|
package/dist/hitl/hitlBridge.js
CHANGED
|
@@ -6,13 +6,48 @@ import { eventSeq } from '../adapter/types.js';
|
|
|
6
6
|
export class HitlSafetyError extends Error {
|
|
7
7
|
code;
|
|
8
8
|
constructor(message,
|
|
9
|
-
/** A stable code the shell can branch on (
|
|
9
|
+
/** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string —
|
|
10
|
+
* 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
|
|
10
11
|
code) {
|
|
11
12
|
super(message);
|
|
12
13
|
this.code = code;
|
|
13
14
|
this.name = 'HitlSafetyError';
|
|
14
15
|
}
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
|
|
19
|
+
* decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
|
|
20
|
+
* BEFORE they know which specific call/question they're presenting — that is the FIRST fetch, the one
|
|
21
|
+
* that decides what gets rendered on the card/overlay.
|
|
22
|
+
*
|
|
23
|
+
* REF-CC-029(2026-08-02,消 hitl2-08/hitl2-01 双病根):this used to be three near-identical copies
|
|
24
|
+
* (this file's own `HitlBridge.findPending` plus one each in `toolApprovalWire.ts` / `askGateWire.ts`),
|
|
25
|
+
* and only `HitlBridge.findPending`'s copy had a `?? pending[0]` last-resort fallback (REF-CC-022) that
|
|
26
|
+
* could resolve a DIFFERENT session's or a background subagent lane's pending row once this taskId's own
|
|
27
|
+
* rows were gone — they all share the same `/v1/approvals` queue. Single-sourcing this lookup here means
|
|
28
|
+
* that divergence can't recur, and passing the resolved row straight into `decideTool`/`answerQuestion`'s
|
|
29
|
+
* `preResolvedPending` param closes the TOCTOU window between "the row the human saw" (this fetch) and
|
|
30
|
+
* "the row decide resolves" (previously a SECOND independent fetch inside the bridge).
|
|
31
|
+
*
|
|
32
|
+
* Match by `taskId` + a caller-supplied predicate over the row's `toolName` (the tool family this wire
|
|
33
|
+
* cares about, e.g. fs-write vs AskUserQuestion) first; fall back to ANY row for this `taskId` (a gate
|
|
34
|
+
* whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
|
|
35
|
+
* — a typed failure when nothing for this taskId is queued.
|
|
36
|
+
*/
|
|
37
|
+
export async function findPendingForTask(client, taskId, matches, opts) {
|
|
38
|
+
let rows;
|
|
39
|
+
try {
|
|
40
|
+
rows = (await client.approvals.list(opts)).pending;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
return { ok: false, reason: `approvals.list failed: ${String(e)}` };
|
|
44
|
+
}
|
|
45
|
+
const pending = rows.find((r) => r.taskId === taskId && matches(typeof r.toolName === 'string' ? r.toolName : undefined)) ??
|
|
46
|
+
rows.find((r) => r.taskId === taskId);
|
|
47
|
+
if (!pending)
|
|
48
|
+
return { ok: false, reason: 'no pending checkpoint for this run (resolved/expired?)', code: 'no_pending' };
|
|
49
|
+
return { ok: true, pending, gatedCallId: pending.toolCallId ?? pending.boundCallId ?? undefined };
|
|
50
|
+
}
|
|
16
51
|
/**
|
|
17
52
|
* The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
|
|
18
53
|
* outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
|
|
@@ -59,6 +94,12 @@ export class HitlBridge {
|
|
|
59
94
|
* Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
|
|
60
95
|
* `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
|
|
61
96
|
* the queue is empty (e.g. resolved/expired under the human → the caller must refetch + re-present).
|
|
97
|
+
*
|
|
98
|
+
* Used only as the fallback when a caller does NOT already have the pending row in hand (see
|
|
99
|
+
* `decideTool`/`answerQuestion`'s `preResolvedPending` param) — the wire modules that surface a card/
|
|
100
|
+
* overlay BEFORE calling decide/answer fetch the row themselves via `findPendingForTask` (below) and
|
|
101
|
+
* pass it straight through, closing the TOCTOU window between "the row the human saw" and "the row
|
|
102
|
+
* decide resolves" (REF-CC-029/hitl2-08).
|
|
62
103
|
*/
|
|
63
104
|
async findPending(toolUseID, opts) {
|
|
64
105
|
const { pending } = await this.client.approvals.list(opts);
|
|
@@ -70,22 +111,29 @@ export class HitlBridge {
|
|
|
70
111
|
return byCall;
|
|
71
112
|
}
|
|
72
113
|
// Fall back to this run's pending row (the suspended run holding the session claim).
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
// REF-CC-022(2026-08-02): NEVER `?? pending[0]` — once this taskId's own rows are exhausted, the
|
|
115
|
+
// first queue row can belong to a DIFFERENT session or a background subagent lane (they share the
|
|
116
|
+
// same `/v1/approvals` queue); deciding on it would resolve someone else's gate. A miss here is a
|
|
117
|
+
// real `no_pending` — the caller's existing typed-fail path (and exactly the signal #110's
|
|
118
|
+
// cancel-by-deny-recovery arm expects), not "decide on whatever happens to be first".
|
|
119
|
+
return pending.find((p) => p.taskId === this.taskId) ?? null;
|
|
75
120
|
}
|
|
76
121
|
/**
|
|
77
122
|
* Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
|
|
78
|
-
* (contract/04 §2.2).
|
|
79
|
-
* back to resolve-by-sessionId. `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
|
|
123
|
+
* (contract/04 §2.2). `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
|
|
80
124
|
* compliant clients send ONLY the two-tuple, approvals.ts:23-27).
|
|
125
|
+
*
|
|
126
|
+
* pre-D-1 的 `{}` 回落臂已删(2026-08-03,#117 提货 / [2356]#2):server 5.x 的 decide 三键必填、
|
|
127
|
+
* 缺键 400,「发一个注定 400 的请求」不如在本地响亮失败——pending 行缺绑定键只可能来自
|
|
128
|
+
* 不再支持的旧 worker 或坏数据,fail-loud 带坐标好过静默烧一轮 HTTP。
|
|
81
129
|
*/
|
|
82
130
|
bindingOf(p) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return
|
|
131
|
+
if (p.boundCallId === undefined || p.boundInputHash === undefined) {
|
|
132
|
+
throw new HitlSafetyError(`pending checkpoint ${p.id ?? '<no-id>'} is missing D-1 binding fields ` +
|
|
133
|
+
`(boundCallId=${String(p.boundCallId)}, boundInputHash=${String(p.boundInputHash)}) — ` +
|
|
134
|
+
'pre-D-1 workers are no longer supported (server 5.x requires the verbatim two-tuple echo)', 'binding_mismatch');
|
|
135
|
+
}
|
|
136
|
+
return { boundCallId: p.boundCallId, boundInputHash: p.boundInputHash };
|
|
89
137
|
}
|
|
90
138
|
// ── §2. Permission gate: approve / deny a tool ──────────────────────────────
|
|
91
139
|
/**
|
|
@@ -100,8 +148,15 @@ export class HitlBridge {
|
|
|
100
148
|
* The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
|
|
101
149
|
* `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
|
|
102
150
|
*/
|
|
103
|
-
async decideTool(outcome, toolUseID, opts
|
|
104
|
-
|
|
151
|
+
async decideTool(outcome, toolUseID, opts,
|
|
152
|
+
/** REF-CC-029(2026-08-02):the pending row the caller ALREADY fetched (via `findPendingForTask`,
|
|
153
|
+
* below) to render the card/overlay the human just decided on. When supplied, this method skips
|
|
154
|
+
* its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
|
|
155
|
+
* row decide resolves" (two independent fetches could otherwise land on different rows if the
|
|
156
|
+
* first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
|
|
157
|
+
* such row in hand (falls back to `findPending`). */
|
|
158
|
+
preResolvedPending) {
|
|
159
|
+
const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
|
|
105
160
|
if (!pending) {
|
|
106
161
|
throw new HitlSafetyError('no pending checkpoint to decide (resolved/expired under the human) — refetch + re-present', 'no_pending');
|
|
107
162
|
}
|
|
@@ -131,8 +186,11 @@ export class HitlBridge {
|
|
|
131
186
|
* `{ answers: [{ header, selected, note? }] }` shape. The D-1 binding STILL binds (it is still a
|
|
132
187
|
* checkpoint). Each answer's `header` MUST match a question header (the worker 400s on mismatch).
|
|
133
188
|
*/
|
|
134
|
-
async answerQuestion(answers, toolUseID, opts
|
|
135
|
-
|
|
189
|
+
async answerQuestion(answers, toolUseID, opts,
|
|
190
|
+
/** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
|
|
191
|
+
* fetched via `findPendingForTask`. */
|
|
192
|
+
preResolvedPending) {
|
|
193
|
+
const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
|
|
136
194
|
if (!pending) {
|
|
137
195
|
throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
|
|
138
196
|
}
|
|
@@ -146,13 +204,20 @@ export class HitlBridge {
|
|
|
146
204
|
// ── §4. plan_review: the distinct wire ──────────────────────────────────────
|
|
147
205
|
/**
|
|
148
206
|
* Resolve a plan-mode gate (contract/04 §3; 08 IH-6). Routed to `assistant.planReview`, NOT `decide`/
|
|
149
|
-
* `resume`.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
207
|
+
* `resume`. Also guards the `editedPlan` invariant (REQUIRED iff `decision==="edit"`, FORBIDDEN
|
|
208
|
+
* otherwise → 400) so the bad call never leaves the shell — that check is unconditional (no
|
|
209
|
+
* `observe()` dependency). The gate-kind guard below is host-optional (see its own comment); the
|
|
210
|
+
* server's 409 `gate_not_plan_review` is the real backstop either way.
|
|
152
211
|
*/
|
|
153
212
|
async reviewPlan(outcome, opts) {
|
|
154
213
|
// Gate-kind guard (contract/04 §3.1): plan_review parks as needs_review / plan_review, distinct from the
|
|
155
|
-
// approval family.
|
|
214
|
+
// approval family. REF-CC-028(2026-08-02,评注现势):this.active only latches when the host calls
|
|
215
|
+
// `observe(ev)` on the downstream stream — BOTH production construction points
|
|
216
|
+
// (askGateWire.ts / toolApprovalWire.ts) build a fresh `HitlBridge` per decide and never call
|
|
217
|
+
// `observe`, so `this.active` is null and this guard is a no-op there today. It is a host-optional
|
|
218
|
+
// auxiliary: a host that DOES drive `observe()` gets a local refuse instead of a round-trip; a host
|
|
219
|
+
// that doesn't (today's shell) relies entirely on the server's 409 `gate_not_plan_review` fail-closed
|
|
220
|
+
// path — the safety property holds either way, this guard just saves the round-trip when wired.
|
|
156
221
|
const kind = this.active?.gate.kind;
|
|
157
222
|
if (kind && kind !== 'plan_review' && kind !== 'needs_review') {
|
|
158
223
|
throw new HitlSafetyError(`cannot plan-review a "${kind}" gate — a plan decision must never resolve a tool approval`, 'wrong_gate');
|
|
@@ -167,7 +232,10 @@ export class HitlBridge {
|
|
|
167
232
|
throw new HitlSafetyError(`plan-review "${outcome.decision}" must NOT carry editedPlan`, 'bad_plan_edit');
|
|
168
233
|
}
|
|
169
234
|
const req = { decision: outcome.decision };
|
|
170
|
-
|
|
235
|
+
// eopt:上面的 invariant 检查已经保证 decision==='edit' ⇒ editedPlan 非空,但那道保证隔着一个
|
|
236
|
+
// 已经关闭的 if 块,TS 不跨块记账——这里重新直接判一次 undefined,narrowing 与赋值同一表达式内完成
|
|
237
|
+
// (PlanReviewRequest 是 SDK 外部 wire 型,不能改;诚实缺席 = 键不落,不传 `editedPlan: undefined`)。
|
|
238
|
+
if (outcome.decision === 'edit' && outcome.editedPlan !== undefined)
|
|
171
239
|
req.editedPlan = outcome.editedPlan;
|
|
172
240
|
if (outcome.reason !== undefined)
|
|
173
241
|
req.reason = outcome.reason;
|
|
@@ -177,7 +245,10 @@ export class HitlBridge {
|
|
|
177
245
|
/**
|
|
178
246
|
* Continue a `resource_limit` slice pause (contract/04 §6.1; 08 IH-9). Bodyless `assistant.resume` — NOT
|
|
179
247
|
* `decide`. A `human`/`irreversible_ask` gate routed here would 409 `gate_not_resumable` (a "continue"
|
|
180
|
-
* must NEVER bypass a human approval)
|
|
248
|
+
* must NEVER bypass a human approval). REF-CC-028(2026-08-02,评注现势):the local guard below is
|
|
249
|
+
* host-optional — it only fires when the host drives `observe(ev)` (neither production call site does
|
|
250
|
+
* today, see `reviewPlan`'s comment); the server's 409 is the fail-closed backstop that actually holds
|
|
251
|
+
* this invariant.
|
|
181
252
|
*/
|
|
182
253
|
async resumeResourceLimit(opts) {
|
|
183
254
|
const kind = this.active?.gate.kind;
|
|
@@ -233,7 +304,8 @@ export function makeHitlCanUseTool(bridge, prompt) {
|
|
|
233
304
|
};
|
|
234
305
|
}
|
|
235
306
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
236
|
-
/** Marshal one answer into the worker-validated wire entry (contract/04 §4.1). Drops undefined `note`.
|
|
307
|
+
/** Marshal one answer into the worker-validated wire entry (contract/04 §4.1). Drops undefined `note`.
|
|
308
|
+
* REF-CC-038(2026-08-02):return 类型是 `AskAnswer` 本身(唯一源),不再是一份重写的内联匿名形。 */
|
|
237
309
|
function marshalAnswer(a) {
|
|
238
310
|
const entry = {
|
|
239
311
|
header: a.header,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ClassifierDenyVerdict } from '../classifierVerdictWire.js';
|
|
2
|
+
/** footer 通知(壳 `AppState.notifications.current` 的形)。 */
|
|
3
|
+
export interface HitlNotice {
|
|
4
|
+
key: string;
|
|
5
|
+
text: string;
|
|
6
|
+
color: 'warning';
|
|
7
|
+
priority: 'immediate';
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
}
|
|
10
|
+
/** HITL 的宿主副作用面 —— 通知上屏 + 分类器 deny 的端侧记账。 */
|
|
11
|
+
export interface HitlHostSurface {
|
|
12
|
+
/** 立即顶到 current(壳:`notifications.current = notice`,queue 不动)。 */
|
|
13
|
+
showNotice(notice: HitlNotice): void;
|
|
14
|
+
/** **仅当** current 仍是这个 key 时清掉(壳原文的 if-still-mine 语义 —— 否则会误清别人的通知)。 */
|
|
15
|
+
clearNoticeIfCurrent(key: string): void;
|
|
16
|
+
/** 分类器 deny 裁决的宿主副作用:footer 通知 + /permissions Recent Denials 记账(壳资产)。 */
|
|
17
|
+
surfaceClassifierDeny(toolName: string, verdict: ClassifierDenyVerdict): void;
|
|
18
|
+
}
|
|
19
|
+
/** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
|
|
20
|
+
export declare function installHitlHostSurface(surface: HitlHostSurface | null): () => void;
|
|
21
|
+
/** W1 带 key 变体:装/卸某会话的 HITL 宿主面(多会话宿主每会话一键,互不顶盖)。 */
|
|
22
|
+
export declare function installHitlHostSurfaceFor(sessionKey: string, surface: HitlHostSurface | null): () => void;
|
|
23
|
+
/** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
|
|
24
|
+
export declare function hitlHostSurfaceMisses(): number;
|
|
25
|
+
/** W1 带 key 变体(miss 计数逐键)。 */
|
|
26
|
+
export declare function hitlHostSurfaceMissesFor(sessionKey: string): number;
|
|
27
|
+
/** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
|
|
28
|
+
export declare function hitlHostSurfaceFor(sessionKey: string): HitlHostSurface | null;
|
|
29
|
+
/** 测试钩:卸口 + 清计数。 */
|
|
30
|
+
export declare function _resetHitlHostSurfaceForTest(): void;
|
|
31
|
+
/** 内部读点(计 miss)。`askGateWire.ts` 的 `surfaceClassifierDeny` 路径与本文件的
|
|
32
|
+
* `surfaceCancelDenyWarn` 共用它。 */
|
|
33
|
+
export declare function surfaceForCurrentSession(): HitlHostSurface | null;
|
|
34
|
+
/** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
|
|
35
|
+
* 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
|
|
36
|
+
* 只是多一行良性 warn,比锁死无线索诚实)。 */
|
|
37
|
+
export declare const CANCEL_DENY_BUDGET_MS = 2000;
|
|
38
|
+
/** warn 行文案(测试锁字面)。 */
|
|
39
|
+
export declare const CANCEL_DENY_WARN_TEXT = "could not cancel the pending question \u2014 the session may stay locked; the run may need engine-side recovery";
|
|
40
|
+
/**
|
|
41
|
+
* 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
|
|
42
|
+
* 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
|
|
43
|
+
* - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
|
|
44
|
+
* - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
|
|
45
|
+
* 撞 409;配合件2b 的专属文案,用户知道现场 + 出路);
|
|
46
|
+
* - `code === 'no_pending'`(判据见上方 `isNoPendingError`)⇒ 良性静默(pending 已被别处消解/
|
|
47
|
+
* 过期 —— run 没锁;hitlBridge decideTool 的同款语义,那里的静默维持不动)。
|
|
48
|
+
*/
|
|
49
|
+
export declare function observeCancelByDeny(settle: Promise<unknown>, taskId: string): void;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hitlHostSurface.ts — REF-CC-023(2026-08-02,P3 wave2 卡C-bridge):共享的 HITL 宿主通知面
|
|
3
|
+
* + cancel-by-deny 有界观察,从 `askGateWire.ts` 提出来给 `toolApprovalWire.ts` 也用。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 为什么要提出来(不是顺手重排):`askGateWire.ts` 已经 `import` `toolApprovalWire.ts`(§ ask 桥
|
|
6
|
+
* 消费工具审批帧判定),反向 `toolApprovalWire.ts` → `askGateWire.ts` 会成环。`observeCancelByDeny`
|
|
7
|
+
* 曾经只活在 `askGateWire.ts` 里,而 `toolApprovalWire.ts:surfaceFsApprovalAndDecide` 的中断-deny
|
|
8
|
+
* 臂原样复刻了它要修的那个事故(件3,中断事故修复批 G,2026-07-15):裸 `.catch(()=>{})` 全吞 ⇒
|
|
9
|
+
* deny 丢失时 run 永卡 suspended、session 锁死,用户下一条消息撞 409「active run」还全无线索。
|
|
10
|
+
* 单源到一个两边都能 import 的模块,两条腿共用同一次修复,而不是「同一个仓、同一个事故、
|
|
11
|
+
* 一处修了另一处原样留着」。
|
|
12
|
+
*
|
|
13
|
+
* 这个模块也顺带带走了 `surfaceCancelDenyWarn` 唯一依赖的宿主通知面(`HitlHostSurface` /
|
|
14
|
+
* `installHitlHostSurface*` / `hitlHostSurfaceFor*`)—— 它不只是给 cancel-by-deny 用(分类器 deny
|
|
15
|
+
* 的端侧记账 `surfaceClassifierDeny` 也走它),`askGateWire.ts` 仍然是它的主要消费方,只是不再是
|
|
16
|
+
* 唯一持有方。
|
|
17
|
+
*/
|
|
18
|
+
import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
19
|
+
import { hostLog } from '../host.js';
|
|
20
|
+
import { unrefTimer } from '../unrefTimer.js';
|
|
21
|
+
import { HitlSafetyError } from './hitlBridge.js';
|
|
22
|
+
// W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)。
|
|
23
|
+
const hostSurfaceByKey = createSessionSlot();
|
|
24
|
+
const hostSurfaceMissesByKey = new Map();
|
|
25
|
+
/** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
|
|
26
|
+
export function installHitlHostSurface(surface) {
|
|
27
|
+
return installHitlHostSurfaceFor(DEFAULT_SESSION_KEY, surface);
|
|
28
|
+
}
|
|
29
|
+
/** W1 带 key 变体:装/卸某会话的 HITL 宿主面(多会话宿主每会话一键,互不顶盖)。 */
|
|
30
|
+
export function installHitlHostSurfaceFor(sessionKey, surface) {
|
|
31
|
+
const prev = hostSurfaceByKey.get(sessionKey);
|
|
32
|
+
hostSurfaceByKey.set(sessionKey, surface);
|
|
33
|
+
return () => {
|
|
34
|
+
hostSurfaceByKey.set(sessionKey, prev);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
|
|
38
|
+
export function hitlHostSurfaceMisses() {
|
|
39
|
+
return hitlHostSurfaceMissesFor(DEFAULT_SESSION_KEY);
|
|
40
|
+
}
|
|
41
|
+
/** W1 带 key 变体(miss 计数逐键)。 */
|
|
42
|
+
export function hitlHostSurfaceMissesFor(sessionKey) {
|
|
43
|
+
return hostSurfaceMissesByKey.get(sessionKey) ?? 0;
|
|
44
|
+
}
|
|
45
|
+
/** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
|
|
46
|
+
export function hitlHostSurfaceFor(sessionKey) {
|
|
47
|
+
return hostSurfaceByKey.get(sessionKey) ?? null;
|
|
48
|
+
}
|
|
49
|
+
/** 测试钩:卸口 + 清计数。 */
|
|
50
|
+
export function _resetHitlHostSurfaceForTest() {
|
|
51
|
+
hostSurfaceByKey.clear();
|
|
52
|
+
hostSurfaceMissesByKey.clear();
|
|
53
|
+
}
|
|
54
|
+
/** 内部读点(计 miss)。`askGateWire.ts` 的 `surfaceClassifierDeny` 路径与本文件的
|
|
55
|
+
* `surfaceCancelDenyWarn` 共用它。 */
|
|
56
|
+
export function surfaceForCurrentSession() {
|
|
57
|
+
const hostSurface = hitlHostSurfaceFor(DEFAULT_SESSION_KEY);
|
|
58
|
+
if (hostSurface === null) {
|
|
59
|
+
hostSurfaceMissesByKey.set(DEFAULT_SESSION_KEY, (hostSurfaceMissesByKey.get(DEFAULT_SESSION_KEY) ?? 0) + 1);
|
|
60
|
+
}
|
|
61
|
+
return hostSurface;
|
|
62
|
+
}
|
|
63
|
+
// ── 件3(中断事故修复批 G,2026-07-15)—— 中断 deny 的有界观察 ─────────────────────────────────
|
|
64
|
+
/** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
|
|
65
|
+
* 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
|
|
66
|
+
* 只是多一行良性 warn,比锁死无线索诚实)。 */
|
|
67
|
+
export const CANCEL_DENY_BUDGET_MS = 2000;
|
|
68
|
+
/** warn 行文案(测试锁字面)。 */
|
|
69
|
+
export const CANCEL_DENY_WARN_TEXT = 'could not cancel the pending question — the session may stay locked; the run may need engine-side recovery';
|
|
70
|
+
const CANCEL_DENY_WARN_KEY = 'hitl-cancel-deny-warn';
|
|
71
|
+
const CANCEL_DENY_WARN_TIMEOUT_MS = 10_000;
|
|
72
|
+
/** 失败 warn 上屏:借 footer notifications 通道。壳原文经 `appStateRef` 直写 store
|
|
73
|
+
* (immediate 语义:立即顶到 current + 到点自清「仅当仍是本 warn 时」);本包把那两次
|
|
74
|
+
* setState 拆成宿主口的两个动词,**时序纪律(2s 预算 / 10s 自清)留在库内**——
|
|
75
|
+
* [paired-mechanisms-must-share-premise]:只给「显示」不给「清」,端各写各的清法必然漂。
|
|
76
|
+
* 无口(print/headless 没屏可上)⇒ 静默,与壳原文 `if (!store) return` 同语义。 */
|
|
77
|
+
function surfaceCancelDenyWarn() {
|
|
78
|
+
try {
|
|
79
|
+
const s = surfaceForCurrentSession();
|
|
80
|
+
if (!s)
|
|
81
|
+
return;
|
|
82
|
+
s.showNotice({
|
|
83
|
+
key: CANCEL_DENY_WARN_KEY,
|
|
84
|
+
text: CANCEL_DENY_WARN_TEXT,
|
|
85
|
+
color: 'warning',
|
|
86
|
+
priority: 'immediate',
|
|
87
|
+
timeoutMs: CANCEL_DENY_WARN_TIMEOUT_MS,
|
|
88
|
+
});
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
try {
|
|
91
|
+
s.clearNoticeIfCurrent(CANCEL_DENY_WARN_KEY);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* fail-soft */
|
|
95
|
+
}
|
|
96
|
+
}, CANCEL_DENY_WARN_TIMEOUT_MS);
|
|
97
|
+
unrefTimer(timer);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* fail-soft:上屏失败绝不再伤害 abort 流 */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* cancel-by-deny 的「良性 no_pending」判据。**结构化 `.code` 为主,`instanceof` 只作加强,绝不替代**:
|
|
105
|
+
* - `.code` 是 client-core 自家 `HitlSafetyError` 的契约属性(hitlBridge.ts,REF-CC-036 起是闭集
|
|
106
|
+
* 联合 binding_mismatch|no_pending|wrong_gate|bad_plan_edit)—— 不是 SDK 错误的 `errorCode`;
|
|
107
|
+
* - 只按 `instanceof` 判会在**跨 realm / 双实例**下假阴:打包后同一个包出现两份副本(本仓
|
|
108
|
+
* `singleton-manifest.json` 把双实例当在册风险处理)、或宿主与包各持一份构造器时,同一个
|
|
109
|
+
* 语义错误 `instanceof` 为假 ⇒ 良性 no_pending 被当成「deny 丢失」弹一行吓人的 warn;
|
|
110
|
+
* - 下游 `sema-cli` 的 `src/sema/hitlCancelDeny.test.ts` 用例 3 正是按 `code + name` 鸭子形造错,
|
|
111
|
+
* 那份契约就是「结构判据」本身。
|
|
112
|
+
* ⚠️ 判据不许松成「有 `.code` 就静默」—— 闭集里只有 `no_pending` 是良性,别的 code 照走 warn 臂。
|
|
113
|
+
*/
|
|
114
|
+
function isNoPendingError(e) {
|
|
115
|
+
if (e instanceof HitlSafetyError)
|
|
116
|
+
return e.code === 'no_pending';
|
|
117
|
+
if (typeof e !== 'object' || e === null || !('code' in e))
|
|
118
|
+
return false;
|
|
119
|
+
const code = e.code;
|
|
120
|
+
return typeof code === 'string' && code === 'no_pending';
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
|
|
124
|
+
* 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
|
|
125
|
+
* - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
|
|
126
|
+
* - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
|
|
127
|
+
* 撞 409;配合件2b 的专属文案,用户知道现场 + 出路);
|
|
128
|
+
* - `code === 'no_pending'`(判据见上方 `isNoPendingError`)⇒ 良性静默(pending 已被别处消解/
|
|
129
|
+
* 过期 —— run 没锁;hitlBridge decideTool 的同款语义,那里的静默维持不动)。
|
|
130
|
+
*/
|
|
131
|
+
export function observeCancelByDeny(settle, taskId) {
|
|
132
|
+
let timer;
|
|
133
|
+
const budget = new Promise((_, reject) => {
|
|
134
|
+
timer = setTimeout(() => reject(new Error(`cancel-by-deny did not settle within ${CANCEL_DENY_BUDGET_MS}ms`)), CANCEL_DENY_BUDGET_MS);
|
|
135
|
+
unrefTimer(timer);
|
|
136
|
+
});
|
|
137
|
+
void Promise.race([settle, budget])
|
|
138
|
+
.then(() => {
|
|
139
|
+
hostLog('debug', `[hitl-cancel-deny] cancel-by-deny settled for run ${taskId} (session unlocked)`);
|
|
140
|
+
})
|
|
141
|
+
.catch((e) => {
|
|
142
|
+
if (isNoPendingError(e)) {
|
|
143
|
+
hostLog('debug', `[hitl-cancel-deny] cancel-by-deny no_pending for run ${taskId} (benign — already resolved)`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
hostLog('error', `[hitl-cancel-deny] cancel-by-deny FAILED for run ${taskId}: ${String(e)}`);
|
|
147
|
+
surfaceCancelDenyWarn();
|
|
148
|
+
})
|
|
149
|
+
.finally(() => {
|
|
150
|
+
if (timer !== undefined)
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
});
|
|
153
|
+
// 超时后原 promise 迟到 reject 不得变成 unhandledRejection。
|
|
154
|
+
void settle.catch(() => { });
|
|
155
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parkResolver — HITL gate 桥**外环**的 park 决断(REF-CC-032 / SPLIT-12 第三刀,2026-08-02)。
|
|
3
|
+
*
|
|
4
|
+
* 内环(`frameRouter.ts`)带出一个 `GatePark` 之后,这里回答唯一一个问题:**接着读流,还是
|
|
5
|
+
* fail-soft 收场**。三条出路:
|
|
6
|
+
* · 决断成功 ⇒ `reattach`(驱动 attach `runs.events`,`lastEventId` 续传);
|
|
7
|
+
* · 「这个 gate 早就被解决了」(durable re-attach 必然重放已决断的 park)⇒ 同样 `reattach`;
|
|
8
|
+
* · 其余(overlay 缺席 / pending 蒸发 / decide 409/404 / turn 中断 / hop 预算耗尽)⇒ `failsoft`:
|
|
9
|
+
* flush HOLD 的毒化帧 + 原样吐 suspended 终帧(durable 腿无终帧则合成一条可见的 failed)。
|
|
10
|
+
*
|
|
11
|
+
* 🔴 本文件不碰流、不 yield:events 以数组交回驱动。`resolvePark` 的**返回值**就是它的全部对外
|
|
12
|
+
* 效果(台账写入除外,且台账写入全经 `GateLedger` 的动词)—— 于是「禁掉某条真臂」这种变异
|
|
13
|
+
* 在驱动的消费点上必然显形,而不是被本文件自己吞掉。
|
|
14
|
+
*/
|
|
15
|
+
import type { AgentEvent } from '@sema-agent/sdk';
|
|
16
|
+
import { type QuestionAnswer } from '../liveQuestionStore.js';
|
|
17
|
+
import type { AskAnsweredOutput, GateLedger } from './gateLedger.js';
|
|
18
|
+
import { type AskGateWireDeps, type GatePark } from './frameRouter.js';
|
|
19
|
+
/**
|
|
20
|
+
* REF-CC-033:`GateOutcome` 的 `failed` 分支闭集判别码。来源两处:①本文件自己的
|
|
21
|
+
* 「pending 行取不到」早退(`surfaceGateAndDecide` 里 `!pending` 那条);②`HitlBridge` 抛出的
|
|
22
|
+
* `HitlSafetyError.code`(hitlBridge.ts 契约:`binding_mismatch|no_pending|wrong_gate|bad_plan_edit`
|
|
23
|
+
* ——该属性今天仍是开集 `string`,TYPESHAPE-07/REF-CC-036 候后续车锁死;这里落地前用
|
|
24
|
+
* `isGateFailureCode` 按闭集白名单过滤,不认得的值一律降级成 `undefined`,退回文案兜底,不让
|
|
25
|
+
* 开集腐蚀这个新判别位)。消费点只认 `'no_pending'` 为「已解决」,其余码(binding_mismatch 等)
|
|
26
|
+
* 是真需要重新呈现给人的失败,不能走「继续读流」这条路。
|
|
27
|
+
*/
|
|
28
|
+
export type GateFailureCode = 'no_pending' | 'binding_mismatch' | 'wrong_gate' | 'bad_plan_edit';
|
|
29
|
+
export type GateOutcome = {
|
|
30
|
+
kind: 'decided';
|
|
31
|
+
gatedCallId?: string | undefined;
|
|
32
|
+
answered?: AskAnsweredOutput | undefined;
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'aborted';
|
|
35
|
+
gatedCallId?: string | undefined;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'failed';
|
|
38
|
+
gatedCallId?: string | undefined;
|
|
39
|
+
reason: string;
|
|
40
|
+
code?: GateFailureCode | undefined;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* decide 的 `failed` 原因是不是「这个 gate 早就被解决了」(#110 缺陷② c 的判据)。
|
|
44
|
+
*
|
|
45
|
+
* REF-CC-033:此前判据**只**锚在文案词(`no pending checkpoint` / `resolved` / `already` 三者
|
|
46
|
+
* 任一在场)——server 原文改一个字,整条命中当场失效(FIX-13 / 域词表-01)。现在消费点(见下方
|
|
47
|
+
* `isAlreadyResolvedFailure`)优先判 `GateOutcome.code`(闭集判别位,见 `GateFailureCode`);
|
|
48
|
+
* 这个函数降级为**文案兜底臂**,只在 outcome 没带 code 的时候才被调用(典型:`FsApprovalOutcome`
|
|
49
|
+
* 来自 `toolApprovalWire.ts`,那份类型今天没有 code 字段——C-bridge 卡的文件域,本卡不改)。
|
|
50
|
+
* 🔴 方向没变:这条判**错**的后果不对称 —— 误判成「已解决」最多让流多读一轮(hop 预算兜底),
|
|
51
|
+
* 误判成「真失败」会把一个已经 completed 的 run 谎报成失败并烧掉整条会话转录。所以宁可宽。
|
|
52
|
+
*/
|
|
53
|
+
/** #114/[C68] 单源化提升:desktop 照抄件(durable 双实现审计 P0-1 修)到货换包导入删抄件。 */
|
|
54
|
+
export declare function isAlreadyResolvedGateReason(reason: unknown): boolean;
|
|
55
|
+
/** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
|
|
56
|
+
* (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
|
|
57
|
+
export declare function toAnsweredOutput(questions: unknown[], answer: QuestionAnswer): AskAnsweredOutput;
|
|
58
|
+
/** 外环决断的两种出路(见文件头注)。 */
|
|
59
|
+
export type ParkResolution = {
|
|
60
|
+
kind: 'reattach';
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'failsoft';
|
|
63
|
+
events: readonly AgentEvent[];
|
|
64
|
+
};
|
|
65
|
+
export interface ParkResolveCtx {
|
|
66
|
+
deps: AskGateWireDeps;
|
|
67
|
+
led: GateLedger;
|
|
68
|
+
/** sync leg 捕获的真 run handle(内环 `TaskIdRef` 的当前值)。 */
|
|
69
|
+
taskId: string;
|
|
70
|
+
/** 本 turn 内这是第几次 park(hop 预算的唯一计量;由驱动累加,本函数只读)。 */
|
|
71
|
+
hops: number;
|
|
72
|
+
signal?: AbortSignal | undefined;
|
|
73
|
+
}
|
|
74
|
+
export declare function resolvePark(park: GatePark, ctx: ParkResolveCtx): Promise<ParkResolution>;
|