@cleocode/playbooks 2026.4.92 → 2026.4.94

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": "@cleocode/playbooks",
3
- "version": "2026.4.92",
3
+ "version": "2026.4.94",
4
4
  "description": "Playbook DSL + runtime for CLEO — T889 Orchestration Coherence v3",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,13 +13,14 @@
13
13
  },
14
14
  "files": [
15
15
  "dist/",
16
- "src/"
16
+ "src/",
17
+ "starter/"
17
18
  ],
18
19
  "dependencies": {
19
20
  "drizzle-orm": "1.0.0-beta.19-d95b7a4",
20
21
  "js-yaml": "^4.1.0",
21
- "@cleocode/core": "2026.4.92",
22
- "@cleocode/contracts": "2026.4.92"
22
+ "@cleocode/contracts": "2026.4.94",
23
+ "@cleocode/core": "2026.4.94"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@types/js-yaml": "^4.0.9",
@@ -0,0 +1,678 @@
1
+ /**
2
+ * W4-10 / T930 runtime state machine tests.
3
+ *
4
+ * Every test runs against a real in-memory `node:sqlite` DB with the T889
5
+ * migration applied. `AgentDispatcher` and `DeterministicRunner` are stubbed
6
+ * inline — no `@cleocode/*` modules are mocked.
7
+ *
8
+ * @task T930 — Playbook Runtime State Machine
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs';
12
+ import { createRequire } from 'node:module';
13
+ import { dirname, resolve } from 'node:path';
14
+ import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
15
+ import { fileURLToPath } from 'node:url';
16
+ import type {
17
+ PlaybookAgenticNode,
18
+ PlaybookApprovalNode,
19
+ PlaybookDefinition,
20
+ PlaybookDeterministicNode,
21
+ } from '@cleocode/contracts';
22
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
+ import { approveGate, rejectGate } from '../approval.js';
24
+ import {
25
+ type AgentDispatcher,
26
+ type AgentDispatchInput,
27
+ type AgentDispatchResult,
28
+ type DeterministicRunInput,
29
+ type DeterministicRunner,
30
+ type DeterministicRunResult,
31
+ E_PLAYBOOK_RESUME_BLOCKED,
32
+ executePlaybook,
33
+ resumePlaybook,
34
+ } from '../runtime.js';
35
+ import { getPlaybookRun, listPlaybookApprovals } from '../state.js';
36
+
37
+ const _require = createRequire(import.meta.url);
38
+ type DatabaseSync = _DatabaseSyncType;
39
+ const { DatabaseSync } = _require('node:sqlite') as {
40
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
41
+ };
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+ const MIGRATION_SQL = resolve(
45
+ __dirname,
46
+ '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
47
+ );
48
+
49
+ function applyMigration(db: DatabaseSync, sql: string): void {
50
+ const statements = sql
51
+ .split(/--> statement-breakpoint/)
52
+ .map((s) => s.trim())
53
+ .filter((s) => s.length > 0);
54
+ for (const stmt of statements) {
55
+ const lines = stmt.split('\n');
56
+ const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
57
+ if (hasSql) db.exec(stmt);
58
+ }
59
+ }
60
+
61
+ // -- Dispatcher stub helpers -------------------------------------------------
62
+
63
+ interface RecordedCall {
64
+ nodeId: string;
65
+ agentId: string;
66
+ iteration: number;
67
+ contextSnapshot: Record<string, unknown>;
68
+ }
69
+
70
+ function makeRecordingDispatcher(
71
+ handler: (input: AgentDispatchInput) => AgentDispatchResult | Promise<AgentDispatchResult>,
72
+ ): AgentDispatcher & { calls: RecordedCall[] } {
73
+ const calls: RecordedCall[] = [];
74
+ const dispatcher: AgentDispatcher & { calls: RecordedCall[] } = {
75
+ calls,
76
+ async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
77
+ calls.push({
78
+ nodeId: input.nodeId,
79
+ agentId: input.agentId,
80
+ iteration: input.iteration,
81
+ contextSnapshot: { ...input.context },
82
+ });
83
+ return handler(input);
84
+ },
85
+ };
86
+ return dispatcher;
87
+ }
88
+
89
+ function makeRecordingRunner(
90
+ handler: (
91
+ input: DeterministicRunInput,
92
+ ) => DeterministicRunResult | Promise<DeterministicRunResult>,
93
+ ): DeterministicRunner & { calls: DeterministicRunInput[] } {
94
+ const calls: DeterministicRunInput[] = [];
95
+ const runner: DeterministicRunner & { calls: DeterministicRunInput[] } = {
96
+ calls,
97
+ async run(input: DeterministicRunInput): Promise<DeterministicRunResult> {
98
+ calls.push({ ...input, args: [...input.args] });
99
+ return handler(input);
100
+ },
101
+ };
102
+ return runner;
103
+ }
104
+
105
+ // -- Canonical playbook shapes ----------------------------------------------
106
+
107
+ function agenticNode(
108
+ id: string,
109
+ overrides: Partial<PlaybookAgenticNode> = {},
110
+ ): PlaybookAgenticNode {
111
+ return {
112
+ id,
113
+ type: 'agentic',
114
+ skill: `skill-${id}`,
115
+ ...overrides,
116
+ };
117
+ }
118
+
119
+ function approvalNode(id: string, prompt = `approve ${id}`): PlaybookApprovalNode {
120
+ return { id, type: 'approval', prompt };
121
+ }
122
+
123
+ function deterministicNode(
124
+ id: string,
125
+ command = 'pnpm',
126
+ args: string[] = ['biome', 'ci', '.'],
127
+ overrides: Partial<PlaybookDeterministicNode> = {},
128
+ ): PlaybookDeterministicNode {
129
+ return {
130
+ id,
131
+ type: 'deterministic',
132
+ command,
133
+ args,
134
+ ...overrides,
135
+ };
136
+ }
137
+
138
+ function linearPlaybook(name: string, ids: string[]): PlaybookDefinition {
139
+ const nodes: PlaybookDefinition['nodes'] = ids.map((id) => agenticNode(id));
140
+ const edges: PlaybookDefinition['edges'] = [];
141
+ for (let i = 0; i < ids.length - 1; i += 1) {
142
+ const from = ids[i];
143
+ const to = ids[i + 1];
144
+ if (from === undefined || to === undefined) continue;
145
+ edges.push({ from, to });
146
+ }
147
+ return { version: '1.0', name, nodes, edges };
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe('W4-10 / T930: playbook runtime state machine', () => {
153
+ let db: DatabaseSync;
154
+
155
+ beforeEach(() => {
156
+ db = new DatabaseSync(':memory:');
157
+ db.exec('PRAGMA foreign_keys=ON');
158
+ applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
159
+ });
160
+ afterEach(() => db.close());
161
+
162
+ // 1 ------------------------------------------------------------------------
163
+ it('success path: linear agentic playbook completes with merged context', async () => {
164
+ const playbook = linearPlaybook('linear', ['a', 'b', 'c']);
165
+ const dispatcher = makeRecordingDispatcher((input) => ({
166
+ status: 'success',
167
+ output: { [`${input.nodeId}_done`]: true, lastNode: input.nodeId },
168
+ }));
169
+
170
+ const result = await executePlaybook({
171
+ db,
172
+ playbook,
173
+ playbookHash: 'hash-1',
174
+ initialContext: { taskId: 'T123' },
175
+ dispatcher,
176
+ });
177
+
178
+ expect(result.terminalStatus).toBe('completed');
179
+ expect(result.finalContext).toMatchObject({
180
+ taskId: 'T123',
181
+ a_done: true,
182
+ b_done: true,
183
+ c_done: true,
184
+ lastNode: 'c',
185
+ });
186
+ expect(dispatcher.calls.map((c) => c.nodeId)).toEqual(['a', 'b', 'c']);
187
+ expect(dispatcher.calls.map((c) => c.agentId)).toEqual(['skill-a', 'skill-b', 'skill-c']);
188
+ // Every call saw the accumulated context up to that point
189
+ expect(dispatcher.calls[0]?.contextSnapshot).toMatchObject({ taskId: 'T123' });
190
+ expect(dispatcher.calls[1]?.contextSnapshot).toMatchObject({ taskId: 'T123', a_done: true });
191
+ expect(dispatcher.calls[2]?.contextSnapshot).toMatchObject({
192
+ taskId: 'T123',
193
+ a_done: true,
194
+ b_done: true,
195
+ });
196
+
197
+ const run = getPlaybookRun(db, result.runId);
198
+ expect(run?.status).toBe('completed');
199
+ expect(run?.currentNode).toBeNull();
200
+ expect(run?.completedAt).toBeTruthy();
201
+ });
202
+
203
+ // 2 ------------------------------------------------------------------------
204
+ it('iteration cap: retries up to cap then terminates with exceeded_iteration_cap', async () => {
205
+ const playbook: PlaybookDefinition = {
206
+ version: '1.0',
207
+ name: 'cap-test',
208
+ nodes: [agenticNode('a', { on_failure: { max_iterations: 2 } }), agenticNode('b')],
209
+ edges: [{ from: 'a', to: 'b' }],
210
+ };
211
+ const dispatcher = makeRecordingDispatcher(() => ({
212
+ status: 'failure',
213
+ output: {},
214
+ error: 'always fails',
215
+ }));
216
+
217
+ const result = await executePlaybook({
218
+ db,
219
+ playbook,
220
+ playbookHash: 'hash-2',
221
+ initialContext: {},
222
+ dispatcher,
223
+ });
224
+
225
+ expect(result.terminalStatus).toBe('exceeded_iteration_cap');
226
+ expect(result.exceededNodeId).toBe('a');
227
+ expect(result.errorContext).toBe('always fails');
228
+ // Node a attempted exactly 2 times (its configured cap), b never executed.
229
+ expect(dispatcher.calls.filter((c) => c.nodeId === 'a')).toHaveLength(2);
230
+ expect(dispatcher.calls.filter((c) => c.nodeId === 'b')).toHaveLength(0);
231
+
232
+ const run = getPlaybookRun(db, result.runId);
233
+ expect(run?.status).toBe('failed');
234
+ expect(run?.iterationCounts['a']).toBe(2);
235
+ });
236
+
237
+ // 3 ------------------------------------------------------------------------
238
+ it('approval-pending: paused at approval node with persisted resume token', async () => {
239
+ const playbook: PlaybookDefinition = {
240
+ version: '1.0',
241
+ name: 'approval-test',
242
+ nodes: [agenticNode('research'), approvalNode('gate'), agenticNode('ship')],
243
+ edges: [
244
+ { from: 'research', to: 'gate' },
245
+ { from: 'gate', to: 'ship' },
246
+ ],
247
+ };
248
+ const dispatcher = makeRecordingDispatcher(() => ({
249
+ status: 'success',
250
+ output: { step: 'done' },
251
+ }));
252
+
253
+ const result = await executePlaybook({
254
+ db,
255
+ playbook,
256
+ playbookHash: 'hash-3',
257
+ initialContext: { taskId: 'T500' },
258
+ dispatcher,
259
+ approvalSecret: 'unit-test-secret',
260
+ });
261
+
262
+ expect(result.terminalStatus).toBe('pending_approval');
263
+ expect(result.approvalToken).toBeDefined();
264
+ expect(result.approvalToken).toMatch(/^[0-9a-f]{32}$/);
265
+ // Only the research node executed; ship is gated.
266
+ expect(dispatcher.calls.map((c) => c.nodeId)).toEqual(['research']);
267
+
268
+ const approvals = listPlaybookApprovals(db, result.runId);
269
+ expect(approvals).toHaveLength(1);
270
+ expect(approvals[0]?.status).toBe('pending');
271
+ expect(approvals[0]?.token).toBe(result.approvalToken);
272
+ expect(approvals[0]?.nodeId).toBe('gate');
273
+
274
+ const run = getPlaybookRun(db, result.runId);
275
+ expect(run?.status).toBe('paused');
276
+ });
277
+
278
+ // 4 ------------------------------------------------------------------------
279
+ it('resume: after approval, execution continues past the gate to completion', async () => {
280
+ const playbook: PlaybookDefinition = {
281
+ version: '1.0',
282
+ name: 'resume-test',
283
+ nodes: [agenticNode('plan'), approvalNode('gate'), agenticNode('ship')],
284
+ edges: [
285
+ { from: 'plan', to: 'gate' },
286
+ { from: 'gate', to: 'ship' },
287
+ ],
288
+ };
289
+ const dispatcher = makeRecordingDispatcher((input) => ({
290
+ status: 'success',
291
+ output: { [`${input.nodeId}_ok`]: true },
292
+ }));
293
+
294
+ const first = await executePlaybook({
295
+ db,
296
+ playbook,
297
+ playbookHash: 'hash-4',
298
+ initialContext: { taskId: 'T777' },
299
+ dispatcher,
300
+ approvalSecret: 'unit-test-secret',
301
+ });
302
+ expect(first.terminalStatus).toBe('pending_approval');
303
+ if (first.approvalToken === undefined) throw new Error('expected approval token');
304
+
305
+ // Human approves.
306
+ approveGate(db, first.approvalToken, 'keaton@cleo', 'LGTM');
307
+
308
+ const second = await resumePlaybook({
309
+ db,
310
+ playbook,
311
+ approvalToken: first.approvalToken,
312
+ dispatcher,
313
+ approvalSecret: 'unit-test-secret',
314
+ });
315
+
316
+ expect(second.terminalStatus).toBe('completed');
317
+ expect(second.finalContext).toMatchObject({
318
+ taskId: 'T777',
319
+ plan_ok: true,
320
+ ship_ok: true,
321
+ });
322
+ // 'ship' executed after resume; 'plan' should not have re-executed.
323
+ const shipCalls = dispatcher.calls.filter((c) => c.nodeId === 'ship');
324
+ const planCalls = dispatcher.calls.filter((c) => c.nodeId === 'plan');
325
+ expect(shipCalls).toHaveLength(1);
326
+ expect(planCalls).toHaveLength(1);
327
+
328
+ const run = getPlaybookRun(db, first.runId);
329
+ expect(run?.status).toBe('completed');
330
+ });
331
+
332
+ // 5 ------------------------------------------------------------------------
333
+ it('failure propagation: cap=0 terminates on first failure with failedNodeId', async () => {
334
+ const playbook: PlaybookDefinition = {
335
+ version: '1.0',
336
+ name: 'fail-prop',
337
+ nodes: [agenticNode('a', { on_failure: { max_iterations: 0 } }), agenticNode('b')],
338
+ edges: [{ from: 'a', to: 'b' }],
339
+ };
340
+ const dispatcher = makeRecordingDispatcher(() => ({
341
+ status: 'failure',
342
+ output: {},
343
+ error: 'bad news',
344
+ }));
345
+
346
+ const result = await executePlaybook({
347
+ db,
348
+ playbook,
349
+ playbookHash: 'hash-5',
350
+ initialContext: {},
351
+ dispatcher,
352
+ });
353
+
354
+ expect(result.terminalStatus).toBe('exceeded_iteration_cap');
355
+ expect(result.exceededNodeId).toBe('a');
356
+ expect(result.errorContext).toBe('bad news');
357
+ // With cap=0, dispatcher still runs once before the cap trips.
358
+ expect(dispatcher.calls).toHaveLength(1);
359
+ });
360
+
361
+ // 6 ------------------------------------------------------------------------
362
+ it('invalid node: multi-successor fan-out throws runtime invariant error', async () => {
363
+ const playbook: PlaybookDefinition = {
364
+ version: '1.0',
365
+ name: 'fanout',
366
+ nodes: [agenticNode('a'), agenticNode('b'), agenticNode('c')],
367
+ edges: [
368
+ { from: 'a', to: 'b' },
369
+ { from: 'a', to: 'c' },
370
+ ],
371
+ };
372
+ const dispatcher = makeRecordingDispatcher(() => ({ status: 'success', output: {} }));
373
+
374
+ await expect(
375
+ executePlaybook({
376
+ db,
377
+ playbook,
378
+ playbookHash: 'hash-6',
379
+ initialContext: {},
380
+ dispatcher,
381
+ }),
382
+ ).rejects.toThrow(/has 2 successors/);
383
+ });
384
+
385
+ // 7 ------------------------------------------------------------------------
386
+ it('resume-blocked: pending token is rejected with E_PLAYBOOK_RESUME_BLOCKED', async () => {
387
+ const playbook: PlaybookDefinition = {
388
+ version: '1.0',
389
+ name: 'resume-pending',
390
+ nodes: [agenticNode('a'), approvalNode('gate'), agenticNode('b')],
391
+ edges: [
392
+ { from: 'a', to: 'gate' },
393
+ { from: 'gate', to: 'b' },
394
+ ],
395
+ };
396
+ const dispatcher = makeRecordingDispatcher(() => ({ status: 'success', output: {} }));
397
+
398
+ const first = await executePlaybook({
399
+ db,
400
+ playbook,
401
+ playbookHash: 'hash-7',
402
+ initialContext: {},
403
+ dispatcher,
404
+ approvalSecret: 'unit-test-secret',
405
+ });
406
+ if (first.approvalToken === undefined) throw new Error('expected token');
407
+
408
+ // Do NOT approve the gate — resume should fail.
409
+ await expect(
410
+ resumePlaybook({
411
+ db,
412
+ playbook,
413
+ approvalToken: first.approvalToken,
414
+ dispatcher,
415
+ approvalSecret: 'unit-test-secret',
416
+ }),
417
+ ).rejects.toThrow(new RegExp(E_PLAYBOOK_RESUME_BLOCKED));
418
+ });
419
+
420
+ // 8 ------------------------------------------------------------------------
421
+ it('resume-blocked: rejected gate raises and marks run failed', async () => {
422
+ const playbook: PlaybookDefinition = {
423
+ version: '1.0',
424
+ name: 'resume-rejected',
425
+ nodes: [agenticNode('a'), approvalNode('gate'), agenticNode('b')],
426
+ edges: [
427
+ { from: 'a', to: 'gate' },
428
+ { from: 'gate', to: 'b' },
429
+ ],
430
+ };
431
+ const dispatcher = makeRecordingDispatcher(() => ({ status: 'success', output: {} }));
432
+
433
+ const first = await executePlaybook({
434
+ db,
435
+ playbook,
436
+ playbookHash: 'hash-8',
437
+ initialContext: {},
438
+ dispatcher,
439
+ approvalSecret: 'unit-test-secret',
440
+ });
441
+ if (first.approvalToken === undefined) throw new Error('expected token');
442
+
443
+ rejectGate(db, first.approvalToken, 'auditor', 'not yet');
444
+
445
+ await expect(
446
+ resumePlaybook({
447
+ db,
448
+ playbook,
449
+ approvalToken: first.approvalToken,
450
+ dispatcher,
451
+ approvalSecret: 'unit-test-secret',
452
+ }),
453
+ ).rejects.toThrow(/was rejected/);
454
+
455
+ const run = getPlaybookRun(db, first.runId);
456
+ expect(run?.status).toBe('failed');
457
+ expect(run?.errorContext).toBe('not yet');
458
+ });
459
+
460
+ // 9 ------------------------------------------------------------------------
461
+ it('context propagation: each node sees prior outputs; dispatcher receives iteration=1 on success', async () => {
462
+ const playbook = linearPlaybook('ctx', ['a', 'b', 'c']);
463
+ const dispatcher = makeRecordingDispatcher((input) => {
464
+ if (input.nodeId === 'a') return { status: 'success', output: { alpha: 1 } };
465
+ if (input.nodeId === 'b') return { status: 'success', output: { beta: 2 } };
466
+ return { status: 'success', output: { gamma: 3 } };
467
+ });
468
+
469
+ const result = await executePlaybook({
470
+ db,
471
+ playbook,
472
+ playbookHash: 'hash-9',
473
+ initialContext: { taskId: 'T42', seed: 'keaton' },
474
+ dispatcher,
475
+ });
476
+
477
+ expect(result.terminalStatus).toBe('completed');
478
+ expect(result.finalContext).toMatchObject({
479
+ taskId: 'T42',
480
+ seed: 'keaton',
481
+ alpha: 1,
482
+ beta: 2,
483
+ gamma: 3,
484
+ });
485
+ expect(dispatcher.calls.map((c) => c.iteration)).toEqual([1, 1, 1]);
486
+ // Node 'c' observes alpha + beta from prior nodes.
487
+ expect(dispatcher.calls[2]?.contextSnapshot).toMatchObject({ alpha: 1, beta: 2 });
488
+ // Context passed to dispatcher is a defensive copy — mutating it cannot
489
+ // leak back into the runtime's bookkeeping.
490
+ expect(dispatcher.calls[0]?.contextSnapshot).toMatchObject({ taskId: 'T42' });
491
+ });
492
+
493
+ // 10 -----------------------------------------------------------------------
494
+ it('parallel-safe: two runs on the same DB produce independent iterations', async () => {
495
+ const playbook = linearPlaybook('parallel', ['a', 'b']);
496
+ const dispatcher = makeRecordingDispatcher((input) => ({
497
+ status: 'success',
498
+ output: { who: input.runId, where: input.nodeId },
499
+ }));
500
+
501
+ const [r1, r2] = await Promise.all([
502
+ executePlaybook({
503
+ db,
504
+ playbook,
505
+ playbookHash: 'hash-10-a',
506
+ initialContext: { taskId: 'T1' },
507
+ dispatcher,
508
+ }),
509
+ executePlaybook({
510
+ db,
511
+ playbook,
512
+ playbookHash: 'hash-10-b',
513
+ initialContext: { taskId: 'T2' },
514
+ dispatcher,
515
+ }),
516
+ ]);
517
+
518
+ expect(r1.runId).not.toBe(r2.runId);
519
+ expect(r1.terminalStatus).toBe('completed');
520
+ expect(r2.terminalStatus).toBe('completed');
521
+ expect(r1.finalContext['taskId']).toBe('T1');
522
+ expect(r2.finalContext['taskId']).toBe('T2');
523
+
524
+ // Both runs persisted independently.
525
+ const run1 = getPlaybookRun(db, r1.runId);
526
+ const run2 = getPlaybookRun(db, r2.runId);
527
+ expect(run1?.status).toBe('completed');
528
+ expect(run2?.status).toBe('completed');
529
+ expect(run1?.bindings['taskId']).toBe('T1');
530
+ expect(run2?.bindings['taskId']).toBe('T2');
531
+ });
532
+
533
+ // 11 -----------------------------------------------------------------------
534
+ it('policy eval: deterministic node is executed via injected runner with full args', async () => {
535
+ const playbook: PlaybookDefinition = {
536
+ version: '1.0',
537
+ name: 'deterministic',
538
+ nodes: [
539
+ agenticNode('plan'),
540
+ deterministicNode('lint', 'pnpm', ['biome', 'ci', '.'], {
541
+ timeout_ms: 60000,
542
+ cwd: '/mnt/projects/cleocode',
543
+ env: { CI: 'true' },
544
+ }),
545
+ ],
546
+ edges: [{ from: 'plan', to: 'lint' }],
547
+ };
548
+ const dispatcher = makeRecordingDispatcher(() => ({ status: 'success', output: {} }));
549
+ const runner = makeRecordingRunner(() => ({
550
+ status: 'success',
551
+ output: { lintExitCode: 0, lintPassed: true },
552
+ }));
553
+
554
+ const result = await executePlaybook({
555
+ db,
556
+ playbook,
557
+ playbookHash: 'hash-11',
558
+ initialContext: { taskId: 'T930' },
559
+ dispatcher,
560
+ deterministicRunner: runner,
561
+ });
562
+
563
+ expect(result.terminalStatus).toBe('completed');
564
+ expect(result.finalContext).toMatchObject({ lintExitCode: 0, lintPassed: true });
565
+ expect(runner.calls).toHaveLength(1);
566
+ expect(runner.calls[0]).toMatchObject({
567
+ nodeId: 'lint',
568
+ command: 'pnpm',
569
+ args: ['biome', 'ci', '.'],
570
+ cwd: '/mnt/projects/cleocode',
571
+ env: { CI: 'true' },
572
+ timeout_ms: 60000,
573
+ iteration: 1,
574
+ });
575
+ });
576
+
577
+ // 12 -----------------------------------------------------------------------
578
+ it('inject_into: node retries via another node when on_failure.inject_into is set', async () => {
579
+ const playbook: PlaybookDefinition = {
580
+ version: '1.0',
581
+ name: 'inject',
582
+ nodes: [
583
+ agenticNode('hint'),
584
+ agenticNode('worker', {
585
+ on_failure: { max_iterations: 2, inject_into: 'hint' },
586
+ }),
587
+ agenticNode('finalize'),
588
+ ],
589
+ edges: [
590
+ { from: 'hint', to: 'worker' },
591
+ { from: 'worker', to: 'finalize' },
592
+ ],
593
+ };
594
+
595
+ // First worker call fails once; after reinjection, succeeds.
596
+ let workerCalls = 0;
597
+ const dispatcher = makeRecordingDispatcher((input) => {
598
+ if (input.nodeId === 'worker') {
599
+ workerCalls += 1;
600
+ if (workerCalls === 1) return { status: 'failure', output: {}, error: 'worker bust' };
601
+ return { status: 'success', output: { workerDone: true } };
602
+ }
603
+ return { status: 'success', output: { [input.nodeId]: 'ok' } };
604
+ });
605
+
606
+ const result = await executePlaybook({
607
+ db,
608
+ playbook,
609
+ playbookHash: 'hash-12',
610
+ initialContext: {},
611
+ dispatcher,
612
+ });
613
+
614
+ expect(result.terminalStatus).toBe('completed');
615
+ expect(result.finalContext).toMatchObject({
616
+ workerDone: true,
617
+ __lastError: 'worker bust',
618
+ __lastFailedNode: 'worker',
619
+ });
620
+ // hint executed twice (initial + re-inject), worker twice (fail + success),
621
+ // finalize once.
622
+ const byNode = dispatcher.calls.reduce<Record<string, number>>((acc, c) => {
623
+ acc[c.nodeId] = (acc[c.nodeId] ?? 0) + 1;
624
+ return acc;
625
+ }, {});
626
+ expect(byNode).toMatchObject({ hint: 2, worker: 2, finalize: 1 });
627
+ });
628
+
629
+ // 13 -----------------------------------------------------------------------
630
+ it('unknown nextNode via inject_into target: terminates as failed (not exceeded)', async () => {
631
+ const playbook: PlaybookDefinition = {
632
+ version: '1.0',
633
+ name: 'bad-inject',
634
+ nodes: [agenticNode('w', { on_failure: { max_iterations: 1, inject_into: 'ghost' } })],
635
+ edges: [],
636
+ };
637
+ const dispatcher = makeRecordingDispatcher(() => ({
638
+ status: 'failure',
639
+ output: {},
640
+ error: 'nope',
641
+ }));
642
+
643
+ const result = await executePlaybook({
644
+ db,
645
+ playbook,
646
+ playbookHash: 'hash-13',
647
+ initialContext: {},
648
+ dispatcher,
649
+ });
650
+ expect(result.terminalStatus).toBe('failed');
651
+ expect(result.failedNodeId).toBe('w');
652
+ const run = getPlaybookRun(db, result.runId);
653
+ expect(run?.status).toBe('failed');
654
+ });
655
+
656
+ // 14 -----------------------------------------------------------------------
657
+ it('injectable clock: completedAt uses supplied now()', async () => {
658
+ const playbook = linearPlaybook('clock', ['a']);
659
+ const dispatcher = makeRecordingDispatcher(() => ({ status: 'success', output: {} }));
660
+ const fixed = new Date('2026-04-17T22:30:00.000Z');
661
+
662
+ await executePlaybook({
663
+ db,
664
+ playbook,
665
+ playbookHash: 'hash-14',
666
+ initialContext: {},
667
+ dispatcher,
668
+ now: () => fixed,
669
+ });
670
+
671
+ // The SQLite migration stamps completedAt via JS, and updatePlaybookRun
672
+ // writes our supplied timestamp.
673
+ const runs = db
674
+ .prepare("SELECT completed_at FROM playbook_runs WHERE playbook_name = 'clock'")
675
+ .all() as Array<{ completed_at: string }>;
676
+ expect(runs[0]?.completed_at).toBe('2026-04-17T22:30:00.000Z');
677
+ });
678
+ });