@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.
Files changed (48) hide show
  1. package/README.md +48 -22
  2. package/dist/commands/monitorTick.d.ts.map +1 -1
  3. package/dist/commands/monitorTick.js +3 -1
  4. package/dist/commands/monitorTick.js.map +1 -1
  5. package/dist/commands/pipelineTick.d.ts.map +1 -1
  6. package/dist/commands/pipelineTick.js +12 -3
  7. package/dist/commands/pipelineTick.js.map +1 -1
  8. package/dist/commands/tick.d.ts +1 -0
  9. package/dist/commands/tick.d.ts.map +1 -1
  10. package/dist/commands/tick.js +64 -8
  11. package/dist/commands/tick.js.map +1 -1
  12. package/dist/commands/workerLaunch.d.ts.map +1 -1
  13. package/dist/commands/workerLaunch.js +12 -3
  14. package/dist/commands/workerLaunch.js.map +1 -1
  15. package/dist/engines/ExecutionEngine.d.ts +29 -32
  16. package/dist/engines/ExecutionEngine.d.ts.map +1 -1
  17. package/dist/engines/ExecutionEngine.js +236 -527
  18. package/dist/engines/ExecutionEngine.js.map +1 -1
  19. package/dist/engines/MonitorEngine.d.ts +14 -27
  20. package/dist/engines/MonitorEngine.d.ts.map +1 -1
  21. package/dist/engines/MonitorEngine.js +91 -313
  22. package/dist/engines/MonitorEngine.js.map +1 -1
  23. package/dist/main.js +0 -0
  24. package/dist/manager/completion-judge.d.ts +27 -0
  25. package/dist/manager/completion-judge.d.ts.map +1 -0
  26. package/dist/manager/completion-judge.js +81 -0
  27. package/dist/manager/completion-judge.js.map +1 -0
  28. package/dist/manager/pm-client.d.ts +10 -0
  29. package/dist/manager/pm-client.d.ts.map +1 -0
  30. package/dist/manager/pm-client.js +245 -0
  31. package/dist/manager/pm-client.js.map +1 -0
  32. package/dist/manager/post-actions.d.ts +60 -0
  33. package/dist/manager/post-actions.d.ts.map +1 -0
  34. package/dist/manager/post-actions.js +326 -0
  35. package/dist/manager/post-actions.js.map +1 -0
  36. package/dist/manager/recovery.d.ts +39 -0
  37. package/dist/manager/recovery.d.ts.map +1 -0
  38. package/dist/manager/recovery.js +133 -0
  39. package/dist/manager/recovery.js.map +1 -0
  40. package/dist/manager/resource-limiter.d.ts +44 -0
  41. package/dist/manager/resource-limiter.d.ts.map +1 -0
  42. package/dist/manager/resource-limiter.js +79 -0
  43. package/dist/manager/resource-limiter.js.map +1 -0
  44. package/dist/manager/supervisor.d.ts +70 -0
  45. package/dist/manager/supervisor.d.ts.map +1 -0
  46. package/dist/manager/supervisor.js +216 -0
  47. package/dist/manager/supervisor.js.map +1 -0
  48. 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, workerProvider, repoBackend, notifier) {
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 tmux/system.
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: wait between worker launches (shorter for print mode)
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 → QA) ──────────────────
166
+ // ─── Inprogress Phase (detect completion → Done) ────────────────
166
167
  /**
167
- * Check an Inprogress card: detect worker completion status and act.
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
- * Detection chain (12 §2):
171
- * COMPLETED → move card to QA
172
- * AUTO_CONFIRM → auto-confirm prompt, continue next tick
173
- * NEEDS_INPUT → mark WAITING-CONFIRMATION, notify
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
- // No active slot MonitorEngine handles orphan detection
180
+ // Slot already released (PostActions handled it via exit callback)
186
181
  return null;
187
182
  }
188
- const [slotName, slotState] = slotEntry;
189
- const session = slotState.tmuxSession;
190
- if (!session)
191
- return null;
192
- // Determine logDir for completion marker detection
193
- const logDir = this.ctx.paths.logsDir;
194
- const branch = slotState.branch || this.buildBranchName(card);
195
- let workerStatus;
196
- try {
197
- workerStatus = await this.workerProvider.detectCompleted(session, logDir, branch);
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
- case 'EXITED_INCOMPLETE':
358
- case 'DEAD':
359
- case 'DEAD_EXCEEDED': {
360
- // Worker exited without completing. Attempt auto-resume if:
361
- // - Print mode (can --resume to continue context)
362
- // - Retry limit not exhausted
363
- // Otherwise mark NEEDS-FIX.
364
- const isPrintMode = slotState.mode === 'print';
365
- const reason = workerStatus === 'EXITED_INCOMPLETE'
366
- ? 'exited without artifacts (token limit / gave up)'
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
- case 'ALIVE':
391
- default:
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
- // Prefer slot with live session (Claude still running → context reuse)
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 (unified: launch + waitReady + sendTask in one call)
347
+ // Step 6: Launch worker via Supervisor
558
348
  try {
559
349
  const promptFile = resolve(worktreePath, '.jarvis_task_prompt.txt');
560
- const launchResult = await this.workerProvider.launch(sessionName, worktreePath, promptFile);
561
- // Store print-mode process info in state
562
- if (launchResult.pid > 0) {
563
- const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
564
- if (freshState.workers[slotName]) {
565
- freshState.workers[slotName].mode = 'print';
566
- freshState.workers[slotName].pid = launchResult.pid;
567
- freshState.workers[slotName].outputFile = launchResult.outputFile;
568
- freshState.workers[slotName].sessionId = launchResult.sessionId || null;
569
- freshState.workers[slotName].exitCode = null;
570
- writeState(this.ctx.paths.stateFile, freshState, 'pipeline-launch-print');
571
- }
572
- // Async: extract session ID from output once available
573
- this.extractSessionIdAsync(sessionName, slotName, launchResult);
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 in session ${sessionName} for seq ${seq}`);
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: stop worker, release slot
415
+ // Rollback: kill worker, release slot
416
+ const workerId = `${this.ctx.projectName}:${slotName}:${card.seq}`;
605
417
  try {
606
- await this.workerProvider.stop(sessionName);
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
- // Read project rules from CLAUDE.md + AGENTS.md (if exist)
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
- // .jarvis_task_prompt.txt project rules + task-specific prompt
658
- // Print mode: piped via stdin to `claude -p` / `codex exec`
659
- // Interactive mode: pasted via tmux buffer
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 — direct merge (MR_MODE=none) or create MR (MR_MODE=create).
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
- `3. git add, commit, and push to branch ${branchName}`,
677
- `4. ${mergeStepDesc} by running:`,
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
- '5. Verify the script output shows success, then say "done"',
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 4 (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).');
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
- * Complete a card directly: Done + release slot + mark worktree for cleanup.
801
- * Used for CI_MODE=none where worker merges directly (no QA/CloseoutEngine needed).
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
- * Asynchronously extract session ID from print-mode output and update state.
904
- * Runs in background does not block the tick.
715
+ * Load skill profiles based on card labels (skill:xxx) or project default.
716
+ * Returns combined profile content for prompt injection.
905
717
  */
