@coralai/sps-cli 0.48.1 → 0.49.1
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/console-assets/assets/{index-BR3iEolY.js → index-Co0H5e5_.js} +85 -85
- package/dist/console-assets/assets/{index-lsG7ZrzD.css → index-DNKOkgDj.css} +1 -1
- package/dist/console-assets/index.html +2 -2
- package/dist/console-server/routes/system.d.ts.map +1 -1
- package/dist/console-server/routes/system.js +30 -0
- package/dist/console-server/routes/system.js.map +1 -1
- package/package.json +4 -1
- package/dist/commands/cardMarkComplete.test.d.ts +0 -2
- package/dist/commands/cardMarkComplete.test.d.ts.map +0 -1
- package/dist/commands/cardMarkComplete.test.js +0 -142
- package/dist/commands/cardMarkComplete.test.js.map +0 -1
- package/dist/commands/cardMarkStarted.test.d.ts +0 -2
- package/dist/commands/cardMarkStarted.test.d.ts.map +0 -1
- package/dist/commands/cardMarkStarted.test.js +0 -98
- package/dist/commands/cardMarkStarted.test.js.map +0 -1
- package/dist/commands/projectInit.test.d.ts +0 -2
- package/dist/commands/projectInit.test.d.ts.map +0 -1
- package/dist/commands/projectInit.test.js +0 -126
- package/dist/commands/projectInit.test.js.map +0 -1
- package/dist/commands/tick-heartbeat.test.d.ts +0 -2
- package/dist/commands/tick-heartbeat.test.d.ts.map +0 -1
- package/dist/commands/tick-heartbeat.test.js +0 -89
- package/dist/commands/tick-heartbeat.test.js.map +0 -1
- package/dist/core/checklist.test.d.ts +0 -2
- package/dist/core/checklist.test.d.ts.map +0 -1
- package/dist/core/checklist.test.js +0 -74
- package/dist/core/checklist.test.js.map +0 -1
- package/dist/core/config.test.d.ts +0 -2
- package/dist/core/config.test.d.ts.map +0 -1
- package/dist/core/config.test.js +0 -352
- package/dist/core/config.test.js.map +0 -1
- package/dist/core/lock.test.d.ts +0 -2
- package/dist/core/lock.test.d.ts.map +0 -1
- package/dist/core/lock.test.js +0 -118
- package/dist/core/lock.test.js.map +0 -1
- package/dist/core/markerFile.test.d.ts +0 -2
- package/dist/core/markerFile.test.d.ts.map +0 -1
- package/dist/core/markerFile.test.js +0 -111
- package/dist/core/markerFile.test.js.map +0 -1
- package/dist/core/queue.test.d.ts +0 -2
- package/dist/core/queue.test.d.ts.map +0 -1
- package/dist/core/queue.test.js +0 -114
- package/dist/core/queue.test.js.map +0 -1
- package/dist/core/sessionCleanup.test.d.ts +0 -2
- package/dist/core/sessionCleanup.test.d.ts.map +0 -1
- package/dist/core/sessionCleanup.test.js +0 -158
- package/dist/core/sessionCleanup.test.js.map +0 -1
- package/dist/core/shellEnv.test.d.ts +0 -2
- package/dist/core/shellEnv.test.d.ts.map +0 -1
- package/dist/core/shellEnv.test.js +0 -116
- package/dist/core/shellEnv.test.js.map +0 -1
- package/dist/core/skillStore.test.d.ts +0 -2
- package/dist/core/skillStore.test.d.ts.map +0 -1
- package/dist/core/skillStore.test.js +0 -203
- package/dist/core/skillStore.test.js.map +0 -1
- package/dist/core/state.test.d.ts +0 -2
- package/dist/core/state.test.d.ts.map +0 -1
- package/dist/core/state.test.js +0 -336
- package/dist/core/state.test.js.map +0 -1
- package/dist/engines/CloseoutEngine.d.ts +0 -72
- package/dist/engines/CloseoutEngine.d.ts.map +0 -1
- package/dist/engines/CloseoutEngine.js +0 -648
- package/dist/engines/CloseoutEngine.js.map +0 -1
- package/dist/engines/EventHandler.test.d.ts +0 -2
- package/dist/engines/EventHandler.test.d.ts.map +0 -1
- package/dist/engines/EventHandler.test.js +0 -169
- package/dist/engines/EventHandler.test.js.map +0 -1
- package/dist/engines/ExecutionEngine.d.ts +0 -125
- package/dist/engines/ExecutionEngine.d.ts.map +0 -1
- package/dist/engines/ExecutionEngine.js +0 -766
- package/dist/engines/ExecutionEngine.js.map +0 -1
- package/dist/engines/MonitorEngine.test.d.ts +0 -2
- package/dist/engines/MonitorEngine.test.d.ts.map +0 -1
- package/dist/engines/MonitorEngine.test.js +0 -355
- package/dist/engines/MonitorEngine.test.js.map +0 -1
- package/dist/engines/engine-pipeline-adapter.test.d.ts +0 -17
- package/dist/engines/engine-pipeline-adapter.test.d.ts.map +0 -1
- package/dist/engines/engine-pipeline-adapter.test.js +0 -707
- package/dist/engines/engine-pipeline-adapter.test.js.map +0 -1
- package/dist/interfaces/HookProvider.d.ts +0 -9
- package/dist/interfaces/HookProvider.d.ts.map +0 -1
- package/dist/interfaces/HookProvider.js +0 -2
- package/dist/interfaces/HookProvider.js.map +0 -1
- package/dist/manager/completion-judge.test.d.ts +0 -17
- package/dist/manager/completion-judge.test.d.ts.map +0 -1
- package/dist/manager/completion-judge.test.js +0 -233
- package/dist/manager/completion-judge.test.js.map +0 -1
- package/dist/manager/integration-queue.d.ts +0 -71
- package/dist/manager/integration-queue.d.ts.map +0 -1
- package/dist/manager/integration-queue.js +0 -137
- package/dist/manager/integration-queue.js.map +0 -1
- package/dist/manager/integration-queue.test.d.ts +0 -17
- package/dist/manager/integration-queue.test.d.ts.map +0 -1
- package/dist/manager/integration-queue.test.js +0 -210
- package/dist/manager/integration-queue.test.js.map +0 -1
- package/dist/manager/pm-client.d.ts +0 -10
- package/dist/manager/pm-client.d.ts.map +0 -1
- package/dist/manager/pm-client.js +0 -260
- package/dist/manager/pm-client.js.map +0 -1
- package/dist/manager/resource-limiter.d.ts +0 -56
- package/dist/manager/resource-limiter.d.ts.map +0 -1
- package/dist/manager/resource-limiter.js +0 -116
- package/dist/manager/resource-limiter.js.map +0 -1
- package/dist/manager/resource-limiter.test.d.ts +0 -17
- package/dist/manager/resource-limiter.test.d.ts.map +0 -1
- package/dist/manager/resource-limiter.test.js +0 -118
- package/dist/manager/resource-limiter.test.js.map +0 -1
- package/dist/manager/supervisor.test.d.ts +0 -17
- package/dist/manager/supervisor.test.d.ts.map +0 -1
- package/dist/manager/supervisor.test.js +0 -216
- package/dist/manager/supervisor.test.js.map +0 -1
- package/dist/manager/worker-manager-impl.test.d.ts +0 -17
- package/dist/manager/worker-manager-impl.test.d.ts.map +0 -1
- package/dist/manager/worker-manager-impl.test.js +0 -446
- package/dist/manager/worker-manager-impl.test.js.map +0 -1
- package/dist/providers/PlaneTaskBackend.d.ts +0 -83
- package/dist/providers/PlaneTaskBackend.d.ts.map +0 -1
- package/dist/providers/PlaneTaskBackend.js +0 -461
- package/dist/providers/PlaneTaskBackend.js.map +0 -1
- package/dist/providers/TrelloTaskBackend.d.ts +0 -64
- package/dist/providers/TrelloTaskBackend.d.ts.map +0 -1
- package/dist/providers/TrelloTaskBackend.js +0 -298
- package/dist/providers/TrelloTaskBackend.js.map +0 -1
- package/dist/providers/adapters/acp-fs-handlers.test.d.ts +0 -2
- package/dist/providers/adapters/acp-fs-handlers.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-fs-handlers.test.js +0 -80
- package/dist/providers/adapters/acp-fs-handlers.test.js.map +0 -1
- package/dist/providers/adapters/acp-permissions.test.d.ts +0 -2
- package/dist/providers/adapters/acp-permissions.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-permissions.test.js +0 -103
- package/dist/providers/adapters/acp-permissions.test.js.map +0 -1
- package/dist/providers/adapters/acp-session-accumulator.test.d.ts +0 -2
- package/dist/providers/adapters/acp-session-accumulator.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-session-accumulator.test.js +0 -88
- package/dist/providers/adapters/acp-session-accumulator.test.js.map +0 -1
- package/dist/providers/adapters/acp-terminal-manager.test.d.ts +0 -2
- package/dist/providers/adapters/acp-terminal-manager.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-terminal-manager.test.js +0 -86
- package/dist/providers/adapters/acp-terminal-manager.test.js.map +0 -1
- package/dist/providers/outputParser.test.d.ts +0 -2
- package/dist/providers/outputParser.test.d.ts.map +0 -1
- package/dist/providers/outputParser.test.js +0 -185
- package/dist/providers/outputParser.test.js.map +0 -1
- package/dist/test-setup.d.ts +0 -2
- package/dist/test-setup.d.ts.map +0 -1
- package/dist/test-setup.js +0 -27
- package/dist/test-setup.js.map +0 -1
|
@@ -1,766 +0,0 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
import { RuntimeStore } from '../core/runtimeStore.js';
|
|
4
|
-
import { resolveGitlabProjectId, resolveWorkflowTransport } from '../core/config.js';
|
|
5
|
-
import { resolveWorktreePath } from '../core/paths.js';
|
|
6
|
-
import { readQueue } from '../core/queue.js';
|
|
7
|
-
import { buildPhasePrompt, DEVELOPMENT_PROMPT_FILE, } from '../core/taskPrompts.js';
|
|
8
|
-
import { Logger } from '../core/logger.js';
|
|
9
|
-
const SKIP_LABELS = ['BLOCKED', 'NEEDS-FIX', 'CONFLICT', 'WAITING-CONFIRMATION', 'STALE-RUNTIME'];
|
|
10
|
-
/** All labels that should be cleaned when a card re-enters the pipeline */
|
|
11
|
-
const CLEANUP_LABELS = [...SKIP_LABELS, 'CLAIMED'];
|
|
12
|
-
export class ExecutionEngine {
|
|
13
|
-
ctx;
|
|
14
|
-
taskBackend;
|
|
15
|
-
repoBackend;
|
|
16
|
-
workerManager;
|
|
17
|
-
pipelineAdapter;
|
|
18
|
-
notifier;
|
|
19
|
-
log;
|
|
20
|
-
runtimeStore;
|
|
21
|
-
constructor(ctx, taskBackend, repoBackend, workerManager, pipelineAdapter, notifier) {
|
|
22
|
-
this.ctx = ctx;
|
|
23
|
-
this.taskBackend = taskBackend;
|
|
24
|
-
this.repoBackend = repoBackend;
|
|
25
|
-
this.workerManager = workerManager;
|
|
26
|
-
this.pipelineAdapter = pipelineAdapter;
|
|
27
|
-
this.notifier = notifier;
|
|
28
|
-
this.log = new Logger('pipeline', ctx.projectName, ctx.paths.logsDir);
|
|
29
|
-
this.runtimeStore = new RuntimeStore(ctx);
|
|
30
|
-
}
|
|
31
|
-
async tick(opts = {}) {
|
|
32
|
-
const actions = [];
|
|
33
|
-
const result = {
|
|
34
|
-
project: this.ctx.projectName,
|
|
35
|
-
component: 'pipeline',
|
|
36
|
-
status: 'ok',
|
|
37
|
-
exitCode: 0,
|
|
38
|
-
actions,
|
|
39
|
-
recommendedActions: [],
|
|
40
|
-
details: {},
|
|
41
|
-
};
|
|
42
|
-
let actionsThisTick = 0;
|
|
43
|
-
const maxActions = this.ctx.config.MAX_ACTIONS_PER_TICK;
|
|
44
|
-
try {
|
|
45
|
-
actions.push(...await this.reconcilePmStatesWithRuntime());
|
|
46
|
-
// 1. Process Inprogress cards (detect completion → move to QA)
|
|
47
|
-
// This runs first to free slots before launching new workers.
|
|
48
|
-
// Completion detection does NOT consume action quota — it's a
|
|
49
|
-
// prerequisite for freeing slots, not a new forward action.
|
|
50
|
-
const inprogressCards = await this.listRuntimeAwareInprogressCards();
|
|
51
|
-
for (const card of inprogressCards) {
|
|
52
|
-
if (this.shouldSkip(card))
|
|
53
|
-
continue;
|
|
54
|
-
const checkResult = await this.checkInprogressCard(card, opts);
|
|
55
|
-
if (checkResult) {
|
|
56
|
-
actions.push(checkResult);
|
|
57
|
-
// NOTE: intentionally not incrementing actionsThisTick here.
|
|
58
|
-
// Completion detection frees slots for new launches and should
|
|
59
|
-
// never block subsequent prepare/launch steps in the same tick.
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// 2. Process Backlog cards (prepare: branch + worktree + move to Todo)
|
|
63
|
-
// Prepare does NOT consume launch quota — it only sets up the
|
|
64
|
-
// environment. This allows prepare + launch to happen in a single tick.
|
|
65
|
-
// However, we limit prepares to available capacity: only prepare as
|
|
66
|
-
// many cards as there are idle slots + remaining launch quota. This
|
|
67
|
-
// prevents cards piling up in Todo when workers can't launch.
|
|
68
|
-
const backlogCards = await this.taskBackend.listByState(this.pipelineAdapter.states.backlog);
|
|
69
|
-
const currentState = this.runtimeStore.readState();
|
|
70
|
-
const idleSlots = Object.values(currentState.workers).filter(w => w.status === 'idle').length;
|
|
71
|
-
const todoCards0 = await this.taskBackend.listByState(this.pipelineAdapter.states.ready);
|
|
72
|
-
const todoCount = todoCards0.filter(c => !this.shouldSkip(c)).length;
|
|
73
|
-
const prepareLimit = Math.max(0, idleSlots - todoCount);
|
|
74
|
-
let preparedThisTick = 0;
|
|
75
|
-
for (const card of backlogCards) {
|
|
76
|
-
if (preparedThisTick >= prepareLimit)
|
|
77
|
-
break;
|
|
78
|
-
// Auto-clean auxiliary labels on Backlog cards — if a card was manually
|
|
79
|
-
// moved back to Planning/Backlog, stale labels should not block it.
|
|
80
|
-
await this.cleanAuxiliaryLabels(card);
|
|
81
|
-
if (this.shouldSkip(card)) {
|
|
82
|
-
actions.push({ action: 'skip', entity: `seq:${card.seq}`, result: 'skip', message: 'Has auxiliary state label' });
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
const prepareResult = await this.prepareCard(card, opts);
|
|
86
|
-
actions.push(prepareResult);
|
|
87
|
-
if (prepareResult.result === 'ok')
|
|
88
|
-
preparedThisTick++;
|
|
89
|
-
}
|
|
90
|
-
// 3. Process Todo cards (launch: claim + context + worker + move to Inprogress)
|
|
91
|
-
// This is the only step that consumes action quota — it starts
|
|
92
|
-
// resource-intensive AI workers that need system capacity.
|
|
93
|
-
// Sort by pipeline_order to respect card priority (#5 skip bug fix).
|
|
94
|
-
let todoCards = await this.taskBackend.listByState(this.pipelineAdapter.states.ready);
|
|
95
|
-
const pipelineOrder = readQueue(this.ctx.paths.pipelineOrderFile);
|
|
96
|
-
if (pipelineOrder.length > 0) {
|
|
97
|
-
todoCards = todoCards.sort((a, b) => {
|
|
98
|
-
const aIdx = pipelineOrder.indexOf(parseInt(a.seq, 10));
|
|
99
|
-
const bIdx = pipelineOrder.indexOf(parseInt(b.seq, 10));
|
|
100
|
-
// Cards in pipeline_order come first, in order; others after
|
|
101
|
-
if (aIdx >= 0 && bIdx >= 0)
|
|
102
|
-
return aIdx - bIdx;
|
|
103
|
-
if (aIdx >= 0)
|
|
104
|
-
return -1;
|
|
105
|
-
if (bIdx >= 0)
|
|
106
|
-
return 1;
|
|
107
|
-
return parseInt(a.seq, 10) - parseInt(b.seq, 10);
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
let launchedThisTick = 0;
|
|
111
|
-
const failedSlots = new Set(); // track slots that failed launch this tick
|
|
112
|
-
for (const card of todoCards) {
|
|
113
|
-
if (actionsThisTick >= maxActions)
|
|
114
|
-
break;
|
|
115
|
-
if (this.shouldSkip(card)) {
|
|
116
|
-
actions.push({ action: 'skip', entity: `seq:${card.seq}`, result: 'skip', message: 'Has auxiliary state label' });
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
// Stagger is handled by ResourceLimiter.enforceStagger() inside launchCard
|
|
120
|
-
const launchResult = await this.launchCard(card, opts, failedSlots);
|
|
121
|
-
actions.push(launchResult);
|
|
122
|
-
if (launchResult.result === 'ok') {
|
|
123
|
-
actionsThisTick++;
|
|
124
|
-
launchedThisTick++;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
130
|
-
this.log.error(`Pipeline tick failed: ${msg}`);
|
|
131
|
-
result.status = 'fail';
|
|
132
|
-
result.exitCode = 1;
|
|
133
|
-
result.details = { error: msg };
|
|
134
|
-
}
|
|
135
|
-
// Check for any failures
|
|
136
|
-
if (actions.some((a) => a.result === 'fail') && result.status === 'ok') {
|
|
137
|
-
result.status = 'fail';
|
|
138
|
-
result.exitCode = 1;
|
|
139
|
-
}
|
|
140
|
-
return result;
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* Launch a single card (for `sps worker launch <project> <seq>`).
|
|
144
|
-
* Assumes card is in Todo state with branch/worktree already prepared.
|
|
145
|
-
*/
|
|
146
|
-
async launchSingle(seq, opts = {}) {
|
|
147
|
-
const result = {
|
|
148
|
-
project: this.ctx.projectName,
|
|
149
|
-
component: 'worker-launch',
|
|
150
|
-
status: 'ok',
|
|
151
|
-
exitCode: 0,
|
|
152
|
-
actions: [],
|
|
153
|
-
recommendedActions: [],
|
|
154
|
-
details: {},
|
|
155
|
-
};
|
|
156
|
-
const card = await this.taskBackend.getBySeq(seq);
|
|
157
|
-
if (!card) {
|
|
158
|
-
result.status = 'fail';
|
|
159
|
-
result.exitCode = 1;
|
|
160
|
-
result.details = { error: `Card seq:${seq} not found` };
|
|
161
|
-
return result;
|
|
162
|
-
}
|
|
163
|
-
// If card is in Backlog, do prepare first
|
|
164
|
-
if (card.state === this.pipelineAdapter.states.backlog) {
|
|
165
|
-
const prepareAction = await this.prepareCard(card, opts);
|
|
166
|
-
result.actions.push(prepareAction);
|
|
167
|
-
if (prepareAction.result === 'fail') {
|
|
168
|
-
result.status = 'fail';
|
|
169
|
-
result.exitCode = 1;
|
|
170
|
-
return result;
|
|
171
|
-
}
|
|
172
|
-
// Reload card after prepare
|
|
173
|
-
const updated = await this.taskBackend.getBySeq(seq);
|
|
174
|
-
if (!updated || updated.state !== this.pipelineAdapter.states.ready) {
|
|
175
|
-
result.status = 'fail';
|
|
176
|
-
result.exitCode = 1;
|
|
177
|
-
result.details = { error: `Card not in ${this.pipelineAdapter.states.ready} after prepare` };
|
|
178
|
-
return result;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
if (card.state !== this.pipelineAdapter.states.ready && card.state !== this.pipelineAdapter.states.backlog) {
|
|
182
|
-
result.status = 'fail';
|
|
183
|
-
result.exitCode = 2;
|
|
184
|
-
result.details = { error: `Card seq:${seq} is in ${card.state}, expected ${this.pipelineAdapter.states.backlog} or ${this.pipelineAdapter.states.ready}` };
|
|
185
|
-
return result;
|
|
186
|
-
}
|
|
187
|
-
const launchAction = await this.launchCard(card, opts);
|
|
188
|
-
result.actions.push(launchAction);
|
|
189
|
-
if (launchAction.result === 'fail') {
|
|
190
|
-
result.status = 'fail';
|
|
191
|
-
result.exitCode = 1;
|
|
192
|
-
}
|
|
193
|
-
return result;
|
|
194
|
-
}
|
|
195
|
-
shouldSkip(card) {
|
|
196
|
-
return SKIP_LABELS.some((label) => card.labels.includes(label));
|
|
197
|
-
}
|
|
198
|
-
async listRuntimeAwareInprogressCards() {
|
|
199
|
-
const cards = await this.taskBackend.listByState(this.pipelineAdapter.states.active);
|
|
200
|
-
const bySeq = new Map(cards.map(card => [card.seq, card]));
|
|
201
|
-
const state = this.runtimeStore.readState();
|
|
202
|
-
for (const [seq, lease] of Object.entries(state.leases)) {
|
|
203
|
-
const slot = lease.slot ? state.workers[lease.slot] || null : null;
|
|
204
|
-
if (this.derivePmStateFromLease(lease, slot) !== this.pipelineAdapter.states.active || bySeq.has(seq))
|
|
205
|
-
continue;
|
|
206
|
-
const card = await this.taskBackend.getBySeq(seq);
|
|
207
|
-
if (card)
|
|
208
|
-
bySeq.set(seq, card);
|
|
209
|
-
}
|
|
210
|
-
return Array.from(bySeq.values()).sort((a, b) => parseInt(a.seq, 10) - parseInt(b.seq, 10));
|
|
211
|
-
}
|
|
212
|
-
async reconcilePmStatesWithRuntime() {
|
|
213
|
-
const state = this.runtimeStore.readState();
|
|
214
|
-
const actions = [];
|
|
215
|
-
for (const [seq, lease] of Object.entries(state.leases)) {
|
|
216
|
-
const slot = lease.slot ? state.workers[lease.slot] || null : null;
|
|
217
|
-
const targetState = this.derivePmStateFromLease(lease, slot);
|
|
218
|
-
if (!targetState)
|
|
219
|
-
continue;
|
|
220
|
-
const card = await this.taskBackend.getBySeq(seq);
|
|
221
|
-
if (!card || card.state === targetState)
|
|
222
|
-
continue;
|
|
223
|
-
try {
|
|
224
|
-
await this.taskBackend.move(seq, targetState);
|
|
225
|
-
this.log.info(`Reconciled seq ${seq} ${card.state} → ${targetState} to match runtime state`);
|
|
226
|
-
actions.push({
|
|
227
|
-
action: 'pm-reconcile',
|
|
228
|
-
entity: `seq:${seq}`,
|
|
229
|
-
result: 'ok',
|
|
230
|
-
message: `${card.state} → ${targetState}`,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
catch (err) {
|
|
234
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
235
|
-
this.log.warn(`Failed to reconcile PM state for seq ${seq}: ${msg}`);
|
|
236
|
-
actions.push({
|
|
237
|
-
action: 'pm-reconcile',
|
|
238
|
-
entity: `seq:${seq}`,
|
|
239
|
-
result: 'skip',
|
|
240
|
-
message: msg,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return actions;
|
|
245
|
-
}
|
|
246
|
-
derivePmStateFromLease(lease, slot) {
|
|
247
|
-
const s = this.pipelineAdapter.states;
|
|
248
|
-
if (lease.phase === 'queued' || lease.phase === 'preparing') {
|
|
249
|
-
return s.ready;
|
|
250
|
-
}
|
|
251
|
-
if (lease.phase === 'coding') {
|
|
252
|
-
return s.active;
|
|
253
|
-
}
|
|
254
|
-
if (lease.phase === 'waiting_confirmation'
|
|
255
|
-
&& lease.pmStateObserved !== s.review
|
|
256
|
-
&& slot?.status !== 'merging'
|
|
257
|
-
&& slot?.status !== 'resolving') {
|
|
258
|
-
return s.active;
|
|
259
|
-
}
|
|
260
|
-
if (['merging', 'resolving_conflict', 'closing'].includes(lease.phase)) {
|
|
261
|
-
return s.review;
|
|
262
|
-
}
|
|
263
|
-
if (lease.phase === 'waiting_confirmation' && lease.pmStateObserved === s.review) {
|
|
264
|
-
return s.review;
|
|
265
|
-
}
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
isRuntimeOwnedSlot(slot) {
|
|
269
|
-
return !!slot && slot.status !== 'idle';
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
* Remove auxiliary state labels (STALE-RUNTIME, NEEDS-FIX, etc.) from a card.
|
|
273
|
-
* Called when a card re-enters Backlog — indicates human intent to retry,
|
|
274
|
-
* so stale labels from previous runs should not block it.
|
|
275
|
-
*/
|
|
276
|
-
async cleanAuxiliaryLabels(card) {
|
|
277
|
-
for (const label of CLEANUP_LABELS) {
|
|
278
|
-
if (card.labels.includes(label)) {
|
|
279
|
-
try {
|
|
280
|
-
await this.taskBackend.removeLabel(card.seq, label);
|
|
281
|
-
card.labels = card.labels.filter(l => l !== label);
|
|
282
|
-
this.log.ok(`Removed stale label "${label}" from seq ${card.seq}`);
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
this.log.warn(`Failed to remove label "${label}" from seq ${card.seq}`);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
// ─── Inprogress Phase (detect completion → Done) ────────────────
|
|
291
|
-
/**
|
|
292
|
-
* Check an Inprogress card: verify worker is still running or handled by exit callback.
|
|
293
|
-
*
|
|
294
|
-
* The Supervisor exit callback triggers CompletionJudge → PostActions automatically,
|
|
295
|
-
* so this method only needs to:
|
|
296
|
-
* - Update heartbeat if worker is still running
|
|
297
|
-
* - Confirm completion if PostActions already processed it
|
|
298
|
-
*/
|
|
299
|
-
async checkInprogressCard(card, opts) {
|
|
300
|
-
const seq = card.seq;
|
|
301
|
-
const state = this.runtimeStore.readState();
|
|
302
|
-
const lease = state.leases[seq] || null;
|
|
303
|
-
const slotName = this.findRuntimeSlotName(state, seq, lease);
|
|
304
|
-
if (!slotName) {
|
|
305
|
-
// Slot already released (PostActions handled it via exit callback)
|
|
306
|
-
return null;
|
|
307
|
-
}
|
|
308
|
-
// Use WorkerManager.inspect() to check worker state
|
|
309
|
-
const snapshots = this.workerManager.inspect({ project: this.ctx.projectName, taskId: seq });
|
|
310
|
-
const snapshot = snapshots[0];
|
|
311
|
-
if (snapshot && (snapshot.state === 'running' || snapshot.state === 'starting')) {
|
|
312
|
-
// Worker still running — update heartbeat
|
|
313
|
-
try {
|
|
314
|
-
this.runtimeStore.updateState('pipeline-heartbeat', (freshState) => {
|
|
315
|
-
if (freshState.workers[slotName]) {
|
|
316
|
-
freshState.workers[slotName].lastHeartbeat = new Date().toISOString();
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
catch { /* non-fatal */ }
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
if (snapshot && (snapshot.state === 'waiting_input' || snapshot.state === 'needs_confirmation')) {
|
|
324
|
-
// Worker waiting for input — log and wait
|
|
325
|
-
this.log.info(`seq ${seq}: worker in state ${snapshot.state}`);
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
if (snapshot && snapshot.state === 'completed') {
|
|
329
|
-
// WM exit callback handled completion
|
|
330
|
-
this.log.ok(`seq ${seq}: Completed (handled by WM exit callback)`);
|
|
331
|
-
return { action: 'complete', entity: `seq:${seq}`, result: 'ok', message: 'Completed via WM exit callback' };
|
|
332
|
-
}
|
|
333
|
-
if (snapshot && snapshot.state === 'failed') {
|
|
334
|
-
// WM exit callback handled failure
|
|
335
|
-
this.log.info(`seq ${seq}: Failed (handled by WM exit callback)`);
|
|
336
|
-
return { action: 'complete', entity: `seq:${seq}`, result: 'fail', message: 'Failed via WM exit callback' };
|
|
337
|
-
}
|
|
338
|
-
// No snapshot found — WM already processed and released the slot
|
|
339
|
-
const freshState = this.runtimeStore.readState();
|
|
340
|
-
if (!freshState.workers[slotName] || freshState.workers[slotName].status === 'idle') {
|
|
341
|
-
this.log.ok(`seq ${seq}: Completed (WM already processed)`);
|
|
342
|
-
return { action: 'complete', entity: `seq:${seq}`, result: 'ok', message: 'Completed (WM processed)' };
|
|
343
|
-
}
|
|
344
|
-
// Still active in state but no snapshot — MonitorEngine/Recovery handles
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
// ─── Prepare Phase (Backlog → Todo) ─────────────────────────────
|
|
348
|
-
/**
|
|
349
|
-
* Prepare a Backlog card: create branch, create worktree, move to Todo.
|
|
350
|
-
* Steps 1-3 per 01 §4.3.
|
|
351
|
-
*/
|
|
352
|
-
async prepareCard(card, opts) {
|
|
353
|
-
const seq = card.seq;
|
|
354
|
-
const branchName = this.buildBranchName(card);
|
|
355
|
-
const worktreePath = resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
|
|
356
|
-
if (opts.dryRun) {
|
|
357
|
-
this.log.info(`[dry-run] Would prepare seq ${seq}: branch=${branchName} worktree=${worktreePath}`);
|
|
358
|
-
return { action: 'prepare', entity: `seq:${seq}`, result: 'ok', message: 'dry-run' };
|
|
359
|
-
}
|
|
360
|
-
// Step 1: Create branch
|
|
361
|
-
try {
|
|
362
|
-
await this.repoBackend.ensureBranch(this.ctx.paths.repoDir, branchName, this.ctx.mergeBranch);
|
|
363
|
-
this.log.ok(`Step 1: Branch ${branchName} created for seq ${seq}`);
|
|
364
|
-
}
|
|
365
|
-
catch (err) {
|
|
366
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
-
this.log.error(`Step 1 failed (branch) for seq ${seq}: ${msg}`);
|
|
368
|
-
this.logEvent('prepare-branch', seq, 'fail', { error: msg });
|
|
369
|
-
return { action: 'prepare', entity: `seq:${seq}`, result: 'fail', message: `Branch creation failed: ${msg}` };
|
|
370
|
-
}
|
|
371
|
-
// Step 2: Create worktree
|
|
372
|
-
try {
|
|
373
|
-
await this.repoBackend.ensureWorktree(this.ctx.paths.repoDir, branchName, worktreePath);
|
|
374
|
-
this.log.ok(`Step 2: Worktree created for seq ${seq} at ${worktreePath}`);
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
378
|
-
this.log.error(`Step 2 failed (worktree) for seq ${seq}: ${msg}`);
|
|
379
|
-
this.logEvent('prepare-worktree', seq, 'fail', { error: msg });
|
|
380
|
-
// Rollback: cleanup branch (best effort, branch may have existed before)
|
|
381
|
-
return { action: 'prepare', entity: `seq:${seq}`, result: 'fail', message: `Worktree creation failed: ${msg}` };
|
|
382
|
-
}
|
|
383
|
-
// Step 3: Move card to Todo
|
|
384
|
-
try {
|
|
385
|
-
await this.taskBackend.move(seq, this.pipelineAdapter.states.ready);
|
|
386
|
-
this.log.ok(`Step 3: Moved seq ${seq} ${this.pipelineAdapter.states.backlog} → ${this.pipelineAdapter.states.ready}`);
|
|
387
|
-
this.logEvent('prepare', seq, 'ok');
|
|
388
|
-
if (this.notifier) {
|
|
389
|
-
await this.notifier.send(`ℹ️ [${this.ctx.projectName}] seq:${seq} environment ready (${this.pipelineAdapter.states.backlog} → ${this.pipelineAdapter.states.ready})`).catch(() => { });
|
|
390
|
-
}
|
|
391
|
-
return { action: 'prepare', entity: `seq:${seq}`, result: 'ok', message: `${this.pipelineAdapter.states.backlog} → ${this.pipelineAdapter.states.ready}` };
|
|
392
|
-
}
|
|
393
|
-
catch (err) {
|
|
394
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
395
|
-
this.log.error(`Step 3 failed (move) for seq ${seq}: ${msg}`);
|
|
396
|
-
this.logEvent('prepare-move', seq, 'fail', { error: msg });
|
|
397
|
-
// Rollback: cleanup branch + worktree would be ideal but risky; log for manual cleanup
|
|
398
|
-
return { action: 'prepare', entity: `seq:${seq}`, result: 'fail', message: `Move to ${this.pipelineAdapter.states.ready} failed: ${msg}` };
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
// ─── Launch Phase (Todo → Inprogress) ────────────────────────────
|
|
402
|
-
/**
|
|
403
|
-
* Launch a Todo card: claim slot, build context, start worker, move to Inprogress.
|
|
404
|
-
* Steps 4-7 per 01 §4.3.
|
|
405
|
-
*/
|
|
406
|
-
async launchCard(card, opts, failedSlots = new Set()) {
|
|
407
|
-
const seq = card.seq;
|
|
408
|
-
const branchName = this.buildBranchName(card);
|
|
409
|
-
const worktreePath = resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
|
|
410
|
-
const workflowTransport = resolveWorkflowTransport(this.ctx.config);
|
|
411
|
-
if (opts.dryRun) {
|
|
412
|
-
this.log.info(`[dry-run] Would launch seq ${seq}`);
|
|
413
|
-
return { action: 'launch', entity: `seq:${seq}`, result: 'ok', message: 'dry-run' };
|
|
414
|
-
}
|
|
415
|
-
// Step 5: PM claim (kept in Engine — PM backend awareness)
|
|
416
|
-
try {
|
|
417
|
-
await this.taskBackend.claim(seq, `pending-wm`);
|
|
418
|
-
}
|
|
419
|
-
catch (err) {
|
|
420
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
421
|
-
this.log.warn(`PM claim for seq ${seq} failed (non-fatal): ${msg}`);
|
|
422
|
-
}
|
|
423
|
-
// Step 5b: Build development prompt (in-memory, archive to .sps/)
|
|
424
|
-
let prompt;
|
|
425
|
-
try {
|
|
426
|
-
prompt = this.buildDevelopmentPrompt(card, worktreePath);
|
|
427
|
-
this.log.ok(`Step 5: Task context built for seq ${seq}`);
|
|
428
|
-
}
|
|
429
|
-
catch (err) {
|
|
430
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
431
|
-
this.log.error(`Step 5 failed (context) for seq ${seq}: ${msg}`);
|
|
432
|
-
this.logEvent('launch-context', seq, 'fail', { error: msg });
|
|
433
|
-
return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Context build failed: ${msg}` };
|
|
434
|
-
}
|
|
435
|
-
// Step 6: Launch worker via WorkerManager.run()
|
|
436
|
-
const logsDir = this.ctx.config.raw.LOGS_DIR || `/tmp/sps-${this.ctx.projectName}`;
|
|
437
|
-
const runRequest = {
|
|
438
|
-
taskId: String(card.seq),
|
|
439
|
-
cardId: String(card.seq),
|
|
440
|
-
project: this.ctx.projectName,
|
|
441
|
-
phase: 'development',
|
|
442
|
-
prompt,
|
|
443
|
-
cwd: worktreePath,
|
|
444
|
-
branch: branchName,
|
|
445
|
-
targetBranch: this.ctx.mergeBranch,
|
|
446
|
-
tool: (this.pipelineAdapter.developStage.agent || this.ctx.config.WORKER_TOOL),
|
|
447
|
-
transport: 'acp-sdk',
|
|
448
|
-
outputFile: resolve(logsDir, `${this.ctx.projectName}-worker-${card.seq}-${Date.now()}.jsonl`),
|
|
449
|
-
timeoutSec: this.ctx.config.WORKER_LAUNCH_TIMEOUT_S,
|
|
450
|
-
maxRetries: this.ctx.config.WORKER_RESTART_LIMIT,
|
|
451
|
-
completionStrategy: this.pipelineAdapter.developStage.completion,
|
|
452
|
-
};
|
|
453
|
-
let response;
|
|
454
|
-
try {
|
|
455
|
-
response = await this.workerManager.run(runRequest);
|
|
456
|
-
}
|
|
457
|
-
catch (err) {
|
|
458
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
459
|
-
this.log.error(`Step 6 failed (WM.run) for seq ${seq}: ${msg}`);
|
|
460
|
-
failedSlots.add(`wm-error-${seq}`);
|
|
461
|
-
this.logEvent('launch-worker', seq, 'fail', { error: msg });
|
|
462
|
-
return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Worker launch failed: ${msg}` };
|
|
463
|
-
}
|
|
464
|
-
if (!response.accepted) {
|
|
465
|
-
this.log.warn(`WM rejected seq ${seq}: ${response.rejectReason}`);
|
|
466
|
-
return {
|
|
467
|
-
action: 'launch',
|
|
468
|
-
entity: `seq:${seq}`,
|
|
469
|
-
result: response.rejectReason === 'resource_exhausted' ? 'skip' : 'fail',
|
|
470
|
-
message: `WM rejected: ${response.rejectReason}`,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
const slotName = response.slot;
|
|
474
|
-
this.log.ok(`Step 6: WM launched worker for seq ${seq} (slot=${slotName}, pid=${response.pid ?? 'n/a'})`);
|
|
475
|
-
if (this.notifier) {
|
|
476
|
-
await this.notifier.send(`▶️ [${this.ctx.projectName}] seq:${seq} worker started (${slotName})`).catch(() => { });
|
|
477
|
-
}
|
|
478
|
-
// Step 7: Move card to Inprogress
|
|
479
|
-
try {
|
|
480
|
-
await this.taskBackend.move(seq, this.pipelineAdapter.states.active);
|
|
481
|
-
// Update active card state
|
|
482
|
-
this.runtimeStore.updateState('pipeline-launch', (freshState) => {
|
|
483
|
-
if (freshState.activeCards[seq]) {
|
|
484
|
-
freshState.activeCards[seq].state = this.pipelineAdapter.states.active;
|
|
485
|
-
if (freshState.leases[seq]) {
|
|
486
|
-
freshState.leases[seq].pmStateObserved = this.pipelineAdapter.states.active;
|
|
487
|
-
if (freshState.leases[seq].phase === 'preparing' || freshState.leases[seq].phase === 'queued') {
|
|
488
|
-
freshState.leases[seq].phase = 'coding';
|
|
489
|
-
}
|
|
490
|
-
freshState.leases[seq].lastTransitionAt = new Date().toISOString();
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
this.log.ok(`Step 7: Moved seq ${seq} ${this.pipelineAdapter.states.ready} → ${this.pipelineAdapter.states.active}`);
|
|
495
|
-
this.logEvent('launch', seq, 'ok', { worker: slotName });
|
|
496
|
-
return { action: 'launch', entity: `seq:${seq}`, result: 'ok', message: `${this.pipelineAdapter.states.ready} → ${this.pipelineAdapter.states.active} (${slotName})` };
|
|
497
|
-
}
|
|
498
|
-
catch (err) {
|
|
499
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
500
|
-
this.log.error(`Step 7 failed (move) for seq ${seq}: ${msg}`);
|
|
501
|
-
// Rollback: cancel worker via WM (handles kill + resource release)
|
|
502
|
-
try {
|
|
503
|
-
await this.workerManager.cancel({ taskId: String(card.seq), project: this.ctx.projectName, reason: 'anomaly' });
|
|
504
|
-
}
|
|
505
|
-
catch { /* best effort */ }
|
|
506
|
-
this.releaseSlot(slotName, seq);
|
|
507
|
-
this.logEvent('launch-move', seq, 'fail', { error: msg });
|
|
508
|
-
return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Move to ${this.pipelineAdapter.states.active} failed: ${msg}` };
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* @deprecated Phase 1 transitional — WM's internal exit callback handles ACP inspection.
|
|
513
|
-
* Kept for edge-case fallback; will be removed when WM fully owns ACP lifecycle.
|
|
514
|
-
*/
|
|
515
|
-
async checkAcpInprogressCard(card, slotName) {
|
|
516
|
-
const seq = card.seq;
|
|
517
|
-
const state = this.runtimeStore.readState();
|
|
518
|
-
const slot = state.workers[slotName];
|
|
519
|
-
if (!slot)
|
|
520
|
-
return null;
|
|
521
|
-
// Use WorkerManager.inspect() for normalized worker state
|
|
522
|
-
const snapshots = this.workerManager.inspect({ project: this.ctx.projectName, taskId: seq });
|
|
523
|
-
const snapshot = snapshots[0];
|
|
524
|
-
if (snapshot && (snapshot.state === 'running' || snapshot.state === 'starting')) {
|
|
525
|
-
// Worker still active — update heartbeat
|
|
526
|
-
this.runtimeStore.updateState('pipeline-acp-heartbeat', (freshState) => {
|
|
527
|
-
const freshSlot = freshState.workers[slotName];
|
|
528
|
-
if (freshSlot) {
|
|
529
|
-
freshSlot.lastHeartbeat = new Date().toISOString();
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
return null;
|
|
533
|
-
}
|
|
534
|
-
if (snapshot && snapshot.state === 'waiting_input') {
|
|
535
|
-
this.log.info(`seq ${seq}: worker waiting for input`);
|
|
536
|
-
return null;
|
|
537
|
-
}
|
|
538
|
-
if (snapshot && snapshot.state === 'needs_confirmation') {
|
|
539
|
-
this.log.warn(`seq ${seq}: worker needs confirmation`);
|
|
540
|
-
return null;
|
|
541
|
-
}
|
|
542
|
-
if (snapshot && snapshot.state === 'completed') {
|
|
543
|
-
this.log.ok(`seq ${seq}: ACP run completed (via WM)`);
|
|
544
|
-
return {
|
|
545
|
-
action: 'complete',
|
|
546
|
-
entity: `seq:${seq}`,
|
|
547
|
-
result: 'ok',
|
|
548
|
-
message: `${resolveWorkflowTransport(this.ctx.config).toUpperCase()} run completed`,
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
if (snapshot && snapshot.state === 'failed') {
|
|
552
|
-
this.log.info(`seq ${seq}: ACP run failed (via WM)`);
|
|
553
|
-
return {
|
|
554
|
-
action: 'complete',
|
|
555
|
-
entity: `seq:${seq}`,
|
|
556
|
-
result: 'fail',
|
|
557
|
-
message: `${resolveWorkflowTransport(this.ctx.config).toUpperCase()} run failed`,
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
// No snapshot — session lost or already cleaned up
|
|
561
|
-
this.runtimeStore.updateState('pipeline-acp-lost', (freshState) => {
|
|
562
|
-
const lostSlot = freshState.workers[slotName];
|
|
563
|
-
if (lostSlot) {
|
|
564
|
-
lostSlot.sessionState = 'offline';
|
|
565
|
-
lostSlot.remoteStatus = 'lost';
|
|
566
|
-
lostSlot.lastEventAt = new Date().toISOString();
|
|
567
|
-
lostSlot.lastHeartbeat = new Date().toISOString();
|
|
568
|
-
}
|
|
569
|
-
});
|
|
570
|
-
this.log.warn(`seq ${seq}: ACP session lost — no WM snapshot found`);
|
|
571
|
-
return {
|
|
572
|
-
action: 'complete',
|
|
573
|
-
entity: `seq:${seq}`,
|
|
574
|
-
result: 'fail',
|
|
575
|
-
message: 'ACP session lost',
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
|
-
findRuntimeSlotName(state, seq, lease) {
|
|
579
|
-
if (lease?.slot && state.workers[lease.slot])
|
|
580
|
-
return lease.slot;
|
|
581
|
-
const slotEntry = Object.entries(state.workers).find(([, worker]) => worker.seq === parseInt(seq, 10) && worker.status !== 'idle');
|
|
582
|
-
return slotEntry?.[0] || null;
|
|
583
|
-
}
|
|
584
|
-
getRetryCount(state, seq) {
|
|
585
|
-
return state.leases[seq]?.retryCount ?? state.activeCards[seq]?.retryCount ?? 0;
|
|
586
|
-
}
|
|
587
|
-
// ─── Helpers ─────────────────────────────────────────────────────
|
|
588
|
-
/**
|
|
589
|
-
* Build branch name from card: feature/<seq>-<slug>
|
|
590
|
-
*/
|
|
591
|
-
buildBranchName(card) {
|
|
592
|
-
const slug = card.name
|
|
593
|
-
.toLowerCase()
|
|
594
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
595
|
-
.replace(/^-|-$/g, '')
|
|
596
|
-
.slice(0, 40);
|
|
597
|
-
return `feature/${card.seq}-${slug}`;
|
|
598
|
-
}
|
|
599
|
-
/**
|
|
600
|
-
* Write task-specific prompt to worktree.
|
|
601
|
-
*
|
|
602
|
-
* CLAUDE.md and AGENTS.md are managed by `sps doctor --fix` and committed
|
|
603
|
-
* to the repo — worktrees inherit them automatically via git.
|
|
604
|
-
*
|
|
605
|
-
* The prompt file includes the project rules (from CLAUDE.md) followed by
|
|
606
|
-
* the task-specific details. This ensures that when a session is reused
|
|
607
|
-
* (WORKER_SESSION_REUSE=true), the worker always receives the latest
|
|
608
|
-
* project rules via the prompt — even though /clear + cd does not
|
|
609
|
-
* trigger Claude/Codex to re-read CLAUDE.md from disk.
|
|
610
|
-
*/
|
|
611
|
-
/**
|
|
612
|
-
* Build the shared prompt context used by both development and integration phases.
|
|
613
|
-
* Loads skill profiles, project rules, and knowledge from the worktree.
|
|
614
|
-
*/
|
|
615
|
-
buildPromptContext(card, worktreePath) {
|
|
616
|
-
const branchName = this.buildBranchName(card);
|
|
617
|
-
const skillContent = this.loadSkillProfiles(card);
|
|
618
|
-
const claudeMdPath = resolve(worktreePath, 'CLAUDE.md');
|
|
619
|
-
const agentsMdPath = resolve(worktreePath, 'AGENTS.md');
|
|
620
|
-
const workerTool = this.pipelineAdapter.developStage.agent || this.ctx.config.WORKER_TOOL;
|
|
621
|
-
let projectRules = '';
|
|
622
|
-
if (existsSync(claudeMdPath)) {
|
|
623
|
-
projectRules = readFileSync(claudeMdPath, 'utf-8').trim();
|
|
624
|
-
}
|
|
625
|
-
if (existsSync(agentsMdPath)) {
|
|
626
|
-
const agentsRules = readFileSync(agentsMdPath, 'utf-8').trim();
|
|
627
|
-
projectRules = projectRules ? `${projectRules}\n\n${agentsRules}` : agentsRules;
|
|
628
|
-
}
|
|
629
|
-
if (!projectRules) {
|
|
630
|
-
const expectedFile = workerTool === 'codex' ? 'AGENTS.md' : 'CLAUDE.md';
|
|
631
|
-
this.log.warn(`${expectedFile} not found in worktree — run: sps doctor ${this.ctx.projectName} --fix`);
|
|
632
|
-
}
|
|
633
|
-
const knowledge = this.loadProjectKnowledge(worktreePath);
|
|
634
|
-
return {
|
|
635
|
-
taskSeq: card.seq,
|
|
636
|
-
taskTitle: card.name,
|
|
637
|
-
taskDescription: card.desc || '(no description)',
|
|
638
|
-
cardId: card.id,
|
|
639
|
-
worktreePath,
|
|
640
|
-
branchName,
|
|
641
|
-
targetBranch: this.ctx.mergeBranch,
|
|
642
|
-
mergeMode: this.ctx.mrMode,
|
|
643
|
-
gitlabProjectId: resolveGitlabProjectId(this.ctx.config),
|
|
644
|
-
skillContent,
|
|
645
|
-
projectRules,
|
|
646
|
-
knowledge,
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
/**
|
|
650
|
-
* Generate development prompt and archive to .sps/ for debugging.
|
|
651
|
-
* Returns the prompt string directly — no disk file dependency.
|
|
652
|
-
*/
|
|
653
|
-
buildDevelopmentPrompt(card, worktreePath) {
|
|
654
|
-
const ctx = this.buildPromptContext(card, worktreePath);
|
|
655
|
-
const prompt = buildPhasePrompt({ ...ctx, phase: 'development' });
|
|
656
|
-
// Archive to .sps/ for debugging (non-blocking)
|
|
657
|
-
try {
|
|
658
|
-
const spsDir = resolve(worktreePath, '.sps');
|
|
659
|
-
if (!existsSync(spsDir))
|
|
660
|
-
mkdirSync(spsDir, { recursive: true });
|
|
661
|
-
writeFileSync(resolve(spsDir, DEVELOPMENT_PROMPT_FILE), prompt);
|
|
662
|
-
}
|
|
663
|
-
catch { /* archive failure should never block worker launch */ }
|
|
664
|
-
return prompt;
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Release a worker slot and remove card from active cards.
|
|
668
|
-
* Used for launch failure rollback.
|
|
669
|
-
*/
|
|
670
|
-
releaseSlot(slotName, seq) {
|
|
671
|
-
try {
|
|
672
|
-
this.runtimeStore.updateState('pipeline-release', (state) => {
|
|
673
|
-
this.runtimeStore.releaseTaskProjection(state, seq, { dropLease: true });
|
|
674
|
-
});
|
|
675
|
-
this.taskBackend.releaseClaim(seq).catch(() => { });
|
|
676
|
-
}
|
|
677
|
-
catch {
|
|
678
|
-
this.log.warn(`Failed to release slot ${slotName} for seq ${seq}`);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
// ─── Skill Profile Loading (label-driven) ─────────────────────
|
|
682
|
-
/**
|
|
683
|
-
* Load skill profiles based on card labels (skill:xxx) or project default.
|
|
684
|
-
* Returns combined profile content for prompt injection.
|
|
685
|
-
*/
|
|
686
|
-
loadSkillProfiles(card) {
|
|
687
|
-
// 1. Extract skill:xxx labels from card
|
|
688
|
-
let skills = card.labels
|
|
689
|
-
.filter(l => l.startsWith('skill:'))
|
|
690
|
-
.map(l => l.slice('skill:'.length));
|
|
691
|
-
// 2. Stage-level profile from pipeline YAML (overrides project default)
|
|
692
|
-
if (skills.length === 0 && this.pipelineAdapter.developStage.profile) {
|
|
693
|
-
skills = this.pipelineAdapter.developStage.profile.split(',').map(s => s.trim()).filter(Boolean);
|
|
694
|
-
}
|
|
695
|
-
// 3. Fallback to project default
|
|
696
|
-
if (skills.length === 0) {
|
|
697
|
-
const defaultSkills = this.ctx.config.raw.DEFAULT_WORKER_SKILLS;
|
|
698
|
-
if (defaultSkills) {
|
|
699
|
-
skills = defaultSkills.split(',').map(s => s.trim()).filter(Boolean);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
if (skills.length === 0)
|
|
703
|
-
return '';
|
|
704
|
-
// Don't inject full profile content — agent loads skills on demand
|
|
705
|
-
// via ~/.claude/skills/ or ~/.codex/skills/ (symlinked from ~/.coral/skills/).
|
|
706
|
-
// Just tell the agent which skills to activate.
|
|
707
|
-
this.log.ok(`Skill labels: ${skills.join(', ')}`);
|
|
708
|
-
return `# Required Skills\n\nThis task requires the following skills: ${skills.join(', ')}.\nLoad the dev-worker skill and read the corresponding references.`;
|
|
709
|
-
}
|
|
710
|
-
// ─── Project Knowledge Loading (truncated) ────────────────────
|
|
711
|
-
/**
|
|
712
|
-
* Load recent project knowledge from docs/DECISIONS.md and docs/CHANGELOG.md.
|
|
713
|
-
* Truncates to recent entries to keep prompt size manageable.
|
|
714
|
-
*/
|
|
715
|
-
loadProjectKnowledge(worktreePath) {
|
|
716
|
-
const sections = ['# Project Knowledge (from previous tasks)'];
|
|
717
|
-
let hasContent = false;
|
|
718
|
-
// Recent decisions (last 10 sections)
|
|
719
|
-
const decisionsPath = resolve(worktreePath, 'docs', 'DECISIONS.md');
|
|
720
|
-
if (existsSync(decisionsPath)) {
|
|
721
|
-
const content = readFileSync(decisionsPath, 'utf-8');
|
|
722
|
-
const recent = this.extractRecentSections(content, 10);
|
|
723
|
-
if (recent) {
|
|
724
|
-
sections.push('## Recent Decisions\n' + recent);
|
|
725
|
-
hasContent = true;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
// Recent changelog (last 5 sections)
|
|
729
|
-
const changelogPath = resolve(worktreePath, 'docs', 'CHANGELOG.md');
|
|
730
|
-
if (existsSync(changelogPath)) {
|
|
731
|
-
const content = readFileSync(changelogPath, 'utf-8');
|
|
732
|
-
const recent = this.extractRecentSections(content, 5);
|
|
733
|
-
if (recent) {
|
|
734
|
-
sections.push('## Recent Changes\n' + recent);
|
|
735
|
-
hasContent = true;
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
return hasContent ? sections.join('\n\n') : '';
|
|
739
|
-
}
|
|
740
|
-
/**
|
|
741
|
-
* Extract the last N ## sections from a markdown file.
|
|
742
|
-
*/
|
|
743
|
-
extractRecentSections(content, maxSections) {
|
|
744
|
-
const lines = content.split('\n');
|
|
745
|
-
const sectionStarts = [];
|
|
746
|
-
for (let i = 0; i < lines.length; i++) {
|
|
747
|
-
if (lines[i].startsWith('## ')) {
|
|
748
|
-
sectionStarts.push(i);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
if (sectionStarts.length === 0)
|
|
752
|
-
return content.trim();
|
|
753
|
-
const start = sectionStarts[Math.max(0, sectionStarts.length - maxSections)];
|
|
754
|
-
return lines.slice(start).join('\n').trim();
|
|
755
|
-
}
|
|
756
|
-
logEvent(action, seq, result, meta) {
|
|
757
|
-
this.log.event({
|
|
758
|
-
component: 'pipeline',
|
|
759
|
-
action,
|
|
760
|
-
entity: `seq:${seq}`,
|
|
761
|
-
result,
|
|
762
|
-
meta,
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
//# sourceMappingURL=ExecutionEngine.js.map
|