Skip to content

Commit ba86210

Browse files
icecrasher321claude
andcommitted
refactor(copilot): decide the run phase where the caller lives, not in the executor
Ten review rounds chased the same question — when exactly may a side effect have occurred — through six positions in the executor, ending with a callback on every block of every execution in the product. Against 543k executions a week, serving a disclosure read about fifty times a week. The precision was never the point: `attempted` and `performed` both mean an execution exists under this id, and the caller was already handed the id that resolves it. Revert all of it. The engine, the orchestrator, both context types and the callback threading through execute-workflow and execution-core go back to staging untouched; the executor's only remaining change is the id carrier in utils/errors.ts. The phase now comes from what the copilot layer already holds. Its `try` opens on the executor call, so everything it catches is post-dispatch by construction while authorization, admission and provenance export throw past it having created nothing — no id means nothing exists, an id means resolve it. A result in hand says how the run ended, which separates cancelled and paused from completed. The harness that motivated this is now in the diff: every outcome the run path can produce, driven through the real handler and the real projection, asserted on the retry decision a caller can reach and on no run content crossing. Six mutations were used to confirm it fails for the right reasons; one of them found the dispatch flag this refactor introduced was already dead, and it is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dd400bf commit ba86210

12 files changed

Lines changed: 202 additions & 299 deletions

File tree

