@hyperdrive.bot/paseo-server 0.3.42 → 0.3.43

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.
Files changed (20) hide show
  1. package/dist/server/server/agent/mcp-server.js +6 -1
  2. package/dist/server/server/bootstrap.js +1 -0
  3. package/dist/server/server/session.js +1 -0
  4. package/dist/server/server/workflow/workflow-agent-resolution.d.ts +82 -0
  5. package/dist/server/server/workflow/workflow-agent-resolution.js +105 -0
  6. package/dist/server/server/workflow/workflow-manager.d.ts +155 -20
  7. package/dist/server/server/workflow/workflow-manager.js +439 -31
  8. package/dist/server/server/workflow/workflow-progress.d.ts +53 -0
  9. package/dist/server/server/workflow/workflow-progress.js +96 -0
  10. package/dist/server/web-ui/_expo/static/js/web/{index-9c3bcdc334cf1c08001b6bea510e0292.js → index-2ff48a7009ad2309c577d5a43b925f69.js} +7 -7
  11. package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.br +0 -0
  12. package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.gz +0 -0
  13. package/dist/server/web-ui/_expo/static/js/web/{index-9c3bcdc334cf1c08001b6bea510e0292.js.map.br → index-2ff48a7009ad2309c577d5a43b925f69.js.map.br} +0 -0
  14. package/dist/server/web-ui/_expo/static/js/web/{index-9c3bcdc334cf1c08001b6bea510e0292.js.map.gz → index-2ff48a7009ad2309c577d5a43b925f69.js.map.gz} +0 -0
  15. package/dist/server/web-ui/index.html +1 -1
  16. package/dist/server/web-ui/index.html.br +0 -0
  17. package/dist/server/web-ui/index.html.gz +0 -0
  18. package/package.json +6 -6
  19. package/dist/server/web-ui/_expo/static/js/web/index-9c3bcdc334cf1c08001b6bea510e0292.js.br +0 -0
  20. package/dist/server/web-ui/_expo/static/js/web/index-9c3bcdc334cf1c08001b6bea510e0292.js.gz +0 -0
@@ -1,9 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { PARENT_AGENT_ID_LABEL, WORKFLOW_ID_LABEL, WORKFLOW_TASK_ID_LABEL, } from "@hyperdrive.bot/paseo-protocol/agent-labels";
3
- import { projectWorkflowPhases, projectWorkflowProgress, resolveExecutionLayers, } from "./workflow-progress.js";
3
+ import { startCreatedAgentInitialPrompt } from "../agent/agent-prompt.js";
4
+ import { assertSchedulableGraph, projectWorkflowPhases, projectWorkflowProgress, resolveExecutionLayers, } from "./workflow-progress.js";
5
+ import { assertKnownAgents, resolveTaskConfig } from "./workflow-agent-resolution.js";
4
6
  // Re-exported for back-compat: this error used to be declared here before the
5
7
  // execution-layer logic moved to `workflow-progress.ts` (Epic 3, Story 3.1).
6
8
  export { WorkflowCycleError } from "./workflow-progress.js";
9
+ // Re-exported alongside WorkflowCycleError so every create-time graph rejection is
10
+ // importable from ONE module. Callers of `createWorkflow` catch against the manager's
11
+ // surface; making them reach into `workflow-progress.js` for half the errors would leak
12
+ // the internal split.
13
+ export { WorkflowInvalidGraphError } from "./workflow-progress.js";
14
+ // Re-exported for the same reason as WorkflowCycleError above: the class is declared in
15
+ // the leaf module `workflow-agent-resolution.ts` (which this file imports as a value) so
16
+ // there is no runtime import cycle, but callers import it from the manager.
17
+ export { WorkflowUnknownAgentError } from "./workflow-agent-resolution.js";
7
18
  // Default settle window (ms) for coalescing a burst of child `agent_state` events
8
19
  // into ONE projection emit. Mirrors AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS.
9
20
  export const WORKFLOW_PROJECTION_COALESCE_WINDOW_MS = 60;