906
- extractSessionIdAsync(sessionName, slotName, launchResult) {
907
- if (launchResult.sessionId)
908
- return; // already known (resume)
909
- // Check output file for session ID after a delay
910
- setTimeout(async () => {
911
- try {
912
- const { parseClaudeSessionId, parseCodexSessionId } = await import('../providers/outputParser.js');
913
- const parser = this.ctx.config.WORKER_TOOL === 'claude'
914
- ? parseClaudeSessionId
915
- : parseCodexSessionId;
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
- const resumeAttempts = typeof meta.resumeAttempts === 'number' ? meta.resumeAttempts : 0;
947
- if (resumeAttempts >= maxRetries) {
948
- this.log.warn(`seq ${seq}: Resume retries exhausted (${resumeAttempts}/${maxRetries})`);
949
- return null; // caller handles NEEDS-FIX
950
- }
951
- const session = slotState.tmuxSession;
952
- const sessionId = slotState.sessionId;
953
- if (!session || !sessionId) {
954
- this.log.warn(`seq ${seq}: No session ID for resume`);
955
- return null;
956
- }
957
- try {
958
- // Build a continuation prompt that tells the worker to pick up where it left off
959
- const branch = slotState.branch || this.buildBranchName(card);
960
- const continuePrompt = [
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
- // Increment resume counter
994
- await this.taskBackend.metaWrite(seq, {
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
- * Resume a worker specifically to create MR and merge it.
1018
- * Used when worker completed coding + push but didn't create/merge MR.
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
- async attemptMergeResume(seq, slotName, slotState, card) {
1021
- const session = slotState.tmuxSession;
1022
- const sessionId = slotState.sessionId;
1023
- if (!session || !sessionId)
1024
- return null;
1025
- // Guard against infinite merge-resume loop
1026
- const maxRetries = this.ctx.config.WORKER_RESTART_LIMIT;
1027
- let meta;
1028
- try {
1029
- meta = await this.taskBackend.metaRead(seq);
1030
- }
1031
- catch {
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
- const branch = slotState.branch || this.buildBranchName(card);
1040
- const gitlabProjectId = this.ctx.config.GITLAB_PROJECT_ID;
1041
- const mergePrompt = [
1042
- 'Your code changes are complete and pushed, but the Merge Request has not been created/merged yet.',
1043
- '',
1044
- `Task: ${card.name}`,
1045
- `Branch: ${branch}`,
1046
- `Target: ${this.ctx.mergeBranch}`,
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
- catch (err) {
1084
- const msg = err instanceof Error ? err.message : String(err);
1085
- this.log.error(`seq ${seq}: Merge resume failed: ${msg}`);
1086
- return null;
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({