@coralai/sps-cli 0.15.11 → 0.16.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.
- package/README.md +48 -22
- package/dist/commands/monitorTick.d.ts.map +1 -1
- package/dist/commands/monitorTick.js +3 -1
- package/dist/commands/monitorTick.js.map +1 -1
- package/dist/commands/pipelineTick.d.ts.map +1 -1
- package/dist/commands/pipelineTick.js +12 -3
- package/dist/commands/pipelineTick.js.map +1 -1
- package/dist/commands/tick.d.ts +1 -0
- package/dist/commands/tick.d.ts.map +1 -1
- package/dist/commands/tick.js +64 -8
- package/dist/commands/tick.js.map +1 -1
- package/dist/commands/workerLaunch.d.ts.map +1 -1
- package/dist/commands/workerLaunch.js +12 -3
- package/dist/commands/workerLaunch.js.map +1 -1
- package/dist/engines/ExecutionEngine.d.ts +29 -32
- package/dist/engines/ExecutionEngine.d.ts.map +1 -1
- package/dist/engines/ExecutionEngine.js +236 -527
- package/dist/engines/ExecutionEngine.js.map +1 -1
- package/dist/engines/MonitorEngine.d.ts +14 -27
- package/dist/engines/MonitorEngine.d.ts.map +1 -1
- package/dist/engines/MonitorEngine.js +91 -313
- package/dist/engines/MonitorEngine.js.map +1 -1
- package/dist/main.js +0 -0
- package/dist/manager/completion-judge.d.ts +27 -0
- package/dist/manager/completion-judge.d.ts.map +1 -0
- package/dist/manager/completion-judge.js +81 -0
- package/dist/manager/completion-judge.js.map +1 -0
- package/dist/manager/pm-client.d.ts +10 -0
- package/dist/manager/pm-client.d.ts.map +1 -0
- package/dist/manager/pm-client.js +245 -0
- package/dist/manager/pm-client.js.map +1 -0
- package/dist/manager/post-actions.d.ts +60 -0
- package/dist/manager/post-actions.d.ts.map +1 -0
- package/dist/manager/post-actions.js +326 -0
- package/dist/manager/post-actions.js.map +1 -0
- package/dist/manager/recovery.d.ts +39 -0
- package/dist/manager/recovery.d.ts.map +1 -0
- package/dist/manager/recovery.js +133 -0
- package/dist/manager/recovery.js.map +1 -0
- package/dist/manager/resource-limiter.d.ts +44 -0
- package/dist/manager/resource-limiter.d.ts.map +1 -0
- package/dist/manager/resource-limiter.js +79 -0
- package/dist/manager/resource-limiter.js.map +1 -0
- package/dist/manager/supervisor.d.ts +70 -0
- package/dist/manager/supervisor.d.ts.map +1 -0
- package/dist/manager/supervisor.js +216 -0
- package/dist/manager/supervisor.js.map +1 -0
- package/package.json +1 -1
|
@@ -7,15 +7,21 @@ const SKIP_LABELS = ['BLOCKED', 'NEEDS-FIX', 'CONFLICT', 'WAITING-CONFIRMATION',
|
|
|
7
7
|
export class ExecutionEngine {
|
|
8
8
|
ctx;
|
|
9
9
|
taskBackend;
|
|
10
|
-
workerProvider;
|
|
11
10
|
repoBackend;
|
|
11
|
+
supervisor;
|
|
12
|
+
completionJudge;
|
|
13
|
+
postActions;
|
|
14
|
+
resourceLimiter;
|
|
12
15
|
notifier;
|
|
13
16
|
log;
|
|
14
|
-
constructor(ctx, taskBackend,
|
|
17
|
+
constructor(ctx, taskBackend, repoBackend, supervisor, completionJudge, postActions, resourceLimiter, notifier) {
|
|
15
18
|
this.ctx = ctx;
|
|
16
19
|
this.taskBackend = taskBackend;
|
|
17
|
-
this.workerProvider = workerProvider;
|
|
18
20
|
this.repoBackend = repoBackend;
|
|
21
|
+
this.supervisor = supervisor;
|
|
22
|
+
this.completionJudge = completionJudge;
|
|
23
|
+
this.postActions = postActions;
|
|
24
|
+
this.resourceLimiter = resourceLimiter;
|
|
19
25
|
this.notifier = notifier;
|
|
20
26
|
this.log = new Logger('pipeline', ctx.projectName, ctx.paths.logsDir);
|
|
21
27
|
}
|
|
@@ -67,7 +73,7 @@ export class ExecutionEngine {
|
|
|
67
73
|
// 3. Process Todo cards (launch: claim + context + worker + move to Inprogress)
|
|
68
74
|
// This is the only step that consumes action quota — it starts
|
|
69
75
|
// resource-intensive AI workers that need system capacity.
|
|
70
|
-
// Stagger launches to avoid overwhelming
|
|
76
|
+
// Stagger launches to avoid overwhelming the system.
|
|
71
77
|
const todoCards = await this.taskBackend.listByState('Todo');
|
|
72
78
|
let launchedThisTick = 0;
|
|
73
79
|
const failedSlots = new Set(); // track slots that failed launch this tick
|
|
@@ -78,12 +84,7 @@ export class ExecutionEngine {
|
|
|
78
84
|
actions.push({ action: 'skip', entity: `seq:${card.seq}`, result: 'skip', message: 'Has auxiliary state label' });
|
|
79
85
|
continue;
|
|
80
86
|
}
|
|
81
|
-
// Stagger
|
|
82
|
-
if (launchedThisTick > 0) {
|
|
83
|
-
const delay = this.ctx.config.WORKER_MODE === 'print' ? 2_000 : 10_000;
|
|
84
|
-
this.log.info(`Waiting ${delay / 1000}s before next worker launch...`);
|
|
85
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
86
|
-
}
|
|
87
|
+
// Stagger is handled by ResourceLimiter.enforceStagger() inside launchCard
|
|
87
88
|
const launchResult = await this.launchCard(card, opts, failedSlots);
|
|
88
89
|
actions.push(launchResult);
|
|
89
90
|
if (launchResult.result === 'ok') {
|
|
@@ -162,245 +163,52 @@ export class ExecutionEngine {
|
|
|
162
163
|
shouldSkip(card) {
|
|
163
164
|
return SKIP_LABELS.some((label) => card.labels.includes(label));
|
|
164
165
|
}
|
|
165
|
-
// ─── Inprogress Phase (detect completion →
|
|
166
|
+
// ─── Inprogress Phase (detect completion → Done) ────────────────
|
|
166
167
|
/**
|
|
167
|
-
* Check an Inprogress card:
|
|
168
|
-
* This is the critical Inprogress → QA bridge (01 §10.2).
|
|
168
|
+
* Check an Inprogress card: verify worker is still running or handled by exit callback.
|
|
169
169
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* BLOCKED → mark BLOCKED
|
|
175
|
-
* ALIVE → no action (worker still working)
|
|
176
|
-
* DEAD → mark STALE-RUNTIME (handled by MonitorEngine)
|
|
177
|
-
* DEAD_EXCEEDED → mark STALE-RUNTIME, notify
|
|
170
|
+
* The Supervisor exit callback triggers CompletionJudge → PostActions automatically,
|
|
171
|
+
* so this method only needs to:
|
|
172
|
+
* - Update heartbeat if worker is still running
|
|
173
|
+
* - Confirm completion if PostActions already processed it
|
|
178
174
|
*/
|
|
179
175
|
async checkInprogressCard(card, opts) {
|
|
180
176
|
const seq = card.seq;
|
|
181
177
|
const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
182
|
-
// Find this card's worker slot
|
|
183
178
|
const slotEntry = Object.entries(state.workers).find(([, w]) => w.seq === parseInt(seq, 10) && w.status === 'active');
|
|
184
179
|
if (!slotEntry) {
|
|
185
|
-
//
|
|
180
|
+
// Slot already released (PostActions handled it via exit callback)
|
|
186
181
|
return null;
|
|
187
182
|
}
|
|
188
|
-
const [slotName
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
catch (err) {
|
|
200
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
201
|
-
this.log.warn(`detectCompleted failed for seq ${seq}: ${msg}`);
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
switch (workerStatus) {
|
|
205
|
-
case 'COMPLETED': {
|
|
206
|
-
if (opts.dryRun) {
|
|
207
|
-
this.log.info(`[dry-run] Would move seq ${seq} Inprogress → QA`);
|
|
208
|
-
return { action: 'complete', entity: `seq:${seq}`, result: 'ok', message: 'dry-run: would move to QA' };
|
|
209
|
-
}
|
|
210
|
-
if (this.ctx.mrMode === 'none') {
|
|
211
|
-
// ── MR_MODE=none: worker merges directly to target branch ──
|
|
212
|
-
// Check if feature branch is merged into target
|
|
213
|
-
const worktree = slotState.worktree;
|
|
214
|
-
let isMerged = false;
|
|
215
|
-
if (worktree) {
|
|
216
|
-
try {
|
|
217
|
-
// Fetch latest and check if feature commits are in target
|
|
218
|
-
const { execFileSync } = await import('node:child_process');
|
|
219
|
-
try {
|
|
220
|
-
execFileSync('git', ['-C', worktree, 'fetch', 'origin', '--quiet'], { timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
221
|
-
}
|
|
222
|
-
catch { /* offline ok */ }
|
|
223
|
-
const unmerged = execFileSync('git', ['-C', worktree, 'rev-list', '--count', `origin/${this.ctx.mergeBranch}..${branch}`], { encoding: 'utf-8', timeout: 5_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
224
|
-
isMerged = parseInt(unmerged, 10) === 0;
|
|
225
|
-
}
|
|
226
|
-
catch { /* git error, fall through */ }
|
|
227
|
-
}
|
|
228
|
-
if (!isMerged) {
|
|
229
|
-
// Worker pushed but didn't merge to target yet — resume it
|
|
230
|
-
this.log.info(`seq ${seq}: Branch not merged into ${this.ctx.mergeBranch}, resuming worker`);
|
|
231
|
-
const isPrintMode = slotState.mode === 'print';
|
|
232
|
-
if (isPrintMode && slotState.sessionId) {
|
|
233
|
-
const resumeResult = await this.attemptMergeResume(seq, slotName, slotState, card);
|
|
234
|
-
if (resumeResult)
|
|
235
|
-
return resumeResult;
|
|
236
|
-
}
|
|
237
|
-
// Resume not possible — system merges directly as fallback
|
|
238
|
-
this.log.info(`seq ${seq}: Resume not possible, system merging directly`);
|
|
239
|
-
let mergeFailed = true;
|
|
240
|
-
if (worktree) {
|
|
241
|
-
try {
|
|
242
|
-
await this.repoBackend.rebase(worktree, this.ctx.mergeBranch);
|
|
243
|
-
await this.repoBackend.push(worktree, branch, true);
|
|
244
|
-
const { execFileSync } = await import('node:child_process');
|
|
245
|
-
execFileSync('git', ['-C', worktree, 'checkout', this.ctx.mergeBranch], { timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
246
|
-
execFileSync('git', ['-C', worktree, 'merge', '--no-ff', branch, '-m', `Merge ${branch} into ${this.ctx.mergeBranch}`], { timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
247
|
-
execFileSync('git', ['-C', worktree, 'push', 'origin', this.ctx.mergeBranch], { timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
248
|
-
this.log.ok(`seq ${seq}: System fallback merged ${branch} into ${this.ctx.mergeBranch}`);
|
|
249
|
-
mergeFailed = false;
|
|
250
|
-
}
|
|
251
|
-
catch (mergeErr) {
|
|
252
|
-
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
|
253
|
-
this.log.error(`seq ${seq}: System fallback merge failed: ${msg}`);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
if (mergeFailed) {
|
|
257
|
-
// Merge failed — mark NEEDS-FIX, don't move to Done
|
|
258
|
-
try {
|
|
259
|
-
await this.taskBackend.addLabel(seq, 'NEEDS-FIX');
|
|
260
|
-
}
|
|
261
|
-
catch { /* best effort */ }
|
|
262
|
-
try {
|
|
263
|
-
await this.taskBackend.comment(seq, `Branch pushed but merge to ${this.ctx.mergeBranch} failed. Manual merge needed.`);
|
|
264
|
-
}
|
|
265
|
-
catch { /* best effort */ }
|
|
266
|
-
this.logEvent('merge-failed', seq, 'fail');
|
|
267
|
-
return { action: 'mark-needs-fix', entity: `seq:${seq}`, result: 'ok', message: `System merge to ${this.ctx.mergeBranch} failed — NEEDS-FIX` };
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
// Merge confirmed — move to Done + release resources + cleanup worktree
|
|
271
|
-
return await this.completeAndRelease(card, slotName, slotState);
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
// ── MR_MODE=create: worker creates MR, task is done ──
|
|
275
|
-
let mrExists = false;
|
|
276
|
-
try {
|
|
277
|
-
const mrStatus = await this.repoBackend.getMrStatus(branch);
|
|
278
|
-
mrExists = mrStatus.exists;
|
|
279
|
-
}
|
|
280
|
-
catch { /* can't check */ }
|
|
281
|
-
if (!mrExists) {
|
|
282
|
-
// MR not found — try resume worker to create it
|
|
283
|
-
this.log.info(`seq ${seq}: MR not found, resuming worker to create it`);
|
|
284
|
-
const isPrintMode = slotState.mode === 'print';
|
|
285
|
-
if (isPrintMode && slotState.sessionId) {
|
|
286
|
-
const resumeResult = await this.attemptMergeResume(seq, slotName, slotState, card);
|
|
287
|
-
if (resumeResult)
|
|
288
|
-
return resumeResult;
|
|
289
|
-
}
|
|
290
|
-
// Fallback: system creates MR
|
|
291
|
-
this.log.info(`seq ${seq}: Resume not possible, system creating MR`);
|
|
292
|
-
try {
|
|
293
|
-
await this.repoBackend.createOrUpdateMr(branch, `${card.seq}: ${card.name}`, `Auto-created by pipeline for seq:${card.seq}.\n\nBranch: ${branch}`);
|
|
294
|
-
this.log.ok(`seq ${seq}: System created MR for branch ${branch}`);
|
|
295
|
-
mrExists = true;
|
|
296
|
-
}
|
|
297
|
-
catch (mrErr) {
|
|
298
|
-
const mrMsg = mrErr instanceof Error ? mrErr.message : String(mrErr);
|
|
299
|
-
this.log.error(`seq ${seq}: System MR creation failed: ${mrMsg}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
if (!mrExists) {
|
|
303
|
-
// MR creation failed — mark NEEDS-FIX
|
|
304
|
-
this.log.error(`seq ${seq}: MR creation failed after all attempts`);
|
|
305
|
-
try {
|
|
306
|
-
await this.taskBackend.addLabel(seq, 'NEEDS-FIX');
|
|
307
|
-
}
|
|
308
|
-
catch { /* best effort */ }
|
|
309
|
-
try {
|
|
310
|
-
await this.taskBackend.comment(seq, 'Branch pushed but MR creation failed. Manual MR needed.');
|
|
311
|
-
}
|
|
312
|
-
catch { /* best effort */ }
|
|
313
|
-
this.logEvent('mr-creation-failed', seq, 'fail');
|
|
314
|
-
return { action: 'mark-needs-fix', entity: `seq:${seq}`, result: 'ok', message: 'MR creation failed — NEEDS-FIX' };
|
|
315
|
-
}
|
|
316
|
-
// MR confirmed — task is done, release resources
|
|
317
|
-
return await this.completeAndRelease(card, slotName, slotState);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
case 'AUTO_CONFIRM': {
|
|
321
|
-
// Non-destructive confirmation prompt → auto-confirm
|
|
322
|
-
this.log.info(`seq ${seq}: Worker waiting for non-destructive confirmation, auto-confirming`);
|
|
323
|
-
try {
|
|
324
|
-
await this.workerProvider.sendFix(session, 'y');
|
|
325
|
-
this.logEvent('auto-confirm', seq, 'ok');
|
|
326
|
-
if (this.notifier) {
|
|
327
|
-
await this.notifier.send(`[${this.ctx.projectName}] seq:${seq} auto-confirmed`, 'info').catch(() => { });
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
catch {
|
|
331
|
-
this.log.warn(`seq ${seq}: Auto-confirm failed`);
|
|
332
|
-
}
|
|
333
|
-
return { action: 'auto-confirm', entity: `seq:${seq}`, result: 'ok', message: 'Auto-confirmed non-destructive prompt' };
|
|
334
|
-
}
|
|
335
|
-
case 'NEEDS_INPUT': {
|
|
336
|
-
// Destructive confirmation → mark WAITING-CONFIRMATION, notify Boss
|
|
337
|
-
this.log.warn(`seq ${seq}: Worker waiting for destructive confirmation`);
|
|
338
|
-
try {
|
|
339
|
-
await this.taskBackend.addLabel(seq, 'WAITING-CONFIRMATION');
|
|
340
|
-
}
|
|
341
|
-
catch { /* best effort */ }
|
|
342
|
-
if (this.notifier) {
|
|
343
|
-
await this.notifier.sendWarning(`[${this.ctx.projectName}] seq:${seq} worker waiting for destructive confirmation`).catch(() => { });
|
|
344
|
-
}
|
|
345
|
-
this.logEvent('waiting-destructive', seq, 'ok');
|
|
346
|
-
return { action: 'mark-waiting', entity: `seq:${seq}`, result: 'ok', message: 'Destructive confirmation — waiting for human' };
|
|
347
|
-
}
|
|
348
|
-
case 'BLOCKED': {
|
|
349
|
-
this.log.warn(`seq ${seq}: Worker appears blocked`);
|
|
350
|
-
try {
|
|
351
|
-
await this.taskBackend.addLabel(seq, 'BLOCKED');
|
|
183
|
+
const [slotName] = slotEntry;
|
|
184
|
+
const workerId = `${this.ctx.projectName}:${slotName}:${seq}`;
|
|
185
|
+
const handle = this.supervisor.get(workerId);
|
|
186
|
+
if (handle && handle.exitCode === null) {
|
|
187
|
+
// Worker still running — update heartbeat
|
|
188
|
+
try {
|
|
189
|
+
const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
190
|
+
if (freshState.workers[slotName]) {
|
|
191
|
+
freshState.workers[slotName].lastHeartbeat = new Date().toISOString();
|
|
192
|
+
writeState(this.ctx.paths.stateFile, freshState, 'pipeline-heartbeat');
|
|
352
193
|
}
|
|
353
|
-
catch { /* best effort */ }
|
|
354
|
-
this.logEvent('blocked', seq, 'ok');
|
|
355
|
-
return { action: 'mark-blocked', entity: `seq:${seq}`, result: 'ok', message: 'Worker blocked' };
|
|
356
194
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
: `process died (${workerStatus})`;
|
|
368
|
-
this.log.warn(`seq ${seq}: Worker ${reason}`);
|
|
369
|
-
if (isPrintMode && slotState.sessionId) {
|
|
370
|
-
const retryResult = await this.attemptResume(seq, slotName, slotState, card, reason);
|
|
371
|
-
if (retryResult)
|
|
372
|
-
return retryResult;
|
|
373
|
-
}
|
|
374
|
-
// No resume possible or retries exhausted → NEEDS-FIX
|
|
375
|
-
if (workerStatus === 'DEAD' || workerStatus === 'DEAD_EXCEEDED') {
|
|
376
|
-
// Also defer to MonitorEngine for STALE-RUNTIME marking
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
try {
|
|
380
|
-
await this.taskBackend.addLabel(seq, 'NEEDS-FIX');
|
|
381
|
-
await this.taskBackend.comment(seq, `Worker ${reason}. Resume retries exhausted.`);
|
|
382
|
-
}
|
|
383
|
-
catch { /* best effort */ }
|
|
384
|
-
if (this.notifier) {
|
|
385
|
-
await this.notifier.sendWarning(`[${this.ctx.projectName}] seq:${seq} worker ${reason} — retries exhausted, NEEDS-FIX`).catch(() => { });
|
|
386
|
-
}
|
|
387
|
-
this.logEvent('exited-incomplete-final', seq, 'ok');
|
|
388
|
-
return { action: 'mark-needs-fix', entity: `seq:${seq}`, result: 'ok', message: `Worker ${reason}, retries exhausted (NEEDS-FIX)` };
|
|
195
|
+
catch { /* non-fatal */ }
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
if (handle && handle.exitCode !== null) {
|
|
199
|
+
// Worker exited but PostActions hasn't finished yet (or just finished)
|
|
200
|
+
// Check if slot is now idle
|
|
201
|
+
const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
202
|
+
if (!freshState.workers[slotName] || freshState.workers[slotName].status === 'idle') {
|
|
203
|
+
this.log.ok(`seq ${seq}: Completed (handled by exit callback)`);
|
|
204
|
+
return { action: 'complete', entity: `seq:${seq}`, result: 'ok', message: 'Completed via exit callback' };
|
|
389
205
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
// Worker still running — no action needed
|
|
393
|
-
// Update heartbeat
|
|
394
|
-
try {
|
|
395
|
-
const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
396
|
-
if (freshState.workers[slotName]) {
|
|
397
|
-
freshState.workers[slotName].lastHeartbeat = new Date().toISOString();
|
|
398
|
-
writeState(this.ctx.paths.stateFile, freshState, 'pipeline-heartbeat');
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
catch { /* non-fatal */ }
|
|
402
|
-
return null;
|
|
206
|
+
// PostActions still processing, wait for next tick
|
|
207
|
+
return null;
|
|
403
208
|
}
|
|
209
|
+
// Handle not found in Supervisor — could be after tick restart
|
|
210
|
+
// MonitorEngine/Recovery handles this case
|
|
211
|
+
return null;
|
|
404
212
|
}
|
|
405
213
|
// ─── Prepare Phase (Backlog → Todo) ─────────────────────────────
|
|
406
214
|
/**
|
|
@@ -478,25 +286,7 @@ export class ExecutionEngine {
|
|
|
478
286
|
this.log.warn(`No idle worker slot available for seq ${seq}`);
|
|
479
287
|
return { action: 'launch', entity: `seq:${seq}`, result: 'skip', message: 'No idle worker slot' };
|
|
480
288
|
}
|
|
481
|
-
|
|
482
|
-
// Only applies to interactive (tmux) mode — print mode workers are one-shot processes
|
|
483
|
-
let slotEntry = idleSlots[0];
|
|
484
|
-
if (this.ctx.config.WORKER_SESSION_REUSE && this.ctx.config.WORKER_MODE !== 'print') {
|
|
485
|
-
for (const entry of idleSlots) {
|
|
486
|
-
const [name] = entry;
|
|
487
|
-
const sessionName = `${this.ctx.projectName}-${name}`;
|
|
488
|
-
try {
|
|
489
|
-
const inspection = await this.workerProvider.inspect(sessionName);
|
|
490
|
-
if (inspection.alive) {
|
|
491
|
-
slotEntry = entry;
|
|
492
|
-
this.log.info(`Preferring slot ${name} with live session`);
|
|
493
|
-
break;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
catch { /* ignore */ }
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
const [slotName] = slotEntry;
|
|
289
|
+
const [slotName] = idleSlots[0];
|
|
500
290
|
const sessionName = `${this.ctx.projectName}-${slotName}`;
|
|
501
291
|
// Claim slot in state.json
|
|
502
292
|
state.workers[slotName] = {
|
|
@@ -554,25 +344,45 @@ export class ExecutionEngine {
|
|
|
554
344
|
this.logEvent('launch-context', seq, 'fail', { error: msg });
|
|
555
345
|
return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Context build failed: ${msg}` };
|
|
556
346
|
}
|
|
557
|
-
// Step 6: Launch worker
|
|
347
|
+
// Step 6: Launch worker via Supervisor
|
|
558
348
|
try {
|
|
559
349
|
const promptFile = resolve(worktreePath, '.jarvis_task_prompt.txt');
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
this.
|
|
350
|
+
// Check global resource limit
|
|
351
|
+
if (!this.resourceLimiter.tryAcquire()) {
|
|
352
|
+
this.log.warn(`Global worker limit reached, skipping seq ${seq}`);
|
|
353
|
+
// Rollback: release slot
|
|
354
|
+
this.releaseSlot(slotName, seq);
|
|
355
|
+
return { action: 'launch', entity: `seq:${seq}`, result: 'skip', message: 'Global worker limit reached' };
|
|
356
|
+
}
|
|
357
|
+
await this.resourceLimiter.enforceStagger();
|
|
358
|
+
const prompt = readFileSync(promptFile, 'utf-8').trim();
|
|
359
|
+
const outputFile = resolve(this.ctx.config.raw.LOGS_DIR || `/tmp/sps-${this.ctx.projectName}`, `${sessionName}-${Date.now()}.jsonl`);
|
|
360
|
+
const workerId = `${this.ctx.projectName}:${slotName}:${card.seq}`;
|
|
361
|
+
const workerHandle = this.supervisor.spawn({
|
|
362
|
+
id: workerId,
|
|
363
|
+
project: this.ctx.projectName,
|
|
364
|
+
seq: card.seq,
|
|
365
|
+
slot: slotName,
|
|
366
|
+
worktree: worktreePath,
|
|
367
|
+
branch: branchName,
|
|
368
|
+
prompt,
|
|
369
|
+
outputFile,
|
|
370
|
+
tool: this.ctx.config.WORKER_TOOL,
|
|
371
|
+
onExit: (exitCode) => {
|
|
372
|
+
this.onWorkerExit(workerId, card, slotName, worktreePath, branchName, exitCode);
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
// Store process info in state
|
|
376
|
+
const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
377
|
+
if (freshState.workers[slotName]) {
|
|
378
|
+
freshState.workers[slotName].mode = 'print';
|
|
379
|
+
freshState.workers[slotName].pid = workerHandle.pid;
|
|
380
|
+
freshState.workers[slotName].outputFile = workerHandle.outputFile;
|
|
381
|
+
freshState.workers[slotName].sessionId = workerHandle.sessionId || null;
|
|
382
|
+
freshState.workers[slotName].exitCode = null;
|
|
383
|
+
writeState(this.ctx.paths.stateFile, freshState, 'pipeline-launch-print');
|
|
574
384
|
}
|
|
575
|
-
this.log.ok(`Step 6: Worker launched
|
|
385
|
+
this.log.ok(`Step 6: Worker launched for seq ${seq} (pid=${workerHandle.pid})`);
|
|
576
386
|
if (this.notifier) {
|
|
577
387
|
await this.notifier.sendSuccess(`[${this.ctx.projectName}] seq:${seq} worker started (${slotName})`).catch(() => { });
|
|
578
388
|
}
|
|
@@ -581,6 +391,7 @@ export class ExecutionEngine {
|
|
|
581
391
|
const msg = err instanceof Error ? err.message : String(err);
|
|
582
392
|
this.log.error(`Step 6 failed (worker launch) for seq ${seq}: ${msg}`);
|
|
583
393
|
failedSlots.add(slotName);
|
|
394
|
+
this.resourceLimiter.release();
|
|
584
395
|
this.releaseSlot(slotName, seq);
|
|
585
396
|
this.logEvent('launch-worker', seq, 'fail', { error: msg });
|
|
586
397
|
return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Worker launch failed: ${msg}` };
|
|
@@ -601,16 +412,70 @@ export class ExecutionEngine {
|
|
|
601
412
|
catch (err) {
|
|
602
413
|
const msg = err instanceof Error ? err.message : String(err);
|
|
603
414
|
this.log.error(`Step 7 failed (move) for seq ${seq}: ${msg}`);
|
|
604
|
-
// Rollback:
|
|
415
|
+
// Rollback: kill worker, release slot
|
|
416
|
+
const workerId = `${this.ctx.projectName}:${slotName}:${card.seq}`;
|
|
605
417
|
try {
|
|
606
|
-
await this.
|
|
418
|
+
await this.supervisor.kill(workerId);
|
|
607
419
|
}
|
|
608
420
|
catch { /* best effort */ }
|
|
421
|
+
this.resourceLimiter.release();
|
|
609
422
|
this.releaseSlot(slotName, seq);
|
|
610
423
|
this.logEvent('launch-move', seq, 'fail', { error: msg });
|
|
611
424
|
return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Move to Inprogress failed: ${msg}` };
|
|
612
425
|
}
|
|
613
426
|
}
|
|
427
|
+
// ─── Worker Exit Callback ───────────────────────────────────────
|
|
428
|
+
/**
|
|
429
|
+
* Called by Supervisor when a worker process exits.
|
|
430
|
+
* Wires CompletionJudge → PostActions to handle completion or failure.
|
|
431
|
+
*/
|
|
432
|
+
onWorkerExit(workerId, card, slotName, worktree, branch, exitCode) {
|
|
433
|
+
const handle = this.supervisor.get(workerId);
|
|
434
|
+
const completion = this.completionJudge.judge({
|
|
435
|
+
worktree,
|
|
436
|
+
branch,
|
|
437
|
+
baseBranch: this.ctx.mergeBranch,
|
|
438
|
+
outputFile: handle?.outputFile || null,
|
|
439
|
+
exitCode,
|
|
440
|
+
logsDir: this.ctx.paths.logsDir,
|
|
441
|
+
});
|
|
442
|
+
const ctx = {
|
|
443
|
+
project: this.ctx.projectName,
|
|
444
|
+
seq: card.seq,
|
|
445
|
+
slot: slotName,
|
|
446
|
+
branch,
|
|
447
|
+
worktree,
|
|
448
|
+
baseBranch: this.ctx.mergeBranch,
|
|
449
|
+
stateFile: this.ctx.paths.stateFile,
|
|
450
|
+
maxWorkers: this.ctx.maxWorkers,
|
|
451
|
+
mrMode: this.ctx.mrMode,
|
|
452
|
+
gitlabProjectId: this.ctx.config.GITLAB_PROJECT_ID,
|
|
453
|
+
gitlabUrl: this.ctx.config.raw.GITLAB_URL || process.env.GITLAB_URL || '',
|
|
454
|
+
gitlabToken: this.ctx.config.raw.GITLAB_TOKEN || process.env.GITLAB_TOKEN || '',
|
|
455
|
+
doneStateId: this.ctx.config.raw.PLANE_STATE_DONE || this.ctx.config.raw.TRELLO_DONE_LIST_ID || '',
|
|
456
|
+
maxRetries: this.ctx.config.WORKER_RESTART_LIMIT,
|
|
457
|
+
logsDir: this.ctx.paths.logsDir,
|
|
458
|
+
tool: this.ctx.config.WORKER_TOOL,
|
|
459
|
+
};
|
|
460
|
+
const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
461
|
+
const activeCard = state.activeCards[card.seq];
|
|
462
|
+
const retryCount = activeCard?.retryCount ?? 0;
|
|
463
|
+
if (completion.status === 'completed') {
|
|
464
|
+
this.postActions.executeCompletion(ctx, completion, handle?.sessionId || null)
|
|
465
|
+
.then((results) => {
|
|
466
|
+
const allOk = results.every(r => r.ok);
|
|
467
|
+
this.log.ok(`seq ${card.seq}: PostActions completed (${allOk ? 'all ok' : 'some failures'})`);
|
|
468
|
+
})
|
|
469
|
+
.catch(err => this.log.error(`seq ${card.seq}: PostActions error: ${err}`));
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
this.postActions.executeFailure(ctx, completion, exitCode, handle?.sessionId || null, retryCount, {
|
|
473
|
+
onExit: (code) => this.onWorkerExit(workerId, card, slotName, worktree, branch, code),
|
|
474
|
+
})
|
|
475
|
+
.then(() => this.log.info(`seq ${card.seq}: Failure handling done`))
|
|
476
|
+
.catch(err => this.log.error(`seq ${card.seq}: Failure handling error: ${err}`));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
614
479
|
// ─── Helpers ─────────────────────────────────────────────────────
|
|
615
480
|
/**
|
|
616
481
|
* Build branch name from card: feature/<seq>-<slug>
|
|
@@ -640,7 +505,9 @@ export class ExecutionEngine {
|
|
|
640
505
|
mkdirSync(worktreePath, { recursive: true });
|
|
641
506
|
}
|
|
642
507
|
const branchName = this.buildBranchName(card);
|
|
643
|
-
//
|
|
508
|
+
// ── 1. Skill Profiles (label-driven) ──
|
|
509
|
+
const skillContent = this.loadSkillProfiles(card);
|
|
510
|
+
// ── 2. Project Rules (CLAUDE.md + AGENTS.md) ──
|
|
644
511
|
const claudeMdPath = resolve(worktreePath, 'CLAUDE.md');
|
|
645
512
|
const agentsMdPath = resolve(worktreePath, 'AGENTS.md');
|
|
646
513
|
let projectRules = '';
|
|
@@ -654,18 +521,26 @@ export class ExecutionEngine {
|
|
|
654
521
|
const agentsRules = readFileSync(agentsMdPath, 'utf-8').trim();
|
|
655
522
|
projectRules = projectRules ? `${projectRules}\n\n${agentsRules}` : agentsRules;
|
|
656
523
|
}
|
|
657
|
-
// .
|
|
658
|
-
|
|
659
|
-
//
|
|
524
|
+
// ── 3. Project Knowledge (truncated) ──
|
|
525
|
+
const knowledge = this.loadProjectKnowledge(worktreePath);
|
|
526
|
+
// ── Assemble prompt ──
|
|
660
527
|
const sections = [];
|
|
528
|
+
if (skillContent) {
|
|
529
|
+
sections.push(skillContent);
|
|
530
|
+
sections.push('---');
|
|
531
|
+
}
|
|
661
532
|
if (projectRules) {
|
|
662
533
|
sections.push(projectRules);
|
|
663
534
|
sections.push('---');
|
|
664
535
|
}
|
|
536
|
+
if (knowledge) {
|
|
537
|
+
sections.push(knowledge);
|
|
538
|
+
sections.push('---');
|
|
539
|
+
}
|
|
665
540
|
// Build requirements based on MR mode
|
|
666
541
|
const mrMode = this.ctx.mrMode; // 'none' | 'create'
|
|
667
542
|
const createMR = mrMode === 'create';
|
|
668
|
-
// Generate .jarvis/merge.sh
|
|
543
|
+
// Generate .jarvis/merge.sh
|
|
669
544
|
this.writeMergeScript(worktreePath, branchName, card, createMR);
|
|
670
545
|
const mergeStepDesc = createMR
|
|
671
546
|
? 'Create the Merge Request'
|
|
@@ -673,15 +548,23 @@ export class ExecutionEngine {
|
|
|
673
548
|
const requirements = [
|
|
674
549
|
'1. Implement the changes described above',
|
|
675
550
|
'2. Self-test your changes (run existing tests if any, ensure no regressions)',
|
|
676
|
-
|
|
677
|
-
|
|
551
|
+
'3. Update project knowledge (create docs/ dir if needed):',
|
|
552
|
+
' - If you made architecture/design choices, append to docs/DECISIONS.md:',
|
|
553
|
+
` ## [${card.seq}-${card.name}] ${new Date().toISOString().slice(0, 10)}`,
|
|
554
|
+
' - Decision: ...',
|
|
555
|
+
' - Reason: ...',
|
|
556
|
+
' - Append a summary of your changes to docs/CHANGELOG.md:',
|
|
557
|
+
` ## [${card.seq}-${card.name}] ${new Date().toISOString().slice(0, 10)}`,
|
|
558
|
+
' - What changed and why',
|
|
559
|
+
`4. git add, commit, and push to branch ${branchName}`,
|
|
560
|
+
`5. ${mergeStepDesc} by running:`,
|
|
678
561
|
' ```bash',
|
|
679
562
|
' bash .jarvis/merge.sh',
|
|
680
563
|
' ```',
|
|
681
|
-
'
|
|
564
|
+
'6. Verify the script output shows success, then say "done"',
|
|
682
565
|
];
|
|
683
566
|
requirements.push('');
|
|
684
|
-
requirements.push('IMPORTANT: You MUST complete ALL steps above. Step
|
|
567
|
+
requirements.push('IMPORTANT: You MUST complete ALL steps above. Step 5 (bash .jarvis/merge.sh) is MANDATORY — just pushing code is NOT enough. After completing, say "done" and STOP. Do NOT run long-running commands (npm run dev, npm start, yarn dev, docker compose up, or any dev server / watch mode).');
|
|
685
568
|
sections.push(`# Current Task
|
|
686
569
|
|
|
687
570
|
Task ID: ${card.seq}
|
|
@@ -797,83 +680,11 @@ ${requirements.join('\n')}`);
|
|
|
797
680
|
writeFileSync(resolve(jarvisDir, 'merge.sh'), lines.join('\n') + '\n', { mode: 0o755 });
|
|
798
681
|
}
|
|
799
682
|
/**
|
|
800
|
-
*
|
|
801
|
-
* Used for
|
|
802
|
-
*/
|
|
803
|
-
async completeAndRelease(card, slotName, slotState) {
|
|
804
|
-
const seq = card.seq;
|
|
805
|
-
const errors = [];
|
|
806
|
-
// 1. Move card to Done — if this fails, abort (don't release slot)
|
|
807
|
-
try {
|
|
808
|
-
await this.taskBackend.move(seq, 'Done');
|
|
809
|
-
this.log.ok(`seq ${seq}: Moved Inprogress → Done`);
|
|
810
|
-
}
|
|
811
|
-
catch (err) {
|
|
812
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
813
|
-
this.log.error(`seq ${seq}: Failed to move to Done: ${msg}. Slot NOT released.`);
|
|
814
|
-
return { action: 'complete-direct', entity: `seq:${seq}`, result: 'fail', message: `Move to Done failed: ${msg}` };
|
|
815
|
-
}
|
|
816
|
-
// 2. Release claim
|
|
817
|
-
try {
|
|
818
|
-
await this.taskBackend.releaseClaim(seq);
|
|
819
|
-
}
|
|
820
|
-
catch { /* best effort */ }
|
|
821
|
-
// 3. Release worker slot (only after Done confirmed)
|
|
822
|
-
try {
|
|
823
|
-
const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
824
|
-
if (state.workers[slotName]) {
|
|
825
|
-
const sessionName = state.workers[slotName].tmuxSession;
|
|
826
|
-
state.workers[slotName] = {
|
|
827
|
-
status: 'idle', seq: null, branch: null, worktree: null,
|
|
828
|
-
tmuxSession: null, claimedAt: null, lastHeartbeat: null,
|
|
829
|
-
mode: null, sessionId: null, pid: null, outputFile: null, exitCode: null,
|
|
830
|
-
};
|
|
831
|
-
delete state.activeCards[seq];
|
|
832
|
-
// 4. Mark worktree for cleanup
|
|
833
|
-
const branchName = slotState.branch || this.buildBranchName(card);
|
|
834
|
-
const worktreePath = slotState.worktree || resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
|
|
835
|
-
const cleanup = state.worktreeCleanup ?? [];
|
|
836
|
-
if (!cleanup.some((e) => e.branch === branchName)) {
|
|
837
|
-
cleanup.push({ branch: branchName, worktreePath, markedAt: new Date().toISOString() });
|
|
838
|
-
state.worktreeCleanup = cleanup;
|
|
839
|
-
}
|
|
840
|
-
writeState(this.ctx.paths.stateFile, state, 'pipeline-complete-release');
|
|
841
|
-
this.log.ok(`seq ${seq}: Slot ${slotName} released, worktree marked for cleanup`);
|
|
842
|
-
// 5. Stop worker session
|
|
843
|
-
if (sessionName) {
|
|
844
|
-
this.workerProvider.stop(sessionName).catch(() => { });
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
catch (err) {
|
|
849
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
850
|
-
this.log.error(`seq ${seq}: Failed to release resources: ${msg}`);
|
|
851
|
-
errors.push(`release: ${msg}`);
|
|
852
|
-
}
|
|
853
|
-
this.logEvent('complete-direct', seq, errors.length === 0 ? 'ok' : 'fail', { slotName });
|
|
854
|
-
if (this.notifier) {
|
|
855
|
-
const statusMsg = errors.length === 0
|
|
856
|
-
? `seq:${seq} completed — merged to ${this.ctx.mergeBranch}, resources released`
|
|
857
|
-
: `seq:${seq} completed with errors: ${errors.join('; ')}`;
|
|
858
|
-
await this.notifier.sendSuccess(`[${this.ctx.projectName}] ${statusMsg}`).catch(() => { });
|
|
859
|
-
}
|
|
860
|
-
return {
|
|
861
|
-
action: 'complete-direct',
|
|
862
|
-
entity: `seq:${seq}`,
|
|
863
|
-
result: errors.length === 0 ? 'ok' : 'fail',
|
|
864
|
-
message: errors.length === 0
|
|
865
|
-
? `Inprogress → Done (merged to ${this.ctx.mergeBranch}, resources released)`
|
|
866
|
-
: `Completed with errors: ${errors.join('; ')}`,
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* Release a worker slot, cleanup tmux session, remove card from active cards.
|
|
683
|
+
* Release a worker slot and remove card from active cards.
|
|
684
|
+
* Used for launch failure rollback.
|
|
871
685
|
*/
|
|
872
686
|
releaseSlot(slotName, seq) {
|
|
873
687
|
try {
|
|
874
|
-
// Kill tmux session if it exists (cleanup from failed launch)
|
|
875
|
-
const sessionName = `${this.ctx.projectName}-${slotName}`;
|
|
876
|
-
this.workerProvider.stop(sessionName).catch(() => { });
|
|
877
688
|
const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
878
689
|
if (state.workers[slotName]) {
|
|
879
690
|
state.workers[slotName] = {
|
|
@@ -899,192 +710,90 @@ ${requirements.join('\n')}`);
|
|
|
899
710
|
this.log.warn(`Failed to release slot ${slotName} for seq ${seq}`);
|
|
900
711
|
}
|
|
901
712
|
}
|
|
713
|
+
// ─── Skill Profile Loading (label-driven) ─────────────────────
|
|
902
714
|
/**
|
|
903
|
-
*
|
|
904
|
-
*
|
|
715
|
+
* Load skill profiles based on card labels (skill:xxx) or project default.
|
|
716
|
+
* Returns combined profile content for prompt injection.
|
|
905
717
|
*/
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
const sid = parser(launchResult.outputFile);
|
|
917
|
-
if (sid) {
|
|
918
|
-
const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
919
|
-
if (state.workers[slotName]?.pid === launchResult.pid) {
|
|
920
|
-
state.workers[slotName].sessionId = sid;
|
|
921
|
-
writeState(this.ctx.paths.stateFile, state, 'pipeline-session-id');
|
|
922
|
-
this.log.info(`Extracted session ID for ${sessionName}: ${sid.slice(0, 8)}...`);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
718
|
+
loadSkillProfiles(card) {
|
|
719
|
+
// 1. Extract skill:xxx labels from card
|
|
720
|
+
let skills = card.labels
|
|
721
|
+
.filter(l => l.startsWith('skill:'))
|
|
722
|
+
.map(l => l.slice('skill:'.length));
|
|
723
|
+
// 2. Fallback to project default
|
|
724
|
+
if (skills.length === 0) {
|
|
725
|
+
const defaultSkills = this.ctx.config.raw.DEFAULT_WORKER_SKILLS;
|
|
726
|
+
if (defaultSkills) {
|
|
727
|
+
skills = defaultSkills.split(',').map(s => s.trim()).filter(Boolean);
|
|
925
728
|
}
|
|
926
|
-
catch { /* non-fatal */ }
|
|
927
|
-
}, 5_000);
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* Attempt to resume a failed/incomplete worker via --resume.
|
|
931
|
-
*
|
|
932
|
-
* Uses metaRead/metaWrite to track resumeAttempts per card.
|
|
933
|
-
* Max retries = WORKER_RESTART_LIMIT (default 2).
|
|
934
|
-
*
|
|
935
|
-
* Returns an ActionRecord if resume was initiated, or null if retries exhausted.
|
|
936
|
-
*/
|
|
937
|
-
async attemptResume(seq, slotName, slotState, card, reason) {
|
|
938
|
-
const maxRetries = this.ctx.config.WORKER_RESTART_LIMIT;
|
|
939
|
-
let meta;
|
|
940
|
-
try {
|
|
941
|
-
meta = await this.taskBackend.metaRead(seq);
|
|
942
|
-
}
|
|
943
|
-
catch {
|
|
944
|
-
meta = {};
|
|
945
729
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
`Your previous session exited before the task was completed (${reason}).`,
|
|
962
|
-
`This is resume attempt ${resumeAttempts + 1} of ${maxRetries}.`,
|
|
963
|
-
'',
|
|
964
|
-
`Task: ${card.name}`,
|
|
965
|
-
`Branch: ${branch}`,
|
|
966
|
-
`Target: ${this.ctx.mergeBranch}`,
|
|
967
|
-
'',
|
|
968
|
-
'Please check the current state and continue:',
|
|
969
|
-
'1. Review what has been done (git log, git status)',
|
|
970
|
-
'2. Complete any remaining implementation',
|
|
971
|
-
'3. Self-test your changes',
|
|
972
|
-
`4. git add, commit, and push to branch ${branch}`,
|
|
973
|
-
'5. Create and merge the MR by running: bash .jarvis/merge.sh',
|
|
974
|
-
'6. Say "done" when finished',
|
|
975
|
-
'',
|
|
976
|
-
'IMPORTANT: Step 5 (bash .jarvis/merge.sh) is MANDATORY. Do NOT skip it.',
|
|
977
|
-
].join('\n');
|
|
978
|
-
const resumeResult = await this.workerProvider.sendFix(session, continuePrompt, sessionId);
|
|
979
|
-
// Update state with new process info
|
|
980
|
-
if (resumeResult && typeof resumeResult === 'object' && 'pid' in resumeResult) {
|
|
981
|
-
const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
982
|
-
if (freshState.workers[slotName]) {
|
|
983
|
-
freshState.workers[slotName].pid = resumeResult.pid;
|
|
984
|
-
freshState.workers[slotName].outputFile = resumeResult.outputFile;
|
|
985
|
-
if (resumeResult.sessionId) {
|
|
986
|
-
freshState.workers[slotName].sessionId = resumeResult.sessionId;
|
|
987
|
-
}
|
|
988
|
-
freshState.workers[slotName].exitCode = null;
|
|
989
|
-
freshState.workers[slotName].lastHeartbeat = new Date().toISOString();
|
|
990
|
-
writeState(this.ctx.paths.stateFile, freshState, 'pipeline-resume');
|
|
991
|
-
}
|
|
730
|
+
if (skills.length === 0)
|
|
731
|
+
return '';
|
|
732
|
+
// 3. Load profile files
|
|
733
|
+
const frameworkDir = this.ctx.config.raw.FRAMEWORK_DIR
|
|
734
|
+
|| resolve(process.env.HOME || '~', 'jarvis-skills');
|
|
735
|
+
const profilesDir = resolve(frameworkDir, 'skills', 'worker-profiles');
|
|
736
|
+
const sections = ['# Skill Profiles'];
|
|
737
|
+
for (const skill of skills) {
|
|
738
|
+
const filePath = resolve(profilesDir, `${skill}.md`);
|
|
739
|
+
if (existsSync(filePath)) {
|
|
740
|
+
const content = readFileSync(filePath, 'utf-8').trim();
|
|
741
|
+
// Strip YAML frontmatter
|
|
742
|
+
const body = content.replace(/^---[\s\S]*?---\s*/, '');
|
|
743
|
+
sections.push(body);
|
|
744
|
+
this.log.ok(`Loaded skill profile: ${skill}`);
|
|
992
745
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
...meta,
|
|
996
|
-
resumeAttempts: resumeAttempts + 1,
|
|
997
|
-
});
|
|
998
|
-
this.log.info(`seq ${seq}: Resumed worker (attempt ${resumeAttempts + 1}/${maxRetries}), reason: ${reason}`);
|
|
999
|
-
if (this.notifier) {
|
|
1000
|
-
await this.notifier.send(`[${this.ctx.projectName}] seq:${seq} worker resumed (${resumeAttempts + 1}/${maxRetries}): ${reason}`, 'info').catch(() => { });
|
|
746
|
+
else {
|
|
747
|
+
this.log.warn(`Skill profile not found: ${filePath}`);
|
|
1001
748
|
}
|
|
1002
|
-
this.logEvent('resume', seq, 'ok', { attempt: resumeAttempts + 1, max: maxRetries, reason });
|
|
1003
|
-
return {
|
|
1004
|
-
action: 'resume',
|
|
1005
|
-
entity: `seq:${seq}`,
|
|
1006
|
-
result: 'ok',
|
|
1007
|
-
message: `Worker resumed (${resumeAttempts + 1}/${maxRetries}): ${reason}`,
|
|
1008
|
-
};
|
|
1009
|
-
}
|
|
1010
|
-
catch (err) {
|
|
1011
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1012
|
-
this.log.error(`seq ${seq}: Resume failed: ${msg}`);
|
|
1013
|
-
return null; // caller handles NEEDS-FIX
|
|
1014
749
|
}
|
|
750
|
+
return sections.length > 1 ? sections.join('\n\n') : '';
|
|
1015
751
|
}
|
|
752
|
+
// ─── Project Knowledge Loading (truncated) ────────────────────
|
|
1016
753
|
/**
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
754
|
+
* Load recent project knowledge from docs/DECISIONS.md and docs/CHANGELOG.md.
|
|
755
|
+
* Truncates to recent entries to keep prompt size manageable.
|
|
1019
756
|
*/
|
|
1020
|
-
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
meta = {};
|
|
1033
|
-
}
|
|
1034
|
-
const mergeAttempts = typeof meta.mergeResumeAttempts === 'number' ? meta.mergeResumeAttempts : 0;
|
|
1035
|
-
if (mergeAttempts >= maxRetries) {
|
|
1036
|
-
this.log.warn(`seq ${seq}: Merge resume retries exhausted (${mergeAttempts}/${maxRetries})`);
|
|
1037
|
-
return null; // fall through to system fallback
|
|
757
|
+
loadProjectKnowledge(worktreePath) {
|
|
758
|
+
const sections = ['# Project Knowledge (from previous tasks)'];
|
|
759
|
+
let hasContent = false;
|
|
760
|
+
// Recent decisions (last 10 sections)
|
|
761
|
+
const decisionsPath = resolve(worktreePath, 'docs', 'DECISIONS.md');
|
|
762
|
+
if (existsSync(decisionsPath)) {
|
|
763
|
+
const content = readFileSync(decisionsPath, 'utf-8');
|
|
764
|
+
const recent = this.extractRecentSections(content, 10);
|
|
765
|
+
if (recent) {
|
|
766
|
+
sections.push('## Recent Decisions\n' + recent);
|
|
767
|
+
hasContent = true;
|
|
768
|
+
}
|
|
1038
769
|
}
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
'',
|
|
1048
|
-
'Run this ONE command to create and merge the MR:',
|
|
1049
|
-
'',
|
|
1050
|
-
' bash .jarvis/merge.sh',
|
|
1051
|
-
'',
|
|
1052
|
-
'Then say "done".',
|
|
1053
|
-
].join('\n');
|
|
1054
|
-
try {
|
|
1055
|
-
const resumeResult = await this.workerProvider.sendFix(session, mergePrompt, sessionId);
|
|
1056
|
-
if (resumeResult && typeof resumeResult === 'object' && 'pid' in resumeResult) {
|
|
1057
|
-
const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
|
|
1058
|
-
if (freshState.workers[slotName]) {
|
|
1059
|
-
freshState.workers[slotName].pid = resumeResult.pid;
|
|
1060
|
-
freshState.workers[slotName].outputFile = resumeResult.outputFile;
|
|
1061
|
-
if (resumeResult.sessionId) {
|
|
1062
|
-
freshState.workers[slotName].sessionId = resumeResult.sessionId;
|
|
1063
|
-
}
|
|
1064
|
-
freshState.workers[slotName].exitCode = null;
|
|
1065
|
-
freshState.workers[slotName].lastHeartbeat = new Date().toISOString();
|
|
1066
|
-
writeState(this.ctx.paths.stateFile, freshState, 'pipeline-merge-resume');
|
|
1067
|
-
}
|
|
770
|
+
// Recent changelog (last 5 sections)
|
|
771
|
+
const changelogPath = resolve(worktreePath, 'docs', 'CHANGELOG.md');
|
|
772
|
+
if (existsSync(changelogPath)) {
|
|
773
|
+
const content = readFileSync(changelogPath, 'utf-8');
|
|
774
|
+
const recent = this.extractRecentSections(content, 5);
|
|
775
|
+
if (recent) {
|
|
776
|
+
sections.push('## Recent Changes\n' + recent);
|
|
777
|
+
hasContent = true;
|
|
1068
778
|
}
|
|
1069
|
-
// Increment merge resume counter
|
|
1070
|
-
await this.taskBackend.metaWrite(seq, {
|
|
1071
|
-
...meta,
|
|
1072
|
-
mergeResumeAttempts: mergeAttempts + 1,
|
|
1073
|
-
});
|
|
1074
|
-
this.log.info(`seq ${seq}: Resumed worker to create/merge MR (attempt ${mergeAttempts + 1}/${maxRetries})`);
|
|
1075
|
-
this.logEvent('merge-resume', seq, 'ok', { branch, attempt: mergeAttempts + 1 });
|
|
1076
|
-
return {
|
|
1077
|
-
action: 'merge-resume',
|
|
1078
|
-
entity: `seq:${seq}`,
|
|
1079
|
-
result: 'ok',
|
|
1080
|
-
message: `Worker resumed to create/merge MR (${mergeAttempts + 1}/${maxRetries})`,
|
|
1081
|
-
};
|
|
1082
779
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
780
|
+
return hasContent ? sections.join('\n\n') : '';
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Extract the last N ## sections from a markdown file.
|
|
784
|
+
*/
|
|
785
|
+
extractRecentSections(content, maxSections) {
|
|
786
|
+
const lines = content.split('\n');
|
|
787
|
+
const sectionStarts = [];
|
|
788
|
+
for (let i = 0; i < lines.length; i++) {
|
|
789
|
+
if (lines[i].startsWith('## ')) {
|
|
790
|
+
sectionStarts.push(i);
|
|
791
|
+
}
|
|
1087
792
|
}
|
|
793
|
+
if (sectionStarts.length === 0)
|
|
794
|
+
return content.trim();
|
|
795
|
+
const start = sectionStarts[Math.max(0, sectionStarts.length - maxSections)];
|
|
796
|
+
return lines.slice(start).join('\n').trim();
|
|
1088
797
|
}
|
|
1089
798
|
logEvent(action, seq, result, meta) {
|
|
1090
799
|
this.log.event({
|