Skip to content

Commit e7e2cc1

Browse files
icecrasher321claude
andcommitted
fix(copilot): key the dispatched-run id off the failure instead of writing to it
Guarding the write was trading one failure for the worse one: a frozen or sealed error kept the process alive but dropped the marker, which turns "this run exists" into "nothing started" — the single direction that duplicates work. Record the id in a WeakMap keyed by the thrown value, the same shape markExecutionFinalizedByCore already keeps for the same reason. Nothing is written to the error, so a non-extensible one is recorded like any other and there is no throw to guard. Identity keying also retires the prototype-chain concern, and the error's own surface stays clean, so a serialized failure no longer carries a stray field. A thrown primitive still cannot be keyed, which costs nothing today because every throw site past the dispatch boundary raises an Error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9c9161c commit e7e2cc1

3 files changed

Lines changed: 39 additions & 27 deletions

File tree

apps/sim/executor/utils/errors.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,54 +36,51 @@ export function attachExecutionResult(error: Error, executionResult: ExecutionRe
3636
Object.assign(error, { executionResult })
3737
}
3838

39-
const ATTEMPTED_EXECUTION_ID = 'attemptedExecutionId'
39+
/**
40+
* Dispatched-run ids, keyed by the thrown value itself.
41+
*
42+
* A side table rather than a property on the error, for the same reason
43+
* {@link markExecutionFinalizedByCore} keeps one: a thrown value is not reliably writable.
44+
* `Object.assign` throws on a frozen or sealed failure, and guarding that throw would drop
45+
* the marker instead — silently converting "this run exists" into "nothing started", which
46+
* is the one direction that duplicates work. Identity keying also means no id can arrive
47+
* through a prototype chain, and nothing is added to the error's own surface, so a
48+
* serialized error carries no stray field.
49+
*/
50+
const attemptedExecutionIds = new WeakMap<object, string>()
4051

4152
/**
4253
* Names the run a failure belongs to once dispatch has been attempted.
4354
*
4455
* A caller that only sees the thrown error cannot tell an authorization refusal — which
4556
* created nothing — from a crash after the run was already dispatched, and those need
46-
* opposite retry decisions. Attaching the id at the point of no return makes its absence
57+
* opposite retry decisions. Recording the id at the point of no return makes its absence
4758
* mean "nothing was started" rather than "we do not know", and its presence a key that
4859
* resolves to zero or one executions.
4960
*
5061
* Distinct from {@link attachExecutionResult}: that says the workflow ran and produced a
5162
* result, this says only that it was dispatched.
5263
*/
5364
export function attachAttemptedExecutionId(error: unknown, executionId: string): void {
54-
if (!isAttachableThrown(error) || !executionId) return
55-
if (Object.hasOwn(error, ATTEMPTED_EXECUTION_ID)) return
56-
/**
57-
* A frozen or sealed failure would otherwise throw here and replace the original error
58-
* partway through cleanup, turning a diagnostic aid into the thing that loses the
59-
* diagnosis. Losing the id is the lesser failure, and the log still records the run.
60-
*/
61-
try {
62-
Object.assign(error, { [ATTEMPTED_EXECUTION_ID]: executionId })
63-
} catch {}
65+
if (!isRecordedThrown(error) || !executionId) return
66+
if (attemptedExecutionIds.has(error)) return
67+
attemptedExecutionIds.set(error, executionId)
6468
}
6569

6670
/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
6771
export function readAttemptedExecutionId(error: unknown): string | undefined {
68-
/**
69-
* Own property only. `in` would accept one reached through the prototype chain, which
70-
* would let an unrelated run's id be disclosed for a refusal that started nothing.
71-
*/
72-
if (!isAttachableThrown(error) || !Object.hasOwn(error, ATTEMPTED_EXECUTION_ID)) return undefined
73-
const value = (error as Record<string, unknown>)[ATTEMPTED_EXECUTION_ID]
74-
return typeof value === 'string' && value.length > 0 ? value : undefined
72+
return isRecordedThrown(error) ? attemptedExecutionIds.get(error) : undefined
7573
}
7674

7775
/**
7876
* Any non-null object, not only an `Error`.
7977
*
8078
* Restricting this to `Error` would silently invert the invariant for a thrown plain object:
81-
* the id would not attach, the absence would then read as "nothing was started", and the
82-
* caller would retry a run that already exists — the exact duplicate-side-effect outcome
83-
* this id exists to prevent. A thrown primitive cannot carry a property at all, so callers
84-
* that must not lose the id normalize before attaching.
79+
* no id would be recorded, its absence would read as "nothing was started", and the caller
80+
* would retry a run that already exists. A thrown primitive cannot be keyed at all, which
81+
* costs nothing today because every throw site past the dispatch boundary raises an `Error`.
8582
*/
86-
function isAttachableThrown(value: unknown): value is Record<string, unknown> {
83+
function isRecordedThrown(value: unknown): value is object {
8784
return typeof value === 'object' && value !== null
8885
}
8986

apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,21 @@ describe('a withheld run_workflow result', () => {
157157
expect(views[2]).toContain(EXECUTION_ID)
158158
})
159159

160+
/** A frozen failure cannot take a property, and losing the id here would invite a duplicate run. */
161+
it('still names the run when the failure cannot be written to', async () => {
162+
const error = Object.freeze(new Error('crashed'))
163+
attachAttemptedExecutionId(error, EXECUTION_ID)
164+
mocks.executeWorkflowUseCase.mockRejectedValue(error)
165+
166+
const { result } = await withheld(await executeRunWorkflow({ workflowId: 'wf-1' }, context))
167+
168+
expect(modelOutput(result)).toEqual({
169+
resultWithheld: true,
170+
effect: 'attempted',
171+
executionId: EXECUTION_ID,
172+
})
173+
})
174+
160175
it('never lets the withheld payload carry the run content past the boundary', async () => {
161176
mocks.executeWorkflowUseCase.mockResolvedValue({
162177
success: false,

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import {
6868
runFromBlockFromCopilot,
6969
runWorkflowFromCopilot,
7070
} from '@/lib/workflows/application/run-workflow-from-copilot'
71-
import { readAttemptedExecutionId } from '@/executor/utils/errors'
71+
import { attachAttemptedExecutionId, readAttemptedExecutionId } from '@/executor/utils/errors'
7272

7373
const principal = {
7474
kind: 'delegated' as const,
@@ -326,9 +326,9 @@ describe('Copilot workflow run application commands', () => {
326326

327327
it('passes through the id executeWorkflow attached at its dispatch boundary', async () => {
328328
mocks.executeWorkflow.mockImplementationOnce(() => {
329-
// Exactly what the execution core does once its logging session has started.
329+
// Exactly what the execution core does once a block could have run.
330330
const dispatchFailure = new Error('database unavailable')
331-
Object.assign(dispatchFailure, { attemptedExecutionId: 'child-execution-1' })
331+
attachAttemptedExecutionId(dispatchFailure, 'child-execution-1')
332332
throw dispatchFailure
333333
})
334334

0 commit comments

Comments
 (0)