Skip to content

Commit d27d944

Browse files
icecrasher321claude
andcommitted
fix(copilot): derive the run phase from what the executor saw, not the result's shape
Nine review rounds found the same class of defect, which makes it a design problem rather than nine bugs. "Did a side effect occur" had two sources that disagreed: a precise marker on the thrown path, and on the returned path an inference from whatever the outcome happened to look like. Every property used for that inference is a proxy that breaks on the paths that matter — an engine failing before its first block still carries an ExecutionResult, and a run that ends without one still ran every block it had — so each round found another path where the proxy lied. There is now one source. The engine reports the moment a block handler is first about to run, which is terminal: no fallible step remains between it and the handler, so there is nothing left for a later reviewer to find in front of it. The signal is threaded to the caller and recorded against the outcome, and the copilot adapter reads it on every exit path instead of inspecting status or the presence of an attached result. The phase then follows from two stated facts rather than a guess: nothing dispatched is not_attempted whatever the result looks like, a run that stopped partway is attempted, and one that reached the end is performed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4719b98 commit d27d944

7 files changed

Lines changed: 121 additions & 26 deletions

File tree

apps/sim/executor/execution/engine.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class ExecutionEngine {
3838
private cancellationController = new AbortController()
3939
private abortSignalListener: (() => void) | null = null
4040
private cancellationUnsubscribe: (() => void) | null = null
41+
private reportedBlocksMayRun = false
4142
private execLogger: Logger
4243

4344
constructor(
@@ -62,6 +63,13 @@ export class ExecutionEngine {
6263
this.initializeAbortHandler()
6364
}
6465

66+
/** Fires the caller's dispatch observer exactly once, however many nodes follow. */
67+
private reportBlocksMayRun(): void {
68+
if (this.reportedBlocksMayRun) return
69+
this.reportedBlocksMayRun = true
70+
this.context.onBlocksMayRun?.()
71+
}
72+
6573
private async subscribeToCancellationSignal(): Promise<void> {
6674
if (!this.context.executionId) return
6775
const executionId = this.context.executionId
@@ -107,15 +115,6 @@ export class ExecutionEngine {
107115
this.initializeQueue(triggerBlockId)
108116
await this.subscribeToCancellationSignal()
109117

110-
/**
111-
* Past every fallible startup step — DAG construction, pipeline assembly and the
112-
* cancellation subscription above all reject having run nothing — and immediately
113-
* before the loop that processes blocks. This is the line a caller means by "could a
114-
* side effect have occurred"; anything earlier reports a run for a request that was
115-
* merely refused.
116-
*/
117-
this.context.onBlocksMayRun?.()
118-
119118
while (this.hasWork()) {
120119
if (this.checkCancellation() || this.errorFlag || this.stoppedEarlyFlag) {
121120
break
@@ -430,6 +429,14 @@ export class ExecutionEngine {
430429
private async executeNodeAsync(nodeId: string): Promise<void> {
431430
try {
432431
const wasAlreadyExecuted = this.context.executedBlocks.has(nodeId)
432+
/**
433+
* The single moment a side effect becomes possible: the last statement before a block
434+
* handler runs. Every earlier candidate was a proxy that a reviewer could then find a
435+
* fallible step in front of — startup, the cancellation subscription, queue and
436+
* subflow initialization all reject having run nothing. There is nothing between here
437+
* and the handler, so there is nothing left to be in front of.
438+
*/
439+
this.reportBlocksMayRun()
433440
const result = await this.nodeOrchestrator.executeNode(this.context, nodeId)
434441

435442
if (!wasAlreadyExecuted) {

apps/sim/executor/utils/errors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@ export function attachAttemptedExecutionId(error: unknown, executionId: string):
6767
attemptedExecutionIds.set(error, executionId)
6868
}
6969

70+
/**
71+
* Whether a block handler ran, keyed by the outcome that carries it — a result or a throw.
72+
*
73+
* Recorded rather than inferred. Every property of an outcome that looks like it answers
74+
* this is a proxy that disagrees on the paths that matter: an engine failing before its
75+
* first block still carries an `ExecutionResult`, and a run that ends without one still ran
76+
* every block it had. Only the executor knows, so only the executor says.
77+
*/
78+
const observedBlockDispatch = new WeakMap<object, boolean>()
79+
80+
/** Records the executor's answer against the outcome a caller will read it from. */
81+
export function recordBlocksMayHaveRun(outcome: unknown, blocksMayHaveRun: boolean): void {
82+
if (!isRecordedThrown(outcome)) return
83+
observedBlockDispatch.set(outcome, blocksMayHaveRun)
84+
}
85+
86+
/** Undefined when nothing observed this run, which callers must treat conservatively. */
87+
export function readBlocksMayHaveRun(outcome: unknown): boolean | undefined {
88+
return isRecordedThrown(outcome) ? observedBlockDispatch.get(outcome) : undefined
89+
}
90+
7091
/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
7192
export function readAttemptedExecutionId(error: unknown): string | undefined {
7293
return isRecordedThrown(error) ? attemptedExecutionIds.get(error) : undefined

apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const { mocks } = vi.hoisted(() => ({
1010
executeWorkflowUseCase: vi.fn(),
1111
hasExecutionResult: vi.fn(),
1212
readAttemptedExecutionId: vi.fn(),
13+
readBlocksMayHaveRun: vi.fn(),
1314
},
1415
}))
1516

@@ -30,6 +31,7 @@ vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({
3031
vi.mock('@/executor/utils/errors', () => ({
3132
hasExecutionResult: mocks.hasExecutionResult,
3233
readAttemptedExecutionId: mocks.readAttemptedExecutionId,
34+
readBlocksMayHaveRun: mocks.readBlocksMayHaveRun,
3335
}))
3436

3537
vi.mock('@/lib/core/telemetry', () => ({
@@ -60,6 +62,8 @@ describe('workflow mutation Copilot adapters', () => {
6062
vi.clearAllMocks()
6163
mocks.hasExecutionResult.mockReturnValue(false)
6264
mocks.readAttemptedExecutionId.mockReturnValue(undefined)
65+
// Default: the executor saw a block run, which is the ordinary case.
66+
mocks.readBlocksMayHaveRun.mockReturnValue(true)
6367
})
6468

6569
it('maps encoded folder aliases into one create application command', async () => {
@@ -295,6 +299,20 @@ describe('workflow mutation Copilot adapters', () => {
295299
run: () => executeRunWorkflow({ workflowId: 'workflow-1' }, context),
296300
effect: { phase: 'attempted', ids: { executionId: 'execution-1' } },
297301
},
302+
{
303+
label: 'refused by the engine before any block ran',
304+
arrange: () => {
305+
mocks.readBlocksMayHaveRun.mockReturnValue(false)
306+
mocks.executeWorkflowUseCase.mockResolvedValueOnce({
307+
success: false,
308+
output: {},
309+
logs: [],
310+
metadata: { executionId: 'execution-1' },
311+
})
312+
},
313+
run: () => executeRunWorkflow({ workflowId: 'workflow-1' }, context),
314+
effect: { phase: 'not_attempted', ids: { executionId: 'execution-1' } },
315+
},
298316
{
299317
label: 'cancelled before it could finish',
300318
arrange: () =>

apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ import {
2929
setWorkflowBlockEnabled,
3030
} from '@/lib/workflows/application/update-workflow-content'
3131
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
32-
import { hasExecutionResult, readAttemptedExecutionId } from '@/executor/utils/errors'
32+
import {
33+
hasExecutionResult,
34+
readAttemptedExecutionId,
35+
readBlocksMayHaveRun,
36+
} from '@/executor/utils/errors'
3337
import type { WorkflowState } from '@/stores/workflows/workflow/types'
3438

3539
function stripBinaryFields(value: unknown): unknown {
@@ -61,19 +65,30 @@ function runRejected(error: string): ToolCallResult {
6165
}
6266

6367
/**
64-
* `performed` claims the run reached the end of its work, so only a run that terminated on
65-
* its own may carry it. A cancelled or paused one stopped partway — it may have run every
66-
* block, one, or none — and `attempted` is the phase that says exactly that: an execution
67-
* exists under this id, resolve it before deciding anything.
68+
* Derives the phase from two facts, neither of them guessed.
69+
*
70+
* `blocksMayHaveRun` comes from the executor and is the only thing that separates a run
71+
* with side effects from a request that was refused; the status says whether that run
72+
* reached the end of its work. Undefined means nothing observed the run, which is treated
73+
* as "it may have" — over-reporting costs a lookup, under-reporting duplicates work.
6874
*/
69-
function executionPhase(status: ExecutionResultStatus): ToolEffectPhase {
75+
function executionPhase(
76+
blocksMayHaveRun: boolean | undefined,
77+
status: ExecutionResultStatus
78+
): ToolEffectPhase {
79+
if (blocksMayHaveRun === false) return TOOL_EFFECT_PHASE.notAttempted
7080
return status === 'cancelled' || status === 'paused'
7181
? TOOL_EFFECT_PHASE.attempted
7282
: TOOL_EFFECT_PHASE.performed
7383
}
7484

7585
type ExecutionResultStatus = 'completed' | 'paused' | 'cancelled' | undefined
7686

87+
/** The phase of a run that returned, from what the executor observed about it. */
88+
function runPhase(result: { status?: ExecutionResultStatus }): ToolEffectPhase {
89+
return executionPhase(readBlocksMayHaveRun(result), result.status)
90+
}
91+
7792
function buildExecutionOutput(
7893
result: {
7994
success: boolean
@@ -83,6 +98,7 @@ function buildExecutionOutput(
8398
error?: string
8499
status?: ExecutionResultStatus
85100
},
101+
phase: ToolEffectPhase,
86102
extra?: Record<string, unknown>
87103
): ToolCallResult {
88104
return {
@@ -95,17 +111,25 @@ function buildExecutionOutput(
95111
logs: stripBinaryFields(result.logs),
96112
},
97113
error: result.success ? undefined : result.error || 'Workflow execution failed',
98-
effect: executionEffect(executionPhase(result.status), result.metadata?.executionId),
114+
effect: executionEffect(phase, result.metadata?.executionId),
99115
}
100116
}
101117

102118
function buildExecutionError(error: unknown): ToolCallResult {
103119
if (hasExecutionResult(error)) {
104-
return buildExecutionOutput({
105-
...error.executionResult,
106-
success: false,
107-
error: error.executionResult.error || 'Workflow execution failed',
108-
})
120+
return buildExecutionOutput(
121+
{
122+
...error.executionResult,
123+
success: false,
124+
error: error.executionResult.error || 'Workflow execution failed',
125+
},
126+
/**
127+
* Read off the error, not the spread copy above: the executor recorded its answer
128+
* against the value it threw, and spreading makes a new object the record cannot
129+
* follow.
130+
*/
131+
executionPhase(readBlocksMayHaveRun(error), error.executionResult.status)
132+
)
109133
}
110134
logger.error('Copilot workflow execution command failed', { error })
111135
/**
@@ -267,7 +291,7 @@ export async function executeRunWorkflow(
267291
lifecycle: copilotRunLifecycle(context),
268292
})
269293

270-
return buildExecutionOutput(result)
294+
return buildExecutionOutput(result, runPhase(result))
271295
} catch (error) {
272296
return buildExecutionError(error)
273297
}
@@ -389,7 +413,9 @@ export async function executeRunWorkflowUntilBlock(
389413
lifecycle: copilotRunLifecycle(context),
390414
})
391415

392-
return buildExecutionOutput(result, { stoppedAfterBlockId: params.stopAfterBlockId })
416+
return buildExecutionOutput(result, runPhase(result), {
417+
stoppedAfterBlockId: params.stopAfterBlockId,
418+
})
393419
} catch (error) {
394420
return buildExecutionError(error)
395421
}
@@ -464,7 +490,7 @@ export async function executeRunFromBlock(
464490
lifecycle: copilotRunLifecycle(context),
465491
})
466492

467-
return buildExecutionOutput(result, { startBlockId: params.startBlockId })
493+
return buildExecutionOutput(result, runPhase(result), { startBlockId: params.startBlockId })
468494
} catch (error) {
469495
return buildExecutionError(error)
470496
}
@@ -550,7 +576,7 @@ export async function executeRunBlock(
550576
lifecycle: copilotRunLifecycle(context),
551577
})
552578

553-
return buildExecutionOutput(result, { blockId: params.blockId })
579+
return buildExecutionOutput(result, runPhase(result), { blockId: params.blockId })
554580
} catch (error) {
555581
return buildExecutionError(error)
556582
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
} from '@/lib/workflows/triggers/run-options'
3030
import type { SerializableExecutionState } from '@/executor/execution/types'
3131
import type { ExecutionResult } from '@/executor/types'
32-
import { attachAttemptedExecutionId } from '@/executor/utils/errors'
32+
import { attachAttemptedExecutionId, recordBlocksMayHaveRun } from '@/executor/utils/errors'
3333

3434
const logger = createLogger('CopilotWorkflowRun')
3535

@@ -251,6 +251,7 @@ async function executeCopilotRun(params: {
251251
)
252252
const completePendingActivation = registry?.beginPendingActivation()
253253
let runReturned = false
254+
let blocksMayHaveRun = false
254255
try {
255256
const result = await executeWorkflow(
256257
{
@@ -273,6 +274,17 @@ async function executeCopilotRun(params: {
273274
stopAfterBlockId: params.stopAfterBlockId,
274275
runFromBlock: params.runFromBlock,
275276
abortSignal: params.input.lifecycle.abortSignal,
277+
/**
278+
* Whether a block could have run, stated by the executor rather than inferred here.
279+
* Every earlier attempt read it off the shape of the outcome — a status field, or
280+
* whether an ExecutionResult rode along on the error — and those are proxies that
281+
* disagree with reality on exactly the paths that matter: an engine that fails
282+
* before its first block still carries a result, and a run that ends without one
283+
* still ran every block it had.
284+
*/
285+
onBlocksMayRun: () => {
286+
blocksMayHaveRun = true
287+
},
276288
billingAttribution: admission.billingAttribution,
277289
...(trustedInitialResolvedSecretTraceProvenance
278290
? { trustedInitialResolvedSecretTraceProvenance }
@@ -293,6 +305,7 @@ async function executeCopilotRun(params: {
293305
childExecutionId
294306
)
295307
runReturned = true
308+
recordBlocksMayHaveRun(result, blocksMayHaveRun)
296309
if (registry) {
297310
await registry.importCrossingProvenance(
298311
result.executionState?.resolvedSecretTraceProvenance,
@@ -309,6 +322,7 @@ async function executeCopilotRun(params: {
309322
* what threw and an execution certainly exists.
310323
*/
311324
if (runReturned) attachAttemptedExecutionId(error, childExecutionId)
325+
recordBlocksMayHaveRun(error, blocksMayHaveRun)
312326
/**
313327
* Recovery must never replace the failure it is describing. Both steps below run only to
314328
* record and release, and either throwing would propagate a different error — one the

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export interface ExecuteWorkflowOptions {
5252
useDraftState?: boolean
5353
/** Stop execution after this block completes. Used for "run until block" feature. */
5454
stopAfterBlockId?: string
55+
/**
56+
* Fired once, when a block handler is first about to run. The only fact that answers
57+
* "could a side effect have occurred", and the only one the executor can state rather
58+
* than have inferred from the shape of a result.
59+
*/
60+
onBlocksMayRun?: () => void
5561
/** Run-from-block configuration using a prior execution snapshot. */
5662
runFromBlock?: {
5763
startBlockId: string
@@ -203,6 +209,7 @@ export async function executeWorkflow(
203209
base64MaxBytes: streamConfig?.base64MaxBytes,
204210
abortSignal: streamConfig?.abortSignal,
205211
stopAfterBlockId: streamConfig?.stopAfterBlockId,
212+
onBlocksMayRun: streamConfig?.onBlocksMayRun,
206213
trustedInitialResolvedSecretTraceProvenance:
207214
streamConfig?.trustedInitialResolvedSecretTraceProvenance,
208215
runFromBlock: streamConfig?.runFromBlock,

apps/sim/lib/workflows/executor/execution-core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export interface ExecuteWorkflowCoreOptions {
117117
includeFileBase64?: boolean
118118
base64MaxBytes?: number
119119
stopAfterBlockId?: string
120+
onBlocksMayRun?: () => void
120121
/** Trusted encrypted provenance captured by a server-only pre-execution boundary. */
121122
trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
122123
/** Immutable deployment admitted by the durable parent log for a resumed execution. */
@@ -973,6 +974,7 @@ async function executeWorkflowCoreImpl(
973974
*/
974975
onBlocksMayRun: () => {
975976
executorStarted = true
977+
options.onBlocksMayRun?.()
976978
},
977979
stream: !!onStream,
978980
selectedOutputs,

0 commit comments

Comments
 (0)