@@ -33,8 +44,9 @@ export class WorkflowManager {
33
44
  this.storage = options.storage;
34
45
  this.coalesceWindowMs = options.coalesceWindowMs ?? WORKFLOW_PROJECTION_COALESCE_WINDOW_MS;
35
46
  this.timers = options.timers ?? { setTimeout, clearTimeout };
47
+ this.logger = options.logger;
36
48
  // Subscribe to child lifecycle changes so progress is projected live. We only
37
- // care about FUTURE state `replayState: false` skips the synchronous replay of
49
+ // care about FUTURE state - `replayState: false` skips the synchronous replay of
38
50
  // every existing agent at construction time.
39
51
  this.unsubscribeAgents = this.agentManager.subscribe((event) => this.onAgentEvent(event), {
40
52
  replayState: false,
@@ -54,9 +66,25 @@ export class WorkflowManager {
54
66
  /**
55
67
  * Build a workflow snapshot from a task graph, persist it (Story 1.1 storage),
56
68
  * register it in memory, emit to subscribers, and return the snapshot. No process
57
- * is spawned here that happens in {@link startWorkflow}/{@link spawnPhaseAgent}.
69
+ * is spawned here - that happens in {@link startWorkflow}/{@link spawnPhaseAgent}.
58
70
  */
59
71
  async createWorkflow(input) {
72
+ // FIRST statement: an unschedulable graph must be rejected before an id is minted,
73
+ // before layers are resolved, and above all before anything is written to disk or
74
+ // registered in `this.workflows`. Throws WorkflowInvalidGraphError (no tasks at all,
75
+ // or the same task id twice) or WorkflowCycleError (dangling dependency, cycle);
76
+ // both callers (session.ts RPC, mcp-server.ts workflow_start) already surface
77
+ // `error.message` verbatim to the operator, which is why those messages name ids.
78
+ assertSchedulableGraph(input.graph);
79
+ // SECOND guard in the same validation region, above the id mint and above every
80
+ // write: a task naming an agent nobody defined is a create-time error, never a
81
+ // silent downgrade to `baseConfig`. Kept separate from `assertSchedulableGraph`,
82
+ // whose contract is pinned to `(graph) => void` derived from dependencies alone.
83
+ assertKnownAgents({
84
+ graph: input.graph,
85
+ agentPresets: input.agentPresets ?? {},
86
+ workflowId: input.id,
87
+ });
60
88
  const id = input.id ?? randomUUID();
61
89
  const now = new Date().toISOString();
62
90
  const layers = resolveExecutionLayers(input.graph);
@@ -73,6 +101,12 @@ export class WorkflowManager {
73
101
  status: "pending",
74
102
  title: input.title ?? input.graph.goal,
75
103
  labels: input.labels ?? {},
104
+ // Gated on non-empty, not `?? {}`: `saveWorkflow` serialises the record verbatim,
105
+ // so an unconditional spread would add an `"agentPresets": {}` line to every
106
+ // workflow file the daemon has ever written (Story 2.2 AC2).
107
+ ...(input.agentPresets && Object.keys(input.agentPresets).length > 0
108
+ ? { agentPresets: input.agentPresets }
109
+ : {}),
76
110
  phases,
77
111
  childAgentIds: [],
78
112
  totalStoryCount: input.graph.tasks.length,
@@ -85,14 +119,18 @@ export class WorkflowManager {
85
119
  snapshot,
86
120
  graph: input.graph,
87
121
  baseConfig: input.baseConfig,
122
+ agentPresets: input.agentPresets ?? {},
88
123
  parentAgentId: input.parentAgentId,
89
124
  terminalOutcomes: new Map(),
125
+ closeRequestedAgentIds: new Set(),
126
+ spawnedTaskIds: new Set(),
127
+ childTaskIds: new Map(),
90
128
  });
91
129
  this.emit(snapshot);
92
130
  return snapshot;
93
131
  }
94
132
  /**
95
- * List every known workflow storage records overlaid with in-memory snapshots.
133
+ * List every known workflow - storage records overlaid with in-memory snapshots.
96
134
  * Live workflows are returned with progress PROJECTED from current child state
97
135
  * (Story 3.1), so list surfaces show the same live counts as the detail/SSE views.
98
136
  */
@@ -109,7 +147,7 @@ export class WorkflowManager {
109
147
  /**
110
148
  * Resolve a single workflow. A hard miss throws {@link WorkflowNotFoundError}.
111
149
  * For a live workflow the returned snapshot carries progress PROJECTED from the
112
- * current child-agent states the persisted counter is never trusted as truth.
150
+ * current child-agent states - the persisted counter is never trusted as truth.
113
151
  */
114
152
  async getWorkflow(id) {
115
153
  const live = this.workflows.get(id);
@@ -155,7 +193,7 @@ export class WorkflowManager {
155
193
  /**
156
194
  * Return the workflow's recorded lifecycle events, derived deterministically
157
195
  * from its snapshot's timestamps (created/started/completed/archived). A
158
- * one-shot read continuous event push is Epic 2. Throws
196
+ * one-shot read - continuous event push is Epic 2. Throws
159
197
  * {@link WorkflowNotFoundError} for an unknown id.
160
198
  */
161
199
  async getWorkflowEvents(workflowId) {
@@ -163,9 +201,47 @@ export class WorkflowManager {
163
201
  return projectWorkflowEvents(snapshot);
164
202
  }
165
203
  /**
166
- * Drive a workflow to completion of its spawn phase: transition pending -> running,
167
- * then walk `executionLayers` sequentially, spawning each layer's task agents (in
168
- * task order). Returns the updated snapshot.
204
+ * Readiness predicate for the spawn scheduler - a pure function of `live`.
205
+ *
206
+ * Returns, in `graph.tasks` array order (so spawn order is deterministic), the NODE of
207
+ * every task that is not yet spawned, not yet terminal, and whose every declared
208
+ * dependency has already recorded a `"success"` outcome. Nodes, not ids: both callers
209
+ * need the node to spawn it, and returning ids made each of them re-scan `graph.tasks`
210
+ * (an O(n^2) lookup) behind an unreachable `if (!node) continue` guard.
211
+ *
212
+ * Readiness is computed from `TaskNode.dependencies` DIRECTLY, never from
213
+ * `resolveExecutionLayers`, whose declared-layer branch returns
214
+ * `metadata.executionLayers` verbatim and unvalidated, and never from layer output.
215
+ *
216
+ * A dependency id absent from `terminalOutcomes` is NOT satisfied. That single rule
217
+ * also makes an out-of-graph dependency unsatisfiable: a ghost id can never gain a
218
+ * terminal outcome, because outcomes are only ever written for real spawned children.
219
+ * This is a DELIBERATE divergence from `computeLayersFromDependencies`
220
+ * (workflow-progress.ts:268), whose `|| !byId.has(dep)` clause treats an out-of-graph
221
+ * dep as already met so it can keep layering already-stored graphs. Under a scheduler,
222
+ * "satisfied because it does not exist" would launch a task whose stated precondition
223
+ * was never met.
224
+ *
225
+ * Performs no I/O, no clock read and no `agentManager` call.
226
+ */
227
+ readyTaskNodes(live) {
228
+ return live.graph.tasks.filter((node) => !live.spawnedTaskIds.has(node.id) &&
229
+ !live.terminalOutcomes.has(node.id) &&
230
+ node.dependencies.every((dep) => live.terminalOutcomes.get(dep) === "success"));
231
+ }
232
+ /**
233
+ * Drive a workflow's FIRST spawn wave: transition pending -> running, then spawn
234
+ * exactly the tasks that are ready: those whose declared `dependencies` are already
235
+ * satisfied. On a fresh graph that is the dependency-free set; a task that declares a
236
+ * dependency is deliberately NOT launched here, which is what makes
237
+ * `TaskNode.dependencies` a real runtime constraint rather than a decorative field.
238
+ *
239
+ * Parallelism is residual, not declared: two tasks run together because nothing holds
240
+ * them, not because a layer listed them side by side.
241
+ *
242
+ * This method never launches a later wave. Tasks unblocked by a child reaching a
243
+ * terminal state are launched by the continuation path (Story 1.2), driven from
244
+ * `onAgentEvent`, not from here. Returns the updated snapshot.
169
245
  */
170
246
  async startWorkflow(workflowId) {
171
247
  const live = this.requireLive(workflowId);
@@ -178,15 +254,39 @@ export class WorkflowManager {
178
254
  };
179
255
  await this.persist(live);
180
256
  this.emit(live.snapshot);
181
- const layers = resolveExecutionLayers(live.graph);
182
- for (const layer of layers) {
183
- for (const taskId of layer) {
184
- const node = live.graph.tasks.find((task) => task.id === taskId);
185
- if (!node) {
186
- throw new Error(`Task ${taskId} not found in workflow ${workflowId} graph`);
187
- }
188
- await this.spawnPhaseAgent({ workflowId, node, config: live.baseConfig });
257
+ // Claim the WHOLE first wave before the first await, not one id per iteration. The
258
+ // ledger must be authoritative for every readiness check that runs while any
259
+ // `createAgent` is in flight -- and from Story 1.2 onward such checks exist: spawning
260
+ // the first child dispatches `agent_state` synchronously inside `await createAgent`
261
+ // (agent-manager.ts:2848, :2854), which re-enters `onAgentEvent` -> `continueWorkflow`
262
+ // while this loop is still suspended on its first await. Claiming per-iteration would
263
+ // leave the rest of the wave unclaimed at that moment, so continuation would spawn
264
+ // those tasks and this loop would then spawn them a second time.
265
+ const firstWave = this.readyTaskNodes(live);
266
+ for (const node of firstWave) {
267
+ live.spawnedTaskIds.add(node.id);
268
+ }
269
+ for (const node of firstWave) {
270
+ // Re-read the status BETWEEN spawns, never once up front: `cancelWorkflow` mutates
271
+ // this same `live.snapshot` and can land while this loop is suspended on a previous
272
+ // `await`. Without this, cancelling a 3-task wave mid-flight still launched every
273
+ // remaining task into a workflow the caller had already stopped.
274
+ //
275
+ // `revokesInFlightWork`, NOT `isTerminalStatus`: a task failing elsewhere in this
276
+ // same wave must not abandon the independent tasks after it. Only a cancel stops
277
+ // the wave. See {@link revokesInFlightWork}.
278
+ if (revokesInFlightWork(live.snapshot.status)) {
279
+ break;
189
280
  }
281
+ await this.spawnPhaseAgent({
282
+ workflowId,
283
+ node,
284
+ config: resolveTaskConfig({
285
+ node,
286
+ baseConfig: live.baseConfig,
287
+ agentPresets: live.agentPresets,
288
+ }),
289
+ });
190
290
  }
191
291
  return this.requireLive(workflowId).snapshot;
192
292
  }
@@ -195,6 +295,16 @@ export class WorkflowManager {
195
295
  * `agentManager.createAgent` surface, tagging it with WORKFLOW_ID_LABEL (and
196
296
  * PARENT_AGENT_ID_LABEL when the workflow is owned by a parent agent). Records the
197
297
  * child id on the snapshot and persists. Never spawns a process directly.
298
+ *
299
+ * `config` arrives ALREADY RESOLVED by the caller (`resolveTaskConfig`, Story 2.1);
300
+ * this method performs no resolution of its own, so one helper serves both spawn
301
+ * call sites with no duplicated precedence rule.
302
+ *
303
+ * Returns the child on the normal path and `undefined` when the workflow was cancelled
304
+ * mid-spawn and the child was discarded (gate 1.002 NIT-4). The two cases are otherwise
305
+ * indistinguishable to a caller - same type, same shape - and the discarded agent is a
306
+ * corpse: archived, and deliberately absent from `childAgentIds`. `undefined` is the
307
+ * only honest way to say "there is no child here" through a public surface.
198
308
  */
199
309
  async spawnPhaseAgent(args) {
200
310
  const { workflowId, node, config } = args;
@@ -202,18 +312,40 @@ export class WorkflowManager {
202
312
  const labels = {
203
313
  ...live.snapshot.labels,
204
314
  [WORKFLOW_ID_LABEL]: workflowId,
205
- // Story 3.1 map this child to its specific story node so the live progress
315
+ // Story 3.1 - map this child to its specific story node so the live progress
206
316
  // projection can derive completion/phase/gate from real child lifecycle state.
207
317
  [WORKFLOW_TASK_ID_LABEL]: node.id,
208
318
  };
209
319
  if (live.parentAgentId) {
210
320
  labels[PARENT_AGENT_ID_LABEL] = live.parentAgentId;
211
321
  }
322
+ // NOTE: `createAgent`'s options bag DECLARES `initialPrompt` and never reads it
323
+ // (agent-manager.ts:1005 declares, zero readers; `initialTitle` at :1028 is read).
324
+ // Passing it here created agents that sat idle forever with no prompt and no
325
+ // provider session, so a workflow reported `running` while doing nothing. The
326
+ // prompt is sent below, through the same helper the MCP/CLI create path uses.
212
327
  const agent = await this.agentManager.createAgent(config, undefined, {
213
328
  labels,
214
- initialPrompt: node.prompt,
215
329
  initialTitle: node.title,
216
330
  });
331
+ // RECONCILE AFTER THE AWAIT. `createAgent` is the only slow step here, and
332
+ // `cancelWorkflow` mutates this same `live.snapshot` synchronously from another task,
333
+ // so the workflow can be CANCELLED while this spawn is in flight. The pre-spawn checks
334
+ // in `startWorkflow`/`continueWorkflow` cannot see that: they ran before this await.
335
+ // Without this branch a cancelled workflow still gained a live, unarchived child and
336
+ // persisted its id onto the cancelled snapshot.
337
+ //
338
+ // Scoped to cancellation on purpose. A workflow that settles to `failed` while this
339
+ // spawn is in flight keeps the child: settling observes the graph, it does not revoke
340
+ // siblings. See {@link revokesInFlightWork} for why the wider test was a defect.
341
+ if (revokesInFlightWork(live.snapshot.status)) {
342
+ await this.discardOrphanedChild(agent.id, workflowId, node.id);
343
+ return undefined;
344
+ }
345
+ // Bound here, in the same synchronous step as the `childAgentIds` append and after the
346
+ // same cancellation reconcile, so the two can never disagree: every acknowledged child
347
+ // has a task binding, and a discarded orphan has neither.
348
+ live.childTaskIds.set(agent.id, node.id);
217
349
  live.snapshot = {
218
350
  ...live.snapshot,
219
351
  childAgentIds: [...live.snapshot.childAgentIds, agent.id],
@@ -221,10 +353,48 @@ export class WorkflowManager {
221
353
  };
222
354
  await this.persist(live);
223
355
  this.emit(live.snapshot);
356
+ // Start the run only AFTER `childTaskIds` is bound and the snapshot is persisted:
357
+ // a child that terminates immediately would otherwise fire `onAgentEvent` before
358
+ // its task binding existed, and its outcome would be dropped on the floor.
359
+ try {
360
+ await startCreatedAgentInitialPrompt({
361
+ agentManager: this.agentManager,
362
+ agentId: agent.id,
363
+ snapshot: agent,
364
+ prompt: node.prompt,
365
+ logger: this.logger,
366
+ });
367
+ }
368
+ catch (error) {
369
+ // A child that never starts can never reach a terminal outcome, so its
370
+ // dependents never become ready and the workflow stalls at `running`. That is
371
+ // the failure this whole story exists to remove, so it is logged loudly rather
372
+ // than swallowed. The child is left in place and visible.
373
+ this.logger?.error({ err: error, workflowId, taskId: node.id, agentId: agent.id }, "Workflow child agent was created but its initial prompt failed to start");
374
+ }
224
375
  return agent;
225
376
  }
226
377
  /**
227
- * Archive every child agent tagged to the workflow no orphans — mirroring
378
+ * Tear down a child that was created for a workflow which went terminal mid-spawn.
379
+ * Uses `archiveAgent`, the same public surface {@link cascadeArchiveChildren} uses, so
380
+ * the child is closed and marked archived rather than left running for a workflow
381
+ * nobody is watching. The child id is deliberately NOT appended to the snapshot: a
382
+ * cancelled workflow gains no children.
383
+ *
384
+ * Best effort and never throws. It runs on the failure path of a spawn that has already
385
+ * happened; a teardown error must not turn into a rejected `startWorkflow`, nor into an
386
+ * unhandled rejection from the detached continuation IIFE.
387
+ */
388
+ async discardOrphanedChild(agentId, workflowId, taskId) {
389
+ try {
390
+ await this.agentManager.archiveAgent(agentId);
391
+ }
392
+ catch (error) {
393
+ this.logger?.error({ workflowId, taskId, agentId, err: error }, "workflow.spawn.discard_failed");
394
+ }
395
+ }
396
+ /**
397
+ * Archive every child agent tagged to the workflow - no orphans - mirroring
228
398
  * `AgentManager.cascadeArchiveChildren`, but keyed on WORKFLOW_ID_LABEL and using
229
399
  * only the public archive surface (`listAgents` + `archiveAgent`). `archiveAgent`
230
400
  * requires a live agent, so we iterate the live set exactly as the original guards
@@ -247,7 +417,7 @@ export class WorkflowManager {
247
417
  }
248
418
  }
249
419
  /**
250
- * Number of live snapshot subscribers. Read-only observability used by the
420
+ * Number of live snapshot subscribers. Read-only observability - used by the
251
421
  * SSE route's leak-guard test (Story 2.1) to assert that a disconnected client
252
422
  * unsubscribes back to baseline. Additive; does not affect emit behaviour.
253
423
  */
@@ -267,14 +437,33 @@ export class WorkflowManager {
267
437
  subscriber({ workflow });
268
438
  }
269
439
  catch {
270
- // Subscriber error isolation one bad listener never blocks the others.
440
+ // Subscriber error isolation - one bad listener never blocks the others.
271
441
  }
272
442
  }
273
443
  }
274
444
  /**
275
- * AgentManager subscription handler (Story 3.1). Reacts only to child state
276
- * changes for an agent tagged to a tracked, non-terminal workflow; schedules a
277
- * coalesced projection emit for that workflow.
445
+ * AgentManager subscription handler. Reacts only to child state changes for an
446
+ * agent tagged to a tracked, RUNNING workflow (pending, cancelled and finished
447
+ * workflows are all rejected by the early return). It does three things:
448
+ *
449
+ * 1. Records the child's terminal outcome while the event still carries the closed
450
+ * agent (Story 3.1),
451
+ * 2. Drives ORCHESTRATION (Story 1.2): every surviving event sweeps the graph for
452
+ * tasks whose dependencies just became satisfied and spawns them. This is the
453
+ * only continuation path; there is no poll loop and no second `startWorkflow`, and
454
+ * 3. SETTLES the workflow (Story 1.3): once every declared task has a terminal
455
+ * outcome it flips to `completed`, and the first `failed` outcome flips it to
456
+ * `failed`, freezing the projected counts into the snapshot at that instant.
457
+ *
458
+ * Then it schedules a coalesced projection emit for the workflow. Continuation runs
459
+ * ALONGSIDE that emit, never behind it: the 60 ms window throttles display, and
460
+ * routing scheduling through it would add a window's dead time per graph edge.
461
+ *
462
+ * MUST stay synchronous and MUST NOT throw. `AgentManager.dispatch` invokes
463
+ * `subscriber.callback(event)` with no try/catch (agent-manager.ts:3992), so a throw
464
+ * here starves every later subscriber and propagates out of the caller's
465
+ * `await closeAgent(id)`. Making it `async` is the other half of the same bug:
466
+ * `dispatch` ignores the returned promise, so a rejection becomes an unhandled one.
278
467
  */
279
468
  onAgentEvent(event) {
280
469
  if (event.type !== "agent_state") {
@@ -285,14 +474,52 @@ export class WorkflowManager {
285
474
  return;
286
475
  }
287
476
  const live = this.workflows.get(workflowId);
288
- if (!live || isTerminalStatus(live.snapshot.status)) {
477
+ // `!== "running"` rather than `!isTerminalStatus(...)`. Both reject a cancelled or
478
+ // finished workflow; only this one also rejects a PENDING one. That mattered the
479
+ // moment continuation shipped: before it, only `startWorkflow` could spawn, so a
480
+ // stray `agent_state` carrying a pending workflow's WORKFLOW_ID_LABEL was harmless.
481
+ // With continuation, the same event drove a never-started workflow into spawning its
482
+ // first wave - leaving it with children while `status` stayed "pending" and
483
+ // `startedAt` stayed null, so the lifecycle projection disagreed with reality. The
484
+ // label is client-reachable (the WS `agent.create` message and MCP `agent_spawn` both
485
+ // forward arbitrary labels), so this is reachable, not theoretical.
486
+ if (!live || live.snapshot.status !== "running") {
289
487
  return;
290
488
  }
291
489
  // A child that reaches a terminal lifecycle is DELETED from AgentManager's live map
292
490
  // (prepareAgentForClosure), so the live projection would never witness its completion.
293
491
  // Record the terminal outcome now -- keyed by task, while the event still carries the
294
492
  // closed agent -- so projectSnapshot counts it even after the agent is gone.
295
- const taskId = event.agent.labels?.[WORKFLOW_TASK_ID_LABEL];
493
+ //
494
+ // OWNERSHIP GATE (gate 1.003 MAJOR-1, closed the rest of the way by MINOR-4). The task
495
+ // id comes from `live.childTaskIds`, the binding `spawnPhaseAgent` wrote itself, NOT
496
+ // from the event's WORKFLOW_TASK_ID_LABEL. Two separate defects need that:
497
+ //
498
+ // 1. The label is client-SETTABLE at create time (the WS `agent.create` message and
499
+ // MCP `agent_spawn` forward an arbitrary `Record<string, string>`), so any stranger
500
+ // wearing a workflow id plus a task id could write into that workflow's ledger, and
501
+ // since Story 1.3 the ledger decides a TERMINAL status. Reproduced twice: a stranger
502
+ // labelled `task-002` failing its turn drove a healthy workflow to `failed` while
503
+ // task-001's real child was still running, and the same stranger closing cleanly
504
+ // produced `completed` with `completedStoryCount: 2` on a graph whose second task
505
+ // had never been spawned. The terminal early return above makes that lie permanent
506
+ // and persists it to disk.
507
+ // 2. The label stays WRITABLE after spawn (`update_agent_request` merges arbitrary
508
+ // labels onto a live agent), so a membership check on `childAgentIds` alone was not
509
+ // enough: relabelling a workflow's OWN child from `task-001` to `task-002` filed
510
+ // task-001's close under task-002, leaving task-001 in `spawnedTaskIds` with no
511
+ // outcome it can ever gain. `allTerminal` was then unreachable and the workflow sat
512
+ // `running` forever, reporting task-001 pending and task-002 completed with zero
513
+ // live children. Only `cancelWorkflow` could end it.
514
+ //
515
+ // A `childTaskIds` hit IS the ownership proof, so no second membership test is needed:
516
+ // the map is written in the same synchronous step as the `childAgentIds` append, after
517
+ // the cancellation reconcile, so a discarded orphan is absent from both. It also subsumes
518
+ // the out-of-graph case (a key can only ever be a `node.id`), which keeps the graph read
519
+ // in `settleWorkflow` a second layer rather than the only one. The label survives as what
520
+ // it was always good for: display, and the live progress projection's own lookup
521
+ // (`workflow-progress.ts:159`), where a wrong label misdraws a row and settles nothing.
522
+ const taskId = live.childTaskIds.get(event.agent.id);
296
523
  if (taskId !== undefined) {
297
524
  if (event.agent.lifecycle === "closed") {
298
525
  live.terminalOutcomes.set(taskId, event.agent.lastError ? "failed" : "success");
@@ -301,14 +528,177 @@ export class WorkflowManager {
301
528
  live.terminalOutcomes.set(taskId, "failed");
302
529
  }
303
530
  }
531
+ // A paseo agent that finishes its task does NOT reach a terminal lifecycle. It goes
532
+ // `idle` with `requiresAttention: true, attentionReason: "finished"` and stays alive
533
+ // awaiting more input, exactly as an interactive session should. `terminalOutcomes`
534
+ // above only records on `closed`/`error`, so without this the ledger stayed empty,
535
+ // `readyTaskIds` never saw a satisfied dependency, and NO workflow could advance past
536
+ // its first layer or ever settle. Measured against a real daemon: task `first` reached
537
+ // attentionReason "finished" at 14:19:52 and the workflow still read 0/2 minutes later.
538
+ //
539
+ // `loop-service.ts` (`:682`, `:798`) already closes its worker for this same reason;
540
+ // workflows simply never did. Closing here re-enters `onAgentEvent` with `closed`, so
541
+ // the outcome logic above stays the single place a task's result is decided.
542
+ //
543
+ // ONLY on "finished". `"permission"` means a human is being asked something and closing
544
+ // would silently discard the request; `"error"` is already handled by the error branch.
545
+ // `AttentionState` is a union carried alongside the lifecycle union, so narrowing on
546
+ // `lifecycle === "idle"` picks `ManagedAgentIdle` and loses sight of it. Read the two
547
+ // fields through an explicit shape rather than widening the public ManagedAgent type.
548
+ // The LIVE agent nests this as `agent.attention` (agent-manager.ts:1542/1778/3841).
549
+ // The flat `requiresAttention` / `attentionReason` pair only exists in the PERSISTED
550
+ // shape, so reading it off the event silently never matched and the close never fired.
551
+ const attention = event.agent.attention;
552
+ if (taskId !== undefined &&
553
+ event.agent.lifecycle === "idle" &&
554
+ attention?.requiresAttention === true &&
555
+ attention.attentionReason === "finished" &&
556
+ !live.closeRequestedAgentIds.has(event.agent.id)) {
557
+ live.closeRequestedAgentIds.add(event.agent.id);
558
+ const finishedAgentId = event.agent.id;
559
+ void this.agentManager.closeAgent(finishedAgentId).catch((error) => {
560
+ // Leave the id in `closeRequestedAgentIds`: retrying on every subsequent event
561
+ // would hammer a failing close. The task simply gains no outcome, which surfaces
562
+ // as a workflow stuck at `running` rather than a silent wrong result.
563
+ this.logger?.error({ err: error, workflowId, taskId, agentId: finishedAgentId }, "Workflow child finished its turn but could not be closed");
564
+ });
565
+ }
566
+ // Called unconditionally, not only on terminal lifecycles: the readiness sweep is a
567
+ // pure function plus a synchronously guarded spawn, so a non-terminal event is a free
568
+ // no-op. That unconditional call is also what exercises the re-entrancy the ledger
569
+ // insert in `continueWorkflow` is there to absorb.
570
+ this.continueWorkflow(live);
571
+ // Continuation BEFORE settling, per the epic, and provably safe: one `agent_state`
572
+ // event carries exactly one agent, so at most one task becomes terminal per event.
573
+ // A FAILING task satisfies no dependency, so `readyTaskNodes` returns empty on that
574
+ // event and nothing is spawned before the flip.
575
+ //
576
+ // `void ... .catch(...)` is mandatory, not stylistic: an `async` function converts a
577
+ // synchronous throw in its prefix into a rejection, so this single `.catch` covers
578
+ // both a bad projection and a failed storage write. `onAgentEvent` stays
579
+ // `void`-returning and never awaits.
580
+ void this.settleWorkflow(live).catch((error) => {
581
+ this.logger?.error({ workflowId, err: error }, "workflow.settle.failed");
582
+ });
304
583
  this.scheduleProjectionEmit(workflowId);
305
584
  }
585
+ /**
586
+ * Spawn every task whose dependencies have just become satisfied. Takes the ALREADY
587
+ * RESOLVED `LiveWorkflow` rather than a workflow id, so it can never call
588
+ * `requireLive` and can never throw `WorkflowNotFoundError` synchronously into
589
+ * `AgentManager.dispatch`. Returns `void`, never a promise, and never throws.
590
+ *
591
+ * The ENTRY guard deliberately lives in the caller (`onAgentEvent`'s early return), not
592
+ * here: a workflow that is not `running` never reaches this method, so cancellation
593
+ * wins for free with no second entry guard to keep in sync. The re-read inside the
594
+ * detached loop is a different question - not "may I start?" but "is this still true
595
+ * after the last await?" - and it has no equivalent in the caller, which returned long
596
+ * before that loop runs.
597
+ *
598
+ * Readiness comes from `readyTaskNodes`, the single Story 1.1 rule. This method adds no
599
+ * second readiness rule, does not flip the workflow status, does not stamp
600
+ * `completedAt` and does not record an outcome for a task whose spawn failed:
601
+ * inventing a failure outcome here would change the projection's counts.
602
+ */
603
+ continueWorkflow(live) {
604
+ const nodes = this.readyTaskNodes(live);
605
+ if (nodes.length === 0) {
606
+ return;
607
+ }
608
+ for (const node of nodes) {
609
+ // THE idempotency guarantee: claimed synchronously, before any `await` anywhere in
610
+ // this method. Spawning a child dispatches further `agent_state` events for this
611
+ // workflow synchronously inside `await createAgent` (agent-manager.ts:2848, :2854),
612
+ // each of which re-enters `onAgentEvent` while this same spawn is still in flight.
613
+ // With the claim before the await, those re-entrant sweeps see the task already
614
+ // taken and do nothing; after the await, every one of them re-spawns it.
615
+ live.spawnedTaskIds.add(node.id);
616
+ }
617
+ // Detached on purpose: `onAgentEvent` must not become async. One try/catch per node so
618
+ // a single failing spawn never blocks its siblings.
619
+ void (async () => {
620
+ for (const node of nodes) {
621
+ // Same between-spawn re-read as `startWorkflow`'s loop: a cancel landing while
622
+ // this loop is suspended on a previous spawn must stop the remaining ones. A
623
+ // failure settling the workflow does not - see {@link revokesInFlightWork}.
624
+ if (revokesInFlightWork(live.snapshot.status)) {
625
+ return;
626
+ }
627
+ try {
628
+ await this.spawnPhaseAgent({
629
+ workflowId: live.snapshot.id,
630
+ node,
631
+ config: resolveTaskConfig({
632
+ node,
633
+ baseConfig: live.baseConfig,
634
+ agentPresets: live.agentPresets,
635
+ }),
636
+ });
637
+ }
638
+ catch (error) {
639
+ this.logger?.error({ workflowId: live.snapshot.id, taskId: node.id, err: error }, "workflow.continue.spawn_failed");
640
+ }
641
+ }
642
+ })();
643
+ }
644
+ /**
645
+ * Settle the workflow to a terminal status once its graph has finished. Takes the
646
+ * ALREADY RESOLVED `LiveWorkflow`, never a workflow id, so it can never call
647
+ * `requireLive` and can never throw `WorkflowNotFoundError` into
648
+ * `AgentManager.dispatch`.
649
+ *
650
+ * The rule is read THROUGH `graph.tasks`, not off `terminalOutcomes.values()`. With the
651
+ * ledger keyed on `live.childTaskIds` (gate 1.003 MINOR-4) an out-of-graph key can no
652
+ * longer enter the map at all, so this is a second layer rather than the only one; it is
653
+ * kept because it costs nothing and it is the layer that survives if the key ever becomes
654
+ * derivable from an event again. Mapping the declared tasks over the map also means a
655
+ * partially populated ledger reads as "not finished" rather than as "finished".
656
+ *
657
+ * Everything up to the first `await` is synchronous ON PURPOSE. `AgentManager.dispatch`
658
+ * is a plain synchronous loop invoked from inside `await closeAgent(...)`, so the status
659
+ * flip and the frozen counts are committed to memory before that expression resolves -
660
+ * which is what lets a caller read the terminal snapshot on the very next line with no
661
+ * polling. Only `persist` and `emit` are asynchronous.
662
+ *
663
+ * Children are never cancelled, closed, archived or interrupted here: an independent
664
+ * in-flight sibling keeps running after a failure.
665
+ */
666
+ async settleWorkflow(live) {
667
+ // pending never has children, and a terminal workflow is already filtered by the
668
+ // early return in `onAgentEvent`. Belt and braces, one line.
669
+ if (live.snapshot.status !== "running") {
670
+ return;
671
+ }
672
+ const outcomes = live.graph.tasks.map((task) => live.terminalOutcomes.get(task.id));
673
+ const anyFailed = outcomes.some((outcome) => outcome === "failed");
674
+ const allTerminal = outcomes.every((outcome) => outcome !== undefined);
675
+ if (!anyFailed && !allTerminal) {
676
+ return;
677
+ }
678
+ // FREEZE THE COUNTS FIRST. `projectSnapshot` short-circuits on a terminal status and
679
+ // returns `live.snapshot` verbatim, whose `completedStoryCount` is still the literal
680
+ // `0` written at createWorkflow - every live count a reader has ever seen came from
681
+ // the projection overlay, never from that stored field. So project WHILE STILL
682
+ // RUNNING, then flip. Spreading `live.snapshot` here instead compiles, type-checks
683
+ // and lints, and silently zeroes every finished workflow's progress.
684
+ const projected = this.projectSnapshot(live);
685
+ const now = new Date().toISOString();
686
+ live.snapshot = {
687
+ ...projected,
688
+ status: anyFailed ? "failed" : "completed",
689
+ completedAt: now,
690
+ updatedAt: now,
691
+ };
692
+ // Persist then emit - the same order as `cancelWorkflow`.
693
+ await this.persist(live);
694
+ this.emit(live.snapshot);
695
+ }
306
696
  /**
307
697
  * Coalesce a burst of child updates into ONE projection emit per settled window
308
698
  * (the `agent-stream-coalescer.ts` discipline). The first event in a burst arms a
309
699
  * timer; subsequent events within the window are absorbed. On fire, the current
310
700
  * child states are projected and the updated snapshot is emitted through the
311
- * existing `workflow.*` / HTTP / SSE channels no new transport, no polling.
701
+ * existing `workflow.*` / HTTP / SSE channels - no new transport, no polling.
312
702
  */
313
703
  scheduleProjectionEmit(workflowId) {
314
704
  if (this.projectionTimers.has(workflowId)) {
@@ -328,7 +718,7 @@ export class WorkflowManager {
328
718
  /**
329
719
  * Overlay LIVE derived progress onto a workflow's base snapshot. Counts, per-phase
330
720
  * gate status, and per-phase completion are recomputed from the current child-agent
331
- * states never read back from the persisted snapshot. Terminal workflows are
721
+ * states - never read back from the persisted snapshot. Terminal workflows are
332
722
  * returned unchanged (nothing left to project). The base snapshot's lifecycle
333
723
  * fields (status, childAgentIds, timestamps, id/title) are preserved verbatim; only
334
724
  * derived progress fields are overlaid, so this stays back-compat and additive.
@@ -351,7 +741,7 @@ export class WorkflowManager {
351
741
  taskGraph: live.graph,
352
742
  terminalOutcomes: live.terminalOutcomes,
353
743
  });
354
- // Overlay by index preserve each phase's persisted id/title, refresh its gate.
744
+ // Overlay by index - preserve each phase's persisted id/title, refresh its gate.
355
745
  const phases = base.phases.map((phase, index) => {
356
746
  const projected = projectedPhases[index];
357
747
  if (!projected) {
@@ -386,6 +776,24 @@ export class WorkflowManager {
386
776
  function isTerminalStatus(status) {
387
777
  return status === "completed" || status === "failed" || status === "cancelled";
388
778
  }
779
+ /**
780
+ * The ONE status that revokes work already in motion. Deliberately narrower than
781
+ * {@link isTerminalStatus}: cancellation is the operator saying "stop", so a spawn still
782
+ * parked inside `createAgent` when it lands is work nobody asked for and gets discarded.
783
+ *
784
+ * A `failed` or `completed` workflow is the opposite case. Settling is an OBSERVATION
785
+ * about the graph, not an instruction about the children, and Story 1.3's contract is
786
+ * explicit that a failure leaves independent siblings alone ("Children are never
787
+ * cancelled, closed, archived or interrupted here" - see {@link WorkflowManager.settleWorkflow}).
788
+ * Keying the spawn-abort on `isTerminalStatus` made that contract depend on the clock:
789
+ * with a fast provider both wave-1 children landed before the failure and survived, while
790
+ * at real provider latency (hundreds of ms per PTY) the in-flight sibling was archived
791
+ * mid-launch and the untouched rest of the wave was never asked for at all - a different
792
+ * surviving work set for the same graph and the same failure. Gate 1.002 MAJOR-3.
793
+ */
794
+ function revokesInFlightWork(status) {
795
+ return status === "cancelled";
796
+ }
389
797
  /** The lifecycle-event type that corresponds to a terminal status. */
390
798
  function terminalEventType(status) {
391
799
  switch (status) {
@@ -401,7 +809,7 @@ function terminalEventType(status) {
401
809
  }
402
810
  /**
403
811
  * Derive the ordered lifecycle-event list from a snapshot's timestamps. Pure and
404
- * deterministic the same snapshot always yields the same events.
812
+ * deterministic - the same snapshot always yields the same events.
405
813
  */
406
814
  function projectWorkflowEvents(snapshot) {
407
815
  const events = [