Skip to content

Commit 9cf9a89

Browse files
icecrasher321claude
andcommitted
fix(copilot): name the dispatched run from the boundary that owns it
Review found the attempted-run id attached around the whole of executeWorkflow, which validates workspace and billing attribution before it can create anything. A preflight refusal therefore reported a run that never existed, telling a caller to resolve an id with nothing behind it and to skip a retry that was safe — the mirror of the defect this branch fixes. Move the attachment inside executeWorkflow, at the point it enters the execution core, which is the first moment a row may exist. Everything above it now correctly carries nothing, and the copilot layer keeps only the window executeWorkflow cannot see: a failure after the run already returned, where the crossing import threw and an execution certainly exists. Also from review: - Attach to any thrown object rather than only an Error, and normalize a thrown primitive past the dispatch boundary. Restricting to Error made the invariant silently invert for a thrown plain object — the id would not attach, its absence would read as "nothing started", and the caller would duplicate a real run. - Void the disclosure when an id would take one of the record's own field names. A valid uuid under `effect` overwrote the phase the retry decision reads, on the same all-or-nothing terms as an unvouchable id. - Drop `effect` from the provider model response. That path spreads every non-output field through verbatim, so the type's claim that the disclosure reaches the model only through the withheld-result projection was true by accident rather than by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 248987c commit 9cf9a89

6 files changed

Lines changed: 107 additions & 26 deletions

File tree

apps/sim/executor/utils/errors.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,31 @@ const ATTEMPTED_EXECUTION_ID = 'attemptedExecutionId'
5151
* result, this says only that it was dispatched.
5252
*/
5353
export function attachAttemptedExecutionId(error: unknown, executionId: string): void {
54-
if (!(error instanceof Error) || !executionId) return
54+
if (!isAttachableThrown(error) || !executionId) return
5555
if (ATTEMPTED_EXECUTION_ID in error) return
5656
Object.assign(error, { [ATTEMPTED_EXECUTION_ID]: executionId })
5757
}
5858

59-
/** Reads the dispatched-run id an error carries, if dispatch was reached at all. */
59+
/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
6060
export function readAttemptedExecutionId(error: unknown): string | undefined {
61-
if (!(error instanceof Error) || !(ATTEMPTED_EXECUTION_ID in error)) return undefined
62-
const value = (error as Error & Record<string, unknown>)[ATTEMPTED_EXECUTION_ID]
61+
if (!isAttachableThrown(error) || !(ATTEMPTED_EXECUTION_ID in error)) return undefined
62+
const value = (error as Record<string, unknown>)[ATTEMPTED_EXECUTION_ID]
6363
return typeof value === 'string' && value.length > 0 ? value : undefined
6464
}
6565

66+
/**
67+
* Any non-null object, not only an `Error`.
68+
*
69+
* Restricting this to `Error` would silently invert the invariant for a thrown plain object:
70+
* the id would not attach, the absence would then read as "nothing was started", and the
71+
* caller would retry a run that already exists — the exact duplicate-side-effect outcome
72+
* this id exists to prevent. A thrown primitive cannot carry a property at all, so callers
73+
* that must not lose the id normalize before attaching.
74+
*/
75+
function isAttachableThrown(value: unknown): value is Record<string, unknown> {
76+
return typeof value === 'object' && value !== null
77+
}
78+
6679
export interface BlockExecutionErrorDetails {
6780
block: SerializedBlock
6881
error: Error | string

apps/sim/lib/copilot/request/tools/resolved-secret-result.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ const READ_ONLY_RESULT_TOOLS = new Set(['read', 'glob', 'grep'])
4646
const SERVER_MINTED_ID_PATTERN =
4747
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
4848

49+
/** Field names the disclosure record owns; an id may not take one. */
50+
const RESERVED_DISCLOSURE_KEYS = new Set(['resultWithheld', 'effect'])
51+
4952
/** Chooses the withheld-result message a tool's caller should surface. */
5053
export function toolResultUnavailableError(toolId?: string): string {
5154
return toolId && READ_ONLY_RESULT_TOOLS.has(toolId)
@@ -102,10 +105,16 @@ function omittedResult(result: ToolExecutionResult, toolId?: string): ToolExecut
102105
}
103106
}
104107