apps/sim/executor/execution/executor.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,6 @@ export class DAGExecutor {
415415

416416
const context: ExecutionContext = {
417417
workflowId,
418-
onBlocksMayRun: this.contextExtensions.onBlocksMayRun,
419418
workspaceId: this.contextExtensions.workspaceId,
420419
executionId: this.contextExtensions.executionId,
421420
largeValueExecutionIds: this.contextExtensions.largeValueExecutionIds,

apps/sim/executor/execution/types.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -238,16 +238,6 @@ export interface PiiBlockOutputRedaction {
238238
}
239239

240240
export interface ContextExtensions {
241-
/**
242-
* Fired once, immediately before the engine may run a block.
243-
*
244-
* Everything the executor does first — DAG construction, snapshot restoration, pipeline
245-
* assembly — can reject a request having changed nothing, so a caller that needs to know
246-
* whether a side effect was possible cannot infer it from having called `execute`. Only
247-
* the executor knows where that line falls, so it reports it rather than being guessed at
248-
* from the outside.
249-
*/
250-
onBlocksMayRun?: () => void
251241
workspaceId?: string
252242
executionId?: string
253243
largeValueExecutionIds?: string[]

apps/sim/executor/orchestrators/node.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,6 @@ export class NodeExecutionOrchestrator {
9595
}
9696
}
9797

98-
/**
99-
* The block handler, and therefore the first moment a side effect is possible. Every
100-
* earlier position was a proxy with something in front of it — engine startup, the
101-
* cancellation subscription, queue setup, and above this line a cache hit, loop and
102-
* parallel scope initialization, and a sentinel that returns without reaching a handler.
103-
* Nothing separates this call from the handler, so nothing can precede it.
104-
*
105-
* Fired per block rather than once; observers record a boolean, so repeats are free.
106-
*/
107-
ctx.onBlocksMayRun?.()
10898
const output = await this.blockExecutor.execute(ctx, node, node.block)
10999
const isFinalOutput = node.outgoingEdges.size === 0
110100
return {

apps/sim/executor/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,8 +372,6 @@ export interface ExecutorDelegationOrigin {
372372
}
373373

374374
export interface ExecutionContext {
375-
/** See {@link ContextExtensions.onBlocksMayRun}. Fired by the engine, once. */
376-
onBlocksMayRun?: () => void
377375
workflowId: string
378376
workspaceId?: string
379377
executionId?: string

apps/sim/executor/utils/errors.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -67,27 +67,6 @@ 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-
9170
/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
9271
export function readAttemptedExecutionId(error: unknown): string | undefined {
9372
return isRecordedThrown(error) ? attemptedExecutionIds.get(error) : undefined

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

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

@@ -31,7 +30,6 @@ vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({
3130
vi.mock('@/executor/utils/errors', () => ({
3231
hasExecutionResult: mocks.hasExecutionResult,
3332
readAttemptedExecutionId: mocks.readAttemptedExecutionId,
34-
readBlocksMayHaveRun: mocks.readBlocksMayHaveRun,
3533
}))
3634

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

6965
it('maps encoded folder aliases into one create application command', async () => {
@@ -299,20 +295,6 @@ describe('workflow mutation Copilot adapters', () => {
299295
run: () => executeRunWorkflow({ workflowId: 'workflow-1' }, context),
300296
effect: { phase: 'attempted', ids: { executionId: 'execution-1' } },
301297
},
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-
},
316298
{
317299
label: 'cancelled before it could finish',
318300
arrange: () =>

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

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

3935
function stripBinaryFields(value: unknown): unknown {
@@ -65,30 +61,24 @@ function runRejected(error: string): ToolCallResult {
6561
}
6662

6763
/**
68-
* Derives the phase from two facts, neither of them guessed.
64+
* The phase of a run whose result came back, from how that run ended.
65+
*
66+
* A result in hand means the executor reached a terminal state and recorded it, so the
67+
* caller can read the whole story by id — `performed`. Cancelled and paused stopped partway
68+
* and may have run every block, one, or none, which is exactly what `attempted` says.
6969
*
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.
70+
* Deliberately does not separate "ran no blocks" from "ran some". Establishing that would
71+
* take a callback on every block of every execution in the product, and buys the caller
72+
* nothing it cannot get by resolving the id it was already handed.
7473
*/
75-
function executionPhase(
76-
blocksMayHaveRun: boolean | undefined,
77-
status: ExecutionResultStatus
78-
): ToolEffectPhase {
79-
if (blocksMayHaveRun === false) return TOOL_EFFECT_PHASE.notAttempted
74+
function settledPhase(status: ExecutionResultStatus): ToolEffectPhase {
8075
return status === 'cancelled' || status === 'paused'
8176
? TOOL_EFFECT_PHASE.attempted
8277
: TOOL_EFFECT_PHASE.performed
8378
}
8479

8580
type ExecutionResultStatus = 'completed' | 'paused' | 'cancelled' | undefined
8681

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-
9282
function buildExecutionOutput(
9383
result: {
9484
success: boolean
@@ -123,12 +113,7 @@ function buildExecutionError(error: unknown): ToolCallResult {
123113
success: false,
124114
error: error.executionResult.error || 'Workflow execution failed',
125115
},
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)
116+
settledPhase(error.executionResult.status)
132117
)
133118
}
134119
logger.error('Copilot workflow execution command failed', { error })
@@ -291,7 +276,7 @@ export async function executeRunWorkflow(
291276
lifecycle: copilotRunLifecycle(context),
292277
})
293278

294-
return buildExecutionOutput(result, runPhase(result))
279+
return buildExecutionOutput(result, settledPhase(result.status))
295280
} catch (error) {
296281
return buildExecutionError(error)
297282
}
@@ -413,7 +398,7 @@ export async function executeRunWorkflowUntilBlock(
413398
lifecycle: copilotRunLifecycle(context),
414399
})
415400

416-
return buildExecutionOutput(result, runPhase(result), {
401+
return buildExecutionOutput(result, settledPhase(result.status), {
417402
stoppedAfterBlockId: params.stopAfterBlockId,
418403
})
419404
} catch (error) {
@@ -490,7 +475,9 @@ export async function executeRunFromBlock(
490475
lifecycle: copilotRunLifecycle(context),
491476
})
492477

493-
return buildExecutionOutput(result, runPhase(result), { startBlockId: params.startBlockId })
478+
return buildExecutionOutput(result, settledPhase(result.status), {
479+
startBlockId: params.startBlockId,
480+
})
494481
} catch (error) {
495482
return buildExecutionError(error)
496483
}
@@ -576,7 +563,7 @@ export async function executeRunBlock(
576563
lifecycle: copilotRunLifecycle(context),
577564
})
578565

579-
return buildExecutionOutput(result, runPhase(result), { blockId: params.blockId })
566+
return buildExecutionOutput(result, settledPhase(result.status), { blockId: params.blockId })
580567
} catch (error) {
581568
return buildExecutionError(error)
582569
}

0 commit comments

Comments
 (0)