@hyperdrive.bot/paseo-server 0.3.41 → 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.
- package/dist/server/server/agent/agent-manager.d.ts +15 -0
- package/dist/server/server/agent/agent-manager.js +142 -25
- package/dist/server/server/agent/mcp-server.js +6 -1
- package/dist/server/server/agent/providers/claude/background-task-tracker.d.ts +38 -1
- package/dist/server/server/agent/providers/claude/background-task-tracker.js +114 -9
- package/dist/server/server/agent/providers/claude/background-work-kinds.d.ts +95 -0
- package/dist/server/server/agent/providers/claude/background-work-kinds.js +73 -0
- package/dist/server/server/agent/providers/claude/pty-session-launcher.js +3 -0
- package/dist/server/server/agent/providers/claude/transport/pty.d.ts +17 -0
- package/dist/server/server/agent/providers/claude/transport/pty.js +51 -1
- package/dist/server/server/agent/providers/claude/transport/tmux.d.ts +74 -0
- package/dist/server/server/agent/providers/claude/transport/tmux.js +157 -0
- package/dist/server/server/agent/providers/claude/transport/types.d.ts +6 -0
- package/dist/server/server/agent/providers/opencode-agent.d.ts +7 -0
- package/dist/server/server/agent/providers/opencode-agent.js +51 -1
- package/dist/server/server/bootstrap.js +1 -0
- package/dist/server/server/session.js +1 -0
- package/dist/server/server/workflow/workflow-agent-resolution.d.ts +82 -0
- package/dist/server/server/workflow/workflow-agent-resolution.js +105 -0
- package/dist/server/server/workflow/workflow-manager.d.ts +155 -20
- package/dist/server/server/workflow/workflow-manager.js +439 -31
- package/dist/server/server/workflow/workflow-progress.d.ts +53 -0
- package/dist/server/server/workflow/workflow-progress.js +96 -0
- package/dist/server/server/workspace-directory.js +32 -14
- package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js → index-2ff48a7009ad2309c577d5a43b925f69.js} +18 -18
- package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.br → index-2ff48a7009ad2309c577d5a43b925f69.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz → index-2ff48a7009ad2309c577d5a43b925f69.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
-
|
|
182
|
-
for
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
440
|
+
// Subscriber error isolation - one bad listener never blocks the others.
|
|
271
441
|
}
|
|
272
442
|
}
|
|
273
443
|
}
|
|
274
444
|
/**
|
|
275
|
-
* AgentManager subscription handler
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
812
|
+
* deterministic - the same snapshot always yields the same events.
|
|
405
813
|
*/
|
|
406
814
|
function projectWorkflowEvents(snapshot) {
|
|
407
815
|
const events = [
|