105-
/** Returns the disclosure only when every id it carries is a shape this system mints. */
108+
/**
109+
* Returns the disclosure only when every id it carries is a shape this system mints and none
110+
* of them would displace the record's own fields. An id named `effect` overwriting the phase
111+
* would corrupt exactly the field the retry decision reads, so a collision voids the
112+
* disclosure on the same all-or-nothing terms as an unvouchable id.
113+
*/
106114
function vouchableEffect(effect: ToolCallEffect | undefined): ToolCallEffect | undefined {
107115
if (!effect) return undefined
108-
for (const value of Object.values(effect.ids ?? {})) {
116+
for (const [key, value] of Object.entries(effect.ids ?? {})) {
117+
if (RESERVED_DISCLOSURE_KEYS.has(key)) return undefined
109118
if (typeof value !== 'string' || !SERVER_MINTED_ID_PATTERN.test(value)) return undefined
110119
}
111120
return effect

apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,9 @@ describe('Copilot workflow run application commands', () => {
308308
})
309309

310310
/**
311-
* A caller whose result was withheld can only decide about retry from whether a run
312-
* exists. Naming it from dispatch onward, and only from there, is what makes the id's
313-
* absence the positive statement that nothing was created.
311+
* A caller whose result was withheld can only decide about retry from whether a run exists.
312+
* `executeWorkflow` owns that boundary and names the run itself once it crosses it; this
313+
* layer only covers the window it cannot see — a failure after the run already returned.
314314
*/
315315
describe('naming the run a failure belongs to', () => {
316316
const runInput = {
@@ -321,22 +321,53 @@ describe('Copilot workflow run application commands', () => {
321321
useMockPayload: true,
322322
}
323323

324-
it('names the dispatched run when execution itself fails', async () => {
325-
mocks.executeWorkflow.mockRejectedValueOnce(new Error('database unavailable'))
324+
const failWith = (input = runInput) =>
325+
runWorkflowFromCopilot.execute({ principal, input }).catch((thrown) => thrown)
326326

327-
const error = await runWorkflowFromCopilot
328-
.execute({ principal, input: runInput })
329-
.catch((thrown) => thrown)
327+
it('passes through the id executeWorkflow attached at its dispatch boundary', async () => {
328+
mocks.executeWorkflow.mockImplementationOnce(() => {
329+
// Exactly what executeWorkflow does once it enters the core.
330+
const dispatchFailure = new Error('database unavailable')
331+
Object.assign(dispatchFailure, { attemptedExecutionId: 'child-execution-1' })
332+
throw dispatchFailure
333+
})
334+
335+
expect(readAttemptedExecutionId(await failWith())).toBe('child-execution-1')
336+
})
337+
338+
it('names the run when the crossing threw after it already returned', async () => {
339+
// Only the post-run crossing throws; the catch re-enters this same method to
340+
// record the failed crossing, and throwing again there would replace the very
341+
// error the id was attached to.
342+
let crossings = 0
343+
const registry = {
344+
exportProvenanceForValue: () => undefined,
345+
beginPendingActivation: () => () => {},
346+
importCrossingProvenance: () => {
347+
if (crossings++ === 0) throw new Error('crossing import failed')
348+
},
349+
}
350+
351+
const error = await failWith({
352+
...runInput,
353+
lifecycle: { resolvedSecretTraceRegistry: registry },
354+
} as typeof runInput)
330355

331356
expect(readAttemptedExecutionId(error)).toBe('child-execution-1')
332357
})
333358

359+
it('names nothing when a preflight failure never reached dispatch', async () => {
360+
mocks.executeWorkflow.mockRejectedValueOnce(
361+
new Error('Billing attribution is required for workspace execution')
362+
)
363+
364+
expect(readAttemptedExecutionId(await failWith())).toBeUndefined()
365+
})
366+
334367
it('names nothing when admission refused the run before it could start', async () => {
335368
mocks.admission.mockRejectedValueOnce(new Error('Usage limit exceeded'))
336369

337-
const error = await runWorkflowFromCopilot
338-
.execute({ principal, input: runInput })
339-
.catch((thrown) => thrown)
370+
const error = await failWith()
340371

341372
expect(mocks.executeWorkflow).not.toHaveBeenCalled()
342373
expect(readAttemptedExecutionId(error)).toBeUndefined()
@@ -345,9 +376,7 @@ describe('Copilot workflow run application commands', () => {
345376
it('names nothing when authorization refused the run', async () => {
346377
mocks.permission.mockResolvedValue('read')
347378

348-
const error = await runWorkflowFromCopilot
349-
.execute({ principal, input: runInput })
350-
.catch((thrown) => thrown)
379+
const error = await failWith()
351380

352381
expect(mocks.admission).not.toHaveBeenCalled()
353382
expect(readAttemptedExecutionId(error)).toBeUndefined()

apps/sim/lib/workflows/application/run-workflow-from-copilot.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ async function executeCopilotRun(params: {
240240
params.executionInput
241241
)
242242
const completePendingActivation = registry?.beginPendingActivation()
243+
let runReturned = false
243244
try {
244245
const result = await executeWorkflow(
245246
{
@@ -280,6 +281,7 @@ async function executeCopilotRun(params: {
280281
},
281282
childExecutionId
282283
)
284+
runReturned = true
283285
if (registry) {
284286
await registry.importCrossingProvenance(
285287
result.executionState?.resolvedSecretTraceProvenance,
@@ -290,11 +292,12 @@ async function executeCopilotRun(params: {
290292
return result
291293
} catch (error) {
292294
/**
293-
* Everything above this `try` — authorization, admission, provenance export — fails
294-
* before a run can exist, so only failures from here carry the id. That asymmetry is
295-
* what lets a caller read its absence as "nothing was created" instead of guessing.
295+
* `executeWorkflow` names the run itself once it crosses its own dispatch boundary, so
296+
* preflight failures inside it correctly carry nothing. This covers only the window it
297+
* cannot see: a failure after the run already returned, where the crossing import is
298+
* what threw and an execution certainly exists.
296299
*/
297-
attachAttemptedExecutionId(error, childExecutionId)
300+
if (runReturned) attachAttemptedExecutionId(error, childExecutionId)
298301
if (registry) {
299302
const executionResult =
300303
typeof error === 'object' &&

apps/sim/lib/workflows/executor/execute-workflow.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
23
import { generateId } from '@sim/utils/id'
34
import {
45
assertBillingAttributionSnapshot,
@@ -12,6 +13,7 @@ import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-pe
1213
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
1314
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
1415
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
16+
import { attachAttemptedExecutionId } from '@/executor/utils/errors'
1517
import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry'
1618
import type { CoreTriggerType } from '@/stores/logs/filters/types'
1719

@@ -122,6 +124,7 @@ export async function executeWorkflow(
122124
loggingSession.setTrustedExecutionCorrelation(streamConfig.trustedExecutionCorrelation)
123125
}
124126
let postExecutionOwnershipTransferred = false
127+
let dispatched = false
125128

126129
try {
127130
const metadata: ExecutionMetadata = {
@@ -162,6 +165,13 @@ export async function executeWorkflow(
162165

163166
const executionStartMs = Date.now()
164167

168+
/**
169+
* Entering the core is the point past which an execution row may exist. Everything above
170+
* it — workspace and billing preflight, snapshot construction — fails without creating
171+
* anything, so a caller can read the absence of this id as "nothing was started" rather
172+
* than as an admission of not knowing.
173+
*/
174+
dispatched = true
165175
const result = await executeWorkflowCore({
166176
snapshot,
167177
callbacks: {
@@ -233,7 +243,14 @@ export async function executeWorkflow(
233243
}
234244

235245
return result
236-
} catch (error: unknown) {
246+
} catch (thrown: unknown) {
247+
/**
248+
* A thrown primitive has nowhere to carry the dispatched-run id, and losing it would make
249+
* a real run read as never started. Normalizing only past the dispatch boundary keeps the
250+
* rethrown value identical on every other path.
251+
*/
252+
const error = dispatched && typeof thrown !== 'object' ? toError(thrown) : thrown
253+
if (dispatched) attachAttemptedExecutionId(error, executionId)
237254
const errorDiagnostic = loggingSession.projectDiagnosticError(error)
238255
logger.error(`[${requestId}] Workflow execution failed`, errorDiagnostic)
239256

apps/sim/providers/runtime-context.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,17 @@ function toProviderModelResponse(
4343
rawResponse: ToolResponse,
4444
projectedResponse: ToolExecutionResult
4545
): ToolResponse {
46-
const { output: _output, error: _error, ...functionalFields } = rawResponse
46+
/**
47+
* `effect` is an input to the egress projection, not content — it reaches the model only as
48+
* the disclosure record that replaces withheld output. This split spreads every other field
49+
* through verbatim, so dropping it here is what keeps that true on the provider path too.
50+
*/
51+
const {
52+
output: _output,
53+
error: _error,
54+
effect: _effect,
55+
...functionalFields
56+
} = rawResponse as ToolResponse & { effect?: unknown }
4757
return {
4858
...functionalFields,
4959
output: Object.hasOwn(projectedResponse, 'output')

0 commit comments

Comments
 (0)