@stoneforge/smithy 1.0.3 → 1.2.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 +100 -18
- package/dist/cli/commands/agent.js +10 -10
- package/dist/cli/commands/agent.js.map +1 -1
- package/dist/cli/commands/pool.d.ts.map +1 -1
- package/dist/cli/commands/pool.js +33 -16
- package/dist/cli/commands/pool.js.map +1 -1
- package/dist/git/merge.js +1 -1
- package/dist/git/merge.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/prompts/director.md +2 -2
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +6 -3
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/persistent-worker.md +1 -1
- package/dist/prompts/steward-base.md +4 -4
- package/dist/prompts/steward-recovery.md +113 -0
- package/dist/prompts/worker.md +12 -1
- package/dist/runtime/spawner.d.ts.map +1 -1
- package/dist/runtime/spawner.js +10 -0
- package/dist/runtime/spawner.js.map +1 -1
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +3 -1
- package/dist/server/config.js.map +1 -1
- package/dist/server/daemon-state.d.ts.map +1 -1
- package/dist/server/daemon-state.js +5 -3
- package/dist/server/daemon-state.js.map +1 -1
- package/dist/server/events-websocket.d.ts.map +1 -1
- package/dist/server/events-websocket.js +7 -5
- package/dist/server/events-websocket.js.map +1 -1
- package/dist/server/formatters.d.ts +16 -2
- package/dist/server/formatters.d.ts.map +1 -1
- package/dist/server/formatters.js +23 -2
- package/dist/server/formatters.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +14 -11
- package/dist/server/index.js.map +1 -1
- package/dist/server/lsp-websocket.d.ts.map +1 -1
- package/dist/server/lsp-websocket.js +10 -8
- package/dist/server/lsp-websocket.js.map +1 -1
- package/dist/server/routes/agents.d.ts.map +1 -1
- package/dist/server/routes/agents.js +69 -15
- package/dist/server/routes/agents.js.map +1 -1
- package/dist/server/routes/daemon.d.ts.map +1 -1
- package/dist/server/routes/daemon.js +6 -4
- package/dist/server/routes/daemon.js.map +1 -1
- package/dist/server/routes/events.d.ts.map +1 -1
- package/dist/server/routes/events.js +4 -2
- package/dist/server/routes/events.js.map +1 -1
- package/dist/server/routes/extensions.d.ts.map +1 -1
- package/dist/server/routes/extensions.js +9 -7
- package/dist/server/routes/extensions.js.map +1 -1
- package/dist/server/routes/plugins.d.ts.map +1 -1
- package/dist/server/routes/plugins.js +6 -4
- package/dist/server/routes/plugins.js.map +1 -1
- package/dist/server/routes/pools.d.ts.map +1 -1
- package/dist/server/routes/pools.js +26 -7
- package/dist/server/routes/pools.js.map +1 -1
- package/dist/server/routes/scheduler.d.ts.map +1 -1
- package/dist/server/routes/scheduler.js +9 -7
- package/dist/server/routes/scheduler.js.map +1 -1
- package/dist/server/routes/sessions.d.ts.map +1 -1
- package/dist/server/routes/sessions.js +17 -15
- package/dist/server/routes/sessions.js.map +1 -1
- package/dist/server/routes/tasks.d.ts.map +1 -1
- package/dist/server/routes/tasks.js +54 -31
- package/dist/server/routes/tasks.js.map +1 -1
- package/dist/server/routes/upload.d.ts.map +1 -1
- package/dist/server/routes/upload.js +3 -1
- package/dist/server/routes/upload.js.map +1 -1
- package/dist/server/routes/workflows.d.ts.map +1 -1
- package/dist/server/routes/workflows.js +17 -15
- package/dist/server/routes/workflows.js.map +1 -1
- package/dist/server/routes/workspace-files.d.ts.map +1 -1
- package/dist/server/routes/workspace-files.js +11 -9
- package/dist/server/routes/workspace-files.js.map +1 -1
- package/dist/server/routes/worktrees.d.ts.map +1 -1
- package/dist/server/routes/worktrees.js +6 -4
- package/dist/server/routes/worktrees.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +10 -8
- package/dist/server/server.js.map +1 -1
- package/dist/server/services/lsp-manager.d.ts.map +1 -1
- package/dist/server/services/lsp-manager.js +15 -13
- package/dist/server/services/lsp-manager.js.map +1 -1
- package/dist/server/services.d.ts +1 -2
- package/dist/server/services.d.ts.map +1 -1
- package/dist/server/services.js +37 -12
- package/dist/server/services.js.map +1 -1
- package/dist/server/static.d.ts.map +1 -1
- package/dist/server/static.js +3 -1
- package/dist/server/static.js.map +1 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +6 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/services/agent-pool-service.d.ts.map +1 -1
- package/dist/services/agent-pool-service.js +7 -8
- package/dist/services/agent-pool-service.js.map +1 -1
- package/dist/services/agent-registry.d.ts +1 -1
- package/dist/services/agent-registry.d.ts.map +1 -1
- package/dist/services/agent-registry.js +2 -0
- package/dist/services/agent-registry.js.map +1 -1
- package/dist/services/dispatch-daemon.d.ts +64 -2
- package/dist/services/dispatch-daemon.d.ts.map +1 -1
- package/dist/services/dispatch-daemon.js +387 -59
- package/dist/services/dispatch-daemon.js.map +1 -1
- package/dist/services/index.d.ts +0 -2
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +0 -11
- package/dist/services/index.js.map +1 -1
- package/dist/services/merge-steward-service.d.ts.map +1 -1
- package/dist/services/merge-steward-service.js +6 -4
- package/dist/services/merge-steward-service.js.map +1 -1
- package/dist/services/steward-scheduler.d.ts +37 -5
- package/dist/services/steward-scheduler.d.ts.map +1 -1
- package/dist/services/steward-scheduler.js +224 -41
- package/dist/services/steward-scheduler.js.map +1 -1
- package/dist/services/task-assignment-service.d.ts.map +1 -1
- package/dist/services/task-assignment-service.js +3 -0
- package/dist/services/task-assignment-service.js.map +1 -1
- package/dist/testing/test-context.d.ts +1 -1
- package/dist/testing/test-context.d.ts.map +1 -1
- package/dist/types/agent-pool.d.ts +4 -0
- package/dist/types/agent-pool.d.ts.map +1 -1
- package/dist/types/agent-pool.js +8 -1
- package/dist/types/agent-pool.js.map +1 -1
- package/dist/types/agent.d.ts +29 -7
- package/dist/types/agent.d.ts.map +1 -1
- package/dist/types/agent.js +2 -2
- package/dist/types/agent.js.map +1 -1
- package/dist/types/role-definition.d.ts +1 -1
- package/dist/types/role-definition.js +1 -1
- package/dist/types/role-definition.js.map +1 -1
- package/dist/types/task-meta.d.ts +6 -0
- package/dist/types/task-meta.d.ts.map +1 -1
- package/dist/types/task-meta.js.map +1 -1
- package/dist/utils/logger.d.ts +66 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +133 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +7 -7
- package/web/assets/index-CNcjZKzg.css +32 -0
- package/web/assets/{index-R1cylSgw.js → index-CtkfxijF.js} +679 -435
- package/web/assets/{utils-vendor-DaJ2Dubl.js → utils-vendor-B7jOGaxP.js} +1 -1
- package/web/index.html +3 -3
- package/dist/prompts/steward-health.md +0 -39
- package/dist/prompts/steward-ops.md +0 -28
- package/dist/prompts/steward-reminder.md +0 -26
- package/dist/services/health-steward-service.d.ts +0 -446
- package/dist/services/health-steward-service.d.ts.map +0 -1
- package/dist/services/health-steward-service.js +0 -866
- package/dist/services/health-steward-service.js.map +0 -1
- package/web/assets/index-DqP-_E4F.css +0 -32
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
20
|
import { EventEmitter } from 'node:events';
|
|
21
|
-
import { InboxStatus, createTimestamp, TaskStatus, asEntityId, asElementId } from '@stoneforge/core';
|
|
21
|
+
import { InboxStatus, createTimestamp, TaskStatus, asEntityId, asElementId, PlanStatus, canAutoComplete } from '@stoneforge/core';
|
|
22
22
|
import { loadTriagePrompt, loadRolePrompt } from '../prompts/index.js';
|
|
23
|
+
import { createLogger } from '../utils/logger.js';
|
|
23
24
|
import { getAgentMetadata } from './agent-registry.js';
|
|
24
25
|
import { getOrchestratorTaskMeta, updateOrchestratorTaskMeta, appendTaskSessionHistory, } from '../types/task-meta.js';
|
|
26
|
+
const logger = createLogger('dispatch-daemon');
|
|
25
27
|
// ============================================================================
|
|
26
28
|
// Constants
|
|
27
29
|
// ============================================================================
|
|
@@ -95,32 +97,32 @@ export class DispatchDaemonImpl {
|
|
|
95
97
|
try {
|
|
96
98
|
const result = await this.sessionManager.reconcileOnStartup();
|
|
97
99
|
if (result.reconciled > 0) {
|
|
98
|
-
|
|
100
|
+
logger.info(`Reconciled ${result.reconciled} stale session(s)`);
|
|
99
101
|
}
|
|
100
102
|
if (result.errors.length > 0) {
|
|
101
|
-
|
|
103
|
+
logger.warn('Reconciliation errors:', result.errors);
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
catch (error) {
|
|
105
|
-
|
|
107
|
+
logger.error('Failed to reconcile on startup:', error);
|
|
106
108
|
}
|
|
107
109
|
// Recover orphaned task assignments (workers with tasks but no session after restart)
|
|
108
110
|
if (this.config.orphanRecoveryEnabled) {
|
|
109
111
|
try {
|
|
110
112
|
const result = await this.recoverOrphanedAssignments();
|
|
111
113
|
if (result.processed > 0) {
|
|
112
|
-
|
|
114
|
+
logger.info(`Startup: recovered ${result.processed} orphaned task assignment(s)`);
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
catch (error) {
|
|
116
|
-
|
|
118
|
+
logger.error('Failed to recover orphaned assignments on startup:', error);
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
// Start the main poll loop
|
|
120
122
|
this.pollIntervalHandle = this.createPollInterval();
|
|
121
123
|
// Run an initial poll cycle immediately
|
|
122
124
|
this.currentPollCycle = this.runPollCycle().catch((error) => {
|
|
123
|
-
|
|
125
|
+
logger.error('Initial poll cycle error:', error);
|
|
124
126
|
});
|
|
125
127
|
}
|
|
126
128
|
async stop() {
|
|
@@ -184,7 +186,7 @@ export class DispatchDaemonImpl {
|
|
|
184
186
|
taskStatus: [TaskStatus.OPEN, TaskStatus.IN_PROGRESS, TaskStatus.REVIEW],
|
|
185
187
|
});
|
|
186
188
|
if (workerTasks.length > 0) {
|
|
187
|
-
|
|
189
|
+
logger.debug(`Worker ${worker.name} already has ${workerTasks.length} assigned task(s), skipping`);
|
|
188
190
|
continue;
|
|
189
191
|
}
|
|
190
192
|
availableWorkers.push(worker);
|
|
@@ -201,7 +203,7 @@ export class DispatchDaemonImpl {
|
|
|
201
203
|
errors++;
|
|
202
204
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
203
205
|
errorMessages.push(`Worker ${worker.name}: ${errorMessage}`);
|
|
204
|
-
|
|
206
|
+
logger.error(`Error assigning task to worker ${worker.name}:`, error);
|
|
205
207
|
}
|
|
206
208
|
}
|
|
207
209
|
}
|
|
@@ -209,7 +211,7 @@ export class DispatchDaemonImpl {
|
|
|
209
211
|
errors++;
|
|
210
212
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
211
213
|
errorMessages.push(errorMessage);
|
|
212
|
-
|
|
214
|
+
logger.error('Error in pollWorkerAvailability:', error);
|
|
213
215
|
}
|
|
214
216
|
const result = {
|
|
215
217
|
pollType: 'worker-availability',
|
|
@@ -294,7 +296,7 @@ export class DispatchDaemonImpl {
|
|
|
294
296
|
errors++;
|
|
295
297
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
296
298
|
errorMessages.push(errorMessage);
|
|
297
|
-
|
|
299
|
+
logger.error('Error in pollInboxes:', error);
|
|
298
300
|
}
|
|
299
301
|
const result = {
|
|
300
302
|
pollType: 'inbox',
|
|
@@ -322,6 +324,8 @@ export class DispatchDaemonImpl {
|
|
|
322
324
|
if (!this.stewardScheduler.isRunning()) {
|
|
323
325
|
// Start the scheduler if it's not running
|
|
324
326
|
await this.stewardScheduler.start();
|
|
327
|
+
const registered = await this.stewardScheduler.registerAllStewards();
|
|
328
|
+
logger.info(`Steward scheduler started, registered ${registered} steward(s)`);
|
|
325
329
|
processed++;
|
|
326
330
|
}
|
|
327
331
|
// Get stats to report on activity
|
|
@@ -332,7 +336,7 @@ export class DispatchDaemonImpl {
|
|
|
332
336
|
errors++;
|
|
333
337
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
334
338
|
errorMessages.push(errorMessage);
|
|
335
|
-
|
|
339
|
+
logger.error('Error in pollStewardTriggers:', error);
|
|
336
340
|
}
|
|
337
341
|
const result = {
|
|
338
342
|
pollType: 'steward-trigger',
|
|
@@ -441,7 +445,7 @@ export class DispatchDaemonImpl {
|
|
|
441
445
|
errors++;
|
|
442
446
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
443
447
|
errorMessages.push(errorMessage);
|
|
444
|
-
|
|
448
|
+
logger.error('Error in pollWorkflowTasks:', error);
|
|
445
449
|
}
|
|
446
450
|
const result = {
|
|
447
451
|
pollType: 'workflow-task',
|
|
@@ -479,17 +483,41 @@ export class DispatchDaemonImpl {
|
|
|
479
483
|
});
|
|
480
484
|
if (workerTasks.length === 0)
|
|
481
485
|
continue;
|
|
482
|
-
// 4.
|
|
486
|
+
// 4. Check if the task is stuck in a resume loop
|
|
483
487
|
const taskAssignment = workerTasks[0];
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
488
|
+
const resumeCount = taskAssignment.orchestratorMeta?.resumeCount ?? 0;
|
|
489
|
+
const maxResumes = this.config.maxResumeAttemptsBeforeRecovery;
|
|
490
|
+
if (maxResumes > 0 && resumeCount >= maxResumes) {
|
|
491
|
+
// Task has been resumed too many times without status change —
|
|
492
|
+
// spawn a recovery steward instead of resuming the worker again
|
|
493
|
+
try {
|
|
494
|
+
await this.spawnRecoveryStewardForTask(worker, taskAssignment.task, taskAssignment.orchestratorMeta);
|
|
495
|
+
processed++;
|
|
496
|
+
logger.info(`[dispatch-daemon] Spawning recovery steward for stuck task ${taskAssignment.task.id} after ${resumeCount} resume attempts`);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
errors++;
|
|
500
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
501
|
+
errorMessages.push(`Recovery steward for ${worker.name}: ${errorMessage}`);
|
|
502
|
+
logger.error(`Error spawning recovery steward for worker ${worker.name}:`, error);
|
|
503
|
+
}
|
|
487
504
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
505
|
+
else {
|
|
506
|
+
// Normal recovery: increment resume count and re-spawn the worker
|
|
507
|
+
try {
|
|
508
|
+
// Increment resumeCount before recovering so the counter is persisted
|
|
509
|
+
await this.api.update(taskAssignment.task.id, {
|
|
510
|
+
metadata: updateOrchestratorTaskMeta(taskAssignment.task.metadata, { resumeCount: resumeCount + 1 }),
|
|
511
|
+
});
|
|
512
|
+
await this.recoverOrphanedTask(worker, taskAssignment.task, taskAssignment.orchestratorMeta);
|
|
513
|
+
processed++;
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
errors++;
|
|
517
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
518
|
+
errorMessages.push(`Worker ${worker.name}: ${errorMessage}`);
|
|
519
|
+
logger.error(`Error recovering orphaned task for worker ${worker.name}:`, error);
|
|
520
|
+
}
|
|
493
521
|
}
|
|
494
522
|
}
|
|
495
523
|
// --- Phase 2: Recover orphaned merge steward assignments ---
|
|
@@ -522,18 +550,18 @@ export class DispatchDaemonImpl {
|
|
|
522
550
|
errors++;
|
|
523
551
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
524
552
|
errorMessages.push(`Merge steward ${steward.name}: ${errorMessage}`);
|
|
525
|
-
|
|
553
|
+
logger.error(`Error recovering orphaned steward task for ${steward.name}:`, error);
|
|
526
554
|
}
|
|
527
555
|
}
|
|
528
556
|
if (processed > 0) {
|
|
529
|
-
|
|
557
|
+
logger.info(`Recovered ${processed} orphaned task assignment(s)`);
|
|
530
558
|
}
|
|
531
559
|
}
|
|
532
560
|
catch (error) {
|
|
533
561
|
errors++;
|
|
534
562
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
535
563
|
errorMessages.push(errorMessage);
|
|
536
|
-
|
|
564
|
+
logger.error('Error in recoverOrphanedAssignments:', error);
|
|
537
565
|
}
|
|
538
566
|
const result = {
|
|
539
567
|
pollType: 'orphan-recovery',
|
|
@@ -578,7 +606,7 @@ export class DispatchDaemonImpl {
|
|
|
578
606
|
// Safety valve: skip if already reconciled 3+ times (prevents infinite loops)
|
|
579
607
|
const currentCount = orchestratorMeta.reconciliationCount ?? 0;
|
|
580
608
|
if (currentCount >= 3) {
|
|
581
|
-
|
|
609
|
+
logger.warn(`Task ${task.id} has been reconciled ${currentCount} times, skipping (safety valve)`);
|
|
582
610
|
continue;
|
|
583
611
|
}
|
|
584
612
|
// Move back to REVIEW with incremented reconciliation count.
|
|
@@ -595,24 +623,24 @@ export class DispatchDaemonImpl {
|
|
|
595
623
|
}),
|
|
596
624
|
});
|
|
597
625
|
processed++;
|
|
598
|
-
|
|
626
|
+
logger.info(`Reconciled closed-but-unmerged task ${task.id} (mergeStatus=${orchestratorMeta.mergeStatus}, attempt=${currentCount + 1})`);
|
|
599
627
|
}
|
|
600
628
|
catch (error) {
|
|
601
629
|
errors++;
|
|
602
630
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
603
631
|
errorMessages.push(`Task ${assignment.taskId}: ${errorMessage}`);
|
|
604
|
-
|
|
632
|
+
logger.error(`Error reconciling task ${assignment.taskId}:`, error);
|
|
605
633
|
}
|
|
606
634
|
}
|
|
607
635
|
if (processed > 0) {
|
|
608
|
-
|
|
636
|
+
logger.info(`Reconciled ${processed} closed-but-unmerged task(s)`);
|
|
609
637
|
}
|
|
610
638
|
}
|
|
611
639
|
catch (error) {
|
|
612
640
|
errors++;
|
|
613
641
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
614
642
|
errorMessages.push(errorMessage);
|
|
615
|
-
|
|
643
|
+
logger.error('Error in reconcileClosedUnmergedTasks:', error);
|
|
616
644
|
}
|
|
617
645
|
const result = {
|
|
618
646
|
pollType: 'closed-unmerged-reconciliation',
|
|
@@ -665,7 +693,7 @@ export class DispatchDaemonImpl {
|
|
|
665
693
|
// Safety valve: skip if already recovered 3+ times
|
|
666
694
|
const currentCount = orchestratorMeta.stuckMergeRecoveryCount ?? 0;
|
|
667
695
|
if (currentCount >= 3) {
|
|
668
|
-
|
|
696
|
+
logger.warn(`Task ${task.id} has been recovered from stuck merge ${currentCount} times, skipping (safety valve)`);
|
|
669
697
|
continue;
|
|
670
698
|
}
|
|
671
699
|
// Reset mergeStatus to 'pending' for fresh steward pickup
|
|
@@ -689,24 +717,24 @@ export class DispatchDaemonImpl {
|
|
|
689
717
|
// Ignore worktree cleanup errors
|
|
690
718
|
}
|
|
691
719
|
processed++;
|
|
692
|
-
|
|
720
|
+
logger.info(`Recovered stuck merge task ${task.id} (mergeStatus=${orchestratorMeta.mergeStatus}, attempt=${currentCount + 1})`);
|
|
693
721
|
}
|
|
694
722
|
catch (error) {
|
|
695
723
|
errors++;
|
|
696
724
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
697
725
|
errorMessages.push(`Task ${assignment.taskId}: ${errorMessage}`);
|
|
698
|
-
|
|
726
|
+
logger.error(`Error recovering stuck merge task ${assignment.taskId}:`, error);
|
|
699
727
|
}
|
|
700
728
|
}
|
|
701
729
|
if (processed > 0) {
|
|
702
|
-
|
|
730
|
+
logger.info(`Recovered ${processed} stuck merge task(s)`);
|
|
703
731
|
}
|
|
704
732
|
}
|
|
705
733
|
catch (error) {
|
|
706
734
|
errors++;
|
|
707
735
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
708
736
|
errorMessages.push(errorMessage);
|
|
709
|
-
|
|
737
|
+
logger.error('Error in recoverStuckMergeTasks:', error);
|
|
710
738
|
}
|
|
711
739
|
const stuckResult = {
|
|
712
740
|
pollType: 'stuck-merge-recovery',
|
|
@@ -719,6 +747,79 @@ export class DispatchDaemonImpl {
|
|
|
719
747
|
this.emitter.emit('poll:complete', stuckResult);
|
|
720
748
|
return stuckResult;
|
|
721
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* Detects active plans where all non-tombstone tasks are closed
|
|
752
|
+
* and marks them as completed.
|
|
753
|
+
*/
|
|
754
|
+
async pollPlanAutoComplete() {
|
|
755
|
+
const startedAt = new Date().toISOString();
|
|
756
|
+
const startTime = Date.now();
|
|
757
|
+
let processed = 0;
|
|
758
|
+
let errors = 0;
|
|
759
|
+
const errorMessages = [];
|
|
760
|
+
this.emitter.emit('poll:start', 'plan-auto-complete');
|
|
761
|
+
try {
|
|
762
|
+
// 1. List all active plans
|
|
763
|
+
const allPlans = await this.api.list({ type: 'plan' });
|
|
764
|
+
const activePlans = allPlans.filter((p) => p.status === PlanStatus.ACTIVE);
|
|
765
|
+
// 2. Check each active plan for auto-completion eligibility
|
|
766
|
+
for (const plan of activePlans) {
|
|
767
|
+
try {
|
|
768
|
+
// Get tasks in this plan (excluding deleted/tombstone)
|
|
769
|
+
const tasks = await this.api.getTasksInPlan(plan.id, { includeDeleted: false });
|
|
770
|
+
// Build status counts
|
|
771
|
+
const statusCounts = {
|
|
772
|
+
[TaskStatus.OPEN]: 0,
|
|
773
|
+
[TaskStatus.IN_PROGRESS]: 0,
|
|
774
|
+
[TaskStatus.BLOCKED]: 0,
|
|
775
|
+
[TaskStatus.CLOSED]: 0,
|
|
776
|
+
[TaskStatus.DEFERRED]: 0,
|
|
777
|
+
[TaskStatus.TOMBSTONE]: 0,
|
|
778
|
+
};
|
|
779
|
+
for (const task of tasks) {
|
|
780
|
+
if (task.status in statusCounts) {
|
|
781
|
+
statusCounts[task.status]++;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// 3. Check if plan can be auto-completed (all non-tombstone tasks are CLOSED)
|
|
785
|
+
if (canAutoComplete(statusCounts)) {
|
|
786
|
+
const now = new Date().toISOString();
|
|
787
|
+
await this.api.update(plan.id, {
|
|
788
|
+
status: PlanStatus.COMPLETED,
|
|
789
|
+
completedAt: now,
|
|
790
|
+
});
|
|
791
|
+
processed++;
|
|
792
|
+
logger.info(`Auto-completed plan ${plan.id} ("${plan.title}")`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
errors++;
|
|
797
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
798
|
+
errorMessages.push(`Plan ${plan.id}: ${errorMessage}`);
|
|
799
|
+
logger.error(`Error checking plan ${plan.id} for auto-completion:`, error);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (processed > 0) {
|
|
803
|
+
logger.info(`Auto-completed ${processed} plan(s)`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
errors++;
|
|
808
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
809
|
+
errorMessages.push(errorMessage);
|
|
810
|
+
logger.error('Error in pollPlanAutoComplete:', error);
|
|
811
|
+
}
|
|
812
|
+
const result = {
|
|
813
|
+
pollType: 'plan-auto-complete',
|
|
814
|
+
startedAt,
|
|
815
|
+
durationMs: Date.now() - startTime,
|
|
816
|
+
processed,
|
|
817
|
+
errors,
|
|
818
|
+
errorMessages: errorMessages.length > 0 ? errorMessages : undefined,
|
|
819
|
+
};
|
|
820
|
+
this.emitter.emit('poll:complete', result);
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
722
823
|
// ----------------------------------------
|
|
723
824
|
// Configuration
|
|
724
825
|
// ----------------------------------------
|
|
@@ -754,7 +855,7 @@ export class DispatchDaemonImpl {
|
|
|
754
855
|
await this.currentPollCycle;
|
|
755
856
|
}
|
|
756
857
|
catch (error) {
|
|
757
|
-
|
|
858
|
+
logger.error('Poll cycle error:', error);
|
|
758
859
|
}
|
|
759
860
|
}, this.config.pollIntervalMs);
|
|
760
861
|
}
|
|
@@ -768,11 +869,14 @@ export class DispatchDaemonImpl {
|
|
|
768
869
|
stewardTriggerPollEnabled: config?.stewardTriggerPollEnabled ?? true,
|
|
769
870
|
workflowTaskPollEnabled: config?.workflowTaskPollEnabled ?? true,
|
|
770
871
|
orphanRecoveryEnabled: config?.orphanRecoveryEnabled ?? true,
|
|
872
|
+
planAutoCompleteEnabled: config?.planAutoCompleteEnabled ?? true,
|
|
771
873
|
closedUnmergedReconciliationEnabled: config?.closedUnmergedReconciliationEnabled ?? true,
|
|
772
874
|
closedUnmergedGracePeriodMs: config?.closedUnmergedGracePeriodMs ?? 120_000,
|
|
773
875
|
stuckMergeRecoveryEnabled: config?.stuckMergeRecoveryEnabled ?? true,
|
|
774
876
|
stuckMergeRecoveryGracePeriodMs: config?.stuckMergeRecoveryGracePeriodMs ?? 600_000,
|
|
877
|
+
maxResumeAttemptsBeforeRecovery: config?.maxResumeAttemptsBeforeRecovery ?? 3,
|
|
775
878
|
maxSessionDurationMs: config?.maxSessionDurationMs ?? 0,
|
|
879
|
+
maxStewardSessionDurationMs: config?.maxStewardSessionDurationMs ?? 30 * 60 * 1000,
|
|
776
880
|
onSessionStarted: config?.onSessionStarted,
|
|
777
881
|
projectRoot: config?.projectRoot ?? process.cwd(),
|
|
778
882
|
directorInboxForwardingEnabled: config?.directorInboxForwardingEnabled ?? true,
|
|
@@ -820,6 +924,10 @@ export class DispatchDaemonImpl {
|
|
|
820
924
|
if (this.config.stuckMergeRecoveryEnabled) {
|
|
821
925
|
await this.recoverStuckMergeTasks();
|
|
822
926
|
}
|
|
927
|
+
// Auto-complete plans where all tasks are closed
|
|
928
|
+
if (this.config.planAutoCompleteEnabled) {
|
|
929
|
+
await this.pollPlanAutoComplete();
|
|
930
|
+
}
|
|
823
931
|
}
|
|
824
932
|
finally {
|
|
825
933
|
this.polling = false;
|
|
@@ -828,9 +936,16 @@ export class DispatchDaemonImpl {
|
|
|
828
936
|
/**
|
|
829
937
|
* Terminates sessions that have exceeded the configured max duration.
|
|
830
938
|
* Prevents stuck workers from blocking their slot indefinitely.
|
|
939
|
+
*
|
|
940
|
+
* Applies two thresholds:
|
|
941
|
+
* - `maxSessionDurationMs` — general limit for all sessions (0 = disabled)
|
|
942
|
+
* - `maxStewardSessionDurationMs` — stricter limit for steward sessions
|
|
943
|
+
* (default 30 minutes), which are expected to be short-lived
|
|
831
944
|
*/
|
|
832
945
|
async reapStaleSessions() {
|
|
833
|
-
|
|
946
|
+
const hasGeneralLimit = this.config.maxSessionDurationMs > 0;
|
|
947
|
+
const hasStewardLimit = this.config.maxStewardSessionDurationMs > 0;
|
|
948
|
+
if (!hasGeneralLimit && !hasStewardLimit)
|
|
834
949
|
return;
|
|
835
950
|
const running = this.sessionManager.listSessions({ status: 'running' });
|
|
836
951
|
const now = Date.now();
|
|
@@ -839,7 +954,24 @@ export class DispatchDaemonImpl {
|
|
|
839
954
|
? session.createdAt
|
|
840
955
|
: new Date(session.createdAt).getTime();
|
|
841
956
|
const age = now - createdAt;
|
|
842
|
-
|
|
957
|
+
// Apply steward-specific limit
|
|
958
|
+
if (hasStewardLimit && session.agentRole === 'steward' && age > this.config.maxStewardSessionDurationMs) {
|
|
959
|
+
try {
|
|
960
|
+
await this.sessionManager.stopSession(session.id, {
|
|
961
|
+
graceful: false,
|
|
962
|
+
reason: `Steward session exceeded max duration (${Math.round(age / 1000)}s, limit: ${Math.round(this.config.maxStewardSessionDurationMs / 1000)}s)`,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
967
|
+
if (!message.includes('not found')) {
|
|
968
|
+
logger.warn(`Failed to reap steward session ${session.id}:`, error);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
// Apply general limit
|
|
974
|
+
if (hasGeneralLimit && age > this.config.maxSessionDurationMs) {
|
|
843
975
|
try {
|
|
844
976
|
await this.sessionManager.stopSession(session.id, {
|
|
845
977
|
graceful: false,
|
|
@@ -849,7 +981,7 @@ export class DispatchDaemonImpl {
|
|
|
849
981
|
catch (error) {
|
|
850
982
|
const message = error instanceof Error ? error.message : String(error);
|
|
851
983
|
if (!message.includes('not found')) {
|
|
852
|
-
|
|
984
|
+
logger.warn(`Failed to reap session ${session.id}:`, error);
|
|
853
985
|
}
|
|
854
986
|
}
|
|
855
987
|
}
|
|
@@ -918,11 +1050,11 @@ export class DispatchDaemonImpl {
|
|
|
918
1050
|
this.config.onSessionStarted(session, events, workerId, `[resumed session for task ${task.id}]`);
|
|
919
1051
|
}
|
|
920
1052
|
this.emitter.emit('agent:spawned', workerId, worktreePath);
|
|
921
|
-
|
|
1053
|
+
logger.info(`Resumed session for orphaned task ${task.id} on worker ${worker.name}`);
|
|
922
1054
|
return;
|
|
923
1055
|
}
|
|
924
1056
|
catch (error) {
|
|
925
|
-
|
|
1057
|
+
logger.warn(`Failed to resume session ${previousSessionId} for worker ${worker.name}, falling back to fresh spawn:`, error);
|
|
926
1058
|
}
|
|
927
1059
|
}
|
|
928
1060
|
// 3. Fall back to fresh spawn
|
|
@@ -950,7 +1082,7 @@ export class DispatchDaemonImpl {
|
|
|
950
1082
|
this.config.onSessionStarted(session, events, workerId, initialPrompt);
|
|
951
1083
|
}
|
|
952
1084
|
this.emitter.emit('agent:spawned', workerId, worktreePath);
|
|
953
|
-
|
|
1085
|
+
logger.info(`Spawned fresh session for orphaned task ${task.id} on worker ${worker.name}`);
|
|
954
1086
|
}
|
|
955
1087
|
/**
|
|
956
1088
|
* Recovers a single orphaned merge steward task by resuming or re-spawning.
|
|
@@ -964,7 +1096,7 @@ export class DispatchDaemonImpl {
|
|
|
964
1096
|
if (worktreePath) {
|
|
965
1097
|
const exists = await this.worktreeManager.worktreeExists(worktreePath);
|
|
966
1098
|
if (!exists) {
|
|
967
|
-
|
|
1099
|
+
logger.warn(`Worktree ${worktreePath} no longer exists for steward task ${task.id}, using project root`);
|
|
968
1100
|
worktreePath = undefined;
|
|
969
1101
|
}
|
|
970
1102
|
}
|
|
@@ -1002,16 +1134,16 @@ export class DispatchDaemonImpl {
|
|
|
1002
1134
|
this.config.onSessionStarted(session, events, stewardId, `[resumed steward session for task ${task.id}]`);
|
|
1003
1135
|
}
|
|
1004
1136
|
this.emitter.emit('agent:spawned', stewardId, worktreePath);
|
|
1005
|
-
|
|
1137
|
+
logger.info(`Resumed steward session for orphaned task ${task.id} on ${steward.name}`);
|
|
1006
1138
|
return;
|
|
1007
1139
|
}
|
|
1008
1140
|
catch (error) {
|
|
1009
|
-
|
|
1141
|
+
logger.warn(`Failed to resume steward session ${previousSessionId} for ${steward.name}, falling back to fresh spawn:`, error);
|
|
1010
1142
|
}
|
|
1011
1143
|
}
|
|
1012
1144
|
// 3. Fall back to fresh spawn (spawnMergeStewardForTask handles metadata update AND session history)
|
|
1013
1145
|
await this.spawnMergeStewardForTask(steward, task);
|
|
1014
|
-
|
|
1146
|
+
logger.info(`Spawned fresh steward session for orphaned task ${task.id} on ${steward.name}`);
|
|
1015
1147
|
}
|
|
1016
1148
|
/**
|
|
1017
1149
|
* Assigns the highest priority unassigned task to a worker.
|
|
@@ -1041,7 +1173,7 @@ export class DispatchDaemonImpl {
|
|
|
1041
1173
|
};
|
|
1042
1174
|
const poolCheck = await this.poolService.canSpawn(spawnRequest);
|
|
1043
1175
|
if (!poolCheck.canSpawn) {
|
|
1044
|
-
|
|
1176
|
+
logger.debug(`Pool capacity reached for worker ${worker.name}: ${poolCheck.reason}`);
|
|
1045
1177
|
return false;
|
|
1046
1178
|
}
|
|
1047
1179
|
}
|
|
@@ -1193,7 +1325,7 @@ export class DispatchDaemonImpl {
|
|
|
1193
1325
|
*
|
|
1194
1326
|
* @param task - The task being reviewed
|
|
1195
1327
|
* @param stewardId - The steward's entity ID
|
|
1196
|
-
* @param stewardFocus - The steward's focus area (merge or
|
|
1328
|
+
* @param stewardFocus - The steward's focus area (merge, docs, or custom)
|
|
1197
1329
|
* @param syncResult - Optional result from pre-spawn branch sync
|
|
1198
1330
|
*/
|
|
1199
1331
|
async buildStewardPrompt(task, stewardId, stewardFocus = 'merge', syncResult) {
|
|
@@ -1285,7 +1417,7 @@ export class DispatchDaemonImpl {
|
|
|
1285
1417
|
};
|
|
1286
1418
|
const poolCheck = await this.poolService.canSpawn(spawnRequest);
|
|
1287
1419
|
if (!poolCheck.canSpawn) {
|
|
1288
|
-
|
|
1420
|
+
logger.debug(`Pool capacity reached for steward ${steward.name}: ${poolCheck.reason}`);
|
|
1289
1421
|
return;
|
|
1290
1422
|
}
|
|
1291
1423
|
}
|
|
@@ -1297,7 +1429,7 @@ export class DispatchDaemonImpl {
|
|
|
1297
1429
|
if (worktreePath) {
|
|
1298
1430
|
const exists = await this.worktreeManager.worktreeExists(worktreePath);
|
|
1299
1431
|
if (!exists) {
|
|
1300
|
-
|
|
1432
|
+
logger.warn(`Worktree ${worktreePath} no longer exists for task ${task.id}, creating fresh worktree`);
|
|
1301
1433
|
const sourceBranch = orchestratorMeta?.branch;
|
|
1302
1434
|
if (sourceBranch) {
|
|
1303
1435
|
try {
|
|
@@ -1308,7 +1440,7 @@ export class DispatchDaemonImpl {
|
|
|
1308
1440
|
worktreePath = result.path;
|
|
1309
1441
|
}
|
|
1310
1442
|
catch (e) {
|
|
1311
|
-
|
|
1443
|
+
logger.error(`Failed to create steward worktree: ${e}`);
|
|
1312
1444
|
worktreePath = undefined;
|
|
1313
1445
|
}
|
|
1314
1446
|
}
|
|
@@ -1330,7 +1462,7 @@ export class DispatchDaemonImpl {
|
|
|
1330
1462
|
// This ensures `git diff origin/master..HEAD` shows only the task's changes
|
|
1331
1463
|
let syncResult;
|
|
1332
1464
|
if (worktreePath) {
|
|
1333
|
-
|
|
1465
|
+
logger.debug(`Syncing task ${task.id} branch before steward spawn...`);
|
|
1334
1466
|
syncResult = await this.syncTaskBranch(task);
|
|
1335
1467
|
// Store sync result in task metadata for audit trail
|
|
1336
1468
|
await this.api.update(task.id, {
|
|
@@ -1388,7 +1520,203 @@ export class DispatchDaemonImpl {
|
|
|
1388
1520
|
await this.poolService.onAgentSpawned(stewardId);
|
|
1389
1521
|
}
|
|
1390
1522
|
this.emitter.emit('agent:spawned', stewardId, worktreePath);
|
|
1391
|
-
|
|
1523
|
+
logger.info(`Spawned merge steward ${steward.name} for task ${task.id}`);
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Spawns a recovery steward for a task stuck in a resume loop.
|
|
1527
|
+
*
|
|
1528
|
+
* When a worker session exits without calling `sf task complete` or
|
|
1529
|
+
* `sf task handoff`, the orphan recovery loop resumes it. After
|
|
1530
|
+
* maxResumeAttemptsBeforeRecovery consecutive resumes without a status
|
|
1531
|
+
* change, this method is called instead to:
|
|
1532
|
+
* 1. Unassign the worker from the task
|
|
1533
|
+
* 2. Find an available recovery steward (or any available steward)
|
|
1534
|
+
* 3. Spawn a recovery steward session with full task context
|
|
1535
|
+
*
|
|
1536
|
+
* @param worker - The worker that was assigned to the stuck task
|
|
1537
|
+
* @param task - The stuck task
|
|
1538
|
+
* @param taskMeta - The task's orchestrator metadata
|
|
1539
|
+
*/
|
|
1540
|
+
async spawnRecoveryStewardForTask(worker, task, taskMeta) {
|
|
1541
|
+
const workerId = asEntityId(worker.id);
|
|
1542
|
+
// 1. Find an available recovery steward
|
|
1543
|
+
const stewards = await this.agentRegistry.listAgents({
|
|
1544
|
+
role: 'steward',
|
|
1545
|
+
stewardFocus: 'recovery',
|
|
1546
|
+
});
|
|
1547
|
+
// Find a steward without an active session
|
|
1548
|
+
let recoverySteward;
|
|
1549
|
+
for (const steward of stewards) {
|
|
1550
|
+
const session = this.sessionManager.getActiveSession(asEntityId(steward.id));
|
|
1551
|
+
if (!session) {
|
|
1552
|
+
recoverySteward = steward;
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
if (!recoverySteward) {
|
|
1557
|
+
// No recovery steward available — emit a notification and leave the task as-is
|
|
1558
|
+
this.emitter.emit('daemon:notification', {
|
|
1559
|
+
type: 'warning',
|
|
1560
|
+
title: 'Recovery steward unavailable',
|
|
1561
|
+
message: `Task ${task.id} is stuck after ${taskMeta?.resumeCount ?? 0} resume attempts, but no recovery steward is available. The task will not be resumed again until a recovery steward is available.`,
|
|
1562
|
+
});
|
|
1563
|
+
logger.warn(`No available recovery steward for stuck task ${task.id}. ` +
|
|
1564
|
+
`Task has been resumed ${taskMeta?.resumeCount ?? 0} times without status change.`);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
const stewardId = asEntityId(recoverySteward.id);
|
|
1568
|
+
// 2. Check pool capacity before spawning
|
|
1569
|
+
const stewardMeta = getAgentMetadata(recoverySteward);
|
|
1570
|
+
if (this.poolService && stewardMeta) {
|
|
1571
|
+
const spawnRequest = {
|
|
1572
|
+
role: 'steward',
|
|
1573
|
+
stewardFocus: stewardMeta.stewardFocus,
|
|
1574
|
+
agentId: stewardId,
|
|
1575
|
+
};
|
|
1576
|
+
const poolCheck = await this.poolService.canSpawn(spawnRequest);
|
|
1577
|
+
if (!poolCheck.canSpawn) {
|
|
1578
|
+
logger.debug(`Pool capacity reached for recovery steward ${recoverySteward.name}: ${poolCheck.reason}`);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
// 3. Resolve worktree — reuse the worker's existing worktree
|
|
1583
|
+
let worktreePath = taskMeta?.worktree ?? taskMeta?.handoffWorktree;
|
|
1584
|
+
const branch = taskMeta?.branch ?? taskMeta?.handoffBranch;
|
|
1585
|
+
if (worktreePath) {
|
|
1586
|
+
const exists = await this.worktreeManager.worktreeExists(worktreePath);
|
|
1587
|
+
if (!exists) {
|
|
1588
|
+
logger.warn(`Worktree ${worktreePath} no longer exists for stuck task ${task.id}`);
|
|
1589
|
+
// Try to create a read-only worktree for the steward
|
|
1590
|
+
if (branch) {
|
|
1591
|
+
try {
|
|
1592
|
+
const result = await this.worktreeManager.createReadOnlyWorktree({
|
|
1593
|
+
agentName: stewardId,
|
|
1594
|
+
purpose: `recovery-${task.id}`,
|
|
1595
|
+
});
|
|
1596
|
+
worktreePath = result.path;
|
|
1597
|
+
}
|
|
1598
|
+
catch (e) {
|
|
1599
|
+
logger.error(`Failed to create recovery steward worktree: ${e}`);
|
|
1600
|
+
worktreePath = undefined;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
else {
|
|
1604
|
+
worktreePath = undefined;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
// Guard: never spawn a steward without a worktree
|
|
1609
|
+
if (!worktreePath) {
|
|
1610
|
+
this.emitter.emit('daemon:notification', {
|
|
1611
|
+
type: 'warning',
|
|
1612
|
+
title: 'Recovery steward skipped',
|
|
1613
|
+
message: `Cannot spawn recovery steward for task ${task.id}: worktree missing and no branch info available to create a new one.`,
|
|
1614
|
+
});
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
// 4. Unassign the worker from the task
|
|
1618
|
+
await this.api.update(task.id, {
|
|
1619
|
+
assignee: undefined,
|
|
1620
|
+
metadata: updateOrchestratorTaskMeta(task.metadata, {
|
|
1621
|
+
assignedAgent: stewardId,
|
|
1622
|
+
}),
|
|
1623
|
+
});
|
|
1624
|
+
// 5. Build the recovery steward prompt
|
|
1625
|
+
const initialPrompt = await this.buildRecoveryStewardPrompt(task, stewardId, taskMeta);
|
|
1626
|
+
// 6. Start the recovery steward session
|
|
1627
|
+
const { session, events } = await this.sessionManager.startSession(stewardId, {
|
|
1628
|
+
workingDirectory: worktreePath,
|
|
1629
|
+
worktree: worktreePath,
|
|
1630
|
+
initialPrompt,
|
|
1631
|
+
interactive: false, // Stewards use headless mode
|
|
1632
|
+
});
|
|
1633
|
+
// 7. Record steward assignment and session history on the task
|
|
1634
|
+
const taskAfterUpdate = await this.api.get(task.id);
|
|
1635
|
+
const sessionHistoryEntry = {
|
|
1636
|
+
sessionId: session.id,
|
|
1637
|
+
providerSessionId: session.providerSessionId,
|
|
1638
|
+
agentId: stewardId,
|
|
1639
|
+
agentName: recoverySteward.name,
|
|
1640
|
+
agentRole: 'steward',
|
|
1641
|
+
startedAt: createTimestamp(),
|
|
1642
|
+
};
|
|
1643
|
+
const metadataWithHistory = appendTaskSessionHistory(taskAfterUpdate?.metadata, sessionHistoryEntry);
|
|
1644
|
+
const finalMetadata = updateOrchestratorTaskMeta(metadataWithHistory, {
|
|
1645
|
+
assignedAgent: stewardId,
|
|
1646
|
+
sessionId: session.providerSessionId ?? session.id,
|
|
1647
|
+
});
|
|
1648
|
+
await this.api.update(task.id, {
|
|
1649
|
+
assignee: stewardId,
|
|
1650
|
+
metadata: finalMetadata,
|
|
1651
|
+
});
|
|
1652
|
+
// 8. Callbacks and notifications
|
|
1653
|
+
if (this.config.onSessionStarted) {
|
|
1654
|
+
this.config.onSessionStarted(session, events, stewardId, initialPrompt);
|
|
1655
|
+
}
|
|
1656
|
+
if (this.poolService) {
|
|
1657
|
+
await this.poolService.onAgentSpawned(stewardId);
|
|
1658
|
+
}
|
|
1659
|
+
this.emitter.emit('agent:spawned', stewardId, worktreePath);
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Builds the initial prompt for a recovery steward session.
|
|
1663
|
+
* Includes the steward-recovery role prompt followed by task context,
|
|
1664
|
+
* session history, and resume count information.
|
|
1665
|
+
*
|
|
1666
|
+
* @param task - The stuck task
|
|
1667
|
+
* @param stewardId - The recovery steward's entity ID
|
|
1668
|
+
* @param taskMeta - The task's orchestrator metadata
|
|
1669
|
+
*/
|
|
1670
|
+
async buildRecoveryStewardPrompt(task, stewardId, taskMeta) {
|
|
1671
|
+
const parts = [];
|
|
1672
|
+
// Load the recovery steward role prompt
|
|
1673
|
+
const roleResult = loadRolePrompt('steward', 'recovery', { projectRoot: this.config.projectRoot });
|
|
1674
|
+
if (roleResult) {
|
|
1675
|
+
parts.push('Please read and internalize the following operating instructions. These define your role and how you should behave:', '', roleResult.prompt, '', '---', '');
|
|
1676
|
+
}
|
|
1677
|
+
// Get the director ID for context
|
|
1678
|
+
const director = await this.agentRegistry.getDirector();
|
|
1679
|
+
const directorId = director?.id ?? 'unknown';
|
|
1680
|
+
const branch = taskMeta?.branch ?? taskMeta?.handoffBranch;
|
|
1681
|
+
const worktree = taskMeta?.worktree ?? taskMeta?.handoffWorktree;
|
|
1682
|
+
const resumeCount = taskMeta?.resumeCount ?? 0;
|
|
1683
|
+
parts.push('## Recovery Assignment', '', `**Steward ID:** ${stewardId}`, `**Director ID:** ${directorId}`, `**Task ID:** ${task.id}`, `**Title:** ${task.title}`, `**Status:** ${task.status}`);
|
|
1684
|
+
if (branch) {
|
|
1685
|
+
parts.push(`**Branch:** ${branch}`);
|
|
1686
|
+
}
|
|
1687
|
+
if (worktree) {
|
|
1688
|
+
parts.push(`**Worktree:** ${worktree}`);
|
|
1689
|
+
}
|
|
1690
|
+
if (task.priority !== undefined) {
|
|
1691
|
+
parts.push(`**Priority:** ${task.priority}`);
|
|
1692
|
+
}
|
|
1693
|
+
parts.push('', '## Recovery Context', '', `This task has been resumed **${resumeCount} times** without a status change.`, 'The previous worker session exited without calling `sf task complete` or `sf task handoff`.', 'This indicates the worker may have crashed, lost context, or encountered an unrecoverable error.', '');
|
|
1694
|
+
// Include session history if available
|
|
1695
|
+
if (taskMeta?.sessionHistory && taskMeta.sessionHistory.length > 0) {
|
|
1696
|
+
parts.push('### Session History', '');
|
|
1697
|
+
for (const entry of taskMeta.sessionHistory) {
|
|
1698
|
+
const ended = entry.endedAt ? ` → ${entry.endedAt}` : ' (may still be running)';
|
|
1699
|
+
parts.push(`- **${entry.agentRole}** ${entry.agentName} (${entry.sessionId}): ${entry.startedAt}${ended}`);
|
|
1700
|
+
}
|
|
1701
|
+
parts.push('');
|
|
1702
|
+
}
|
|
1703
|
+
// Fetch and include the description content
|
|
1704
|
+
if (task.descriptionRef) {
|
|
1705
|
+
try {
|
|
1706
|
+
const doc = await this.api.get(asElementId(task.descriptionRef));
|
|
1707
|
+
if (doc?.content) {
|
|
1708
|
+
parts.push('### Task Description', doc.content);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
catch {
|
|
1712
|
+
parts.push(`**Description Document:** ${task.descriptionRef}`);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
// Include acceptance criteria if any
|
|
1716
|
+
if (task.acceptanceCriteria) {
|
|
1717
|
+
parts.push('', '### Acceptance Criteria', task.acceptanceCriteria);
|
|
1718
|
+
}
|
|
1719
|
+
return parts.join('\n');
|
|
1392
1720
|
}
|
|
1393
1721
|
/**
|
|
1394
1722
|
* Processes an inbox item for an agent.
|
|
@@ -1540,7 +1868,7 @@ export class DispatchDaemonImpl {
|
|
|
1540
1868
|
processed += channelItems.length;
|
|
1541
1869
|
}
|
|
1542
1870
|
catch (error) {
|
|
1543
|
-
|
|
1871
|
+
logger.error(`Failed to spawn triage session for agent ${agent.name}:`, error);
|
|
1544
1872
|
}
|
|
1545
1873
|
// Only one triage session per poll cycle per agent — remaining channels
|
|
1546
1874
|
// will be picked up in subsequent cycles
|
|
@@ -1629,7 +1957,7 @@ export class DispatchDaemonImpl {
|
|
|
1629
1957
|
this.inboxService.markAsReadBatch(items.map((item) => item.id));
|
|
1630
1958
|
}
|
|
1631
1959
|
catch (error) {
|
|
1632
|
-
|
|
1960
|
+
logger.warn('Failed to mark triage items as read:', error);
|
|
1633
1961
|
}
|
|
1634
1962
|
try {
|
|
1635
1963
|
await this.worktreeManager.removeWorktree(worktreeResult.path);
|
|
@@ -1665,7 +1993,7 @@ export class DispatchDaemonImpl {
|
|
|
1665
1993
|
}
|
|
1666
1994
|
}
|
|
1667
1995
|
catch (error) {
|
|
1668
|
-
|
|
1996
|
+
logger.warn(`Failed to fetch content for message ${message.id}:`, error);
|
|
1669
1997
|
}
|
|
1670
1998
|
}
|
|
1671
1999
|
formattedMessages.push(`--- Inbox Item ID: ${inboxItemId} | Message ID: ${message.id} | From: ${senderId} | At: ${timestamp} ---`, content, '');
|
|
@@ -1692,7 +2020,7 @@ export class DispatchDaemonImpl {
|
|
|
1692
2020
|
}
|
|
1693
2021
|
}
|
|
1694
2022
|
catch (error) {
|
|
1695
|
-
|
|
2023
|
+
logger.warn(`Failed to fetch content for forwarded message ${message.id}:`, error);
|
|
1696
2024
|
}
|
|
1697
2025
|
}
|
|
1698
2026
|
return content; // No prefix — messageSession() handles the [Message from ...] prefix
|
|
@@ -1768,7 +2096,7 @@ export class DispatchDaemonImpl {
|
|
|
1768
2096
|
timeout: 120_000,
|
|
1769
2097
|
});
|
|
1770
2098
|
// Merge succeeded
|
|
1771
|
-
|
|
2099
|
+
logger.debug(`Synced task ${task.id} branch with ${remoteBranch}`);
|
|
1772
2100
|
return {
|
|
1773
2101
|
success: true,
|
|
1774
2102
|
message: `Branch synced with ${remoteBranch}`,
|
|
@@ -1791,7 +2119,7 @@ export class DispatchDaemonImpl {
|
|
|
1791
2119
|
conflicts.push(match[2]);
|
|
1792
2120
|
}
|
|
1793
2121
|
if (conflicts.length > 0) {
|
|
1794
|
-
|
|
2122
|
+
logger.debug(`Merge conflicts detected for task ${task.id}: ${conflicts.join(', ')}`);
|
|
1795
2123
|
return {
|
|
1796
2124
|
success: false,
|
|
1797
2125
|
conflicts,
|