@smithers-orchestrator/driver 0.22.0 → 0.24.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/package.json +6 -6
- package/src/RunOptions.ts +1 -1
- package/src/SmithersCtx.js +78 -6
- package/src/SmithersRuntimeConfig.ts +3 -0
- package/src/SpawnCaptureOptions.ts +7 -0
- package/src/SpawnCaptureResult.ts +4 -0
- package/src/WorkflowDriver.js +185 -36
- package/src/child-process.js +7 -5
- package/src/index.d.ts +26 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smithers-orchestrator/driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Workflow execution driver — runs tasks, acts on EngineDecisions, reports results",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -63,11 +63,11 @@
|
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"effect": "^3.21.1",
|
|
65
65
|
"zod": "^4.3.6",
|
|
66
|
-
"@smithers-orchestrator/
|
|
67
|
-
"@smithers-orchestrator/errors": "0.
|
|
68
|
-
"@smithers-orchestrator/
|
|
69
|
-
"@smithers-orchestrator/observability": "0.
|
|
70
|
-
"@smithers-orchestrator/
|
|
66
|
+
"@smithers-orchestrator/db": "0.24.0",
|
|
67
|
+
"@smithers-orchestrator/errors": "0.24.0",
|
|
68
|
+
"@smithers-orchestrator/graph": "0.24.0",
|
|
69
|
+
"@smithers-orchestrator/observability": "0.24.0",
|
|
70
|
+
"@smithers-orchestrator/scheduler": "0.24.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@types/bun": "latest",
|
package/src/RunOptions.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { SmithersEvent } from "@smithers-orchestrator/observability/Smither
|
|
|
4
4
|
export type HotReloadOptions = {
|
|
5
5
|
/** Root directory to watch for changes (default: auto-detect from workflow entry) */
|
|
6
6
|
rootDir?: string;
|
|
7
|
-
/** Directory for generation overlays (default:
|
|
7
|
+
/** Directory for generation overlays (default: rootDir/.smithers/hmr) */
|
|
8
8
|
outDir?: string;
|
|
9
9
|
/** Max overlay generations to keep (default: 3) */
|
|
10
10
|
maxGenerations?: number;
|
package/src/SmithersCtx.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
2
|
+
import { stripAutoColumns } from "@smithers-orchestrator/db/output";
|
|
2
3
|
import { buildCurrentScopes } from "./buildCurrentScopes.js";
|
|
3
4
|
import { filterRowsByNodeId } from "./filterRowsByNodeId.js";
|
|
4
5
|
import { normalizeInputRow } from "./normalizeInputRow.js";
|
|
5
6
|
import { withLogicalIterationShortcuts } from "./withLogicalIterationShortcuts.js";
|
|
7
|
+
import { resolveWorktreePath } from "@smithers-orchestrator/graph";
|
|
6
8
|
/** @typedef {import("./OutputKey.ts").OutputKey} OutputKey */
|
|
7
9
|
/** @typedef {import("./SafeParser.ts").SafeParser} SafeParser */
|
|
8
10
|
/** @typedef {import("./SmithersCtxOptions.ts").SmithersCtxOptions} SmithersCtxOptions */
|
|
9
11
|
/** @typedef {import("./RunAuthContext.ts").RunAuthContext} RunAuthContext */
|
|
10
12
|
/** @typedef {import("./SmithersRuntimeConfig.ts").SmithersRuntimeConfig} SmithersRuntimeConfig */
|
|
11
13
|
/** @typedef {unknown} TableRef */
|
|
12
|
-
/** @typedef {Record<string, unknown>
|
|
14
|
+
/** @typedef {Record<string, unknown>} OutputRow User-visible output row — harness metadata fields (runId, nodeId, iteration) are stripped. */
|
|
13
15
|
/**
|
|
14
16
|
* @template Schema
|
|
15
17
|
* @typedef {import("./OutputAccessor.ts").OutputAccessor<Schema>} OutputAccessor
|
|
@@ -50,6 +52,8 @@ export class SmithersCtx {
|
|
|
50
52
|
auth;
|
|
51
53
|
/** @type {SmithersRuntimeConfig | null | undefined} */
|
|
52
54
|
__smithersRuntime;
|
|
55
|
+
/** @type {Record<string, string>} */
|
|
56
|
+
_worktreePaths;
|
|
53
57
|
/** @type {OutputAccessor<Schema>} */
|
|
54
58
|
outputs;
|
|
55
59
|
/** @type {import("./OutputSnapshot.ts").OutputSnapshot} */
|
|
@@ -68,9 +72,20 @@ export class SmithersCtx {
|
|
|
68
72
|
this.input = /** @type {Schema extends { input: infer T } ? T : unknown} */ (normalizeInputRow(opts.input));
|
|
69
73
|
this.auth = opts.auth ?? null;
|
|
70
74
|
this.__smithersRuntime = opts.runtimeConfig ?? null;
|
|
75
|
+
this._worktreePaths = opts.runtimeConfig?.worktreePaths ?? {};
|
|
71
76
|
this._outputs = opts.outputs;
|
|
72
77
|
this._zodToKeyName = opts.zodToKeyName;
|
|
73
78
|
this._currentScopes = buildCurrentScopes(this.iterations);
|
|
79
|
+
/**
|
|
80
|
+
* Tasks that declared `deps` but could not resolve them this render, so
|
|
81
|
+
* they deferred (returned null) instead of mounting. The engine reads this
|
|
82
|
+
* after each render: a deferral is normal while an upstream is still
|
|
83
|
+
* producing, but one that survives to quiescence means the dependency can
|
|
84
|
+
* never resolve (e.g. a deps/needs key that maps to a node id no task
|
|
85
|
+
* produces) and the run would otherwise finish silently without it.
|
|
86
|
+
* @type {{ nodeId: string; waitingOn: string[] }[]}
|
|
87
|
+
*/
|
|
88
|
+
this._deferredDeps = [];
|
|
74
89
|
/**
|
|
75
90
|
* @param {string} table
|
|
76
91
|
*/
|
|
@@ -80,6 +95,29 @@ export class SmithersCtx {
|
|
|
80
95
|
}
|
|
81
96
|
this.outputs = /** @type {OutputAccessor<Schema>} */ (/** @type {unknown} */ (outputsFn));
|
|
82
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Return the resolved absolute path for a rendered worktree or task id.
|
|
100
|
+
* The lookup is populated from task descriptors, so task node ids and
|
|
101
|
+
* explicit <Worktree id> values both work once the worktree has rendered.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} id
|
|
104
|
+
* @returns {string | undefined}
|
|
105
|
+
*/
|
|
106
|
+
worktreePath(id) {
|
|
107
|
+
return this._worktreePaths[id];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a <Worktree path> prop against the active workflow root using
|
|
111
|
+
* the same resolver graph extraction uses.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} path
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
resolveWorktreePath(path) {
|
|
117
|
+
return resolveWorktreePath(path, {
|
|
118
|
+
baseRootDir: this.__smithersRuntime?.baseRootDir,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
83
121
|
/**
|
|
84
122
|
* @param {TableRef} table
|
|
85
123
|
* @param {OutputKey} key
|
|
@@ -90,7 +128,7 @@ export class SmithersCtx {
|
|
|
90
128
|
if (!row) {
|
|
91
129
|
throw new SmithersError("MISSING_OUTPUT", `Missing output for nodeId=${key.nodeId} iteration=${key.iteration ?? 0}`, { nodeId: key.nodeId, iteration: key.iteration ?? 0 });
|
|
92
130
|
}
|
|
93
|
-
return row;
|
|
131
|
+
return /** @type {OutputRow} */ (stripAutoColumns(row));
|
|
94
132
|
}
|
|
95
133
|
/**
|
|
96
134
|
* @param {TableRef} table
|
|
@@ -98,7 +136,8 @@ export class SmithersCtx {
|
|
|
98
136
|
* @returns {OutputRow | undefined}
|
|
99
137
|
*/
|
|
100
138
|
outputMaybe(table, key) {
|
|
101
|
-
|
|
139
|
+
const row = this.resolveRow(table, key);
|
|
140
|
+
return row !== undefined ? /** @type {OutputRow} */ (stripAutoColumns(row)) : undefined;
|
|
102
141
|
}
|
|
103
142
|
/**
|
|
104
143
|
* @param {TableRef} table
|
|
@@ -179,6 +218,21 @@ export class SmithersCtx {
|
|
|
179
218
|
return zodKey;
|
|
180
219
|
return resolveDrizzleName(table) ?? String(table);
|
|
181
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Record that a task with `deps` deferred this render because its
|
|
223
|
+
* dependencies were not resolvable. Called by the Task component before it
|
|
224
|
+
* returns null. The engine inspects these at quiescence to turn a permanent
|
|
225
|
+
* deferral (a never-satisfiable dependency) into a loud error instead of a
|
|
226
|
+
* silent skip.
|
|
227
|
+
* @param {string} nodeId
|
|
228
|
+
* @param {string[]} waitingOn
|
|
229
|
+
* @returns {void}
|
|
230
|
+
*/
|
|
231
|
+
recordDeferredDep(nodeId, waitingOn) {
|
|
232
|
+
if (typeof nodeId !== "string" || nodeId.length === 0)
|
|
233
|
+
return;
|
|
234
|
+
this._deferredDeps.push({ nodeId, waitingOn: Array.isArray(waitingOn) ? waitingOn : [] });
|
|
235
|
+
}
|
|
182
236
|
/**
|
|
183
237
|
* @param {TableRef} table
|
|
184
238
|
* @param {OutputKey} key
|
|
@@ -188,8 +242,26 @@ export class SmithersCtx {
|
|
|
188
242
|
const tableName = this.resolveTableName(table);
|
|
189
243
|
const rows = this._outputs[tableName] ?? [];
|
|
190
244
|
const matching = filterRowsByNodeId(rows, key.nodeId, this._currentScopes);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
245
|
+
const targetIteration = key.iteration ?? this.iteration;
|
|
246
|
+
const exact = matching.find((row) => (row.iteration ?? 0) === targetIteration);
|
|
247
|
+
if (exact)
|
|
248
|
+
return exact;
|
|
249
|
+
// Cross-loop-boundary resolution: a task INSIDE a loop (this.iteration >= 1)
|
|
250
|
+
// that depends — via deps/needs — on a task OUTSIDE the loop never matched
|
|
251
|
+
// the strict equality above, because the upstream wrote its row with an
|
|
252
|
+
// unscoped nodeId at its own iteration (typically 0) while the depender
|
|
253
|
+
// renders at the loop iteration. That mismatch made the dependent task
|
|
254
|
+
// resolve `undefined` and unmount forever. Resolve an unscoped producer
|
|
255
|
+
// (one that ran outside any loop) at its own iteration instead.
|
|
256
|
+
//
|
|
257
|
+
// The relaxation is deliberately narrow: it only applies when the caller
|
|
258
|
+
// did not pin an explicit iteration, and only to rows whose nodeId carries
|
|
259
|
+
// no loop scope (`@@`). An in-loop producer always writes scoped rows, so it
|
|
260
|
+
// keeps the strict gate above — a not-yet-produced iteration still defers
|
|
261
|
+
// instead of resolving stale data from an earlier pass.
|
|
262
|
+
if (key.iteration === undefined) {
|
|
263
|
+
return matching.find((row) => typeof row.nodeId === "string" && !row.nodeId.includes("@@"));
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
194
266
|
}
|
|
195
267
|
}
|
|
@@ -6,6 +6,13 @@ export type SpawnCaptureOptions = {
|
|
|
6
6
|
timeoutMs?: number;
|
|
7
7
|
idleTimeoutMs?: number;
|
|
8
8
|
maxOutputBytes?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Which end of overflowing STDOUT to keep. CLI agents that emit their final
|
|
11
|
+
* result at the end of an NDJSON stream should keep the tail. Stderr always
|
|
12
|
+
* keeps the head: failure classification reads the leading error text.
|
|
13
|
+
* @default "head"
|
|
14
|
+
*/
|
|
15
|
+
truncateKeep?: "head" | "tail";
|
|
9
16
|
detached?: boolean;
|
|
10
17
|
onStdout?: (chunk: string) => void;
|
|
11
18
|
onStderr?: (chunk: string) => void;
|
|
@@ -2,4 +2,8 @@ export type SpawnCaptureResult = {
|
|
|
2
2
|
stdout: string;
|
|
3
3
|
stderr: string;
|
|
4
4
|
exitCode: number | null;
|
|
5
|
+
/** True when captured stdout exceeded maxOutputBytes and was truncated. */
|
|
6
|
+
stdoutTruncated: boolean;
|
|
7
|
+
/** True when captured stderr exceeded maxOutputBytes and was truncated. */
|
|
8
|
+
stderrTruncated: boolean;
|
|
5
9
|
};
|
package/src/WorkflowDriver.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
1
2
|
import { SmithersCtx } from "./SmithersCtx.js";
|
|
2
3
|
import { defaultTaskExecutor } from "./defaultTaskExecutor.js";
|
|
3
4
|
import { withAbort } from "./withAbort.js";
|
|
@@ -65,6 +66,23 @@ function recordFromIterations(iterations) {
|
|
|
65
66
|
}
|
|
66
67
|
return iterations;
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* @param {readonly TaskDescriptor[]} tasks
|
|
71
|
+
* @returns {Record<string, string>}
|
|
72
|
+
*/
|
|
73
|
+
function buildWorktreePathLookup(tasks) {
|
|
74
|
+
/** @type {Record<string, string>} */
|
|
75
|
+
const lookup = {};
|
|
76
|
+
for (const task of tasks) {
|
|
77
|
+
if (!task.worktreePath)
|
|
78
|
+
continue;
|
|
79
|
+
lookup[task.nodeId] = task.worktreePath;
|
|
80
|
+
if (task.worktreeId && lookup[task.worktreeId] === undefined) {
|
|
81
|
+
lookup[task.worktreeId] = task.worktreePath;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lookup;
|
|
85
|
+
}
|
|
68
86
|
/**
|
|
69
87
|
* @param {RenderContext} context
|
|
70
88
|
* @param {ReadonlyMap<string, string>} [knownOutputTables]
|
|
@@ -153,6 +171,29 @@ async function loadCreateSession() {
|
|
|
153
171
|
}
|
|
154
172
|
return null;
|
|
155
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Build a diagnostic for tasks that declared `deps` they could never resolve, so
|
|
176
|
+
* they deferred (returned null) instead of mounting and the run reached
|
|
177
|
+
* quiescence without them. Left undetected this is a silent skip — the run
|
|
178
|
+
* "finishes" without ever running the task.
|
|
179
|
+
* @param {{ nodeId: string; waitingOn: string[] }[]} deferred
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
function describeDeferredDeadlock(deferred) {
|
|
183
|
+
const lines = deferred.map(({ nodeId, waitingOn }) => {
|
|
184
|
+
const detail = waitingOn.length === 0
|
|
185
|
+
? "its deps never resolved"
|
|
186
|
+
: `waiting on ${waitingOn.map((id) => `'${id}'`).join(", ")}`;
|
|
187
|
+
return ` - '${nodeId}' never ran (${detail})`;
|
|
188
|
+
});
|
|
189
|
+
return [
|
|
190
|
+
"Workflow has task(s) that can never run: their deps reference outputs no task produces.",
|
|
191
|
+
...lines,
|
|
192
|
+
"",
|
|
193
|
+
"A deps={{ <key>: ... }} entry resolves <key> as the upstream task's id unless you remap it. " +
|
|
194
|
+
"Add needs={{ <key>: '<upstream task id>' }} (or rename the upstream task to match the key) so the dependency points at a real task.",
|
|
195
|
+
].join("\n");
|
|
196
|
+
}
|
|
156
197
|
/**
|
|
157
198
|
* @param {unknown} error
|
|
158
199
|
* @returns {boolean}
|
|
@@ -225,10 +266,18 @@ export class WorkflowDriver {
|
|
|
225
266
|
activeOptions;
|
|
226
267
|
/** @type {import("@smithers-orchestrator/graph").WorkflowGraph | undefined} */
|
|
227
268
|
lastGraph;
|
|
269
|
+
/** @type {{ nodeId: string; waitingOn: string[] }[]} Tasks that deferred on unresolved deps in the latest render. */
|
|
270
|
+
lastDeferredDeps = [];
|
|
271
|
+
/** @type {Record<string, string>} */
|
|
272
|
+
worktreePathsById = {};
|
|
228
273
|
/** @type {Map<string, string>} */
|
|
229
274
|
outputTablesByNodeId = new Map();
|
|
230
275
|
/** @type {OutputSnapshot} */
|
|
231
276
|
baseOutputs = {};
|
|
277
|
+
/** @type {Map<string, Promise<{ key: string; task: TaskDescriptor; kind: "completed" | "failed" | "cancelled"; output?: unknown; error?: unknown }>>} */
|
|
278
|
+
inflightTasks = new Map();
|
|
279
|
+
/** @type {Array<{ key: string; task: TaskDescriptor; kind: "completed" | "failed" | "cancelled"; output?: unknown; error?: unknown }>} */
|
|
280
|
+
settledTasks = [];
|
|
232
281
|
/**
|
|
233
282
|
* @param {import("./WorkflowDriverOptions.ts").WorkflowDriverOptions<Schema>} options
|
|
234
283
|
*/
|
|
@@ -298,10 +347,21 @@ export class WorkflowDriver {
|
|
|
298
347
|
break;
|
|
299
348
|
}
|
|
300
349
|
case "ContinueAsNew":
|
|
350
|
+
await this.drainInflight();
|
|
301
351
|
return this.continueAsNew(decision.transition);
|
|
302
|
-
case "Finished":
|
|
352
|
+
case "Finished": {
|
|
353
|
+
if (this.lastDeferredDeps.length > 0) {
|
|
354
|
+
await this.drainInflight();
|
|
355
|
+
return {
|
|
356
|
+
runId,
|
|
357
|
+
status: "failed",
|
|
358
|
+
error: new SmithersError("DEPENDENCY_DEADLOCK", describeDeferredDeadlock(this.lastDeferredDeps)),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
303
361
|
return decision.result;
|
|
362
|
+
}
|
|
304
363
|
case "Failed":
|
|
364
|
+
await this.drainInflight();
|
|
305
365
|
return { runId, status: "failed", error: decision.error };
|
|
306
366
|
default:
|
|
307
367
|
return {
|
|
@@ -344,6 +404,8 @@ export class WorkflowDriver {
|
|
|
344
404
|
}
|
|
345
405
|
const iteration = typeof context.iteration === "number" ? context.iteration : 0;
|
|
346
406
|
const iterations = recordFromIterations(context.iterations ?? context.ralphIterations);
|
|
407
|
+
const baseRootDir = this.activeOptions?.rootDir ?? this.rootDir;
|
|
408
|
+
const workflowPath = this.activeOptions?.workflowPath ?? this.workflowPath ?? null;
|
|
347
409
|
const ctx = new SmithersCtx({
|
|
348
410
|
runId: context.runId,
|
|
349
411
|
iteration,
|
|
@@ -352,21 +414,31 @@ export class WorkflowDriver {
|
|
|
352
414
|
auth: context.auth,
|
|
353
415
|
outputs: mergeOutputSnapshots(this.baseOutputs, snapshotFromContext(context, this.outputTablesByNodeId)),
|
|
354
416
|
zodToKeyName: this.workflow.zodToKeyName,
|
|
355
|
-
runtimeConfig:
|
|
356
|
-
|
|
357
|
-
|
|
417
|
+
runtimeConfig: {
|
|
418
|
+
...(this.activeOptions?.cliAgentToolsDefault
|
|
419
|
+
? { cliAgentToolsDefault: this.activeOptions.cliAgentToolsDefault }
|
|
420
|
+
: {}),
|
|
421
|
+
baseRootDir,
|
|
422
|
+
workflowPath,
|
|
423
|
+
worktreePaths: this.worktreePathsById,
|
|
424
|
+
},
|
|
358
425
|
});
|
|
359
426
|
const graph = await this.renderer.render(this.workflow.build(ctx), {
|
|
360
427
|
ralphIterations: context.iterations ?? context.ralphIterations,
|
|
361
428
|
defaultIteration: iteration,
|
|
362
|
-
baseRootDir
|
|
363
|
-
workflowPath
|
|
429
|
+
baseRootDir,
|
|
430
|
+
workflowPath,
|
|
364
431
|
});
|
|
432
|
+
// Capture tasks that deferred on unresolved deps this render so the run
|
|
433
|
+
// loop can fail loudly if any survive to a Finished decision instead of
|
|
434
|
+
// silently skipping them.
|
|
435
|
+
this.lastDeferredDeps = ctx._deferredDeps ?? [];
|
|
365
436
|
for (const task of graph.tasks) {
|
|
366
437
|
if (task.outputTableName) {
|
|
367
438
|
this.outputTablesByNodeId.set(task.nodeId, task.outputTableName);
|
|
368
439
|
}
|
|
369
440
|
}
|
|
441
|
+
this.worktreePathsById = buildWorktreePathLookup(graph.tasks);
|
|
370
442
|
this.lastGraph = graph;
|
|
371
443
|
return this.runEffect(this.session.submitGraph(graph));
|
|
372
444
|
}
|
|
@@ -386,47 +458,98 @@ export class WorkflowDriver {
|
|
|
386
458
|
if (context.signal?.aborted) {
|
|
387
459
|
return this.cancelRun();
|
|
388
460
|
}
|
|
389
|
-
|
|
390
|
-
|
|
461
|
+
for (const task of tasks) {
|
|
462
|
+
this.startInflightTask(task, context);
|
|
463
|
+
}
|
|
464
|
+
return this.nextCompletionDecision();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Start a task without blocking the driver loop on its completion. Settled
|
|
468
|
+
* tasks queue in `settledTasks` and are reported to the session one at a
|
|
469
|
+
* time from `nextCompletionDecision`, so each decision is computed against
|
|
470
|
+
* fresh session state and a slow task never blocks scheduling work that
|
|
471
|
+
* became ready elsewhere in the graph (#267).
|
|
472
|
+
* @param {TaskDescriptor} task
|
|
473
|
+
* @param {{ runId: string; options: RunOptions; signal?: AbortSignal }} context
|
|
474
|
+
*/
|
|
475
|
+
startInflightTask(task, context) {
|
|
476
|
+
const key = `${task.nodeId}::${task.iteration}`;
|
|
477
|
+
if (this.inflightTasks.has(key)) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const promise = (async () => {
|
|
481
|
+
try {
|
|
482
|
+
const output = await withAbort(Promise.resolve().then(() => this.executeTask(task, context)), context.signal);
|
|
483
|
+
return { key, task, kind: /** @type {const} */ ("completed"), output };
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (context.signal?.aborted || isAbortError(error)) {
|
|
487
|
+
return { key, task, kind: /** @type {const} */ ("cancelled") };
|
|
488
|
+
}
|
|
489
|
+
return { key, task, kind: /** @type {const} */ ("failed"), error };
|
|
490
|
+
}
|
|
491
|
+
})().then((settled) => {
|
|
492
|
+
this.inflightTasks.delete(key);
|
|
493
|
+
this.settledTasks.push(settled);
|
|
494
|
+
return settled;
|
|
495
|
+
});
|
|
496
|
+
this.inflightTasks.set(key, promise);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Wait for the next settled task (or an optional deadline) and report it to
|
|
500
|
+
* the session for a fresh decision. Completions that landed while a previous
|
|
501
|
+
* one was being processed drain from `settledTasks` first.
|
|
502
|
+
* @param {number | null} [deadlineMs]
|
|
503
|
+
* @returns {Promise<EngineDecision | RunResult>}
|
|
504
|
+
*/
|
|
505
|
+
async nextCompletionDecision(deadlineMs = null) {
|
|
506
|
+
if (!this.session) {
|
|
507
|
+
throw new Error("WorkflowSession is not initialized.");
|
|
508
|
+
}
|
|
391
509
|
const waitStart = performance.now();
|
|
392
510
|
try {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
report = await this.runEffect(this.session.taskCompleted({
|
|
398
|
-
nodeId: task.nodeId,
|
|
399
|
-
iteration: task.iteration,
|
|
400
|
-
output,
|
|
401
|
-
}));
|
|
511
|
+
if (this.settledTasks.length === 0 && this.inflightTasks.size > 0) {
|
|
512
|
+
const racers = [...this.inflightTasks.values()];
|
|
513
|
+
if (deadlineMs != null) {
|
|
514
|
+
racers.push(sleepWithAbort(deadlineMs, this.activeOptions?.signal).then(() => null));
|
|
402
515
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
cancelled = true;
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
report = await this.runEffect(this.session.taskFailed({
|
|
409
|
-
nodeId: task.nodeId,
|
|
410
|
-
iteration: task.iteration,
|
|
411
|
-
error,
|
|
412
|
-
}));
|
|
413
|
-
}
|
|
414
|
-
if (isEngineDecision(report)) {
|
|
415
|
-
latestDecision = report;
|
|
416
|
-
}
|
|
417
|
-
}));
|
|
516
|
+
await Promise.race(racers);
|
|
517
|
+
}
|
|
418
518
|
}
|
|
419
519
|
finally {
|
|
420
520
|
await this.onSchedulerWait?.(performance.now() - waitStart, {
|
|
421
521
|
runId: this.activeRunId,
|
|
422
|
-
tasks,
|
|
522
|
+
tasks: [],
|
|
423
523
|
});
|
|
424
524
|
}
|
|
425
|
-
if (
|
|
525
|
+
if (this.activeOptions?.signal?.aborted) {
|
|
426
526
|
return this.cancelRun();
|
|
427
527
|
}
|
|
428
|
-
|
|
429
|
-
|
|
528
|
+
const settled = this.settledTasks.shift();
|
|
529
|
+
if (!settled) {
|
|
530
|
+
// Deadline elapsed (retry backoff / timer) without a completion —
|
|
531
|
+
// re-submit the last graph for a fresh decision.
|
|
532
|
+
if (this.lastGraph) {
|
|
533
|
+
return this.runEffect(this.session.submitGraph(this.lastGraph));
|
|
534
|
+
}
|
|
535
|
+
return { runId: this.activeRunId, status: "waiting-event" };
|
|
536
|
+
}
|
|
537
|
+
if (settled.kind === "cancelled") {
|
|
538
|
+
return this.cancelRun();
|
|
539
|
+
}
|
|
540
|
+
const report = settled.kind === "completed"
|
|
541
|
+
? await this.runEffect(this.session.taskCompleted({
|
|
542
|
+
nodeId: settled.task.nodeId,
|
|
543
|
+
iteration: settled.task.iteration,
|
|
544
|
+
output: settled.output,
|
|
545
|
+
}))
|
|
546
|
+
: await this.runEffect(this.session.taskFailed({
|
|
547
|
+
nodeId: settled.task.nodeId,
|
|
548
|
+
iteration: settled.task.iteration,
|
|
549
|
+
error: settled.error,
|
|
550
|
+
}));
|
|
551
|
+
if (isEngineDecision(report)) {
|
|
552
|
+
return report;
|
|
430
553
|
}
|
|
431
554
|
if (typeof this.session.getNextDecision === "function") {
|
|
432
555
|
return this.runEffect(this.session.getNextDecision());
|
|
@@ -434,10 +557,36 @@ export class WorkflowDriver {
|
|
|
434
557
|
throw new Error("WorkflowSession did not provide the next EngineDecision.");
|
|
435
558
|
}
|
|
436
559
|
/**
|
|
560
|
+
* Await every in-flight task without reporting further decisions. Used
|
|
561
|
+
* before run-level exits (failure, continue-as-new) so task executors are
|
|
562
|
+
* not abandoned mid-write. This matches the pre-#267 barrier semantics:
|
|
563
|
+
* failure reporting waits for in-flight siblings (bounded by their
|
|
564
|
+
* timeouts), trading latency for the invariant that no executor writes
|
|
565
|
+
* after the run is terminal. Fail-fast would need a per-run abort threaded
|
|
566
|
+
* through executors.
|
|
567
|
+
*/
|
|
568
|
+
async drainInflight() {
|
|
569
|
+
while (this.inflightTasks.size > 0) {
|
|
570
|
+
await Promise.allSettled([...this.inflightTasks.values()]);
|
|
571
|
+
}
|
|
572
|
+
this.settledTasks.length = 0;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
437
575
|
* @param {WaitReason} reason
|
|
438
576
|
* @returns {Promise<EngineDecision | RunResult>}
|
|
439
577
|
*/
|
|
440
578
|
async handleWait(reason) {
|
|
579
|
+
if (this.inflightTasks.size > 0 || this.settledTasks.length > 0) {
|
|
580
|
+
// Work is still in flight — consume the next completion instead of
|
|
581
|
+
// suspending the run. Deadline-style waits keep their deadline so a
|
|
582
|
+
// retry backoff or timer elsewhere in the graph still fires on time.
|
|
583
|
+
const deadlineMs = reason._tag === "RetryBackoff"
|
|
584
|
+
? Math.max(0, reason.waitMs)
|
|
585
|
+
: reason._tag === "Timer"
|
|
586
|
+
? Math.max(0, reason.resumeAtMs - Date.now())
|
|
587
|
+
: null;
|
|
588
|
+
return this.nextCompletionDecision(deadlineMs);
|
|
589
|
+
}
|
|
441
590
|
if (this.onWait) {
|
|
442
591
|
return this.onWait(reason, {
|
|
443
592
|
runId: this.activeRunId,
|
package/src/child-process.js
CHANGED
|
@@ -13,11 +13,13 @@ import { logDebug, logWarning } from "@smithers-orchestrator/observability/loggi
|
|
|
13
13
|
* @param {number} maxBytes
|
|
14
14
|
* @returns {string}
|
|
15
15
|
*/
|
|
16
|
-
function truncateToBytes(text, maxBytes) {
|
|
16
|
+
function truncateToBytes(text, maxBytes, keep = "head") {
|
|
17
17
|
const buf = Buffer.from(text, "utf8");
|
|
18
18
|
if (buf.length <= maxBytes)
|
|
19
19
|
return text;
|
|
20
|
-
return
|
|
20
|
+
return keep === "tail"
|
|
21
|
+
? buf.subarray(buf.length - maxBytes).toString("utf8")
|
|
22
|
+
: buf.subarray(0, maxBytes).toString("utf8");
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
25
|
* @param {string} command
|
|
@@ -26,7 +28,7 @@ function truncateToBytes(text, maxBytes) {
|
|
|
26
28
|
* @returns {Effect.Effect<SpawnCaptureResult, SmithersError>}
|
|
27
29
|
*/
|
|
28
30
|
export function spawnCaptureEffect(command, args, options) {
|
|
29
|
-
const { cwd, env, input, signal, timeoutMs, idleTimeoutMs, maxOutputBytes = 200_000, detached = false, onStdout, onStderr, } = options;
|
|
31
|
+
const { cwd, env, input, signal, timeoutMs, idleTimeoutMs, maxOutputBytes = 200_000, truncateKeep = "head", detached = false, onStdout, onStderr, } = options;
|
|
30
32
|
const errorDetails = {
|
|
31
33
|
command,
|
|
32
34
|
args,
|
|
@@ -160,7 +162,7 @@ export function spawnCaptureEffect(command, args, options) {
|
|
|
160
162
|
stream: "stdout",
|
|
161
163
|
}, span);
|
|
162
164
|
}
|
|
163
|
-
stdout = truncateToBytes(nextStdout, maxOutputBytes);
|
|
165
|
+
stdout = truncateToBytes(nextStdout, maxOutputBytes, truncateKeep);
|
|
164
166
|
onStdout?.(text);
|
|
165
167
|
});
|
|
166
168
|
child.stderr?.on("data", (chunk) => {
|
|
@@ -197,7 +199,7 @@ export function spawnCaptureEffect(command, args, options) {
|
|
|
197
199
|
}
|
|
198
200
|
});
|
|
199
201
|
child.on("close", (code) => {
|
|
200
|
-
finalize({ stdout, stderr, exitCode: code ?? null });
|
|
202
|
+
finalize({ stdout, stderr, exitCode: code ?? null, stdoutTruncated, stderrTruncated });
|
|
201
203
|
});
|
|
202
204
|
child.stdin?.on("error", (error) => {
|
|
203
205
|
logWarning("child process stdin error", {
|
package/src/index.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ type RunAuthContext$2 = {
|
|
|
43
43
|
type HotReloadOptions$1 = {
|
|
44
44
|
/** Root directory to watch for changes (default: auto-detect from workflow entry) */
|
|
45
45
|
rootDir?: string;
|
|
46
|
-
/** Directory for generation overlays (default:
|
|
46
|
+
/** Directory for generation overlays (default: rootDir/.smithers/hmr) */
|
|
47
47
|
outDir?: string;
|
|
48
48
|
/** Max overlay generations to keep (default: 3) */
|
|
49
49
|
maxGenerations?: number;
|
|
@@ -68,6 +68,7 @@ type RunOptions$2 = {
|
|
|
68
68
|
maxOutputBytes?: number;
|
|
69
69
|
toolTimeoutMs?: number;
|
|
70
70
|
hot?: boolean | HotReloadOptions$1;
|
|
71
|
+
annotations?: Record<string, string | number | boolean>;
|
|
71
72
|
auth?: RunAuthContext$2 | null;
|
|
72
73
|
config?: Record<string, unknown>;
|
|
73
74
|
cliAgentToolsDefault?: "all" | "explicit-only";
|
|
@@ -142,6 +143,9 @@ type OutputForTable<Schema, Table> = Table extends OutputSchemaKey<Schema> ? Inf
|
|
|
142
143
|
|
|
143
144
|
type SmithersRuntimeConfig$1 = {
|
|
144
145
|
cliAgentToolsDefault?: "all" | "explicit-only";
|
|
146
|
+
baseRootDir?: string;
|
|
147
|
+
workflowPath?: string | null;
|
|
148
|
+
worktreePaths?: Record<string, string>;
|
|
145
149
|
};
|
|
146
150
|
|
|
147
151
|
type OutputSnapshot$2<TFallback = unknown> = {
|
|
@@ -196,6 +200,8 @@ declare class SmithersCtx<Schema extends unknown = unknown> {
|
|
|
196
200
|
auth: RunAuthContext$1 | null;
|
|
197
201
|
/** @type {SmithersRuntimeConfig | null | undefined} */
|
|
198
202
|
__smithersRuntime: SmithersRuntimeConfig | null | undefined;
|
|
203
|
+
/** @type {Record<string, string>} */
|
|
204
|
+
_worktreePaths: Record<string, string>;
|
|
199
205
|
/** @type {OutputAccessor<Schema>} */
|
|
200
206
|
outputs: OutputAccessor$1<Schema>;
|
|
201
207
|
/** @type {import("./OutputSnapshot.ts").OutputSnapshot} */
|
|
@@ -204,6 +210,23 @@ declare class SmithersCtx<Schema extends unknown = unknown> {
|
|
|
204
210
|
_zodToKeyName: Map<unknown, string> | undefined;
|
|
205
211
|
/** @type {Set<string>} */
|
|
206
212
|
_currentScopes: Set<string>;
|
|
213
|
+
/**
|
|
214
|
+
* Return the resolved absolute path for a rendered worktree or task id.
|
|
215
|
+
* The lookup is populated from task descriptors, so task node ids and
|
|
216
|
+
* explicit <Worktree id> values both work once the worktree has rendered.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} id
|
|
219
|
+
* @returns {string | undefined}
|
|
220
|
+
*/
|
|
221
|
+
worktreePath(id: string): string | undefined;
|
|
222
|
+
/**
|
|
223
|
+
* Resolve a <Worktree path> prop against the active workflow root using
|
|
224
|
+
* the same resolver graph extraction uses.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} path
|
|
227
|
+
* @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
resolveWorktreePath(path: string): string;
|
|
207
230
|
/**
|
|
208
231
|
* @param {TableRef} table
|
|
209
232
|
* @param {OutputKey} key
|
|
@@ -252,10 +275,8 @@ type SmithersCtxOptions$1 = SmithersCtxOptions$2;
|
|
|
252
275
|
type RunAuthContext$1 = RunAuthContext$2;
|
|
253
276
|
type SmithersRuntimeConfig = SmithersRuntimeConfig$1;
|
|
254
277
|
type TableRef = unknown;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
nodeId?: string;
|
|
258
|
-
};
|
|
278
|
+
/** User-visible output row — harness metadata fields (runId, nodeId, iteration) are stripped. */
|
|
279
|
+
type OutputRow = Record<string, unknown>;
|
|
259
280
|
type OutputAccessor$1<Schema> = OutputAccessor$2<Schema>;
|
|
260
281
|
|
|
261
282
|
type WorkflowElement = {
|