@smithers-orchestrator/driver 0.23.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/driver",
3
- "version": "0.23.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/errors": "0.23.0",
67
- "@smithers-orchestrator/observability": "0.23.0",
68
- "@smithers-orchestrator/db": "0.23.0",
69
- "@smithers-orchestrator/graph": "0.23.0",
70
- "@smithers-orchestrator/scheduler": "0.23.0"
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: .smithers/hmr/<runId>) */
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;
@@ -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> & { iteration?: number; nodeId?: string }} OutputRow */
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
- return this.resolveRow(table, key);
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
- return matching.find((row) => {
192
- return (row.iteration ?? 0) === (key.iteration ?? this.iteration);
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
  }
@@ -1,3 +1,6 @@
1
1
  export type SmithersRuntimeConfig = {
2
2
  cliAgentToolsDefault?: "all" | "explicit-only";
3
+ baseRootDir?: string;
4
+ workflowPath?: string | null;
5
+ worktreePaths?: Record<string, string>;
3
6
  };
@@ -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
  };
@@ -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: this.activeOptions?.cliAgentToolsDefault
356
- ? { cliAgentToolsDefault: this.activeOptions.cliAgentToolsDefault }
357
- : undefined,
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: this.activeOptions?.rootDir ?? this.rootDir,
363
- workflowPath: this.activeOptions?.workflowPath ?? this.workflowPath ?? null,
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
- let latestDecision;
390
- let cancelled = false;
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
- await Promise.all(tasks.map(async (task) => {
394
- let report;
395
- try {
396
- const output = await withAbort(Promise.resolve().then(() => this.executeTask(task, context)), context.signal);
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
- catch (error) {
404
- if (context.signal?.aborted || isAbortError(error)) {
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 (cancelled || context.signal?.aborted) {
525
+ if (this.activeOptions?.signal?.aborted) {
426
526
  return this.cancelRun();
427
527
  }
428
- if (latestDecision) {
429
- return latestDecision;
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,
@@ -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 buf.subarray(0, maxBytes).toString("utf8");
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: .smithers/hmr/<runId>) */
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
- type OutputRow = Record<string, unknown> & {
256
- iteration?: number;
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 = {