@sema-agent/client-core 0.82.1 → 0.82.2

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.
@@ -1,5 +1,5 @@
1
1
  import type { AgentEvent } from '@sema-agent/sdk';
2
- import type { HitlClientLike } from './hitlBridge.js';
2
+ import type { DecideOperationObserver, HitlClientLike } from './hitlBridge.js';
3
3
  import { type RespondToolApprovalFn, type ToolApprovalFrame, type ToolApprovalFrameLaneOpts, type ToolApprovalFrameOutcome } from './toolApprovalWire.js';
4
4
  import type { GateLedger } from './gateLedger.js';
5
5
  export declare const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
@@ -22,6 +22,7 @@ export interface AskGateWireDeps {
22
22
  onParkReattach?: (e: ParkReattachNotice) => void;
23
23
  approvalLane?: ToolApprovalFrameLaneOpts;
24
24
  onToolApprovalOutcome?: (frame: ToolApprovalFrame, outcome: ToolApprovalFrameOutcome) => void | Promise<void>;
25
+ onDecideOperation?: DecideOperationObserver;
25
26
  }
26
27
  export type GateKind = 'ask' | 'fs';
27
28
  export interface GatePark {
@@ -64,6 +64,29 @@ export declare class DecideTransportRetryExhaustedError extends Error {
64
64
  readonly attempts: number;
65
65
  constructor(attempts: number, lastFailureText: string);
66
66
  }
67
+ export interface DecideOperationIdentity {
68
+ readonly operation: object;
69
+ readonly taskId: string;
70
+ readonly sessionId: string;
71
+ readonly boundCallId?: string;
72
+ }
73
+ export interface DecideOperationStart extends DecideOperationIdentity {
74
+ readonly phase: 'start';
75
+ }
76
+ export interface DecideOperationEnd extends DecideOperationIdentity {
77
+ readonly phase: 'end';
78
+ readonly ok: boolean;
79
+ readonly attempts: number;
80
+ readonly retryExhausted: boolean;
81
+ }
82
+ export type DecideOperationEvent = DecideOperationStart | DecideOperationEnd;
83
+ export type DecideOperationObserver = (ev: DecideOperationEvent) => void | Promise<void>;
84
+ export interface HitlBridgeOptions {
85
+ onDecideOperation?: DecideOperationObserver;
86
+ }
87
+ export interface DecideOperationSpan {
88
+ close(): void;
89
+ }
67
90
  export interface ActiveGate {
68
91
  gate: CheckpointGate;
69
92
  seq?: string | undefined;
@@ -85,7 +108,8 @@ export declare class HitlBridge {
85
108
  private readonly client;
86
109
  private readonly taskId;
87
110
  private active;
88
- constructor(client: HitlClientLike, taskId: string);
111
+ private readonly decideObserver;
112
+ constructor(client: HitlClientLike, taskId: string, opts?: HitlBridgeOptions);
89
113
  observe(ev: AgentEvent): void;
90
114
  currentGate(): CheckpointGate | null;
91
115
  currentGateReopen(): SuspendedReopenReading | null;
@@ -97,6 +121,7 @@ export declare class HitlBridge {
97
121
  remember?: 'session' | undefined;
98
122
  } | HitlDecideDenyOutcome, toolUseID?: string, opts?: {
99
123
  signal?: AbortSignal;
124
+ operation?: DecideOperationSpan;
100
125
  }, preResolvedPending?: PendingCheckpoint): Promise<unknown>;
101
126
  answerQuestion(answers: AskAnswer[], toolUseID?: string, opts?: {
102
127
  signal?: AbortSignal;
@@ -108,6 +133,9 @@ export declare class HitlBridge {
108
133
  signal?: AbortSignal;
109
134
  }): Promise<AssistantTaskStatus>;
110
135
  private decideRaw;
136
+ private decideIdentityOf;
137
+ openDecideOperation(): DecideOperationSpan;
138
+ private decideRawAttempts;
111
139
  }
112
140
  export type GatePrompt<D extends HitlPermissionDecisionLike = HitlPermissionDecisionLike> = (ctx: {
113
141
  toolName: string;
@@ -106,6 +106,94 @@ export class DecideTransportRetryExhaustedError extends Error {
106
106
  this.name = 'DecideTransportRetryExhaustedError';
107
107
  }
108
108
  }
109
+ class DecideSpan {
110
+ owner;
111
+ observer;
112
+ identity;
113
+ attempts = 0;
114
+ ok = false;
115
+ retryExhausted = false;
116
+ closedFlag = false;
117
+ inFlight = 0;
118
+ ended = false;
119
+ constructor(owner, observer) {
120
+ this.owner = owner;
121
+ this.observer = observer;
122
+ }
123
+ admits(bridge, identity) {
124
+ if (bridge !== this.owner || this.closedFlag)
125
+ return false;
126
+ const mine = this.identity;
127
+ return mine === undefined || (mine.sessionId === identity.sessionId && mine.boundCallId === identity.boundCallId);
128
+ }
129
+ async run(identity, send) {
130
+ if (this.observer !== undefined && this.identity === undefined) {
131
+ this.identity = identity;
132
+ notifyDecideObserver(this.observer, Object.freeze({ ...this.identity, phase: 'start' }));
133
+ }
134
+ this.inFlight++;
135
+ const tally = { attempts: 0 };
136
+ try {
137
+ const r = await send(tally);
138
+ this.ok = true;
139
+ this.retryExhausted = false;
140
+ return r;
141
+ }
142
+ catch (e) {
143
+ this.ok = false;
144
+ this.retryExhausted = e instanceof DecideTransportRetryExhaustedError;
145
+ throw e;
146
+ }
147
+ finally {
148
+ this.attempts += tally.attempts;
149
+ this.inFlight--;
150
+ if (this.closedFlag && this.inFlight === 0)
151
+ this.emitEnd();
152
+ }
153
+ }
154
+ close() {
155
+ if (this.closedFlag)
156
+ return;
157
+ this.closedFlag = true;
158
+ if (this.inFlight === 0)
159
+ this.emitEnd();
160
+ }
161
+ emitEnd() {
162
+ if (this.ended)
163
+ return;
164
+ this.ended = true;
165
+ if (this.observer === undefined || this.identity === undefined)
166
+ return;
167
+ notifyDecideObserver(this.observer, Object.freeze({ ...this.identity, phase: 'end', ok: this.ok, attempts: this.attempts, retryExhausted: this.retryExhausted }));
168
+ }
169
+ }
170
+ function reportObserverFault(phase, what, e) {
171
+ try {
172
+ hostLog('debug', `hitlBridge: onDecideOperation(${phase}) ${what}: ${String(e).slice(0, 160)}`);
173
+ return true;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ function wireOptsOf(opts) {
180
+ if (opts === undefined || opts.operation === undefined)
181
+ return opts;
182
+ return opts.signal !== undefined ? { signal: opts.signal } : undefined;
183
+ }
184
+ function notifyDecideObserver(observer, ev) {
185
+ try {
186
+ const out = observer(ev);
187
+ if (out && typeof out.then === 'function') {
188
+ void Promise.resolve(out).then(undefined, (e) => {
189
+ reportObserverFault(ev.phase, 'rejected', e);
190
+ });
191
+ }
192
+ }
193
+ catch (e) {
194
+ reportObserverFault(ev.phase, 'threw', e);
195
+ }
196
+ }
109
197
  const ANY_TOOL_FAMILY = () => true;
110
198
  export async function findPendingForTask(client, taskId, matches, opts, callId, identityAccepts = (r) => matches(typeof r.toolName === 'string' ? r.toolName : undefined)) {
111
199
  let rows;
@@ -132,9 +220,11 @@ export class HitlBridge {
132
220
  client;
133
221
  taskId;
134
222
  active = null;
135
- constructor(client, taskId) {
223
+ decideObserver;
224
+ constructor(client, taskId, opts) {
136
225
  this.client = client;
137
226
  this.taskId = taskId;
227
+ this.decideObserver = typeof opts?.onDecideOperation === 'function' ? opts.onDecideOperation : undefined;
138
228
  }
139
229
  observe(ev) {
140
230
  switch (ev.type) {
@@ -180,7 +270,7 @@ export class HitlBridge {
180
270
  return { boundCallId: p.boundCallId, boundInputHash: p.boundInputHash };
181
271
  }
182
272
  async decideTool(outcome, toolUseID, opts, preResolvedPending) {
183
- const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, opts));
273
+ const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, wireOptsOf(opts)));
184
274
  if (!pending) {
185
275
  throw new HitlSafetyError('no pending checkpoint to decide (resolved/expired under the human) — refetch + re-present', 'no_pending');
186
276
  }
@@ -253,7 +343,48 @@ export class HitlBridge {
253
343
  }
254
344
  return this.client.assistant.resume(this.taskId, opts);
255
345
  }
256
- async decideRaw(sessionId, decision, opts) {
346
+ async decideRaw(sessionId, decision, optsIn) {
347
+ const opts = wireOptsOf(optsIn);
348
+ const op = optsIn?.operation;
349
+ if (op instanceof DecideSpan) {
350
+ const identity = this.decideIdentityOf(sessionId, decision);
351
+ if (op.admits(this, identity))
352
+ return op.run(identity, (tally) => this.decideRawAttempts(sessionId, decision, opts, tally));
353
+ }
354
+ const observer = this.decideObserver;
355
+ const tally = { attempts: 0 };
356
+ if (observer === undefined)
357
+ return this.decideRawAttempts(sessionId, decision, opts, tally);
358
+ const identity = this.decideIdentityOf(sessionId, decision);
359
+ notifyDecideObserver(observer, Object.freeze({ ...identity, phase: 'start' }));
360
+ let ok = false;
361
+ let retryExhausted = false;
362
+ try {
363
+ const r = await this.decideRawAttempts(sessionId, decision, opts, tally);
364
+ ok = true;
365
+ return r;
366
+ }
367
+ catch (e) {
368
+ retryExhausted = e instanceof DecideTransportRetryExhaustedError;
369
+ throw e;
370
+ }
371
+ finally {
372
+ notifyDecideObserver(observer, Object.freeze({ ...identity, phase: 'end', ok, attempts: tally.attempts, retryExhausted }));
373
+ }
374
+ }
375
+ decideIdentityOf(sessionId, decision) {
376
+ const bound = decision.boundCallId;
377
+ return {
378
+ operation: Object.freeze({}),
379
+ taskId: this.taskId,
380
+ sessionId,
381
+ ...(typeof bound === 'string' && bound.length > 0 ? { boundCallId: bound } : {}),
382
+ };
383
+ }
384
+ openDecideOperation() {
385
+ return new DecideSpan(this, this.decideObserver);
386
+ }
387
+ async decideRawAttempts(sessionId, decision, opts, tally) {
257
388
  const callerAborted = () => opts?.signal?.aborted === true;
258
389
  const startedAt = Date.now();
259
390
  let attempts = 0;
@@ -261,6 +392,7 @@ export class HitlBridge {
261
392
  let backoff = DECIDE_TRANSPORT_RETRY_BACKOFF_MS;
262
393
  for (;;) {
263
394
  attempts++;
395
+ tally.attempts = attempts;
264
396
  try {
265
397
  const r = await this.client.approvals.decide(sessionId, decision, opts);
266
398
  return r;
@@ -116,7 +116,7 @@ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal, parkGat
116
116
  onPresented?.();
117
117
  publishQuestionFrame({ type: 'question', questionId, questions });
118
118
  });
119
- const bridge = new HitlBridge(deps.client, taskId);
119
+ const bridge = new HitlBridge(deps.client, taskId, deps.onDecideOperation ? { onDecideOperation: deps.onDecideOperation } : undefined);
120
120
  if (answer === null) {
121
121
  observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
122
122
  return { kind: 'aborted', gatedCallId };
@@ -170,7 +170,11 @@ function isFetchStepNoPending(outcome) {
170
170
  async function surfaceParkGate(park, ctx, gatedCallId, onPresented) {
171
171
  const { deps, led, taskId } = ctx;
172
172
  if (park.gate === 'fs') {
173
- const outcome = await surfaceFsApprovalAndDecide({ client: deps.client, ...(onPresented ? { onPresented } : {}) }, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
173
+ const outcome = await surfaceFsApprovalAndDecide({
174
+ client: deps.client,
175
+ ...(onPresented ? { onPresented } : {}),
176
+ ...(deps.onDecideOperation ? { onDecideOperation: deps.onDecideOperation } : {}),
177
+ }, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
174
178
  const outcomeDenied = 'denied' in outcome ? outcome.denied : undefined;
175
179
  if (outcome.kind === 'decided' && outcomeDenied === true && outcome.gatedCallId) {
176
180
  const denySettledBy = 'denySettledBy' in outcome ? outcome.denySettledBy : undefined;
@@ -29,6 +29,12 @@ export declare function armPlanReviewApproval(result: unknown, sessionKey?: stri
29
29
  submittedInPlanMode?: boolean;
30
30
  }): boolean;
31
31
  export type PlanReviewDecisionEffect = 'took_effect' | 'advanced' | 'still_parked' | 'not_sent' | 'not_applied' | 'unconfirmed';
32
+ export interface PlanReviewOutcomeMeta {
33
+ taskId: string;
34
+ dispatchNo: number;
35
+ decision: 'approve' | 'reject';
36
+ effect: PlanReviewDecisionEffect;
37
+ }
32
38
  export declare function planReviewOutcomeTail(taskId: string, decision: 'approve' | 'reject', effect: PlanReviewDecisionEffect): string;
33
39
  export declare function __resetPlanReviewDecisionLatchForTests(): void;
34
40
  export declare function decidePlanReview(taskId: string, decision: 'approve' | 'reject', permissionModeAfter?: string): Promise<void>;
@@ -347,7 +347,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
347
347
  }
348
348
  }
349
349
  try {
350
- const delivered = enqueuePlanReviewOutcome(`<plan-review-outcome>\n${outcome}\n${planReviewOutcomeTail(taskId, decision, effect)}\n</plan-review-outcome>`);
350
+ const delivered = enqueuePlanReviewOutcome(`<plan-review-outcome>\n${outcome}\n${planReviewOutcomeTail(taskId, decision, effect)}\n</plan-review-outcome>`, { taskId, dispatchNo, decision, effect });
351
351
  if (!delivered) {
352
352
  hostLog('error', `planReviewWire: outcome enqueue MISSED (queue port not installed or lacks enqueueMetaPrompt) for task ${taskId} — model will not learn the ${decision} result`);
353
353
  }
@@ -1,4 +1,4 @@
1
- import { type GateCurrentPending, type HitlFailureStage, type HitlSafetyCode, type HitlClientLike } from './hitlBridge.js';
1
+ import { type GateCurrentPending, type HitlFailureStage, type HitlSafetyCode, type HitlClientLike, type DecideOperationObserver } from './hitlBridge.js';
2
2
  import { type RuleStoreUnreadableKind } from '../gateVocabulary.js';
3
3
  import { type DenyAttributionRefusalDetail } from '../wireRefusalCopy.js';
4
4
  import { type DecideReceiptView } from '../decideReceipt.js';
@@ -43,6 +43,7 @@ export type FsApprovalOutcome = {
43
43
  export interface FsApprovalWireDeps {
44
44
  client: HitlClientLike;
45
45
  onPresented?: () => void;
46
+ onDecideOperation?: DecideOperationObserver;
46
47
  }
47
48
  export declare function structurallyEqual(a: unknown, b: unknown): boolean;
48
49
  export type RuleOfferMatch = SdkRuleOfferMatch;
@@ -133,7 +133,7 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
133
133
  if (typeof args !== 'object' || args === null) {
134
134
  return { kind: 'failed', stage: 'input', gatedCallId, reason: 'gate has no tool input payload' };
135
135
  }
136
- const bridge = new HitlBridge(deps.client, taskId);
136
+ const bridge = new HitlBridge(deps.client, taskId, deps.onDecideOperation ? { onDecideOperation: deps.onDecideOperation } : undefined);
137
137
  const callKey = approvalCallKey(gatedCallId, taskId);
138
138
  const ruleOffersReadOnly = readRuleOfferSupply(pending.ruleOffers, pending.ruleSuggestions);
139
139
  const durableProbeCause = pending.riskDescriptor?.probeCause;
@@ -163,110 +163,117 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
163
163
  ? { toolCallId: pending.toolCallId }
164
164
  : {}),
165
165
  });
166
- switch (card.kind) {
167
- case 'failed':
168
- return { kind: 'failed', stage: 'card', gatedCallId, reason: card.reason };
169
- case 'retracted':
170
- hostLog('debug', `liveHitlAskWire: approval card retracted without a decision for ${gatedCallId ?? '(no call)'} — nothing sent`);
171
- return { kind: 'retracted', gatedCallId };
172
- case 'aborted':
173
- observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
174
- return { kind: 'aborted', gatedCallId };
175
- case 'allow':
176
- try {
177
- if (card.allowSession) {
178
- try {
179
- const raw = await bridge.decideTool({ decision: 'approve', remember: 'session', ...(card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}) }, gatedCallId, signal ? { signal } : undefined, pending);
180
- const receipt = readDecideReceipt(raw);
181
- return { kind: 'decided', gatedCallId, ...(receipt !== undefined ? { receipt } : {}) };
182
- }
183
- catch (e) {
184
- if (e instanceof HitlSafetyError || e instanceof DecideTransportRetryExhaustedError)
185
- throw e;
186
- if (readDecideCurrentPending(e) !== undefined)
187
- throw e;
188
- hostLog('debug', `liveToolApprovalWire: decide(approve+remember) failed (${String(e)}) — falling back to plain approve`);
166
+ const decideSpan = bridge.openDecideOperation();
167
+ const decideOpts = { ...(signal ? { signal } : {}), operation: decideSpan };
168
+ try {
169
+ switch (card.kind) {
170
+ case 'failed':
171
+ return { kind: 'failed', stage: 'card', gatedCallId, reason: card.reason };
172
+ case 'retracted':
173
+ hostLog('debug', `liveHitlAskWire: approval card retracted without a decision for ${gatedCallId ?? '(no call)'} — nothing sent`);
174
+ return { kind: 'retracted', gatedCallId };
175
+ case 'aborted':
176
+ observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
177
+ return { kind: 'aborted', gatedCallId };
178
+ case 'allow':
179
+ try {
180
+ if (card.allowSession) {
181
+ try {
182
+ const raw = await bridge.decideTool({ decision: 'approve', remember: 'session', ...(card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}) }, gatedCallId, decideOpts, pending);
183
+ const receipt = readDecideReceipt(raw);
184
+ return { kind: 'decided', gatedCallId, ...(receipt !== undefined ? { receipt } : {}) };
185
+ }
186
+ catch (e) {
187
+ if (e instanceof HitlSafetyError || e instanceof DecideTransportRetryExhaustedError)
188
+ throw e;
189
+ if (readDecideCurrentPending(e) !== undefined)
190
+ throw e;
191
+ hostLog('debug', `liveToolApprovalWire: decide(approve+remember) failed (${String(e)}) — falling back to plain approve`);
192
+ }
189
193
  }
194
+ const raw = await bridge.decideTool({ decision: 'approve', ...(card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}) }, gatedCallId, decideOpts, pending);
195
+ const receipt = readDecideReceipt(raw);
196
+ return { kind: 'decided', gatedCallId, ...(receipt !== undefined ? { receipt } : {}) };
190
197
  }
191
- const raw = await bridge.decideTool({ decision: 'approve', ...(card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}) }, gatedCallId, signal ? { signal } : undefined, pending);
192
- const receipt = readDecideReceipt(raw);
193
- return { kind: 'decided', gatedCallId, ...(receipt !== undefined ? { receipt } : {}) };
194
- }
195
- catch (e) {
196
- const currentPending = readDecideCurrentPending(e);
197
- const wireCode = readWireErrorCode(e);
198
- const safetyCode = e instanceof HitlSafetyError ? e.code : undefined;
199
- return {
200
- kind: 'failed',
201
- stage: 'decide',
202
- gatedCallId,
203
- reason: `decide(approve) failed: ${String(e)}${decideFailureNote(e)}`,
204
- ...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
205
- ...(currentPending !== undefined ? { currentPending } : {}),
206
- ...(wireCode !== undefined ? { errorCode: wireCode } : {}),
207
- ...(safetyCode !== undefined ? { safetyCode } : {}),
208
- };
209
- }
210
- case 'deny': {
211
- const denySettledByForWire = readApprovalDenySettledBy(card);
212
- const denyReasonSnapshot = typeof card.reason === 'string' ? card.reason : undefined;
213
- let lastSendCarriedAttribution = false;
214
- try {
215
- const denyBody = {
216
- decision: 'deny',
217
- reason: denyReasonForWire(denyReasonSnapshot, `task ${taskId}`) ?? DEFAULT_DENY_REASON,
218
- ...(denySettledByForWire === 'policy' ? { settledBy: 'policy' } : {}),
219
- };
220
- let attributionDropped = false;
221
- let raw;
222
- lastSendCarriedAttribution = denySettledByForWire === 'policy';
198
+ catch (e) {
199
+ const currentPending = readDecideCurrentPending(e);
200
+ const wireCode = readWireErrorCode(e);
201
+ const safetyCode = e instanceof HitlSafetyError ? e.code : undefined;
202
+ return {
203
+ kind: 'failed',
204
+ stage: 'decide',
205
+ gatedCallId,
206
+ reason: `decide(approve) failed: ${String(e)}${decideFailureNote(e)}`,
207
+ ...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
208
+ ...(currentPending !== undefined ? { currentPending } : {}),
209
+ ...(wireCode !== undefined ? { errorCode: wireCode } : {}),
210
+ ...(safetyCode !== undefined ? { safetyCode } : {}),
211
+ };
212
+ }
213
+ case 'deny': {
214
+ const denySettledByForWire = readApprovalDenySettledBy(card);
215
+ const denyReasonSnapshot = typeof card.reason === 'string' ? card.reason : undefined;
216
+ let lastSendCarriedAttribution = false;
223
217
  try {
224
- raw = await bridge.decideTool(denyBody, gatedCallId, signal ? { signal } : undefined, pending);
218
+ const denyBody = {
219
+ decision: 'deny',
220
+ reason: denyReasonForWire(denyReasonSnapshot, `task ${taskId}`) ?? DEFAULT_DENY_REASON,
221
+ ...(denySettledByForWire === 'policy' ? { settledBy: 'policy' } : {}),
222
+ };
223
+ let attributionDropped = false;
224
+ let raw;
225
+ lastSendCarriedAttribution = denySettledByForWire === 'policy';
226
+ try {
227
+ raw = await bridge.decideTool(denyBody, gatedCallId, decideOpts, pending);
228
+ }
229
+ catch (first) {
230
+ if (!shouldResendWithoutAttribution(first, lastSendCarriedAttribution, signal))
231
+ throw first;
232
+ const { settledBy: _droppedKey, ...withoutAttribution } = denyBody;
233
+ void _droppedKey;
234
+ lastSendCarriedAttribution = false;
235
+ hostLog('debug', `liveToolApprovalWire: decide(deny) for task ${taskId} was refused because the settlement note is not part of ` +
236
+ 'what this deployment signs — re-sending the SAME decision exactly once with that one key removed ' +
237
+ '(the refusal happened before the approval was judged, so nothing was consumed and this is a re-delivery, not a second act)');
238
+ raw = await bridge.decideTool(withoutAttribution, gatedCallId, decideOpts, pending);
239
+ attributionDropped = true;
240
+ }
241
+ const receipt = readDecideReceipt(raw);
242
+ const denySettledBy = denySettledByForWire;
243
+ const denyReason = denyReasonSnapshot !== undefined && denyReasonSnapshot.trim() !== '' ? denyReasonSnapshot : undefined;
244
+ return {
245
+ kind: 'decided',
246
+ gatedCallId,
247
+ denied: true,
248
+ ...(denySettledBy !== undefined ? { denySettledBy } : {}),
249
+ ...(denyReason !== undefined ? { denyReason } : {}),
250
+ ...(attributionDropped ? { denyAttributionDropped: true } : {}),
251
+ ...(receipt !== undefined ? { receipt } : {}),
252
+ };
225
253
  }
226
- catch (first) {
227
- if (!shouldResendWithoutAttribution(first, lastSendCarriedAttribution, signal))
228
- throw first;
229
- const { settledBy: _droppedKey, ...withoutAttribution } = denyBody;
230
- void _droppedKey;
231
- lastSendCarriedAttribution = false;
232
- hostLog('debug', `liveToolApprovalWire: decide(deny) for task ${taskId} was refused because the settlement note is not part of ` +
233
- 'what this deployment signs — re-sending the SAME decision exactly once with that one key removed ' +
234
- '(the refusal happened before the approval was judged, so nothing was consumed and this is a re-delivery, not a second act)');
235
- raw = await bridge.decideTool(withoutAttribution, gatedCallId, signal ? { signal } : undefined, pending);
236
- attributionDropped = true;
254
+ catch (e) {
255
+ const currentPending = readDecideCurrentPending(e);
256
+ const wireCode = readWireErrorCode(e);
257
+ const safetyCode = e instanceof HitlSafetyError ? e.code : undefined;
258
+ const attributionRefusal = denyAttributionRefusalFromError(e, lastSendCarriedAttribution);
259
+ return {
260
+ kind: 'failed',
261
+ stage: 'decide',
262
+ gatedCallId,
263
+ reason: `decide(deny) failed: ${String(e)}${decideFailureNote(e)}`,
264
+ ...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
265
+ ...(currentPending !== undefined ? { currentPending } : {}),
266
+ ...(wireCode !== undefined ? { errorCode: wireCode } : {}),
267
+ ...(safetyCode !== undefined ? { safetyCode } : {}),
268
+ ...(attributionRefusal !== null ? { denyAttributionRefusal: attributionRefusal } : {}),
269
+ };
237
270
  }
238
- const receipt = readDecideReceipt(raw);
239
- const denySettledBy = denySettledByForWire;
240
- const denyReason = denyReasonSnapshot !== undefined && denyReasonSnapshot.trim() !== '' ? denyReasonSnapshot : undefined;
241
- return {
242
- kind: 'decided',
243
- gatedCallId,
244
- denied: true,
245
- ...(denySettledBy !== undefined ? { denySettledBy } : {}),
246
- ...(denyReason !== undefined ? { denyReason } : {}),
247
- ...(attributionDropped ? { denyAttributionDropped: true } : {}),
248
- ...(receipt !== undefined ? { receipt } : {}),
249
- };
250
- }
251
- catch (e) {
252
- const currentPending = readDecideCurrentPending(e);
253
- const wireCode = readWireErrorCode(e);
254
- const safetyCode = e instanceof HitlSafetyError ? e.code : undefined;
255
- const attributionRefusal = denyAttributionRefusalFromError(e, lastSendCarriedAttribution);
256
- return {
257
- kind: 'failed',
258
- stage: 'decide',
259
- gatedCallId,
260
- reason: `decide(deny) failed: ${String(e)}${decideFailureNote(e)}`,
261
- ...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
262
- ...(currentPending !== undefined ? { currentPending } : {}),
263
- ...(wireCode !== undefined ? { errorCode: wireCode } : {}),
264
- ...(safetyCode !== undefined ? { safetyCode } : {}),
265
- ...(attributionRefusal !== null ? { denyAttributionRefusal: attributionRefusal } : {}),
266
- };
267
271
  }
268
272
  }
269
273
  }
274
+ finally {
275
+ decideSpan.close();
276
+ }
270
277
  }
271
278
  function readReadRootCandidate(v) {
272
279
  if (typeof v !== 'object' || v === null)
@@ -1,3 +1,4 @@
1
+ import type { PlanReviewOutcomeMeta } from './hitl/planReviewWire.js';
1
2
  export interface TaskNotificationFields {
2
3
  taskId: string;
3
4
  status: string;
@@ -48,12 +49,13 @@ export interface NotificationQueuePort {
48
49
  priority: 'later';
49
50
  isMeta: true;
50
51
  workload: string;
52
+ _sema_planReviewOutcome?: PlanReviewOutcomeMeta;
51
53
  }): void;
52
54
  }
53
55
  export declare function installNotificationQueuePort(port: NotificationQueuePort | null): void;
54
56
  export declare function notificationQueuePortMisses(): number;
55
57
  export declare function _resetNotificationQueuePortForTest(): void;
56
- export declare function enqueuePlanReviewOutcome(value: string): boolean;
58
+ export declare function enqueuePlanReviewOutcome(value: string, meta?: PlanReviewOutcomeMeta): boolean;
57
59
  export declare function dropQueuedNotificationsForRun(taskId: string): number;
58
60
  export declare function markEngineWorkflowNotified(runId: string): void;
59
61
  export declare function isEngineWorkflowNotified(runId: string): boolean;
@@ -188,7 +188,7 @@ function notifiedBgCycleAtLeast(taskId, cycle) {
188
188
  const high = notifiedBgCycleHigh.get(taskId);
189
189
  return high !== undefined && high.cycle >= cycle ? high : undefined;
190
190
  }
191
- export function enqueuePlanReviewOutcome(value) {
191
+ export function enqueuePlanReviewOutcome(value, meta) {
192
192
  const p = port();
193
193
  if (!p)
194
194
  return false;
@@ -196,7 +196,23 @@ export function enqueuePlanReviewOutcome(value) {
196
196
  queuePortMisses++;
197
197
  return false;
198
198
  }
199
- p.enqueueMetaPrompt({ value, mode: 'prompt', priority: 'later', isMeta: true, workload: 'plan-review' });
199
+ p.enqueueMetaPrompt({
200
+ value,
201
+ mode: 'prompt',
202
+ priority: 'later',
203
+ isMeta: true,
204
+ workload: 'plan-review',
205
+ ...(meta !== undefined
206
+ ? {
207
+ _sema_planReviewOutcome: Object.freeze({
208
+ taskId: meta.taskId,
209
+ dispatchNo: meta.dispatchNo,
210
+ decision: meta.decision,
211
+ effect: meta.effect,
212
+ }),
213
+ }
214
+ : {}),
215
+ });
200
216
  return true;
201
217
  }
202
218
  export function dropQueuedNotificationsForRun(taskId) {