@coralai/sps-cli 0.28.7 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +72 -2
  2. package/dist/commands/agentCommand.d.ts.map +1 -1
  3. package/dist/commands/agentCommand.js +163 -7
  4. package/dist/commands/agentCommand.js.map +1 -1
  5. package/dist/commands/agentRenderer.d.ts +1 -0
  6. package/dist/commands/agentRenderer.d.ts.map +1 -1
  7. package/dist/commands/agentRenderer.js +22 -10
  8. package/dist/commands/agentRenderer.js.map +1 -1
  9. package/dist/commands/cardDashboard.d.ts.map +1 -1
  10. package/dist/commands/cardDashboard.js +1 -0
  11. package/dist/commands/cardDashboard.js.map +1 -1
  12. package/dist/commands/monitorTick.d.ts.map +1 -1
  13. package/dist/commands/monitorTick.js +3 -1
  14. package/dist/commands/monitorTick.js.map +1 -1
  15. package/dist/commands/pipelineRunner.d.ts +3 -0
  16. package/dist/commands/pipelineRunner.d.ts.map +1 -0
  17. package/dist/commands/pipelineRunner.js +242 -0
  18. package/dist/commands/pipelineRunner.js.map +1 -0
  19. package/dist/commands/pipelineTick.d.ts.map +1 -1
  20. package/dist/commands/pipelineTick.js +4 -2
  21. package/dist/commands/pipelineTick.js.map +1 -1
  22. package/dist/commands/qaTick.d.ts.map +1 -1
  23. package/dist/commands/qaTick.js +4 -2
  24. package/dist/commands/qaTick.js.map +1 -1
  25. package/dist/commands/schedulerTick.d.ts.map +1 -1
  26. package/dist/commands/schedulerTick.js +3 -1
  27. package/dist/commands/schedulerTick.js.map +1 -1
  28. package/dist/commands/tick.d.ts.map +1 -1
  29. package/dist/commands/tick.js +24 -9
  30. package/dist/commands/tick.js.map +1 -1
  31. package/dist/commands/workerLaunch.d.ts.map +1 -1
  32. package/dist/commands/workerLaunch.js +4 -2
  33. package/dist/commands/workerLaunch.js.map +1 -1
  34. package/dist/core/pipelineConfig.d.ts +97 -0
  35. package/dist/core/pipelineConfig.d.ts.map +1 -0
  36. package/dist/core/pipelineConfig.js +176 -0
  37. package/dist/core/pipelineConfig.js.map +1 -0
  38. package/dist/core/projectPipelineAdapter.d.ts +69 -0
  39. package/dist/core/projectPipelineAdapter.d.ts.map +1 -0
  40. package/dist/core/projectPipelineAdapter.js +236 -0
  41. package/dist/core/projectPipelineAdapter.js.map +1 -0
  42. package/dist/daemon/daemonClient.d.ts.map +1 -1
  43. package/dist/daemon/daemonClient.js +7 -0
  44. package/dist/daemon/daemonClient.js.map +1 -1
  45. package/dist/daemon/sessionDaemon.d.ts.map +1 -1
  46. package/dist/daemon/sessionDaemon.js +45 -3
  47. package/dist/daemon/sessionDaemon.js.map +1 -1
  48. package/dist/engines/CloseoutEngine.d.ts +3 -1
  49. package/dist/engines/CloseoutEngine.d.ts.map +1 -1
  50. package/dist/engines/CloseoutEngine.js +9 -7
  51. package/dist/engines/CloseoutEngine.js.map +1 -1
  52. package/dist/engines/EventHandler.d.ts +3 -0
  53. package/dist/engines/EventHandler.d.ts.map +1 -1
  54. package/dist/engines/EventHandler.js +5 -3
  55. package/dist/engines/EventHandler.js.map +1 -1
  56. package/dist/engines/ExecutionEngine.d.ts +3 -1
  57. package/dist/engines/ExecutionEngine.d.ts.map +1 -1
  58. package/dist/engines/ExecutionEngine.js +32 -29
  59. package/dist/engines/ExecutionEngine.js.map +1 -1
  60. package/dist/engines/MonitorEngine.d.ts +3 -1
  61. package/dist/engines/MonitorEngine.d.ts.map +1 -1
  62. package/dist/engines/MonitorEngine.js +16 -13
  63. package/dist/engines/MonitorEngine.js.map +1 -1
  64. package/dist/engines/SchedulerEngine.d.ts +3 -1
  65. package/dist/engines/SchedulerEngine.d.ts.map +1 -1
  66. package/dist/engines/SchedulerEngine.js +11 -9
  67. package/dist/engines/SchedulerEngine.js.map +1 -1
  68. package/dist/engines/engine-pipeline-adapter.test.d.ts +2 -0
  69. package/dist/engines/engine-pipeline-adapter.test.d.ts.map +1 -0
  70. package/dist/engines/engine-pipeline-adapter.test.js +677 -0
  71. package/dist/engines/engine-pipeline-adapter.test.js.map +1 -0
  72. package/dist/interfaces/ACPClient.d.ts +11 -0
  73. package/dist/interfaces/ACPClient.d.ts.map +1 -1
  74. package/dist/interfaces/AgentRuntime.d.ts +4 -1
  75. package/dist/interfaces/AgentRuntime.d.ts.map +1 -1
  76. package/dist/main.js +49 -3
  77. package/dist/main.js.map +1 -1
  78. package/dist/manager/supervisor.d.ts.map +1 -1
  79. package/dist/manager/supervisor.js +6 -0
  80. package/dist/manager/supervisor.js.map +1 -1
  81. package/dist/manager/worker-manager-impl.d.ts +1 -0
  82. package/dist/manager/worker-manager-impl.d.ts.map +1 -1
  83. package/dist/manager/worker-manager-impl.js +37 -5
  84. package/dist/manager/worker-manager-impl.js.map +1 -1
  85. package/dist/manager/worker-manager.d.ts +5 -0
  86. package/dist/manager/worker-manager.d.ts.map +1 -1
  87. package/dist/models/types.d.ts +2 -1
  88. package/dist/models/types.d.ts.map +1 -1
  89. package/dist/providers/ACPWorkerRuntime.d.ts +3 -1
  90. package/dist/providers/ACPWorkerRuntime.d.ts.map +1 -1
  91. package/dist/providers/ACPWorkerRuntime.js +2 -1
  92. package/dist/providers/ACPWorkerRuntime.js.map +1 -1
  93. package/dist/providers/PlaneTaskBackend.d.ts.map +1 -1
  94. package/dist/providers/PlaneTaskBackend.js +31 -9
  95. package/dist/providers/PlaneTaskBackend.js.map +1 -1
  96. package/dist/providers/adapters/AcpSdkAdapter.d.ts.map +1 -1
  97. package/dist/providers/adapters/AcpSdkAdapter.js +23 -6
  98. package/dist/providers/adapters/AcpSdkAdapter.js.map +1 -1
  99. package/package.json +2 -1
  100. package/profiles/critic.md +35 -0
