fix(telemetry): address #4321 review — Copilot inline + code-reviewer + silent-failure-hunter

Eight discrete fixes plus two new tests, all surfaced in the Phase 2 review
rounds. Grouped here because they touch the same handful of code paths.

Copilot inline (#4321 PR):
1. startToolSpan attrs naming: drop redundant `tool_name` (helper already
   sets `'tool.name'` from the first arg) and rename `call_id` to the
   namespaced `'tool.call_id'`. Two sites: `_schedule` validating-loop
   start, and the defensive fallback in executeSingleToolCall. Without
   this, traces emit non-namespaced `tool_name` / `call_id` attributes
   that consumers grepping for `tool.call_id` miss.
2. PreToolUse hook span: propagate the actual `preHookResult.blockType`
   ('denied' / 'ask' / 'stop') instead of collapsing every block to
   'denied'. Also record `hasAdditionalContext` for parity with the
   PostToolUse / failure-hook spans.
3. blocked_on_user `source` detection: use `config.getIdeMode()` (best-
   effort) so IDE-driven decisions don't all show up as `'cli'`.
   Centralized in a new `getBlockedSource()` helper.

silent-failure-hunter / code-reviewer:
4. Hook span error-tracking is dead code. firePreToolUseHook /
   firePostToolUseHook / safelyFirePostToolUseFailureHook all swallow
   throws internally — every `catch (e) { endMeta = { error, ... };
   throw e }` block in the scheduler was unreachable. Simplify all 6
   sites to `try { ... } finally { endHookSpan(...) }`. The default
   `endMeta = { success: false }` keeps the span sensible if a future
   hook impl decides to throw.
5. handleConfirmationResponse had no error handling. modifyWithEditor /
   _applyInlineModify / attemptExecutionOfScheduledCalls can throw and
   would otherwise leak both the tool span and the blocked_on_user span
   until the 30-min TTL fires. Wrap the body in a try/catch that
   finalizes both spans on rethrow. Extracted the body to
   `_handleConfirmationResponseInner` for clarity.
6. Add `'error'` to the `ToolBlockedDecision` union for system-error
   closes, so dashboards counting `decision: 'cancel'` don't get
   polluted by thrown exceptions.
7. _schedule's outer catch was labelling its non-aborted close as
   `'cancel'`. Switch to `'error'` (uses #6).
8. signal.aborted vs explicit user Cancel: when both are true, the old
   code reported `'aborted'/'system'` even though the user actually
   clicked Cancel. Reverse the precedence so `outcome === Cancel`
   wins, with `getBlockedSource()` for the source.

Tests:
- T1: extend the existing ProceedAlways auto-approve test to assert the
  two siblings' blocked spans end with `decision: 'auto_approved'`,
  `source: 'auto'`, while the first tool ends as `'proceed_always'`/cli.
- T2: existing cancel-during-confirmation test now also asserts exactly
  one blocked span is recorded for the lifecycle — the same invariant
  ModifyWithEditor's intentional preservation across editor side trips
  must not break.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
doudouOUC 2026-05-19 19:24:52 +08:00
parent 38ba22d9c3
commit 6767469b23
3 changed files with 124 additions and 48 deletions

View file

@ -2380,6 +2380,13 @@ describe('CoreToolScheduler request queueing', () => {
const abortController = new AbortController();
// toolSpanRecords accumulates across tests in this describe block.
// Snapshot before schedule() so the assertions below see only this
// test's records.
const blockedSpansBefore = toolSpanRecords.filter(
(r) => r.name === 'tool.blocked_on_user',
).length;
// Schedule multiple tools that need confirmation
const requests = [
{
@ -2436,6 +2443,26 @@ describe('CoreToolScheduler request queueing', () => {
// Verify approval mode was changed
expect(approvalMode).toBe(ApprovalMode.AUTO_EDIT);
// #3731 Phase 2 / #4321 review: the first tool's blocked span ends as
// 'proceed_always' / cli; the two siblings auto-approved by
// autoApproveCompatiblePendingTools must end as
// 'auto_approved' / 'auto'. Slice from blockedSpansBefore so we see
// only the spans this test produced.
const blockedRecords = toolSpanRecords
.filter((r) => r.name === 'tool.blocked_on_user')
.slice(blockedSpansBefore);
expect(blockedRecords).toHaveLength(3);
const decisions = blockedRecords
.map((r) => r.blockedMetadata?.decision)
.sort();
const sources = blockedRecords.map((r) => r.blockedMetadata?.source).sort();
expect(decisions).toEqual([
'auto_approved',
'auto_approved',
'proceed_always',
]);
expect(sources).toEqual(['auto', 'auto', 'cli']);
});
});
@ -4010,6 +4037,13 @@ describe('CoreToolScheduler telemetry spans', () => {
);
expect(toolSpans).toHaveLength(1);
expect(toolSpans[0].ended).toBe(true);
// #4321 review: the awaiting_approval phase produces exactly one
// blocked_on_user span across the lifecycle. ModifyWithEditor's
// intentional invariant is the same — re-entering awaiting_approval
// must NOT spawn a second span. This assertion guards against a
// future refactor that re-starts the blocked span on each transition.
expect(blockedSpans).toHaveLength(1);
});
it('hook span records shouldProceed=false / blockType=denied when pre-hook blocks (#3731 Phase 2)', async () => {

View file

@ -1035,6 +1035,17 @@ export class CoreToolScheduler {
endToolBlockedOnUserSpan(span, { decision, source });
}
/**
* Best-effort attribution of the surface that resolved the blocked
* decision. When IDE mode is on, confirmations are most often resolved
* via the IDE diff flow (`openIdeDiffIfEnabled`) but a CLI-fallback
* confirmation in IDE mode is also reported as 'ide' here. Operators
* can drill into the trace if they need finer-grained attribution.
*/
private getBlockedSource(): ToolBlockedSource {
return this.config.getIdeMode?.() ? 'ide' : 'cli';
}
private buildInvocation(
tool: AnyDeclarativeTool,
args: object,
@ -1351,9 +1362,10 @@ export class CoreToolScheduler {
// Phase 2). Every cancel/error path below — and the existing
// success path in executeSingleToolCall — must call
// finalizeToolSpan(callId, ...) to avoid leaking spans.
// `tool.name` is set automatically by startToolSpan from the first
// arg; only namespaced extras go in attrs.
const toolSpan = startToolSpan(canonicalName, {
tool_name: canonicalName,
call_id: reqInfo.callId,
'tool.call_id': reqInfo.callId,
});
this.toolSpans.set(reqInfo.callId, toolSpan);
@ -1693,7 +1705,10 @@ export class CoreToolScheduler {
explicitErrorType ?? ToolErrorType.UNHANDLED_EXCEPTION,
),
);
this.finalizeBlockedSpan(reqInfo.callId, 'cancel', 'system');
// Non-aborted catch is a system error (e.g. getConfirmationDetails
// threw). 'error' decision keeps it distinct from user 'cancel'
// counts in dashboards.
this.finalizeBlockedSpan(reqInfo.callId, 'error', 'system');
setToolSpanFailure(
toolSpan,
TOOL_FAILURE_KIND_TOOL_EXCEPTION,
@ -1728,6 +1743,47 @@ export class CoreToolScheduler {
// processing and potential re-execution.
if (!toolCall) return;
try {
await this._handleConfirmationResponseInner(
callId,
toolCall,
originalOnConfirm,
outcome,
signal,
payload,
);
} catch (error) {
// Defensive: any throw from originalOnConfirm / modifyWithEditor /
// _applyInlineModify / attemptExecutionOfScheduledCalls would
// otherwise leave the blocked + tool spans open until the 30-min
// TTL fires. Finalize both so the trace shows a deterministic
// close. finalizeXSpan are idempotent — if the success/cancel path
// already closed them, these are no-ops.
this.finalizeBlockedSpan(callId, 'error', 'system');
const toolSpan = this.toolSpans.get(callId);
if (toolSpan) {
setToolSpanFailure(
toolSpan,
TOOL_FAILURE_KIND_TOOL_EXCEPTION,
error instanceof Error ? error.message : String(error),
);
}
this.finalizeToolSpan(callId);
throw error;
}
}
private async _handleConfirmationResponseInner(
callId: string,
toolCall: ToolCall,
originalOnConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>,
outcome: ToolConfirmationOutcome,
signal: AbortSignal,
payload?: ToolConfirmationPayload,
): Promise<void> {
await originalOnConfirm(outcome, payload);
if (
@ -1759,10 +1815,15 @@ export class CoreToolScheduler {
if (toolSpan) {
setToolSpanCancelled(toolSpan);
}
// Explicit user Cancel takes precedence over a concurrent global
// abort: when both are true, treat it as an explicit cancel so
// dashboards counting `decision: 'aborted'` aren't polluted by
// benign user actions that race with shutdown.
const explicitCancel = outcome === ToolConfirmationOutcome.Cancel;
this.finalizeBlockedSpan(
callId,
signal.aborted ? 'aborted' : 'cancel',
signal.aborted ? 'system' : 'cli',
explicitCancel ? 'cancel' : 'aborted',
explicitCancel ? this.getBlockedSource() : 'system',
);
this.finalizeToolSpan(callId);
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
@ -1826,7 +1887,7 @@ export class CoreToolScheduler {
outcome === ToolConfirmationOutcome.ProceedOnce
? 'proceed_once'
: 'proceed_always';
this.finalizeBlockedSpan(callId, decision, 'cli');
this.finalizeBlockedSpan(callId, decision, this.getBlockedSource());
}
await this.attemptExecutionOfScheduledCalls(signal);
}
@ -2020,8 +2081,7 @@ export class CoreToolScheduler {
let toolSpan = this.toolSpans.get(callId);
if (!toolSpan) {
toolSpan = startToolSpan(toolName, {
tool_name: toolName,
call_id: callId,
'tool.call_id': callId,
});
this.toolSpans.set(callId, toolSpan);
}
@ -2082,6 +2142,10 @@ export class CoreToolScheduler {
toolName: canonicalName,
toolUseId,
});
// try/finally (no catch): firePreToolUseHook is wrapped in its own
// safelyFire-style guard inside toolHookTriggers and never throws.
// The default endMeta records success: false so a future change that
// makes it throw would still close the span with a sensible state.
let endMeta: HookSpanMetadata = { success: false };
try {
const preHookResult = await firePreToolUseHook(
@ -2094,7 +2158,12 @@ export class CoreToolScheduler {
endMeta = {
success: true,
shouldProceed: preHookResult.shouldProceed,
blockType: preHookResult.shouldProceed ? undefined : 'denied',
// Propagate the actual blockType ('denied' / 'ask' / 'stop')
// instead of collapsing every block to 'denied'.
blockType: preHookResult.shouldProceed
? undefined
: preHookResult.blockType,
hasAdditionalContext: !!preHookResult.additionalContext,
};
if (!preHookResult.shouldProceed) {
@ -2120,12 +2189,6 @@ export class CoreToolScheduler {
);
return;
}
} catch (e) {
endMeta = {
success: false,
error: e instanceof Error ? e.message : String(e),
};
throw e;
} finally {
endHookSpan(hookSpan, endMeta);
}
@ -2230,6 +2293,8 @@ export class CoreToolScheduler {
toolUseId,
isInterrupt: true,
});
// safelyFirePostToolUseFailureHook absorbs throws — try/finally
// is enough; default endMeta covers a hypothetical future change.
let endMeta: HookSpanMetadata = { success: false };
try {
const failureHookResult = await safelyFirePostToolUseFailureHook(
@ -2250,12 +2315,6 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
cancelMessage += `\n\n${failureHookResult.additionalContext}`;
}
} catch (e) {
endMeta = {
success: false,
error: e instanceof Error ? e.message : String(e),
};
throw e;
} finally {
endHookSpan(hookSpan, endMeta);
}
@ -2288,8 +2347,12 @@ export class CoreToolScheduler {
toolName: canonicalName,
toolUseId,
});
// try/finally; firePostToolUseHook is wrapped via the
// safelyFire-style guard inside toolHookTriggers and never
// throws today. definite-assignment lets us use the result
// after the finally.
let endMeta: HookSpanMetadata = { success: false };
let postHookResult: Awaited<ReturnType<typeof firePostToolUseHook>>;
let postHookResult!: Awaited<ReturnType<typeof firePostToolUseHook>>;
try {
postHookResult = await firePostToolUseHook(
messageBus,
@ -2305,15 +2368,9 @@ export class CoreToolScheduler {
hasAdditionalContext: !!postHookResult.additionalContext,
blockType: postHookResult.shouldStop ? 'stop' : undefined,
};
} catch (e) {
endMeta = {
success: false,
error: e instanceof Error ? e.message : String(e),
};
} finally {
endHookSpan(hookSpan, endMeta);
throw e;
}
endHookSpan(hookSpan, endMeta);
// Append additional context from hook if provided
if (postHookResult.additionalContext) {
@ -2498,12 +2555,6 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
errorMessage += `\n\n${failureHookResult.additionalContext}`;
}
} catch (e) {
endMeta = {
success: false,
error: e instanceof Error ? e.message : String(e),
};
throw e;
} finally {
endHookSpan(hookSpan, endMeta);
}
@ -2578,12 +2629,6 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
cancelMessage += `\n\n${failureHookResult.additionalContext}`;
}
} catch (e) {
endMeta = {
success: false,
error: e instanceof Error ? e.message : String(e),
};
throw e;
} finally {
endHookSpan(hookSpan, endMeta);
}
@ -2627,12 +2672,6 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
exceptionErrorMessage += `\n\n${failureHookResult.additionalContext}`;
}
} catch (e) {
endMeta = {
success: false,
error: e instanceof Error ? e.message : String(e),
};
throw e;
} finally {
endHookSpan(hookSpan, endMeta);
}

View file

@ -536,7 +536,10 @@ export type ToolBlockedDecision =
| 'proceed_always'
| 'cancel'
| 'aborted'
| 'auto_approved';
| 'auto_approved'
// System-error close — distinct from user 'cancel' so dashboards counting
// user cancels don't double-count thrown exceptions in the approval path.
| 'error';
export type ToolBlockedSource = 'cli' | 'ide' | 'hook' | 'auto' | 'system';