@bevel-software/ael 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +32 -3
- package/bin/bevel-agent.mjs +15 -0
- package/executor-entrypoint.sh +41 -0
- package/executor.Dockerfile +37 -0
- package/package.json +61 -6
- package/src/agent/agent-file.ts +242 -0
- package/src/cli/main.ts +81 -0
- package/src/cli/run-local.ts +224 -0
- package/src/cli/run-step.ts +147 -0
- package/src/executor/activities.ts +295 -0
- package/src/executor/docker-cli.ts +148 -0
- package/src/executor/driver.ts +80 -0
- package/src/executor/harness-mount.ts +66 -0
- package/src/executor/in-place.ts +180 -0
- package/src/executor/main.ts +230 -0
- package/src/executor/personal.ts +355 -0
- package/src/executor/slots.ts +60 -0
- package/src/harness/claude-code.ts +397 -0
- package/src/harness/pi-in-process.ts +444 -0
- package/src/harness/session-state.ts +86 -0
- package/src/harness/stall-guard.ts +97 -0
- package/src/index.ts +164 -0
- package/src/interpreter/contract.ts +403 -0
- package/src/interpreter/dispatcher.workflow.ts +412 -0
- package/src/interpreter/interpret.workflow.ts +907 -0
- package/src/interpreter/scheduled.workflow.ts +28 -0
- package/src/interpreter/workflows.ts +8 -0
- package/src/run/contract.ts +150 -0
- package/src/run/outcome.ts +63 -0
- package/src/run/reporter.ts +127 -0
- package/src/run/system-prompt.ts +35 -0
- package/src/run/verdict.ts +46 -0
- package/src/shared/front-matter.ts +27 -0
- package/src/shared/redact.ts +78 -0
- package/src/shared/schedule.ts +93 -0
- package/src/temporal/payload-codec.ts +84 -0
- package/src/transcript/render.ts +106 -0
- package/src/workflow/definition.ts +196 -0
- package/src/workflow/failure-policy.ts +100 -0
- package/src/workflow/parameters.ts +175 -0
- package/src/workflow/workflow-file.ts +457 -0
- package/src/workitem/node.ts +170 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActivityCancellationType,
|
|
3
|
+
ActivityFailure,
|
|
4
|
+
ApplicationFailure,
|
|
5
|
+
CancellationScope,
|
|
6
|
+
CancelledFailure,
|
|
7
|
+
ContinueAsNew,
|
|
8
|
+
condition,
|
|
9
|
+
continueAsNew,
|
|
10
|
+
defineSignal,
|
|
11
|
+
TimeoutFailure,
|
|
12
|
+
getExternalWorkflowHandle,
|
|
13
|
+
isCancellation,
|
|
14
|
+
log,
|
|
15
|
+
proxyActivities,
|
|
16
|
+
setHandler,
|
|
17
|
+
uuid4,
|
|
18
|
+
workflowInfo,
|
|
19
|
+
} from '@temporalio/workflow';
|
|
20
|
+
import type { Verdict } from '../run/contract.js';
|
|
21
|
+
import { ATTEMPTS_KEY, ENTRIES_KEY, RETRIES_KEY, decide, errorForVerdict, isRetryKeyOf } from '../workflow/failure-policy.js';
|
|
22
|
+
import { evaluate, interpolate, nextAfter, stepByName, stripReservedKeys, type ErrorName, type StepDef, type WorkflowDefinition } from '../workflow/definition.js';
|
|
23
|
+
import {
|
|
24
|
+
EXECUTOR_TASK_QUEUE,
|
|
25
|
+
INTERPRETER_STATE_KEYS,
|
|
26
|
+
MAX_RUN_STEPS,
|
|
27
|
+
MAX_STEP_RESTARTS,
|
|
28
|
+
PLATFORM_RESTART,
|
|
29
|
+
PLATFORM_TASK_QUEUE,
|
|
30
|
+
RUN_AT_GATE_SIGNAL,
|
|
31
|
+
RUN_NEEDS_SLOT_SIGNAL,
|
|
32
|
+
RUN_SETTLED_SIGNAL,
|
|
33
|
+
SLOT_GRANTED_SIGNAL,
|
|
34
|
+
CONTAINER_STEP_HEARTBEAT_MS,
|
|
35
|
+
namedExecutorQueue,
|
|
36
|
+
type Actor,
|
|
37
|
+
type AgentStepInput,
|
|
38
|
+
type AgentStepOutcome,
|
|
39
|
+
type ExecutorActivities,
|
|
40
|
+
type InterpretInput,
|
|
41
|
+
type InterpretResult,
|
|
42
|
+
type InterpreterActivities,
|
|
43
|
+
type RunStatus,
|
|
44
|
+
type ToolChainOutcome,
|
|
45
|
+
} from './contract.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The interpreter: one Temporal workflow per run of a workflow. It encodes no
|
|
49
|
+
* step behaviour. Everything a step does comes from the `.workflow` file,
|
|
50
|
+
* re-read through an activity before every step (edits take effect at the
|
|
51
|
+
* next step; a change to the step structure blocks the run). The failure
|
|
52
|
+
* policy is the file's; this code only applies it. Temporal gives the loop
|
|
53
|
+
* what the old runner's worker and state machine were for: durable timers for
|
|
54
|
+
* gates and backoff, signals for gate outcomes, recovery when a
|
|
55
|
+
* worker or executor dies, and a history of every decision.
|
|
56
|
+
*
|
|
57
|
+
* Ported from the execution layer's state machine and step handlers; the
|
|
58
|
+
* decisions and their reasons are theirs.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
export const gateOutcomeSignal = defineSignal<[string]>('gateOutcome');
|
|
62
|
+
export const cancelRunSignal = defineSignal<[string | undefined]>('cancelRun');
|
|
63
|
+
|
|
64
|
+
const PROBE_ERROR_CAP = 3;
|
|
65
|
+
const DEFAULT_GATE_POLL_SECONDS = 60;
|
|
66
|
+
const DEFAULT_GATE_TIMEOUT_SECONDS = 30 * 24 * 3600;
|
|
67
|
+
const ACCOUNT_LIMIT_DEFAULT_PAUSE_MS = 15 * 60_000;
|
|
68
|
+
/** How long a run waits for a slot before its thread says that is what it waits for. */
|
|
69
|
+
const SLOT_NOTICE_MS = 30_000;
|
|
70
|
+
/**
|
|
71
|
+
* How long a run waits for a slot before it asks its dispatcher again. The
|
|
72
|
+
* grant is the fast path; asking again is the net under it, for a grant or
|
|
73
|
+
* an ask that was lost, and for a dispatcher that has gone meanwhile (the
|
|
74
|
+
* ask then fails, and the run goes on: no cap is left to wait under).
|
|
75
|
+
*/
|
|
76
|
+
const SLOT_ASK_AGAIN_MS = 10 * 60_000;
|
|
77
|
+
|
|
78
|
+
/** The platform's activities: short, retried on infrastructure errors. */
|
|
79
|
+
const platform = proxyActivities<Pick<InterpreterActivities, 'loadWorkflow' | 'resolveRunParameters' | 'openStepThread' | 'recordTransition' | 'notifyRunFailed' | 'prepareContainerStep'>>({
|
|
80
|
+
taskQueue: PLATFORM_TASK_QUEUE,
|
|
81
|
+
startToCloseTimeout: '10 minutes',
|
|
82
|
+
retry: { maximumAttempts: 5, initialInterval: '2 seconds', backoffCoefficient: 2 },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The end of a step: its thread closed and its keys revoked. A key left
|
|
87
|
+
* live is the one thing an outage must not leave behind, so these are
|
|
88
|
+
* retried for as long as a key store can plausibly be away — a day, with
|
|
89
|
+
* the interval capped — rather than the handful of tries a read gets.
|
|
90
|
+
*/
|
|
91
|
+
const stepEnd = proxyActivities<Pick<InterpreterActivities, 'closeStepThread' | 'finishAgentStep' | 'finishContainerStep'>>({
|
|
92
|
+
taskQueue: PLATFORM_TASK_QUEUE,
|
|
93
|
+
startToCloseTimeout: '10 minutes',
|
|
94
|
+
retry: { maximumAttempts: 300, initialInterval: '2 seconds', backoffCoefficient: 2, maximumInterval: '5 minutes' },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** A tool chain or a gate's probe: a step like any other, so a Cancel reaches it — the workflow moves on at once and the activity is told. */
|
|
98
|
+
const chainStep = proxyActivities<Pick<InterpreterActivities, 'runToolChain' | 'runProbe'>>({
|
|
99
|
+
taskQueue: PLATFORM_TASK_QUEUE,
|
|
100
|
+
startToCloseTimeout: '10 minutes',
|
|
101
|
+
retry: { maximumAttempts: 5, initialInterval: '2 seconds', backoffCoefficient: 2 },
|
|
102
|
+
cancellationType: ActivityCancellationType.TRY_CANCEL,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** An in-process agent step on the platform's own worker. Retries are the file's policy, not Temporal's. */
|
|
106
|
+
const inProcessStep = proxyActivities<Pick<InterpreterActivities, 'runAgentStep'>>({
|
|
107
|
+
taskQueue: PLATFORM_TASK_QUEUE,
|
|
108
|
+
startToCloseTimeout: '6 hours',
|
|
109
|
+
heartbeatTimeout: '2 minutes',
|
|
110
|
+
retry: { maximumAttempts: 1 },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A container step on an executor queue: the shared one, the executor this
|
|
115
|
+
* run is pinned to, or the actor's own executor (`waitForIt`).
|
|
116
|
+
*/
|
|
117
|
+
function containerStep(queue: string, waitForIt = false) {
|
|
118
|
+
return proxyActivities<ExecutorActivities>({
|
|
119
|
+
taskQueue: queue,
|
|
120
|
+
startToCloseTimeout: '12 hours',
|
|
121
|
+
// A pinned executor that is gone never picks the task up; the schedule-
|
|
122
|
+
// to-start timeout is how the run notices and falls back to the shared
|
|
123
|
+
// queue with a fresh workspace. A person's own executor is waited for
|
|
124
|
+
// instead, however long it is away: nothing else reaches what it reaches.
|
|
125
|
+
scheduleToStartTimeout: queue === EXECUTOR_TASK_QUEUE || waitForIt ? undefined : '5 minutes',
|
|
126
|
+
// Above the platform's limit for a silent personal executor, so its reason is the one recorded.
|
|
127
|
+
heartbeatTimeout: CONTAINER_STEP_HEARTBEAT_MS,
|
|
128
|
+
retry: { maximumAttempts: 1 },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The end of a run on the executor it was pinned to: its workspace volume
|
|
134
|
+
* goes. Short and never retried: an executor that is gone misses it, and
|
|
135
|
+
* its own sweep removes the volume once it is back.
|
|
136
|
+
*/
|
|
137
|
+
function pinnedRelease(queue: string) {
|
|
138
|
+
return proxyActivities<Pick<ExecutorActivities, 'releaseRun'>>({
|
|
139
|
+
taskQueue: queue,
|
|
140
|
+
scheduleToStartTimeout: '1 minute',
|
|
141
|
+
startToCloseTimeout: '2 minutes',
|
|
142
|
+
retry: { maximumAttempts: 1 },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type Counters = Record<string, number>;
|
|
147
|
+
|
|
148
|
+
function counters(state: Record<string, unknown>, key: string): Counters {
|
|
149
|
+
const raw = state[key];
|
|
150
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...(raw as Counters) } : {};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function errMessage(err: unknown): string {
|
|
154
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
155
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A wait, in the unit that reads naturally: `7 days`, `3 h`, `45 min`, `5 s`. */
|
|
159
|
+
function durationText(ms: number): string {
|
|
160
|
+
if (ms >= 86_400_000) return `${Math.round(ms / 86_400_000)} days`;
|
|
161
|
+
if (ms >= 3_600_000) return `${Math.round(ms / 3_600_000)} h`;
|
|
162
|
+
if (ms >= 60_000) return `${Math.round(ms / 60_000)} min`;
|
|
163
|
+
return `${Math.round(ms / 1000)} s`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What an activity actually failed with. Temporal hands the workflow an
|
|
168
|
+
* `ActivityFailure` whose `cause` is the failure the activity raised (an
|
|
169
|
+
* `ApplicationFailure`, a timeout); a check on the wrapper alone sees none
|
|
170
|
+
* of it.
|
|
171
|
+
*/
|
|
172
|
+
function activityCause(err: unknown): unknown {
|
|
173
|
+
return err instanceof ActivityFailure ? (err.cause ?? err) : err;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether a step failed because the platform stopped under it rather than on its own: the worker said so as it
|
|
178
|
+
* stopped (`PLATFORM_RESTART`), or it went without a word and missed its heartbeat.
|
|
179
|
+
*/
|
|
180
|
+
function cutOffByRestart(err: unknown): boolean {
|
|
181
|
+
const cause = activityCause(err);
|
|
182
|
+
if (cause instanceof ApplicationFailure) return cause.type === PLATFORM_RESTART;
|
|
183
|
+
return cause instanceof TimeoutFailure && cause.timeoutType === 'HEARTBEAT';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The failure's own words, through the wrapper; a cancellation says so, whatever its message. */
|
|
187
|
+
function failureReason(err: unknown): string {
|
|
188
|
+
if (isCancellation(err) || err instanceof CancelledFailure) return 'cancelled';
|
|
189
|
+
const cause = activityCause(err);
|
|
190
|
+
const text = (cause instanceof Error ? cause.message || cause.name : String(cause)) || (err instanceof Error ? err.message : String(err));
|
|
191
|
+
return errMessage(err instanceof ActivityFailure && err.message ? `${text} (${err.message})` : text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function interpret(input: InterpretInput): Promise<InterpretResult> {
|
|
195
|
+
const actor: Actor = input.actor;
|
|
196
|
+
const state: Record<string, unknown> = { ...(input.initialState ?? {}) };
|
|
197
|
+
const threadIds: Record<string, string> = { ...(counters(state, 'sessionIds') as unknown as Record<string, string>) };
|
|
198
|
+
let pendingGate: string | null = null;
|
|
199
|
+
let cancelled: string | null = null;
|
|
200
|
+
let current: string | null = null;
|
|
201
|
+
let executorQueue: string | null = input.executorQueue ?? null;
|
|
202
|
+
let finalReason: string | null = null;
|
|
203
|
+
// The deadline of the gate a continued-as-new execution resumes in, and its probe's errors in a row so far; each used once.
|
|
204
|
+
let carriedGateDeadline: number | null = input.gateDeadline ?? null;
|
|
205
|
+
let carriedProbeErrors: number | null = input.probeErrors ?? null;
|
|
206
|
+
/** A step paused on an account limit: the thread it goes on on when the limit lifts. */
|
|
207
|
+
let pausedThread: { step: string; threadId: string } | null = input.pausedThread ?? null;
|
|
208
|
+
/** Steps taken by this RUN, every execution counted: the cap a cycling file hits, however many times the history was cut. */
|
|
209
|
+
let stepsTaken = input.stepsTaken ?? 0;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The run goes on as a new execution from its recorded step, the state
|
|
213
|
+
* carried, when Temporal says the history is getting long — a run of many
|
|
214
|
+
* steps, or a gate polled for weeks, would otherwise be terminated with its
|
|
215
|
+
* history at the limit and nothing recorded. Only between steps or polls,
|
|
216
|
+
* with no signal pending: a signal waiting to be read would be lost.
|
|
217
|
+
*/
|
|
218
|
+
let stepsThisExecution = 0;
|
|
219
|
+
const continueIfSuggested = async (gate: { deadline: number; probeErrors: number } | null): Promise<void> => {
|
|
220
|
+
const suggested = workflowInfo().continueAsNewSuggested || (input.continueAfterSteps !== undefined && stepsThisExecution >= input.continueAfterSteps);
|
|
221
|
+
if (!suggested || pendingGate !== null || cancelled !== null) return;
|
|
222
|
+
state.sessionIds = { ...threadIds };
|
|
223
|
+
// The position the next execution resumes at: the step about to run (or the gate being polled), not the one just finished.
|
|
224
|
+
if (current) state.step = current;
|
|
225
|
+
await continueAsNew<typeof interpret>({ ...input, initialState: state, workflowVersion: version, executorQueue: executorQueue ?? undefined, gateDeadline: gate?.deadline, probeErrors: gate?.probeErrors, pausedThread: pausedThread ?? undefined, stepsTaken, slotGivenBack: !holdsSlot });
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// A capped run holds a slot under its trigger's cap while it has work to
|
|
229
|
+
// do and none while it waits at a gate: a gate can wait days for a person,
|
|
230
|
+
// and a slot held that long is a trigger that starts nothing. The key (the
|
|
231
|
+
// work item) stays the run's throughout.
|
|
232
|
+
let holdsSlot = !input.slotGivenBack;
|
|
233
|
+
let slotGranted = false;
|
|
234
|
+
setHandler(defineSignal(SLOT_GRANTED_SIGNAL), () => {
|
|
235
|
+
slotGranted = true;
|
|
236
|
+
});
|
|
237
|
+
/** Whether the dispatcher was told; false when it is gone, or there is none. */
|
|
238
|
+
const tellDispatcher = async (signal: string): Promise<boolean> => {
|
|
239
|
+
const parent = workflowInfo().parent;
|
|
240
|
+
if (!parent) return false;
|
|
241
|
+
try {
|
|
242
|
+
await getExternalWorkflowHandle(parent.workflowId).signal(signal, input.runId);
|
|
243
|
+
return true;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
log.warn(`run ${input.runId}: its dispatcher ${parent.workflowId} did not take "${signal}": ${errMessage(err)}`);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const giveSlotBack = async (): Promise<void> => {
|
|
250
|
+
if (!input.capped || !holdsSlot) return;
|
|
251
|
+
holdsSlot = false;
|
|
252
|
+
await tellDispatcher(RUN_AT_GATE_SIGNAL);
|
|
253
|
+
};
|
|
254
|
+
/** Returns once the run holds a slot, its dispatcher is gone, or the run was cancelled (the caller looks). */
|
|
255
|
+
const takeSlot = async (stepName: string): Promise<void> => {
|
|
256
|
+
if (!input.capped || holdsSlot) return;
|
|
257
|
+
let said = false;
|
|
258
|
+
while (cancelled === null) {
|
|
259
|
+
slotGranted = false;
|
|
260
|
+
if (!(await tellDispatcher(RUN_NEEDS_SLOT_SIGNAL))) break;
|
|
261
|
+
if (await condition(() => slotGranted || cancelled !== null, said ? SLOT_ASK_AGAIN_MS : SLOT_NOTICE_MS)) break;
|
|
262
|
+
if (!said) {
|
|
263
|
+
said = true;
|
|
264
|
+
await record(stepName, `⏳ ${stepName} waits for a free slot: its trigger has as many runs at work as it allows`, true);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
holdsSlot = true;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
setHandler(gateOutcomeSignal, (outcome) => {
|
|
271
|
+
pendingGate = outcome;
|
|
272
|
+
});
|
|
273
|
+
// The step in flight, if one: a cancel reaches it through its scope, so the
|
|
274
|
+
// activity is cancelled where it is (the session aborted, the container
|
|
275
|
+
// stopped) instead of running on to its own end.
|
|
276
|
+
let stepScope: CancellationScope | null = null;
|
|
277
|
+
setHandler(cancelRunSignal, (reason) => {
|
|
278
|
+
cancelled = reason ?? 'cancelled';
|
|
279
|
+
stepScope?.cancel();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const gatePollMs = (input.gatePollSeconds ?? DEFAULT_GATE_POLL_SECONDS) * 1000;
|
|
283
|
+
const gateTimeoutMs = (input.gateTimeoutSeconds ?? DEFAULT_GATE_TIMEOUT_SECONDS) * 1000;
|
|
284
|
+
|
|
285
|
+
let def: WorkflowDefinition;
|
|
286
|
+
// The happy path as the thread's record needs it: empty until the file is read.
|
|
287
|
+
let happyPath: string[] = [];
|
|
288
|
+
try {
|
|
289
|
+
def = await platform.loadWorkflow({ workflowPath: input.workflowPath, actor });
|
|
290
|
+
} catch (err) {
|
|
291
|
+
// The thread still learns how the run ended: a run that never read its file is blocked, not running for ever.
|
|
292
|
+
return finish('blocked', `the workflow could not be read: ${failureReason(err)}`);
|
|
293
|
+
}
|
|
294
|
+
// The structure a continued execution started under is the one it keeps checking against: an edit between executions blocks like one between steps.
|
|
295
|
+
const version = input.workflowVersion ?? def.version;
|
|
296
|
+
if (def.version !== version) return finish('blocked', `the workflow's steps changed while this run was in flight (${version} → ${def.version}); release the run to start it over`);
|
|
297
|
+
happyPath = def.happyPath;
|
|
298
|
+
state.workflowId = def.id;
|
|
299
|
+
state.workflowVersion = version;
|
|
300
|
+
const reserved = new Set<string>([...INTERPRETER_STATE_KEYS, ...def.reservedStateKeys]);
|
|
301
|
+
|
|
302
|
+
let params: Record<string, string>;
|
|
303
|
+
let missing: string[];
|
|
304
|
+
try {
|
|
305
|
+
({ values: params, missing } = await platform.resolveRunParameters({ definition: def, nodePath: input.subjectPath ?? input.workItemPath, actor }));
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (isCancellation(err) || err instanceof CancelledFailure) return finish('cancelled', cancelled ?? 'cancelled');
|
|
308
|
+
return finish('blocked', `the run's parameters could not be resolved: ${failureReason(err)}`);
|
|
309
|
+
}
|
|
310
|
+
if (missing.length > 0) {
|
|
311
|
+
return finish('blocked', `missing required parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A released or resumed run continues from its recorded step when that
|
|
315
|
+
// step still exists; anything else starts at the top — through the same
|
|
316
|
+
// door every step is entered by, so the first entry counts against a budget.
|
|
317
|
+
// `step` is where a continued execution of this run was: its counters came
|
|
318
|
+
// with it, so it goes straight on. `resumeStep` is where an earlier run of
|
|
319
|
+
// this work item stopped (see `finish`): this run's counters are new, so it
|
|
320
|
+
// enters that step through the door, like the top.
|
|
321
|
+
const released = typeof state.resumeStep === 'string' && stepByName(def, state.resumeStep) ? state.resumeStep : null;
|
|
322
|
+
delete state.resumeStep;
|
|
323
|
+
if (!released && typeof state.step === 'string' && stepByName(def, state.step)) {
|
|
324
|
+
current = state.step;
|
|
325
|
+
} else {
|
|
326
|
+
const entered = await enter('start', released ?? def.happyPath[0]);
|
|
327
|
+
if (entered !== 'moved') return { status: entered, state, ...(finalReason ? { reason: finalReason } : {}) };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** `waiting`: the run is parked in the step until something outside it answers (a gate, a limit); the thread says so. */
|
|
331
|
+
async function record(status: string, logLine: string | null, waiting = false): Promise<void> {
|
|
332
|
+
state.sessionIds = { ...threadIds };
|
|
333
|
+
await platform.recordTransition({
|
|
334
|
+
runId: input.runId,
|
|
335
|
+
workflowPath: input.workflowPath,
|
|
336
|
+
workItemPath: input.workItemPath,
|
|
337
|
+
workItemFields: input.workItemFields,
|
|
338
|
+
actor,
|
|
339
|
+
status,
|
|
340
|
+
stepName: current,
|
|
341
|
+
waiting,
|
|
342
|
+
log: logLine,
|
|
343
|
+
state: { ...state },
|
|
344
|
+
threadIds: { ...threadIds },
|
|
345
|
+
happyPath,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The end of the run, whatever ended it — a run whose file was never read
|
|
351
|
+
* included. Its activities run in a scope cancellation cannot reach: a
|
|
352
|
+
* cancelled run's record, its notice and its workspace release are
|
|
353
|
+
* exactly what a cancel must still write.
|
|
354
|
+
*/
|
|
355
|
+
async function finish(status: RunStatus, reason: string | null): Promise<InterpretResult> {
|
|
356
|
+
finalReason = reason;
|
|
357
|
+
if (reason) state.lastError = reason;
|
|
358
|
+
if (status === 'blocked' && reason) state.blocker = reason;
|
|
359
|
+
// Where a run that did not finish stopped: a work item released back to its trigger's status goes on from there.
|
|
360
|
+
if (status !== 'done' && current) state.resumeStep = current;
|
|
361
|
+
state.step = status;
|
|
362
|
+
state.assignee = null;
|
|
363
|
+
await CancellationScope.nonCancellable(async () => {
|
|
364
|
+
// A step still parked on an account limit when the run ends: its thread
|
|
365
|
+
// is closed here, whatever ended the run, before the end is recorded.
|
|
366
|
+
if (pausedThread) {
|
|
367
|
+
const parked = pausedThread;
|
|
368
|
+
pausedThread = null;
|
|
369
|
+
await stepEnd.closeStepThread({ threadId: parked.threadId, reason: `the run ended ${status}${reason ? `: ${reason}` : ''}` });
|
|
370
|
+
}
|
|
371
|
+
await record(status, reason ? `${status}: ${reason}` : status);
|
|
372
|
+
if (status === 'failed' || status === 'blocked') {
|
|
373
|
+
await platform.notifyRunFailed({ runId: input.runId, workflowPath: input.workflowPath, actor, status, reason: reason ?? status });
|
|
374
|
+
}
|
|
375
|
+
if (executorQueue) {
|
|
376
|
+
try {
|
|
377
|
+
await pinnedRelease(executorQueue).releaseRun({ runId: input.runId });
|
|
378
|
+
} catch (err) {
|
|
379
|
+
log.warn(`executor ${executorQueue} did not release the workspace of run ${input.runId}: ${errMessage(err)}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// The dispatcher that started this run, if one did, learns it is over
|
|
383
|
+
// here — the way that survives the dispatcher continuing as new.
|
|
384
|
+
const parent = workflowInfo().parent;
|
|
385
|
+
if (parent) {
|
|
386
|
+
try {
|
|
387
|
+
await getExternalWorkflowHandle(parent.workflowId).signal(RUN_SETTLED_SIGNAL, input.runId);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
log.warn(`run ${input.runId}: its dispatcher ${parent.workflowId} could not be told it ended: ${errMessage(err)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return { status, state, ...(reason ? { reason } : {}) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Route into `target`, charging its entry budget. Returns the terminal
|
|
398
|
+
* status when the run settled instead. `exhausted` names the steps whose
|
|
399
|
+
* spent budget redirected this routing so far: budgets that point at each
|
|
400
|
+
* other (A's `then` is B, B's is A) would otherwise chase each other for
|
|
401
|
+
* ever without a step running — a loop the step cap never sees.
|
|
402
|
+
*/
|
|
403
|
+
async function enter(fromStep: string, target: string, exhausted: Set<string> = new Set()): Promise<RunStatus | 'moved'> {
|
|
404
|
+
const step = stepByName(def, target);
|
|
405
|
+
if (!step?.entries) {
|
|
406
|
+
current = target;
|
|
407
|
+
return 'moved';
|
|
408
|
+
}
|
|
409
|
+
const entries = counters(state, ENTRIES_KEY);
|
|
410
|
+
const count = (entries[target] ?? 0) + 1;
|
|
411
|
+
state[ENTRIES_KEY] = { ...entries, [target]: count };
|
|
412
|
+
if (count <= step.entries.max) {
|
|
413
|
+
current = target;
|
|
414
|
+
return 'moved';
|
|
415
|
+
}
|
|
416
|
+
const reason = `"${target}" entered ${count} times, budget ${step.entries.max} — needs a human`;
|
|
417
|
+
const then = step.entries.then;
|
|
418
|
+
if (then === 'blocked') return (await finish('blocked', `${fromStep}: ${reason}`)).status;
|
|
419
|
+
if (then === 'failed') return (await finish('failed', `${fromStep}: ${reason}`)).status;
|
|
420
|
+
if (then === 'done') return (await finish('done', null)).status;
|
|
421
|
+
if (exhausted.has(target)) {
|
|
422
|
+
return (await finish('blocked', `${fromStep}: the entry budgets of ${[...exhausted, target].join(' → ')} route in a circle — needs a human`)).status;
|
|
423
|
+
}
|
|
424
|
+
await record(fromStep, `⛔ ${fromStep}: ${reason}. Routing to ${then}.`);
|
|
425
|
+
// The budget's own target is entered like any other, and its budget, if it has one, counts this too. `continue` is the step after
|
|
426
|
+
// the BUDGETED step — the file says "when this step is spent, go on past it" — not the step after whichever step was routing into it.
|
|
427
|
+
const spent = new Set([...exhausted, target]);
|
|
428
|
+
return then === 'continue' ? continueFrom(target, spent) : enter(target, then, spent);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The step after `fromStep` on the happy path, entered; `done` when there is
|
|
433
|
+
* none. An off-path step has no place on it, and the file may not say
|
|
434
|
+
* `continue` in its own routing (the parser refuses); a `defaults` catcher
|
|
435
|
+
* that says it applies to every step, so from an off-path one it is a
|
|
436
|
+
* routing the file left undefined — the run is blocked by name, not failed
|
|
437
|
+
* by an exception.
|
|
438
|
+
*/
|
|
439
|
+
async function continueFrom(fromStep: string, exhausted?: Set<string>): Promise<RunStatus | 'moved'> {
|
|
440
|
+
if (!def.happyPath.includes(fromStep)) {
|
|
441
|
+
return (await finish('blocked', `"${fromStep}" is off the happy path, and its catcher says \`continue\`, which names no step there — name the step`)).status;
|
|
442
|
+
}
|
|
443
|
+
const next = nextAfter(def, fromStep);
|
|
444
|
+
if (next === null) return (await finish('done', null)).status;
|
|
445
|
+
return enter(fromStep, next, exhausted);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** `continue`, a terminal target or a step name, as the file spells them. Routing to `failed` or `blocked` goes through the policy. */
|
|
449
|
+
async function route(fromStep: string, target: string, exhausted?: Set<string>): Promise<RunStatus | 'moved' | 'retry'> {
|
|
450
|
+
if (target === 'continue') return continueFrom(fromStep, exhausted);
|
|
451
|
+
if (target === 'done') return (await finish('done', null)).status;
|
|
452
|
+
if (target === 'failed') return fail(fromStep, 'Fail', 'routed to failed');
|
|
453
|
+
if (target === 'blocked') return fail(fromStep, 'Blocked', 'routed to blocked');
|
|
454
|
+
return enter(fromStep, target, exhausted);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Apply the file's failure policy to a named error at `stepName`. */
|
|
458
|
+
async function fail(stepName: string, error: ErrorName, reason: string): Promise<RunStatus | 'moved' | 'retry'> {
|
|
459
|
+
const step = stepByName(def, stepName)!;
|
|
460
|
+
const attempts = counters(state, ATTEMPTS_KEY);
|
|
461
|
+
const retries = counters(state, RETRIES_KEY);
|
|
462
|
+
const decision = decide(def, { step, error, attempts, retries });
|
|
463
|
+
|
|
464
|
+
if (decision.action === 'unhandled') return (await finish('blocked', `${stepName}: unhandled ${error}: ${reason}`)).status;
|
|
465
|
+
|
|
466
|
+
if (decision.action === 'retry') {
|
|
467
|
+
state[RETRIES_KEY] = { ...retries, [decision.counterKey]: decision.attempt };
|
|
468
|
+
state.lastError = reason;
|
|
469
|
+
await record(stepName, `🔁 ${stepName} hit ${error} (retry ${decision.attempt}/${decision.max}): ${reason}`);
|
|
470
|
+
// A wait a Cancel wakes: the loop's next turn sees the cancellation.
|
|
471
|
+
if (decision.delayMs > 0) await condition(() => cancelled !== null, decision.delayMs);
|
|
472
|
+
return 'retry';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (decision.counterKey) state[ATTEMPTS_KEY] = { ...attempts, [decision.counterKey]: decision.count };
|
|
476
|
+
state.lastError = reason;
|
|
477
|
+
state[RETRIES_KEY] = {};
|
|
478
|
+
const target = decision.target;
|
|
479
|
+
if (target === 'blocked') return (await finish('blocked', `${stepName}: ${reason}`)).status;
|
|
480
|
+
if (target === 'failed') {
|
|
481
|
+
const exhausted = decision.max !== undefined && decision.count !== undefined && decision.count > decision.max;
|
|
482
|
+
return (await finish('failed', `${stepName} ${error}: ${reason}${exhausted ? ` (sent back ${decision.max} times already)` : ''}`)).status;
|
|
483
|
+
}
|
|
484
|
+
if (target === 'done') return (await finish('done', null)).status;
|
|
485
|
+
await record(stepName, `❌ ${stepName} ${error}: ${reason}. Routing to ${target}${decision.max !== undefined ? ` (${decision.count}/${decision.max})` : ''}.`);
|
|
486
|
+
// A catcher's target is routed as the file spells it: a step name, or `continue` for the step after the one that failed.
|
|
487
|
+
return route(stepName, target);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function clearRetries(stepName: string): void {
|
|
491
|
+
const retries = counters(state, RETRIES_KEY);
|
|
492
|
+
const rest = Object.fromEntries(Object.entries(retries).filter(([key]) => !isRetryKeyOf(stepName, key)));
|
|
493
|
+
state[RETRIES_KEY] = rest;
|
|
494
|
+
delete state.lastError;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function thenFor(step: StepDef, token: string): string | null {
|
|
498
|
+
if (step.then === undefined) return token === 'PASS' ? 'continue' : null;
|
|
499
|
+
if (typeof step.then === 'string') return token === 'PASS' ? step.then : null;
|
|
500
|
+
return step.then[token] ?? null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function mergeOutputs(stepName: string, outputs: Record<string, unknown> | null): void {
|
|
504
|
+
if (!outputs) return;
|
|
505
|
+
const kept = stripReservedKeys(outputs, [...reserved], (key) => log.warn(`step "${stepName}" tried to write reserved STATE key "${key}" — dropped`));
|
|
506
|
+
Object.assign(state, kept);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const vars = () => ({ ...params, ...state });
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
while (current) {
|
|
513
|
+
if (cancelled) return finish('cancelled', cancelled);
|
|
514
|
+
// The cap is the run's, not the execution's: a file whose routing loops is stopped at the same count whether or not the history was cut meanwhile.
|
|
515
|
+
if (stepsTaken >= MAX_RUN_STEPS) return finish('blocked', `the run exceeded ${MAX_RUN_STEPS} steps`);
|
|
516
|
+
// The slot follows the step about to run: given back at a gate, held
|
|
517
|
+
// for a step that does work. A choice does none and changes nothing.
|
|
518
|
+
const kind = stepByName(def, current)?.kind;
|
|
519
|
+
if (kind === 'wait') await giveSlotBack();
|
|
520
|
+
else if (kind === 'agent' || kind === 'tool_chain') {
|
|
521
|
+
await takeSlot(current);
|
|
522
|
+
if (cancelled) return finish('cancelled', cancelled);
|
|
523
|
+
}
|
|
524
|
+
await continueIfSuggested(null);
|
|
525
|
+
stepsTaken += 1;
|
|
526
|
+
stepsThisExecution += 1;
|
|
527
|
+
|
|
528
|
+
// The file is re-read before every step: prose edits apply at the next
|
|
529
|
+
// step, a structural change blocks rather than restarting silently.
|
|
530
|
+
let fresh: WorkflowDefinition;
|
|
531
|
+
try {
|
|
532
|
+
fresh = await platform.loadWorkflow({ workflowPath: input.workflowPath, actor });
|
|
533
|
+
} catch (err) {
|
|
534
|
+
if (isCancellation(err) || err instanceof CancelledFailure) throw err;
|
|
535
|
+
// The file went away or became unreadable mid-run: the thread says so, like the first read.
|
|
536
|
+
return finish('blocked', `the workflow could not be read: ${failureReason(err)}`);
|
|
537
|
+
}
|
|
538
|
+
if (fresh.version !== version) return finish('blocked', `the workflow's steps changed while this run was in flight (${version} → ${fresh.version}); release the run to start it over`);
|
|
539
|
+
def = fresh;
|
|
540
|
+
happyPath = fresh.happyPath;
|
|
541
|
+
const step = stepByName(def, current);
|
|
542
|
+
if (!step) return finish('blocked', `step "${current}" is not in the workflow`);
|
|
543
|
+
|
|
544
|
+
state.step = current;
|
|
545
|
+
// In-place retries of this step so far, under each retrier's own counter (`retryKey`): the attempt number the thread and the transcript links carry.
|
|
546
|
+
const stepName = current;
|
|
547
|
+
const retried = Object.entries(counters(state, RETRIES_KEY))
|
|
548
|
+
.filter(([key]) => isRetryKeyOf(stepName, key))
|
|
549
|
+
.reduce((n, [, count]) => n + count, 0);
|
|
550
|
+
const attempt = retried + 1;
|
|
551
|
+
state.attempt = attempt;
|
|
552
|
+
// A retry goes on from the attempt before it: its session state is saved after every turn, so the step resumes where it stopped.
|
|
553
|
+
// A step paused on an account limit goes on on the thread it paused on, the same way: a new thread beside it would leave it `waiting` for ever.
|
|
554
|
+
// The memo stays until that thread's step has ended: a transition write that fails before it ends the run through `finish`, which closes a thread still remembered.
|
|
555
|
+
const paused = pausedThread?.step === stepName ? pausedThread.threadId : null;
|
|
556
|
+
const resumeThreadId = paused ?? (attempt > 1 ? (threadIds[`${stepName}#${attempt - 1}`] ?? null) : null);
|
|
557
|
+
|
|
558
|
+
let moved: RunStatus | 'moved' | 'retry';
|
|
559
|
+
switch (step.kind) {
|
|
560
|
+
case 'agent': {
|
|
561
|
+
await record(current, `→ ${current}`);
|
|
562
|
+
// The step's thread exists, and the run knows it, before the step runs: an attempt that dies mid-way leaves no thread nobody can find.
|
|
563
|
+
// Its id is chosen here (replay-safe) and remembered BEFORE the activity is awaited, so the activity's retries open one thread, not one per
|
|
564
|
+
// attempt — and an open that fails for good, or is cancelled once its insert landed, still names the thread this run has to close.
|
|
565
|
+
const threadId = resumeThreadId ?? uuid4();
|
|
566
|
+
threadIds[`${stepName}#${attempt}`] = threadId;
|
|
567
|
+
if (!resumeThreadId) {
|
|
568
|
+
try {
|
|
569
|
+
await platform.openStepThread({ runId: input.runId, workflowPath: input.workflowPath, actor, stepName, attempt, workItemPath: input.workItemPath, threadId });
|
|
570
|
+
} catch (err) {
|
|
571
|
+
// The row may be there (its completion lost) or not (`closeStepThread` leaves a thread it cannot find alone): closed from outside
|
|
572
|
+
// the scope a cancel just ended, and the failure then goes on as it would have — a cancel to the run's end, anything else as the run's failure.
|
|
573
|
+
const cancelledOpen = isCancellation(err) || err instanceof CancelledFailure;
|
|
574
|
+
await CancellationScope.nonCancellable(() =>
|
|
575
|
+
stepEnd.closeStepThread({ threadId, reason: cancelledOpen ? 'the run was cancelled' : `the step's thread could not be opened: ${failureReason(err)}` }),
|
|
576
|
+
);
|
|
577
|
+
throw err;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const outcome = await runAgent(step, threadId, resumeThreadId);
|
|
581
|
+
// The step's end, for good whatever the outcome: its keys revoked
|
|
582
|
+
// (the activity's own revocation as it ended was its first try)
|
|
583
|
+
// and its thread's status written from the outcome, by the one
|
|
584
|
+
// activity Temporal retries until the stores answer.
|
|
585
|
+
await CancellationScope.nonCancellable(() => stepEnd.finishAgentStep({ threadId, outcome }));
|
|
586
|
+
// The thread this step ran on has its status now, paused or not: a memo of an earlier pause on it is spent (a new pause sets its own below).
|
|
587
|
+
if (pausedThread?.threadId === threadId) pausedThread = null;
|
|
588
|
+
moved = await settleAgent(step, outcome, threadId);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
case 'tool_chain': {
|
|
592
|
+
await record(current, `→ ${current}`);
|
|
593
|
+
const result = await inStepScope(() =>
|
|
594
|
+
chainStep.runToolChain({
|
|
595
|
+
runId: input.runId,
|
|
596
|
+
workflowPath: input.workflowPath,
|
|
597
|
+
stepName: step.name,
|
|
598
|
+
actor,
|
|
599
|
+
// As written, like a probe: the chain reads `state` and `params` as objects, not through `${…}`.
|
|
600
|
+
chain: step.instructions,
|
|
601
|
+
state: { ...state },
|
|
602
|
+
params,
|
|
603
|
+
workItemPath: input.workItemPath,
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
moved = await settleChain(step, result);
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
case 'wait': {
|
|
610
|
+
await record(current, `⏸ ${current}`, true);
|
|
611
|
+
moved = await gate(step);
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
case 'choice': {
|
|
615
|
+
const chosen = step.choices?.find((rule) => evaluate(rule.when, vars()))?.then ?? step.default;
|
|
616
|
+
if (!chosen) return finish('blocked', `choice "${step.name}" matched nothing and has no default`);
|
|
617
|
+
moved = await route(step.name, chosen);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (moved === 'moved' || moved === 'retry') continue;
|
|
622
|
+
return { status: moved, state, ...(finalReason ? { reason: finalReason } : {}) };
|
|
623
|
+
}
|
|
624
|
+
// Every step enters the next by name, so the loop ends only through a terminal status above; this is the honest answer should it not.
|
|
625
|
+
return finish('blocked', 'the run has no step to go on with');
|
|
626
|
+
} catch (err) {
|
|
627
|
+
if (isCancellation(err) || err instanceof CancelledFailure) return finish('cancelled', cancelled ?? 'cancelled');
|
|
628
|
+
// Continue-as-new is how the run goes on, not a failure: Temporal's control exception passes through.
|
|
629
|
+
if (err instanceof ContinueAsNew) throw err;
|
|
630
|
+
// Anything else is the run's failure, and the thread learns it: a thread
|
|
631
|
+
// left `running` by a workflow that died would be polled for ever.
|
|
632
|
+
log.error(`run ${input.runId} failed: ${failureReason(err)}`);
|
|
633
|
+
return finish('failed', `the run failed: ${failureReason(err)}`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* One agent step. An activity that fails as an activity — a worker that
|
|
638
|
+
* died mid-step, a heartbeat that stopped, no executor polling the queue,
|
|
639
|
+
* a refusal the activity raised — is a harness error of the step, which
|
|
640
|
+
* the file's failure policy then handles; it never fails the run as a
|
|
641
|
+
* whole. Cancellation alone passes through.
|
|
642
|
+
*/
|
|
643
|
+
/**
|
|
644
|
+
* The scope a Cancel reaches: every step's activity — an agent session, a
|
|
645
|
+
* tool chain, a gate's probe — runs in it, so the cancel signal stops the
|
|
646
|
+
* step where it is instead of waiting for it to end on its own.
|
|
647
|
+
*/
|
|
648
|
+
async function inStepScope<T>(work: () => Promise<T>): Promise<T> {
|
|
649
|
+
return CancellationScope.cancellable(async () => {
|
|
650
|
+
stepScope = CancellationScope.current();
|
|
651
|
+
if (cancelled !== null) stepScope.cancel();
|
|
652
|
+
try {
|
|
653
|
+
return await work();
|
|
654
|
+
} finally {
|
|
655
|
+
stepScope = null;
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function runAgent(step: StepDef, threadId: string, resumeThreadId: string | null): Promise<AgentStepOutcome> {
|
|
661
|
+
try {
|
|
662
|
+
return await inStepScope(() => runAgentActivity(step, threadId, resumeThreadId));
|
|
663
|
+
} catch (err) {
|
|
664
|
+
if (isCancellation(err) || err instanceof CancelledFailure) {
|
|
665
|
+
// A cancelled step closes its own thread when its activity is alive
|
|
666
|
+
// to hear the cancel; one whose worker died cannot, so the thread is
|
|
667
|
+
// closed from here too, outside the scope the cancel just ended. A
|
|
668
|
+
// thread the activity closed already is left as it is.
|
|
669
|
+
await CancellationScope.nonCancellable(() => stepEnd.closeStepThread({ threadId, reason: 'the run was cancelled' }));
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
const reason = `the step could not be run: ${failureReason(err)}`;
|
|
673
|
+
log.warn(`step "${step.name}" of run ${input.runId}: ${failureReason(err)}`);
|
|
674
|
+
// The activity may have died before it could close the thread it was
|
|
675
|
+
// given (a worker gone mid-step): closed from here, so the harness error
|
|
676
|
+
// the file's policy handles leaves no step thread running.
|
|
677
|
+
await stepEnd.closeStepThread({ threadId, reason });
|
|
678
|
+
return { kind: 'harness-error', threadId, reason };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function runAgentActivity(step: StepDef, threadId: string, resumeThreadId: string | null): Promise<AgentStepOutcome> {
|
|
683
|
+
const stepInput: AgentStepInput = {
|
|
684
|
+
runId: input.runId,
|
|
685
|
+
workflowPath: input.workflowPath,
|
|
686
|
+
stepName: step.name,
|
|
687
|
+
stepIndex: step.index,
|
|
688
|
+
attempt: typeof state.attempt === 'number' ? state.attempt : 1,
|
|
689
|
+
threadId,
|
|
690
|
+
actor,
|
|
691
|
+
agentPath: step.agent ?? null,
|
|
692
|
+
systemPrompt: step.systemPrompt ?? null,
|
|
693
|
+
instructions: interpolate(step.instructions, vars()),
|
|
694
|
+
verdict: step.verdict ?? null,
|
|
695
|
+
expectedOutputs: step.outputs ?? [],
|
|
696
|
+
workItemPath: input.workItemPath,
|
|
697
|
+
input: input.input,
|
|
698
|
+
state: { ...state },
|
|
699
|
+
params,
|
|
700
|
+
resumeThreadId,
|
|
701
|
+
// A continued conversation: only the very first session of the run starts from the earlier thread's state.
|
|
702
|
+
continueFromThreadId: step.index === 1 && !resumeThreadId && (typeof state.attempt !== 'number' || state.attempt === 1) ? (input.continueFromThreadId ?? null) : null,
|
|
703
|
+
executorQueue,
|
|
704
|
+
environment: def.environment,
|
|
705
|
+
};
|
|
706
|
+
if (def.environment.kind === 'none') {
|
|
707
|
+
// The platform stopped under the step (a deploy): it goes on on the worker that comes up, on its own thread,
|
|
708
|
+
// from its saved session. A person's run on 9 Nov 2026 was blocked by a deploy instead, and nobody answered them.
|
|
709
|
+
for (let restarts = 0; ; restarts++) {
|
|
710
|
+
try {
|
|
711
|
+
return await inProcessStep.runAgentStep(restarts === 0 ? stepInput : { ...stepInput, resumeThreadId: threadId });
|
|
712
|
+
} catch (err) {
|
|
713
|
+
if (isCancellation(err) || err instanceof CancelledFailure || restarts >= MAX_STEP_RESTARTS || !cutOffByRestart(err)) throw err;
|
|
714
|
+
await record(step.name, `↻ ${step.name} goes on: the platform restarted under it (${failureReason(err)})`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// A container step: the platform prepares it (the thread, the run key,
|
|
720
|
+
// the configuration, the Dockerfile's content), an executor runs it, and
|
|
721
|
+
// the platform closes it whatever happened — the key must not outlive
|
|
722
|
+
// the step.
|
|
723
|
+
const prepared = await platform.prepareContainerStep(stepInput);
|
|
724
|
+
if (prepared.kind === 'blocked') return { kind: 'blocked', threadId: prepared.threadId, reason: prepared.reason };
|
|
725
|
+
// A run that names an executor runs there and nowhere else, and while it
|
|
726
|
+
// is not connected the step waits for it — never a fallback to another,
|
|
727
|
+
// which cannot reach what the named one reaches (a person's VPN, a
|
|
728
|
+
// server's network). A personal name is resolved against the actor, so
|
|
729
|
+
// no run reaches another person's machine.
|
|
730
|
+
const named = namedExecutorQueue({ ...input, actor });
|
|
731
|
+
const namedLabel = input.executorKind === 'deployment' ? `executor "${input.executor}"` : `your executor "${input.executor}"`;
|
|
732
|
+
/** What the executor is handed: the prepared step, or the same in a fresh workspace once its pinned executor is gone. */
|
|
733
|
+
let containerInput = prepared.input;
|
|
734
|
+
let outcome: AgentStepOutcome | null = null;
|
|
735
|
+
let error: string | null = null;
|
|
736
|
+
try {
|
|
737
|
+
for (;;) {
|
|
738
|
+
try {
|
|
739
|
+
outcome = await containerStep(named ?? executorQueue ?? EXECUTOR_TASK_QUEUE, named !== null).runContainerStep(containerInput);
|
|
740
|
+
break;
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (isCancellation(err)) throw err;
|
|
743
|
+
const cause = activityCause(err);
|
|
744
|
+
const notTaken = cause instanceof ApplicationFailure && cause.type === 'ExecutorNotConnected';
|
|
745
|
+
if (cause instanceof ApplicationFailure && cause.type === 'ExecutorPaused') {
|
|
746
|
+
// The executor is paused until the provider's reset time: the run
|
|
747
|
+
// waits, as it does when the limit is hit mid-step, and keeps its
|
|
748
|
+
// workspace; nothing is charged to the item.
|
|
749
|
+
const resetAt = typeof cause.details?.[0] === 'number' ? cause.details[0] : null;
|
|
750
|
+
outcome = { kind: 'account-limit', threadId: prepared.input.threadId, resetAt, text: cause.message };
|
|
751
|
+
} else if (named && notTaken) {
|
|
752
|
+
// It went away before it took the step: nothing ran, so the step is sent again and waits for it.
|
|
753
|
+
log.info(`${namedLabel} left before taking step "${step.name}" (${failureReason(err)}) — waiting for it again`);
|
|
754
|
+
continue;
|
|
755
|
+
} else if (named) {
|
|
756
|
+
// It took the step and failed it: a harness error, so the file's own retriers and catchers decide.
|
|
757
|
+
outcome = { kind: 'harness-error', threadId: prepared.input.threadId, reason: `${namedLabel} did not finish the step: ${failureReason(err)}` };
|
|
758
|
+
} else if (executorQueue && (notTaken || !(cause instanceof ApplicationFailure && cause.nonRetryable))) {
|
|
759
|
+
// The pinned executor did not answer, or left before taking the
|
|
760
|
+
// step: it is gone. On the shared queue from now on, with a fresh
|
|
761
|
+
// workspace (the session is told so).
|
|
762
|
+
log.warn(`executor ${executorQueue} did not pick up step "${step.name}" (${failureReason(err)}) — falling back to the shared queue`);
|
|
763
|
+
executorQueue = null;
|
|
764
|
+
containerInput = { ...prepared.input, step: { ...prepared.input.step, executorQueue: null }, freshWorkspace: true };
|
|
765
|
+
continue;
|
|
766
|
+
} else if (notTaken) {
|
|
767
|
+
// Every deployment executor left before one took it: nothing ran; it waits on the shared queue again.
|
|
768
|
+
log.info(`the deployment's executors left before taking step "${step.name}" — waiting for one again`);
|
|
769
|
+
continue;
|
|
770
|
+
} else {
|
|
771
|
+
throw err;
|
|
772
|
+
}
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// A run on a named executor stays pinned to the queue that reaches it, whatever name the executor gives itself.
|
|
777
|
+
if (named && 'executorQueue' in outcome && outcome.executorQueue) outcome = { ...outcome, executorQueue: named };
|
|
778
|
+
return outcome;
|
|
779
|
+
} catch (err) {
|
|
780
|
+
error = failureReason(err);
|
|
781
|
+
throw err;
|
|
782
|
+
} finally {
|
|
783
|
+
// Closed whatever happened, a cancel included: the key must not outlive the step.
|
|
784
|
+
await CancellationScope.nonCancellable(() => stepEnd.finishContainerStep({ threadId: prepared.input.threadId, keyId: prepared.input.actorKey.keyId ?? null, outcome, error }));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function settleAgent(step: StepDef, outcome: AgentStepOutcome, threadId: string): Promise<RunStatus | 'moved' | 'retry'> {
|
|
789
|
+
if ('threadId' in outcome && outcome.threadId) threadIds[`${step.name}#${state.attempt ?? 1}`] = outcome.threadId;
|
|
790
|
+
if ('executorQueue' in outcome && outcome.executorQueue) executorQueue = outcome.executorQueue;
|
|
791
|
+
switch (outcome.kind) {
|
|
792
|
+
case 'verdict': {
|
|
793
|
+
if (outcome.verdict === 'PASS') {
|
|
794
|
+
mergeOutputs(step.name, outcome.outputs);
|
|
795
|
+
clearRetries(step.name);
|
|
796
|
+
state.verdict = 'PASS';
|
|
797
|
+
await record(step.name, `✅ ${step.name} passed`);
|
|
798
|
+
return route(step.name, thenFor(step, 'PASS') ?? 'continue');
|
|
799
|
+
}
|
|
800
|
+
state.verdict = outcome.verdict;
|
|
801
|
+
const explicit = thenFor(step, outcome.verdict);
|
|
802
|
+
if (explicit) {
|
|
803
|
+
clearRetries(step.name);
|
|
804
|
+
return route(step.name, explicit);
|
|
805
|
+
}
|
|
806
|
+
return fail(step.name, errorForVerdict(outcome.verdict as Verdict), outcome.reason ?? `verdict ${outcome.verdict}`);
|
|
807
|
+
}
|
|
808
|
+
case 'no-verdict':
|
|
809
|
+
return fail(step.name, 'NoVerdict', outcome.reason);
|
|
810
|
+
case 'timeout':
|
|
811
|
+
return fail(step.name, 'Timeout', outcome.reason);
|
|
812
|
+
case 'harness-error':
|
|
813
|
+
return fail(step.name, 'HarnessError', outcome.reason);
|
|
814
|
+
case 'blocked':
|
|
815
|
+
return fail(step.name, 'Blocked', outcome.reason);
|
|
816
|
+
case 'account-limit': {
|
|
817
|
+
// Nothing about the item changes: no attempt, no retry, no error. The
|
|
818
|
+
// step runs again when the limit lifts.
|
|
819
|
+
const until = outcome.resetAt ? Math.max(0, outcome.resetAt - Date.now()) : ACCOUNT_LIMIT_DEFAULT_PAUSE_MS;
|
|
820
|
+
// The thread the step goes on on when the limit lifts, from the state
|
|
821
|
+
// it saved — remembered BEFORE anything that can fail: a run that
|
|
822
|
+
// ends while it is parked here (the pause's own record failing, a
|
|
823
|
+
// Cancel waking the pause, Temporal's cancellation, a file gone or
|
|
824
|
+
// changed at the next loop) closes it in `finish`, the one terminal
|
|
825
|
+
// path, so no thread is left `waiting` for good.
|
|
826
|
+
pausedThread = { step: step.name, threadId };
|
|
827
|
+
await record(step.name, `⏳ ${step.name} paused: the account is refusing sessions (${outcome.text}); retrying in ${Math.round(until / 60_000)} min`, true);
|
|
828
|
+
await condition(() => cancelled !== null, until); // a Cancel signal wakes the pause
|
|
829
|
+
return 'retry';
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function settleChain(step: StepDef, result: ToolChainOutcome): Promise<RunStatus | 'moved' | 'retry'> {
|
|
835
|
+
if (result.threadId) threadIds[`${step.name}#${state.attempt ?? 1}`] = result.threadId;
|
|
836
|
+
if (result.kind === 'error') return fail(step.name, 'HarnessError', result.reason);
|
|
837
|
+
clearRetries(step.name);
|
|
838
|
+
const target = typeof step.then === 'string' || step.then === undefined ? (step.then ?? 'continue') : step.then[result.outcome];
|
|
839
|
+
if (!target) return fail(step.name, 'Fail', `chain answered "${result.outcome}", which the step does not route`);
|
|
840
|
+
await record(step.name, `✅ ${step.name}: ${result.outcome}`);
|
|
841
|
+
return route(step.name, target);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function gate(step: StepDef): Promise<RunStatus | 'moved' | 'retry'> {
|
|
845
|
+
const outcomes = step.outcomes ?? {};
|
|
846
|
+
// A probed gate has the same deadline as a signalled one, kept across a
|
|
847
|
+
// continue-as-new; every poll past it is history for nothing.
|
|
848
|
+
const deadline = carriedGateDeadline ?? Date.now() + gateTimeoutMs;
|
|
849
|
+
carriedGateDeadline = null;
|
|
850
|
+
// Probe errors in a row: a cap on consecutive failures, not on failures over a gate's whole life (a gate polls for weeks). Carried across a continue-as-new like the deadline.
|
|
851
|
+
let probeErrors = carriedProbeErrors ?? 0;
|
|
852
|
+
carriedProbeErrors = null;
|
|
853
|
+
for (;;) {
|
|
854
|
+
if (cancelled) return (await finish('cancelled', cancelled)).status;
|
|
855
|
+
if (step.probe) {
|
|
856
|
+
await continueIfSuggested({ deadline, probeErrors });
|
|
857
|
+
if (Date.now() >= deadline) return fail(step.name, 'Timeout', `no answer at "${step.name}" within ${durationText(gateTimeoutMs)}`);
|
|
858
|
+
const probe = step.probe;
|
|
859
|
+
const result = await inStepScope(() =>
|
|
860
|
+
chainStep.runProbe({
|
|
861
|
+
runId: input.runId,
|
|
862
|
+
workflowPath: input.workflowPath,
|
|
863
|
+
stepName: step.name,
|
|
864
|
+
actor,
|
|
865
|
+
// The source as written: a chain reads `state` and `params` as
|
|
866
|
+
// objects the activity hands it, never through `${…}` text,
|
|
867
|
+
// which is JavaScript's own template syntax inside it.
|
|
868
|
+
chain: probe,
|
|
869
|
+
state: { ...state },
|
|
870
|
+
params,
|
|
871
|
+
workItemPath: input.workItemPath,
|
|
872
|
+
}),
|
|
873
|
+
);
|
|
874
|
+
if (result.kind === 'error') {
|
|
875
|
+
probeErrors += 1;
|
|
876
|
+
if (probeErrors >= PROBE_ERROR_CAP) return fail(step.name, 'ProbeError', result.reason);
|
|
877
|
+
} else if (result.outcome === 'wait') {
|
|
878
|
+
// A probe that answered, even "not yet", is working: the errors in a row start over.
|
|
879
|
+
probeErrors = 0;
|
|
880
|
+
} else {
|
|
881
|
+
const target = outcomes[result.outcome];
|
|
882
|
+
if (!target) return fail(step.name, 'ProbeError', `probe answered "${result.outcome}", which is not one of ${Object.keys(outcomes).join(', ')}`);
|
|
883
|
+
clearRetries(step.name);
|
|
884
|
+
await record(step.name, `✅ ${step.name}: ${result.outcome}`);
|
|
885
|
+
return route(step.name, target);
|
|
886
|
+
}
|
|
887
|
+
await condition(() => pendingGate !== null || cancelled !== null, gatePollMs);
|
|
888
|
+
} else {
|
|
889
|
+
// What is left of the gate's deadline, not the whole timeout again: an answer the gate does not declare leaves it waiting, never waiting longer.
|
|
890
|
+
const signalled = await condition(() => pendingGate !== null || cancelled !== null, Math.max(0, deadline - Date.now()));
|
|
891
|
+
if (!signalled) return fail(step.name, 'Timeout', `no answer at "${step.name}" within ${durationText(gateTimeoutMs)}`);
|
|
892
|
+
}
|
|
893
|
+
if (pendingGate !== null) {
|
|
894
|
+
const outcome: string = pendingGate;
|
|
895
|
+
pendingGate = null;
|
|
896
|
+
const target = outcomes[outcome];
|
|
897
|
+
if (!target) {
|
|
898
|
+
log.warn(`gate "${step.name}" was signalled "${outcome}", which it does not declare — still waiting`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
clearRetries(step.name);
|
|
902
|
+
await record(step.name, `✅ ${step.name}: ${outcome}`);
|
|
903
|
+
return route(step.name, target);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|