@@ -0,0 +1,677 @@
1
+ /**
2
+ * Integration test: verify all engines use ProjectPipelineAdapter states
3
+ * instead of hardcoded 'Planning'/'Backlog'/'Todo'/'Inprogress'/'QA'/'Done'.
4
+ *
5
+ * Uses a custom pipeline YAML with non-default state names to prove
6
+ * configurability end-to-end.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+ import { mkdtempSync, mkdirSync, cpSync, rmSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+ import { ProjectPipelineAdapter } from '../core/projectPipelineAdapter.js';
13
+ import { ExecutionEngine } from './ExecutionEngine.js';
14
+ import { CloseoutEngine } from './CloseoutEngine.js';
15
+ import { MonitorEngine } from './MonitorEngine.js';
16
+ import { SchedulerEngine } from './SchedulerEngine.js';
17
+ import { SPSEventHandler } from './EventHandler.js';
18
+ import { RuntimeStore } from '../core/runtimeStore.js';
19
+ import { writeState, createIdleWorkerSlot } from '../core/state.js';
20
+ // ─── Custom state names (must match __fixtures__/custom-pipeline.yaml) ──
21
+ const CUSTOM = {
22
+ planning: 'Planned',
23
+ backlog: 'Queue',
24
+ ready: 'Ready',
25
+ active: 'Working',
26
+ review: 'Review',
27
+ done: 'Shipped',
28
+ };
29
+ // ─── Helpers ────────────────────────────────────────────────────────
30
+ function makeTempDir() {
31
+ return mkdtempSync(join(tmpdir(), 'sps-engine-adapter-test-'));
32
+ }
33
+ function makeConfig(overrides = {}) {
34
+ return {
35
+ PROJECT_NAME: 'test-project',
36
+ PROJECT_DIR: '/tmp/test-project',
37
+ GITLAB_PROJECT: 'test/project',
38
+ GITLAB_PROJECT_ID: '1',
39
+ GITLAB_MERGE_BRANCH: 'main',
40
+ PM_TOOL: 'plane',
41
+ MR_MODE: 'create',
42
+ WORKER_TOOL: 'claude',
43
+ WORKER_TRANSPORT: 'proc',
44
+ MAX_CONCURRENT_WORKERS: 2,
45
+ WORKER_RESTART_LIMIT: 3,
46
+ MAX_ACTIONS_PER_TICK: 5,
47
+ INPROGRESS_TIMEOUT_HOURS: 4,
48
+ MONITOR_AUTO_QA: true,
49
+ CONFLICT_DEFAULT: 'parallel',
50
+ TICK_LOCK_TIMEOUT_MINUTES: 10,
51
+ WORKER_LAUNCH_TIMEOUT_S: 60,
52
+ WORKER_IDLE_TIMEOUT_M: 30,
53
+ raw: {},
54
+ ...overrides,
55
+ };
56
+ }
57
+ function makeDefaultState(maxWorkers) {
58
+ const workers = {};
59
+ for (let i = 0; i < maxWorkers; i++) {
60
+ workers[`worker-${i}`] = createIdleWorkerSlot();
61
+ }
62
+ return {
63
+ version: 1,
64
+ generation: 0,
65
+ updatedAt: new Date().toISOString(),
66
+ updatedBy: 'test',
67
+ workers,
68
+ activeCards: {},
69
+ leases: {},
70
+ worktreeEvidence: {},
71
+ worktreeCleanup: [],
72
+ sessions: {},
73
+ integrationQueues: {},
74
+ pendingPMActions: [],
75
+ };
76
+ }
77
+ function makeCard(seq, state, overrides = {}) {
78
+ return {
79
+ id: `card-${seq}`,
80
+ seq,
81
+ name: `Test card ${seq}`,
82
+ desc: `Description for card ${seq}`,
83
+ state,
84
+ labels: [],
85
+ meta: {},
86
+ ...overrides,
87
+ };
88
+ }
89
+ function makeCtx(tempDir, config) {
90
+ const stateFile = join(tempDir, 'state.json');
91
+ const logsDir = join(tempDir, 'logs');
92
+ mkdirSync(logsDir, { recursive: true });
93
+ return {
94
+ projectName: 'test-project',
95
+ config,
96
+ paths: {
97
+ repoDir: tempDir,
98
+ stateFile,
99
+ lockFile: join(tempDir, 'tick.lock'),
100
+ logsDir,
101
+ pipelineOrderFile: join(tempDir, 'pipeline_order.json'),
102
+ },
103
+ pmTool: config.PM_TOOL,
104
+ workerTool: config.WORKER_TOOL,
105
+ maxWorkers: config.MAX_CONCURRENT_WORKERS,
106
+ mrMode: config.MR_MODE,
107
+ mergeBranch: config.GITLAB_MERGE_BRANCH,
108
+ validate: () => ({ ok: true, errors: [] }),
109
+ reload: () => { },
110
+ };
111
+ }
112
+ function makeTaskBackend() {
113
+ return {
114
+ listAll: vi.fn().mockResolvedValue([]),
115
+ listByState: vi.fn().mockResolvedValue([]),
116
+ getBySeq: vi.fn().mockResolvedValue(null),
117
+ move: vi.fn().mockResolvedValue(undefined),
118
+ addLabel: vi.fn().mockResolvedValue(undefined),
119
+ removeLabel: vi.fn().mockResolvedValue(undefined),
120
+ claim: vi.fn().mockResolvedValue(undefined),
121
+ releaseClaim: vi.fn().mockResolvedValue(undefined),
122
+ comment: vi.fn().mockResolvedValue(undefined),
123
+ create: vi.fn().mockResolvedValue(null),
124
+ checklistCreate: vi.fn().mockResolvedValue(undefined),
125
+ checklistList: vi.fn().mockResolvedValue([]),
126
+ metaRead: vi.fn().mockResolvedValue({}),
127
+ metaWrite: vi.fn().mockResolvedValue(undefined),
128
+ bootstrap: vi.fn().mockResolvedValue(undefined),
129
+ };
130
+ }
131
+ function makeRepoBackend() {
132
+ return {
133
+ ensureCleanBase: vi.fn().mockResolvedValue(undefined),
134
+ ensureBranch: vi.fn().mockResolvedValue(undefined),
135
+ ensureWorktree: vi.fn().mockResolvedValue(undefined),
136
+ commit: vi.fn().mockResolvedValue(undefined),
137
+ push: vi.fn().mockResolvedValue(undefined),
138
+ createOrUpdateMr: vi.fn().mockResolvedValue({ url: '', iid: 1 }),
139
+ getMrStatus: vi.fn().mockResolvedValue({ exists: false, state: 'none', merged: false }),
140
+ mergeMr: vi.fn().mockResolvedValue({ merged: true }),
141
+ detectMerged: vi.fn().mockResolvedValue(false),
142
+ rebase: vi.fn().mockResolvedValue({ success: true, conflictFiles: [] }),
143
+ removeWorktree: vi.fn().mockResolvedValue(undefined),
144
+ };
145
+ }
146
+ function makeWorkerManager() {
147
+ const response = {
148
+ accepted: true,
149
+ slot: 'worker-0',
150
+ workerId: 'test-project:worker-0:1',
151
+ pid: 99999,
152
+ sessionId: 'test-session',
153
+ };
154
+ return {
155
+ run: vi.fn().mockResolvedValue(response),
156
+ cancel: vi.fn().mockResolvedValue(undefined),
157
+ inspect: vi.fn().mockReturnValue([]),
158
+ onEvent: vi.fn(),
159
+ cleanup: vi.fn(),
160
+ };
161
+ }
162
+ function makeSupervisor() {
163
+ return {
164
+ spawn: vi.fn(),
165
+ kill: vi.fn().mockResolvedValue(undefined),
166
+ remove: vi.fn(),
167
+ get: vi.fn().mockReturnValue(null),
168
+ list: vi.fn().mockReturnValue([]),
169
+ };
170
+ }
171
+ // ─── Setup pipeline adapter from YAML fixture ──────────────────────
172
+ function setupAdapterWithCustomYaml(tempDir, config) {
173
+ // Copy the custom YAML to the temp project dir
174
+ const pipelinesDir = join(tempDir, '.sps', 'pipelines');
175
+ mkdirSync(pipelinesDir, { recursive: true });
176
+ cpSync(join(__dirname, '__fixtures__', 'custom-pipeline.yaml'), join(pipelinesDir, 'custom-pipeline.yaml'));
177
+ return new ProjectPipelineAdapter(config, tempDir);
178
+ }
179
+ // ─── Tests ──────────────────────────────────────────────────────────
180
+ describe('ProjectPipelineAdapter YAML loading', () => {
181
+ let tempDir;
182
+ beforeEach(() => { tempDir = makeTempDir(); });
183
+ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
184
+ it('loads custom state names from YAML', () => {
185
+ const config = makeConfig({ PROJECT_DIR: tempDir });
186
+ const adapter = setupAdapterWithCustomYaml(tempDir, config);
187
+ expect(adapter.states.planning).toBe(CUSTOM.planning);
188
+ expect(adapter.states.backlog).toBe(CUSTOM.backlog);
189
+ expect(adapter.states.ready).toBe(CUSTOM.ready);
190
+ expect(adapter.states.active).toBe(CUSTOM.active);
191
+ expect(adapter.states.review).toBe(CUSTOM.review);
192
+ expect(adapter.states.done).toBe(CUSTOM.done);
193
+ });
194
+ it('loads custom stages from YAML', () => {
195
+ const config = makeConfig({ PROJECT_DIR: tempDir });
196
+ const adapter = setupAdapterWithCustomYaml(tempDir, config);
197
+ expect(adapter.stages).toHaveLength(2);
198
+ expect(adapter.stages[0].name).toBe('develop');
199
+ expect(adapter.stages[0].triggerState).toBe('Ready');
200
+ expect(adapter.stages[0].activeState).toBe('Working');
201
+ expect(adapter.stages[0].onCompleteState).toBe('Review');
202
+ expect(adapter.stages[1].name).toBe('integrate');
203
+ expect(adapter.stages[1].triggerState).toBe('Review');
204
+ expect(adapter.stages[1].onCompleteState).toBe('Shipped');
205
+ });
206
+ it('activeStates uses custom names', () => {
207
+ const config = makeConfig({ PROJECT_DIR: tempDir });
208
+ const adapter = setupAdapterWithCustomYaml(tempDir, config);
209
+ expect(adapter.activeStates).toContain('Planned');
210
+ expect(adapter.activeStates).toContain('Queue');
211
+ expect(adapter.activeStates).toContain('Ready');
212
+ expect(adapter.activeStates).toContain('Working');
213
+ expect(adapter.activeStates).toContain('Review');
214
+ expect(adapter.activeStates).not.toContain('Done');
215
+ expect(adapter.activeStates).not.toContain('Shipped');
216
+ });
217
+ it('derivePmState maps lease phases to custom states', () => {
218
+ const config = makeConfig({ PROJECT_DIR: tempDir });
219
+ const adapter = setupAdapterWithCustomYaml(tempDir, config);
220
+ expect(adapter.derivePmState('queued')).toBe('Ready');
221
+ expect(adapter.derivePmState('coding')).toBe('Working');
222
+ expect(adapter.derivePmState('merging')).toBe('Review');
223
+ expect(adapter.derivePmState('resolving_conflict')).toBe('Review');
224
+ expect(adapter.derivePmState('closing')).toBe('Review');
225
+ });
226
+ });
227
+ describe('SchedulerEngine uses adapter states', () => {
228
+ let tempDir;
229
+ let taskBackend;
230
+ let adapter;
231
+ beforeEach(() => {
232
+ tempDir = makeTempDir();
233
+ taskBackend = makeTaskBackend();
234
+ const config = makeConfig({ PROJECT_DIR: tempDir });
235
+ adapter = setupAdapterWithCustomYaml(tempDir, config);
236
+ });
237
+ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
238
+ it('lists cards by custom planning state', async () => {
239
+ const config = makeConfig({ PROJECT_DIR: tempDir });
240
+ const ctx = makeCtx(tempDir, config);
241
+ const state = makeDefaultState(2);
242
+ writeState(ctx.paths.stateFile, state, 'test');
243
+ // Return a card with AI-PIPELINE label
244
+ const card = makeCard('1', CUSTOM.planning, { labels: ['AI-PIPELINE'] });
245
+ taskBackend.listByState.mockResolvedValue([card]);
246
+ const engine = new SchedulerEngine(ctx, taskBackend, adapter);
247
+ await engine.tick({ dryRun: true });
248
+ // Should call listByState with custom 'Planned' state (not 'Planning')
249
+ expect(taskBackend.listByState).toHaveBeenCalledWith('Planned');
250
+ });
251
+ it('moves cards to custom backlog state', async () => {
252
+ const config = makeConfig({ PROJECT_DIR: tempDir });
253
+ const ctx = makeCtx(tempDir, config);
254
+ const state = makeDefaultState(2);
255
+ writeState(ctx.paths.stateFile, state, 'test');
256
+ const card = makeCard('1', CUSTOM.planning, { labels: ['AI-PIPELINE'] });
257
+ taskBackend.listByState.mockResolvedValue([card]);
258
+ const engine = new SchedulerEngine(ctx, taskBackend, adapter);
259
+ const result = await engine.tick();
260
+ // Should move to custom 'Queue' state (not 'Backlog')
261
+ expect(taskBackend.move).toHaveBeenCalledWith('1', 'Queue');
262
+ });
263
+ });
264
+ describe('ExecutionEngine uses adapter states', () => {
265
+ let tempDir;
266
+ let taskBackend;
267
+ let repoBackend;
268
+ let workerManager;
269
+ let adapter;
270
+ beforeEach(() => {
271
+ tempDir = makeTempDir();
272
+ taskBackend = makeTaskBackend();
273
+ repoBackend = makeRepoBackend();
274
+ workerManager = makeWorkerManager();
275
+ const config = makeConfig({ PROJECT_DIR: tempDir });
276
+ adapter = setupAdapterWithCustomYaml(tempDir, config);
277
+ });
278
+ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
279
+ it('lists backlog cards by custom state', async () => {
280
+ const config = makeConfig({ PROJECT_DIR: tempDir });
281
+ const ctx = makeCtx(tempDir, config);
282
+ const state = makeDefaultState(2);
283
+ writeState(ctx.paths.stateFile, state, 'test');
284
+ const engine = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
285
+ await engine.tick({ dryRun: true });
286
+ // Should query custom states: 'Working' (active), 'Queue' (backlog), 'Ready' (ready)
287
+ const calls = taskBackend.listByState.mock.calls.map(c => c[0]);
288
+ expect(calls).toContain('Working'); // listRuntimeAwareInprogressCards
289
+ expect(calls).toContain('Queue'); // backlog cards
290
+ expect(calls).toContain('Ready'); // todo cards (at least 2 calls)
291
+ });
292
+ it('prepares card: moves to custom ready state', async () => {
293
+ const config = makeConfig({ PROJECT_DIR: tempDir });
294
+ const ctx = makeCtx(tempDir, config);
295
+ const state = makeDefaultState(2);
296
+ writeState(ctx.paths.stateFile, state, 'test');
297
+ // Return a backlog card, empty for other states
298
+ const backlogCard = makeCard('1', CUSTOM.backlog);
299
+ taskBackend.listByState.mockImplementation((s) => {
300
+ if (s === CUSTOM.backlog)
301
+ return Promise.resolve([backlogCard]);
302
+ return Promise.resolve([]);
303
+ });
304
+ const engine = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
305
+ await engine.tick();
306
+ // prepare phase should move Backlog → Ready (custom states)
307
+ expect(taskBackend.move).toHaveBeenCalledWith('1', 'Ready');
308
+ });
309
+ it('launches card: moves to custom active state', async () => {
310
+ const config = makeConfig({ PROJECT_DIR: tempDir });
311
+ const ctx = makeCtx(tempDir, config);
312
+ const state = makeDefaultState(2);
313
+ writeState(ctx.paths.stateFile, state, 'test');
314
+ // Return a Todo/Ready card ready to launch
315
+ const readyCard = makeCard('1', CUSTOM.ready);
316
+ taskBackend.listByState.mockImplementation((s) => {
317
+ if (s === CUSTOM.ready)
318
+ return Promise.resolve([readyCard]);
319
+ return Promise.resolve([]);
320
+ });
321
+ const engine = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
322
+ await engine.tick();
323
+ // launch phase should move Ready → Working (custom states)
324
+ expect(taskBackend.move).toHaveBeenCalledWith('1', 'Working');
325
+ });
326
+ });
327
+ describe('CloseoutEngine uses adapter states', () => {
328
+ let tempDir;
329
+ let taskBackend;
330
+ let repoBackend;
331
+ let workerManager;
332
+ let adapter;
333
+ beforeEach(() => {
334
+ tempDir = makeTempDir();
335
+ taskBackend = makeTaskBackend();
336
+ repoBackend = makeRepoBackend();
337
+ workerManager = makeWorkerManager();
338
+ const config = makeConfig({ PROJECT_DIR: tempDir });
339
+ adapter = setupAdapterWithCustomYaml(tempDir, config);
340
+ });
341
+ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
342
+ it('lists cards by custom review state', async () => {
343
+ const config = makeConfig({ PROJECT_DIR: tempDir });
344
+ const ctx = makeCtx(tempDir, config);
345
+ const state = makeDefaultState(2);
346
+ writeState(ctx.paths.stateFile, state, 'test');
347
+ const engine = new CloseoutEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
348
+ await engine.tick();
349
+ // Should call listByState with custom 'Review' (not 'QA')
350
+ expect(taskBackend.listByState).toHaveBeenCalledWith('Review');
351
+ });
352
+ });
353
+ describe('MonitorEngine uses adapter states', () => {
354
+ let tempDir;
355
+ let taskBackend;
356
+ let repoBackend;
357
+ let supervisor;
358
+ let adapter;
359
+ beforeEach(() => {
360
+ tempDir = makeTempDir();
361
+ taskBackend = makeTaskBackend();
362
+ repoBackend = makeRepoBackend();
363
+ supervisor = makeSupervisor();
364
+ const config = makeConfig({ PROJECT_DIR: tempDir });
365
+ adapter = setupAdapterWithCustomYaml(tempDir, config);
366
+ });
367
+ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
368
+ it('lists inprogress cards by custom active state', async () => {
369
+ const config = makeConfig({ PROJECT_DIR: tempDir });
370
+ const ctx = makeCtx(tempDir, config);
371
+ const state = makeDefaultState(2);
372
+ writeState(ctx.paths.stateFile, state, 'test');
373
+ const engine = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
374
+ await engine.tick();
375
+ // Should call listByState with custom 'Working' (not 'Inprogress')
376
+ const calls = taskBackend.listByState.mock.calls.map(c => c[0]);
377
+ expect(calls).toContain('Working');
378
+ });
379
+ it('checkBlockedCards iterates custom state names', async () => {
380
+ const config = makeConfig({ PROJECT_DIR: tempDir });
381
+ const ctx = makeCtx(tempDir, config);
382
+ const state = makeDefaultState(2);
383
+ writeState(ctx.paths.stateFile, state, 'test');
384
+ const engine = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
385
+ await engine.tick();
386
+ // checkBlockedCards should iterate Queue/Ready/Working/Review (not Backlog/Todo/Inprogress/QA)
387
+ const calls = taskBackend.listByState.mock.calls.map(c => c[0]);
388
+ expect(calls).toContain('Queue');
389
+ expect(calls).toContain('Ready');
390
+ expect(calls).toContain('Review');
391
+ });
392
+ it('auto-retry moves to custom ready state', async () => {
393
+ const config = makeConfig({ PROJECT_DIR: tempDir, MONITOR_AUTO_QA: true });
394
+ const ctx = makeCtx(tempDir, config);
395
+ // Set up state with an active card that has a stale slot
396
+ const state = makeDefaultState(2);
397
+ state.workers['worker-0'] = {
398
+ ...createIdleWorkerSlot(),
399
+ status: 'active',
400
+ seq: 1,
401
+ branch: 'feature/1-test',
402
+ worktree: '/tmp/wt-1',
403
+ claimedAt: new Date(Date.now() - 600_000).toISOString(),
404
+ lastHeartbeat: null,
405
+ mode: 'print',
406
+ transport: 'proc',
407
+ outputFile: '/tmp/non-existent-output.jsonl',
408
+ };
409
+ state.activeCards['1'] = {
410
+ seq: 1,
411
+ state: 'Working',
412
+ worker: 'worker-0',
413
+ mrUrl: null,
414
+ conflictDomains: [],
415
+ startedAt: new Date(Date.now() - 600_000).toISOString(),
416
+ retryCount: 0,
417
+ };
418
+ state.leases['1'] = {
419
+ seq: 1,
420
+ pmStateObserved: 'Working',
421
+ phase: 'coding',
422
+ slot: 'worker-0',
423
+ branch: 'feature/1-test',
424
+ worktree: '/tmp/wt-1',
425
+ sessionId: null,
426
+ runId: null,
427
+ claimedAt: new Date(Date.now() - 600_000).toISOString(),
428
+ retryCount: 0,
429
+ lastTransitionAt: new Date(Date.now() - 600_000).toISOString(),
430
+ };
431
+ writeState(ctx.paths.stateFile, state, 'test');
432
+ // Return the card as being in 'Working' state
433
+ const card = makeCard('1', CUSTOM.active);
434
+ taskBackend.listByState.mockImplementation((s) => {
435
+ if (s === CUSTOM.active)
436
+ return Promise.resolve([card]);
437
+ return Promise.resolve([]);
438
+ });
439
+ // getMrStatus returns no MR (so it's a genuine stale runtime)
440
+ repoBackend.getMrStatus.mockResolvedValue({
441
+ exists: false,
442
+ state: 'none',
443
+ merged: false,
444
+ });
445
+ const engine = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
446
+ await engine.tick();
447
+ // With MONITOR_AUTO_QA, stale runtime should be moved to Review (custom, not QA)
448
+ const moveCalls = taskBackend.move.mock.calls;
449
+ if (moveCalls.length > 0) {
450
+ // Should use 'Review' (custom) not 'QA' (default)
451
+ const targets = moveCalls.map(c => c[1]);
452
+ expect(targets.every(t => t !== 'QA')).toBe(true);
453
+ // Check for either Review (auto-qa) or Ready (auto-retry)
454
+ expect(targets.some(t => t === 'Review' || t === 'Ready')).toBe(true);
455
+ }
456
+ });
457
+ });
458
+ describe('SPSEventHandler uses adapter states', () => {
459
+ let tempDir;
460
+ let taskBackend;
461
+ let adapter;
462
+ beforeEach(() => {
463
+ tempDir = makeTempDir();
464
+ taskBackend = makeTaskBackend();
465
+ const config = makeConfig({ PROJECT_DIR: tempDir });
466
+ adapter = setupAdapterWithCustomYaml(tempDir, config);
467
+ });
468
+ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
469
+ it('onCompleted moves to custom done state for integration', async () => {
470
+ const config = makeConfig({ PROJECT_DIR: tempDir });
471
+ const ctx = makeCtx(tempDir, config);
472
+ const state = makeDefaultState(2);
473
+ state.leases['1'] = {
474
+ seq: 1,
475
+ pmStateObserved: 'Review',
476
+ phase: 'merging',
477
+ slot: 'worker-0',
478
+ branch: 'feature/1-test',
479
+ worktree: '/tmp/wt-1',
480
+ sessionId: null,
481
+ runId: null,
482
+ claimedAt: new Date().toISOString(),
483
+ retryCount: 0,
484
+ lastTransitionAt: new Date().toISOString(),
485
+ };
486
+ writeState(ctx.paths.stateFile, state, 'test');
487
+ const runtimeStore = new RuntimeStore({
488
+ paths: { stateFile: ctx.paths.stateFile },
489
+ maxWorkers: config.MAX_CONCURRENT_WORKERS,
490
+ });
491
+ const handler = new SPSEventHandler({
492
+ taskBackend,
493
+ runtimeStore,
494
+ project: 'test-project',
495
+ pipelineAdapter: adapter,
496
+ });
497
+ // Simulate a completed integration event
498
+ handler.handle({
499
+ type: 'run.completed',
500
+ taskId: '1',
501
+ cardId: '1',
502
+ workerId: 'test-project:worker-0:1',
503
+ timestamp: new Date().toISOString(),
504
+ phase: 'integration',
505
+ slot: 'worker-0',
506
+ project: 'test-project',
507
+ state: 'completed',
508
+ exitCode: 0,
509
+ completionResult: { status: 'completed', reason: 'already_merged' },
510
+ });
511
+ // Allow async handlers to complete
512
+ await new Promise(r => setTimeout(r, 100));
513
+ // Should move to 'Shipped' (custom done), not 'Done'
514
+ expect(taskBackend.move).toHaveBeenCalledWith('1', 'Shipped');
515
+ });
516
+ it('onCompleted moves to custom review state for development', async () => {
517
+ const config = makeConfig({ PROJECT_DIR: tempDir });
518
+ const ctx = makeCtx(tempDir, config);
519
+ const state = makeDefaultState(2);
520
+ state.leases['1'] = {
521
+ seq: 1,
522
+ pmStateObserved: 'Working',
523
+ phase: 'coding',
524
+ slot: 'worker-0',
525
+ branch: 'feature/1-test',
526
+ worktree: '/tmp/wt-1',
527
+ sessionId: null,
528
+ runId: null,
529
+ claimedAt: new Date().toISOString(),
530
+ retryCount: 0,
531
+ lastTransitionAt: new Date().toISOString(),
532
+ };
533
+ writeState(ctx.paths.stateFile, state, 'test');
534
+ const runtimeStore = new RuntimeStore({
535
+ paths: { stateFile: ctx.paths.stateFile },
536
+ maxWorkers: config.MAX_CONCURRENT_WORKERS,
537
+ });
538
+ const handler = new SPSEventHandler({
539
+ taskBackend,
540
+ runtimeStore,
541
+ project: 'test-project',
542
+ pipelineAdapter: adapter,
543
+ });
544
+ // Simulate a completed development event
545
+ handler.handle({
546
+ type: 'run.completed',
547
+ taskId: '1',
548
+ cardId: '1',
549
+ workerId: 'test-project:worker-0:1',
550
+ timestamp: new Date().toISOString(),
551
+ phase: 'development',
552
+ slot: 'worker-0',
553
+ project: 'test-project',
554
+ state: 'completed',
555
+ exitCode: 0,
556
+ completionResult: { status: 'completed', reason: 'branch_pushed' },
557
+ });
558
+ // Allow async handlers to complete
559
+ await new Promise(r => setTimeout(r, 100));
560
+ // Should move to 'Review' (custom review), not 'QA'
561
+ expect(taskBackend.move).toHaveBeenCalledWith('1', 'Review');
562
+ });
563
+ it('releaseSlot sets custom review state in pmStateObserved', async () => {
564
+ const config = makeConfig({ PROJECT_DIR: tempDir });
565
+ const ctx = makeCtx(tempDir, config);
566
+ const state = makeDefaultState(2);
567
+ state.workers['worker-0'] = {
568
+ ...createIdleWorkerSlot(),
569
+ status: 'active',
570
+ seq: 1,
571
+ branch: 'feature/1-test',
572
+ worktree: '/tmp/wt-1',
573
+ claimedAt: new Date().toISOString(),
574
+ lastHeartbeat: null,
575
+ };
576
+ state.activeCards['1'] = {
577
+ seq: 1,
578
+ state: 'Working',
579
+ worker: 'worker-0',
580
+ mrUrl: null,
581
+ conflictDomains: [],
582
+ startedAt: new Date().toISOString(),
583
+ };
584
+ state.leases['1'] = {
585
+ seq: 1,
586
+ pmStateObserved: 'Working',
587
+ phase: 'coding',
588
+ slot: 'worker-0',
589
+ branch: 'feature/1-test',
590
+ worktree: '/tmp/wt-1',
591
+ sessionId: null,
592
+ runId: null,
593
+ claimedAt: new Date().toISOString(),
594
+ retryCount: 0,
595
+ lastTransitionAt: new Date().toISOString(),
596
+ };
597
+ writeState(ctx.paths.stateFile, state, 'test');
598
+ const runtimeStore = new RuntimeStore({
599
+ paths: { stateFile: ctx.paths.stateFile },
600
+ maxWorkers: config.MAX_CONCURRENT_WORKERS,
601
+ });
602
+ const handler = new SPSEventHandler({
603
+ taskBackend,
604
+ runtimeStore,
605
+ project: 'test-project',
606
+ pipelineAdapter: adapter,
607
+ });
608
+ // Simulate development completion
609
+ handler.handle({
610
+ type: 'run.completed',
611
+ taskId: '1',
612
+ cardId: '1',
613
+ workerId: 'test-project:worker-0:1',
614
+ timestamp: new Date().toISOString(),
615
+ phase: 'development',
616
+ slot: 'worker-0',
617
+ project: 'test-project',
618
+ state: 'completed',
619
+ exitCode: 0,
620
+ completionResult: { status: 'completed', reason: 'branch_pushed' },
621
+ });
622
+ await new Promise(r => setTimeout(r, 100));
623
+ // Verify runtime state has custom review state
624
+ const freshState = runtimeStore.readState();
625
+ if (freshState.leases['1']) {
626
+ expect(freshState.leases['1'].pmStateObserved).toBe('Review');
627
+ }
628
+ });
629
+ });
630
+ describe('Full pipeline flow with custom states (dry-run)', () => {
631
+ let tempDir;
632
+ let taskBackend;
633
+ let repoBackend;
634
+ let workerManager;
635
+ let adapter;
636
+ beforeEach(() => {
637
+ tempDir = makeTempDir();
638
+ taskBackend = makeTaskBackend();
639
+ repoBackend = makeRepoBackend();
640
+ workerManager = makeWorkerManager();
641
+ const config = makeConfig({ PROJECT_DIR: tempDir });
642
+ adapter = setupAdapterWithCustomYaml(tempDir, config);
643
+ });
644
+ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
645
+ it('never uses default state names when custom YAML is loaded', async () => {
646
+ const config = makeConfig({ PROJECT_DIR: tempDir });
647
+ const ctx = makeCtx(tempDir, config);
648
+ const state = makeDefaultState(2);
649
+ writeState(ctx.paths.stateFile, state, 'test');
650
+ const DEFAULTS = ['Planning', 'Backlog', 'Todo', 'Inprogress', 'QA', 'Done'];
651
+ // Run all engines in sequence
652
+ const scheduler = new SchedulerEngine(ctx, taskBackend, adapter);
653
+ await scheduler.tick();
654
+ const execution = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
655
+ await execution.tick();
656
+ const closeout = new CloseoutEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
657
+ await closeout.tick();
658
+ const supervisor = makeSupervisor();
659
+ const monitor = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
660
+ await monitor.tick();
661
+ // Collect all calls to listByState and move
662
+ const listCalls = taskBackend.listByState.mock.calls.map(c => c[0]);
663
+ const moveCalls = taskBackend.move.mock.calls.map(c => c[1]);
664
+ const allStateCalls = [...listCalls, ...moveCalls];
665
+ // No call should use any default state name
666
+ for (const call of allStateCalls) {
667
+ expect(DEFAULTS).not.toContain(call);
668
+ }
669
+ // Should use custom state names instead
670
+ expect(listCalls).toContain('Planned'); // Scheduler
671
+ expect(listCalls).toContain('Queue'); // Execution (backlog)
672
+ expect(listCalls).toContain('Ready'); // Execution (todo)
673
+ expect(listCalls).toContain('Working'); // Execution (inprogress) + Monitor
674
+ expect(listCalls).toContain('Review'); // Closeout + Monitor blocked check
675
+ });
676
+ });
677
+ //# sourceMappingURL=engine-pipeline-adapter.test.js.map