@the-open-engine/zeroshot 6.6.0 → 6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/bin/zeroshot-cluster-worker.js +16 -0
  2. package/cli/commands/providers.js +4 -1
  3. package/cli/index.js +153 -24
  4. package/docs/openengine-cluster-protocol/v1/legacy-worker.md +117 -0
  5. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/adapters/claude.js +23 -10
  7. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  8. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
  9. package/lib/agent-cli-provider/adapters/codex.js +4 -0
  10. package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
  11. package/lib/agent-cli-provider/adapters/common.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/adapters/common.js +2 -1
  13. package/lib/agent-cli-provider/adapters/common.js.map +1 -1
  14. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  15. package/lib/agent-cli-provider/contract-options.js +2 -1
  16. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  17. package/lib/agent-cli-provider/index.d.ts +1 -1
  18. package/lib/agent-cli-provider/index.d.ts.map +1 -1
  19. package/lib/agent-cli-provider/index.js.map +1 -1
  20. package/lib/agent-cli-provider/provider-registry.d.ts +1 -1
  21. package/lib/agent-cli-provider/provider-registry.js +1 -1
  22. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  23. package/lib/agent-cli-provider/single-agent-runtime.js +6 -2
  24. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  25. package/lib/agent-cli-provider/types.d.ts +3 -1
  26. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  27. package/lib/agent-cli-provider/types.js.map +1 -1
  28. package/lib/cluster-worker/contracts.js +194 -0
  29. package/lib/cluster-worker/engine-adapter.js +300 -0
  30. package/lib/cluster-worker/executable.js +229 -0
  31. package/lib/cluster-worker/index.d.ts +227 -0
  32. package/lib/cluster-worker/index.js +294 -0
  33. package/lib/cluster-worker/object-utils.js +17 -0
  34. package/lib/cluster-worker/process-stdio.js +37 -0
  35. package/lib/cluster-worker/profiles.js +81 -0
  36. package/lib/cluster-worker/runtime-engine.js +50 -0
  37. package/lib/cluster-worker/runtime-support.js +298 -0
  38. package/lib/cluster-worker/state-machine.js +147 -0
  39. package/lib/cluster-worker/terminal-normalizer.js +95 -0
  40. package/lib/cluster-worker/worker-internals.js +20 -0
  41. package/lib/detached-startup.js +127 -11
  42. package/lib/process-liveness.js +26 -0
  43. package/lib/settings.js +1 -0
  44. package/lib/start-cluster.js +93 -18
  45. package/package.json +8 -2
  46. package/protocol/openengine-cluster/v1/worker.schema.json +1174 -0
  47. package/scripts/assert-release-published.js +65 -11
  48. package/scripts/run-lint-staged-no-stash.js +68 -0
  49. package/src/agent/agent-lifecycle.js +368 -91
  50. package/src/agent/agent-task-executor.js +384 -101
  51. package/src/agent-cli-provider/adapters/claude.ts +29 -11
  52. package/src/agent-cli-provider/adapters/codex.ts +4 -0
  53. package/src/agent-cli-provider/adapters/common.ts +2 -1
  54. package/src/agent-cli-provider/contract-options.ts +2 -1
  55. package/src/agent-cli-provider/index.ts +1 -0
  56. package/src/agent-cli-provider/provider-registry.ts +1 -1
  57. package/src/agent-cli-provider/single-agent-runtime.ts +8 -2
  58. package/src/agent-cli-provider/types.ts +3 -1
  59. package/src/agent-wrapper.js +9 -2
  60. package/src/config-validator.js +10 -11
  61. package/src/orchestrator.js +482 -67
  62. package/task-lib/attachable-watcher.js +10 -1
  63. package/task-lib/commands/kill.js +34 -9
  64. package/task-lib/commands/status.js +13 -3
  65. package/task-lib/process-termination.js +202 -0
  66. package/task-lib/runner.js +8 -20
  67. package/task-lib/store.js +28 -6
  68. package/task-lib/watcher.js +14 -2
@@ -48,6 +48,7 @@ const TemplateResolver = require('./template-resolver');
48
48
  const { loadSettings } = require('../lib/settings');
49
49
  const { normalizeProviderName } = require('../lib/provider-names');
50
50
  const { resolveRunPlan } = require('../lib/run-plan');
51
+ const { isProcessRunning } = require('../lib/process-liveness');
51
52
  const { getProvider } = require('./providers');
52
53
  const StateSnapshotter = require('./state-snapshotter');
53
54
  const { resolveClusterRequiredQualityGates } = require('./quality-gates');
@@ -581,7 +582,9 @@ class Orchestrator {
581
582
  isolationManager,
582
583
  containerId: isolation?.containerId || null,
583
584
  });
584
- this._startSnapshotter(clusterContext);
585
+ if (clusterContext.state === 'running' && this._isProcessRunning(clusterContext.pid)) {
586
+ this._startSnapshotter(clusterContext);
587
+ }
585
588
  }
586
589
  this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`);
587
590
 
@@ -626,8 +629,13 @@ class Orchestrator {
626
629
  id: clusterId,
627
630
  quiet: this.quiet,
628
631
  modelOverride: clusterData.modelOverride || null,
632
+ testMode: Boolean(this.taskRunner),
629
633
  };
630
634
 
635
+ if (this.taskRunner) {
636
+ agentOptions.taskRunner = this.taskRunner;
637
+ }
638
+
631
639
  if (isolation?.enabled && isolationManager) {
632
640
  agentOptions.isolation = {
633
641
  enabled: true,
@@ -758,21 +766,13 @@ class Orchestrator {
758
766
  if (inactiveStates.includes(cluster.state)) continue;
759
767
 
760
768
  // Check if process is still running (zombie detection)
761
- if (cluster.pid) {
762
- try {
763
- // process.kill with signal 0 checks if process exists
764
- process.kill(cluster.pid, 0);
765
- // Process exists - cluster is active
766
- activeClusters.push({
767
- id: clusterId,
768
- state: cluster.state,
769
- createdAt: cluster.createdAt,
770
- pid: cluster.pid,
771
- });
772
- } catch {
773
- // Process doesn't exist - cluster is a zombie (stale entry)
774
- // Don't include in active list
775
- }
769
+ if (cluster.pid && this._isProcessRunning(cluster.pid)) {
770
+ activeClusters.push({
771
+ id: clusterId,
772
+ state: cluster.state,
773
+ createdAt: cluster.createdAt,
774
+ pid: cluster.pid,
775
+ });
776
776
  }
777
777
  }
778
778
 
@@ -1126,11 +1126,7 @@ class Orchestrator {
1126
1126
  // Resolve input (issue/file/text) and reject duplicate active runs on the same issue
1127
1127
  // BEFORE allocating any resources. A duplicate-run rejection must have zero side
1128
1128
  // effects: no ledger db file, no worktree/container, no clusters.json entry.
1129
- const { inputData, issueProviderId } = await this._resolveClusterInput(
1130
- input,
1131
- options,
1132
- clusterId
1133
- );
1129
+ let { inputData, issueProviderId } = await this._resolveClusterInput(input, options, clusterId);
1134
1130
 
1135
1131
  // Create ledger and message bus with persistent storage
1136
1132
  const dbPath = config.dbPath || path.join(this.storageDir, `${clusterId}.db`);
@@ -1220,6 +1216,34 @@ class Orchestrator {
1220
1216
  });
1221
1217
 
1222
1218
  try {
1219
+ if (typeof options.prepareIsolatedInput === 'function') {
1220
+ const dockerWorkspace =
1221
+ isolationManager?.isolatedDirs?.get(clusterId)?.path || options.cwd || process.cwd();
1222
+ const isolation = worktreeInfo
1223
+ ? Object.freeze({
1224
+ kind: 'worktree',
1225
+ hostRoot: worktreeInfo.path,
1226
+ runtimeRoot: worktreeInfo.path,
1227
+ })
1228
+ : containerId && dockerWorkspace
1229
+ ? Object.freeze({
1230
+ kind: 'docker',
1231
+ hostRoot: dockerWorkspace,
1232
+ runtimeRoot: '/workspace',
1233
+ })
1234
+ : null;
1235
+ if (!isolation) {
1236
+ throw new Error('Prepared input requires an allocated worktree or Docker workspace');
1237
+ }
1238
+ const preparedText = await options.prepareIsolatedInput(
1239
+ Object.freeze({ clusterId, isolation })
1240
+ );
1241
+ if (typeof preparedText !== 'string' || preparedText.trim().length === 0) {
1242
+ throw new Error('Prepared isolated input must be a nonempty string');
1243
+ }
1244
+ inputData = InputHelpers.createTextInput(preparedText);
1245
+ }
1246
+
1223
1247
  // Input (issue/file/text) was already resolved and duplicate-checked in
1224
1248
  // _resolveClusterInput() before any resource was allocated (see above).
1225
1249
  commandProofs = mergeCommandProofs(
@@ -1511,6 +1535,7 @@ class Orchestrator {
1511
1535
  const agentRole = message.content?.data?.role;
1512
1536
  const attempts = message.content?.data?.attempts || 1;
1513
1537
  const hookFailure = message.content?.data?.hookFailure === true;
1538
+ const restartExhausted = message.content?.data?.restartExhausted === true;
1514
1539
 
1515
1540
  await this._saveClusters();
1516
1541
 
@@ -1518,7 +1543,7 @@ class Orchestrator {
1518
1543
  agentRole === 'implementation' ||
1519
1544
  agentRole === 'coordinator' ||
1520
1545
  message.sender === 'consensus-coordinator';
1521
- const shouldStop = shouldStopForRole && (hookFailure || attempts >= 3);
1546
+ const shouldStop = shouldStopForRole && (hookFailure || restartExhausted || attempts >= 3);
1522
1547
 
1523
1548
  if (shouldStop) {
1524
1549
  this._log(`\n${'='.repeat(80)}`);
@@ -1527,6 +1552,7 @@ class Orchestrator {
1527
1552
  this._log(
1528
1553
  `${message.sender} (${agentRole || 'unknown role'}) failed` +
1529
1554
  (hookFailure ? ` (hookFailure=true)` : ``) +
1555
+ (restartExhausted ? ` (restartExhausted=true)` : ``) +
1530
1556
  ` after ${attempts} attempts`
1531
1557
  );
1532
1558
  this._log(`Error: ${message.content?.data?.error || 'unknown'}`);
@@ -1549,7 +1575,7 @@ class Orchestrator {
1549
1575
  });
1550
1576
  }
1551
1577
 
1552
- _registerAgentLifecycleHandlers(messageBus, clusterId) {
1578
+ _registerAgentLifecycleHandlers(messageBus, _clusterId) {
1553
1579
  messageBus.on('topic:AGENT_LIFECYCLE', async (message) => {
1554
1580
  const event = message.content?.data?.event;
1555
1581
  if (
@@ -1572,13 +1598,14 @@ class Orchestrator {
1572
1598
  const timeSinceLastOutput = message.content?.data?.timeSinceLastOutput;
1573
1599
  const analysis = message.content?.data?.analysis || 'No analysis available';
1574
1600
 
1601
+ const consecutiveWarnings = message.content?.data?.consecutiveWarnings;
1602
+ const warningsBeforeKill = message.content?.data?.warningsBeforeKill;
1575
1603
  this._log(
1576
- `⚠️ Orchestrator: Agent ${agentId} appears stale (${Math.round(timeSinceLastOutput / 1000)}s no output) but will NOT be killed`
1604
+ `⚠️ Orchestrator: Agent ${agentId} has produced no output for ${Math.round(timeSinceLastOutput / 1000)}s ` +
1605
+ `(warning ${consecutiveWarnings}/${warningsBeforeKill})`
1577
1606
  );
1578
1607
  this._log(` Analysis: ${analysis}`);
1579
- this._log(
1580
- ` Manual intervention may be needed - use 'zeroshot resume ${clusterId}' if stuck`
1581
- );
1608
+ this._log(` Zeroshot will terminate and restart the task if inactivity persists`);
1582
1609
  });
1583
1610
  }
1584
1611
 
@@ -2337,6 +2364,15 @@ class Orchestrator {
2337
2364
  return;
2338
2365
  }
2339
2366
  this.closed = true;
2367
+
2368
+ for (const cluster of this.clusters.values()) {
2369
+ if (typeof cluster.snapshotter?.stop === 'function') {
2370
+ cluster.snapshotter.stop();
2371
+ }
2372
+ if (typeof cluster.messageBus?.close === 'function') {
2373
+ cluster.messageBus.close();
2374
+ }
2375
+ }
2340
2376
  }
2341
2377
 
2342
2378
  /**
@@ -2369,8 +2405,17 @@ class Orchestrator {
2369
2405
  }
2370
2406
 
2371
2407
  if (cluster.state === 'running') {
2372
- throw new Error(
2373
- `Cluster ${clusterId} is still running. Use 'zeroshot stop' first if you want to restart it.`
2408
+ if (this._isProcessRunning(cluster.pid)) {
2409
+ throw new Error(
2410
+ `Cluster ${clusterId} is still running. Use 'zeroshot stop' first if you want to restart it.`
2411
+ );
2412
+ }
2413
+ // state says running but the recorded PID is dead: this is the same
2414
+ // zombie condition getStatus()/listClusters() already detect. Recover
2415
+ // it here instead of forcing a manual `stop` for a process that no
2416
+ // longer exists.
2417
+ this._log(
2418
+ `[Orchestrator] Cluster ${clusterId} was marked running with dead PID ${cluster.pid}; recovering as a zombie instead of rejecting resume.`
2374
2419
  );
2375
2420
  }
2376
2421
 
@@ -2388,19 +2433,28 @@ class Orchestrator {
2388
2433
  }
2389
2434
 
2390
2435
  const failureInfo = this._resolveFailureInfo(cluster, clusterId);
2436
+ const recentMessages = this._loadRecentMessages(cluster, clusterId, 50);
2437
+ const cleanResumePlan = failureInfo ? null : this._buildCleanResumePlan(clusterId, cluster);
2438
+
2439
+ if (cleanResumePlan?.kind === 'failure') {
2440
+ return this._failResumeReconstruction(clusterId, cluster, cleanResumePlan);
2441
+ }
2442
+
2443
+ if (failureInfo) {
2444
+ this._requireFailedAgent(clusterId, cluster, failureInfo);
2445
+ }
2391
2446
 
2392
2447
  await this._ensureIsolationForResume(clusterId, cluster);
2393
2448
  this._ensureWorktreeForResume(clusterId, cluster);
2394
2449
  this._startSnapshotter(cluster);
2450
+ this._clearTransientAgentState(cluster);
2395
2451
  await this._restartClusterAgents(cluster);
2396
2452
 
2397
- const recentMessages = this._loadRecentMessages(cluster, clusterId, 50);
2398
-
2399
2453
  if (failureInfo) {
2400
2454
  return this._resumeFailedCluster(clusterId, cluster, failureInfo, recentMessages, prompt);
2401
2455
  }
2402
2456
 
2403
- return this._resumeCleanCluster(clusterId, cluster, recentMessages, prompt);
2457
+ return this._resumeCleanCluster(clusterId, cluster, recentMessages, prompt, cleanResumePlan);
2404
2458
  }
2405
2459
 
2406
2460
  _validateGuidanceAgentArgs(clusterId, agentId, text) {
@@ -2596,7 +2650,7 @@ class Orchestrator {
2596
2650
 
2597
2651
  _resolveFailureInfo(cluster, clusterId) {
2598
2652
  if (cluster.failureInfo) {
2599
- return cluster.failureInfo;
2653
+ return cluster.failureInfo.agentId ? cluster.failureInfo : null;
2600
2654
  }
2601
2655
 
2602
2656
  const errors = cluster.messageBus.query({
@@ -2622,6 +2676,15 @@ class Orchestrator {
2622
2676
  return failureInfo;
2623
2677
  }
2624
2678
 
2679
+ _requireFailedAgent(clusterId, cluster, failureInfo) {
2680
+ const failedAgent = cluster.agents.find((agent) => agent.id === failureInfo.agentId);
2681
+ if (!failedAgent) {
2682
+ throw new Error(
2683
+ `Cannot resume cluster ${clusterId}: Failed agent '${failureInfo.agentId}' is not present in the restored configuration.`
2684
+ );
2685
+ }
2686
+ }
2687
+
2625
2688
  _checkContainerExists(containerId) {
2626
2689
  const { spawn } = require('child_process');
2627
2690
  const checkContainer = spawn('docker', ['inspect', containerId], { stdio: 'ignore' });
@@ -2715,6 +2778,22 @@ class Orchestrator {
2715
2778
  .reverse();
2716
2779
  }
2717
2780
 
2781
+ _findLastDurableWorkflowTrigger(cluster, clusterId) {
2782
+ let latest = null;
2783
+
2784
+ for (const topic of WORKFLOW_TRIGGERS) {
2785
+ const candidate = cluster.messageBus.findLast({
2786
+ cluster_id: clusterId,
2787
+ topic,
2788
+ });
2789
+ if (candidate && (!latest || candidate.timestamp > latest.timestamp)) {
2790
+ latest = candidate;
2791
+ }
2792
+ }
2793
+
2794
+ return latest;
2795
+ }
2796
+
2718
2797
  _buildResumeContext(recentMessages, prompt, options) {
2719
2798
  const resumePrompt = prompt || 'Continue from where you left off. Complete the task.';
2720
2799
  let context = `${options.header}\n\n`;
@@ -2723,6 +2802,13 @@ class Orchestrator {
2723
2802
  context += `Previous error: ${options.error}\n\n`;
2724
2803
  }
2725
2804
 
2805
+ if (options.durableMessages?.length > 0) {
2806
+ context += `## Durable Workflow Context\n\n`;
2807
+ for (const msg of options.durableMessages) {
2808
+ context += `[${msg.topic}] [${msg.sender}]\n${msg.content?.text || ''}\n\n`;
2809
+ }
2810
+ }
2811
+
2726
2812
  context += `## Recent Context\n\n`;
2727
2813
  for (const msg of recentMessages.slice(-10)) {
2728
2814
  if (options.topics.includes(msg.topic)) {
@@ -2745,7 +2831,8 @@ class Orchestrator {
2745
2831
  }
2746
2832
 
2747
2833
  _selectAgentsToResume(cluster, lastTrigger) {
2748
- const agentsToResume = [];
2834
+ const matching = [];
2835
+ const eligible = [];
2749
2836
 
2750
2837
  for (const agent of cluster.agents) {
2751
2838
  if (!agent.config.triggers) continue;
@@ -2755,15 +2842,365 @@ class Orchestrator {
2755
2842
  );
2756
2843
  if (!matchingTrigger) continue;
2757
2844
 
2845
+ const candidate = { agent, message: lastTrigger, trigger: matchingTrigger };
2846
+ matching.push(candidate);
2847
+
2758
2848
  if (matchingTrigger.logic?.script) {
2759
2849
  const shouldTrigger = agent._evaluateTrigger(matchingTrigger, lastTrigger);
2760
2850
  if (!shouldTrigger) continue;
2761
2851
  }
2762
2852
 
2763
- agentsToResume.push({ agent, message: lastTrigger, trigger: matchingTrigger });
2853
+ eligible.push(candidate);
2854
+ }
2855
+
2856
+ return { matching, eligible };
2857
+ }
2858
+
2859
+ _findDurableTriggerBefore(cluster, clusterId, topic, timestamp) {
2860
+ if (!topic || topic === 'AGENT_RESUME') {
2861
+ return null;
2862
+ }
2863
+
2864
+ return cluster.messageBus.findLast({
2865
+ cluster_id: clusterId,
2866
+ topic,
2867
+ until: timestamp - 1,
2868
+ });
2869
+ }
2870
+
2871
+ _collectDurableResumeContext(cluster, clusterId, messages = []) {
2872
+ const durableMessages = [
2873
+ cluster.messageBus.findLast({ cluster_id: clusterId, topic: 'ISSUE_OPENED' }),
2874
+ cluster.messageBus.findLast({ cluster_id: clusterId, topic: 'PLAN_READY' }),
2875
+ cluster.messageBus.findLast({ cluster_id: clusterId, topic: 'IMPLEMENTATION_READY' }),
2876
+ ...messages,
2877
+ ].filter(Boolean);
2878
+ const uniqueById = new Map(durableMessages.map((message) => [message.id, message]));
2879
+ return [...uniqueById.values()].sort((left, right) => left.timestamp - right.timestamp);
2880
+ }
2881
+
2882
+ _findInterruptedResumeTargets(clusterId, cluster) {
2883
+ const targets = [];
2884
+ const invalid = [];
2885
+ const issueMessage = cluster.messageBus.findLast({
2886
+ cluster_id: clusterId,
2887
+ topic: 'ISSUE_OPENED',
2888
+ });
2889
+
2890
+ for (const agent of cluster.agents) {
2891
+ if (agent.state !== 'executing_task') {
2892
+ continue;
2893
+ }
2894
+
2895
+ const lifecycle = cluster.messageBus.query({
2896
+ cluster_id: clusterId,
2897
+ topic: 'AGENT_LIFECYCLE',
2898
+ sender: agent.id,
2899
+ });
2900
+ const startedIndex = lifecycle.findLastIndex(
2901
+ (message) => message.content?.data?.event === 'TASK_STARTED'
2902
+ );
2903
+ const lastStarted = startedIndex >= 0 ? lifecycle[startedIndex] : null;
2904
+ const lifecycleAfterStart = startedIndex >= 0 ? lifecycle.slice(startedIndex + 1) : [];
2905
+ const completedAfterStart = lifecycleAfterStart.some(
2906
+ (message) => message.content?.data?.event === 'TASK_COMPLETED'
2907
+ );
2908
+ const lastTaskIdAssigned = [...lifecycleAfterStart]
2909
+ .reverse()
2910
+ .find((message) => message.content?.data?.event === 'TASK_ID_ASSIGNED');
2911
+ const assignedTaskId = lastTaskIdAssigned?.content?.data?.taskId || null;
2912
+ const startedIteration = lastStarted?.content?.data?.iteration;
2913
+ const triggeredBy = lastStarted?.content?.data?.triggeredBy;
2914
+ const triggeringMessage = lastStarted
2915
+ ? this._findDurableTriggerBefore(cluster, clusterId, triggeredBy, lastStarted.timestamp)
2916
+ : null;
2917
+
2918
+ if (
2919
+ !agent.currentTaskId ||
2920
+ !lastStarted ||
2921
+ completedAfterStart ||
2922
+ startedIteration !== agent.iteration ||
2923
+ !assignedTaskId ||
2924
+ assignedTaskId !== agent.currentTaskId ||
2925
+ !issueMessage
2926
+ ) {
2927
+ invalid.push({
2928
+ agentId: agent.id,
2929
+ reason: !issueMessage ? 'missing_durable_context' : 'ambiguous_task_identity',
2930
+ state: agent.state,
2931
+ iteration: agent.iteration,
2932
+ currentTaskId: agent.currentTaskId || null,
2933
+ assignedTaskId,
2934
+ lastStartedIteration: startedIteration ?? null,
2935
+ completedAfterStart: Boolean(completedAfterStart),
2936
+ });
2937
+ continue;
2938
+ }
2939
+
2940
+ targets.push({
2941
+ agent,
2942
+ message: triggeringMessage || issueMessage,
2943
+ lifecycleMessage: lastStarted,
2944
+ issueMessage,
2945
+ trigger: null,
2946
+ reason: 'interrupted_task',
2947
+ });
2948
+ }
2949
+
2950
+ const interruptedRoles = new Set(targets.map(({ agent }) => agent.role));
2951
+ if (interruptedRoles.size > 1) {
2952
+ invalid.push({
2953
+ reason: 'multiple_interrupted_phases',
2954
+ agents: targets.map(({ agent }) => agent.id),
2955
+ });
2956
+ }
2957
+
2958
+ return { targets, invalid };
2959
+ }
2960
+
2961
+ _findPartialValidationTargets(clusterId, cluster, lastTrigger) {
2962
+ if (
2963
+ !lastTrigger ||
2964
+ !['IMPLEMENTATION_READY', 'VALIDATION_RESULT'].includes(lastTrigger.topic)
2965
+ ) {
2966
+ return null;
2967
+ }
2968
+
2969
+ const implementationReady = cluster.messageBus.findLast({
2970
+ cluster_id: clusterId,
2971
+ topic: 'IMPLEMENTATION_READY',
2972
+ until: lastTrigger.timestamp,
2973
+ });
2974
+ if (!implementationReady) {
2975
+ return null;
2976
+ }
2977
+
2978
+ const validators = cluster.agents.filter((agent) => agent.role === 'validator');
2979
+ if (validators.length === 0) {
2980
+ return null;
2981
+ }
2982
+
2983
+ const validatorIds = new Set(validators.map((agent) => agent.id));
2984
+ const validationResults = cluster.messageBus
2985
+ .query({
2986
+ cluster_id: clusterId,
2987
+ topic: 'VALIDATION_RESULT',
2988
+ since: implementationReady.timestamp,
2989
+ })
2990
+ .filter(
2991
+ (message) =>
2992
+ message.timestamp > implementationReady.timestamp && validatorIds.has(message.sender)
2993
+ );
2994
+ const responded = new Set(validationResults.map((message) => message.sender));
2995
+ const missing = validators.filter((agent) => !responded.has(agent.id));
2996
+ if (missing.length === 0) {
2997
+ return { kind: 'complete', validationResults, implementationReady };
2998
+ }
2999
+
3000
+ const missingIds = new Set(missing.map((agent) => agent.id));
3001
+ const selection = this._selectAgentsToResume(cluster, implementationReady);
3002
+ const matching = selection.matching.filter(({ agent }) => missingIds.has(agent.id));
3003
+ const eligible = selection.eligible.filter(({ agent }) => missingIds.has(agent.id));
3004
+
3005
+ if (eligible.length !== missing.length) {
3006
+ return {
3007
+ kind: 'failure',
3008
+ missingAgentIds: missing.map((agent) => agent.id),
3009
+ matchingAgentIds: matching.map(({ agent }) => agent.id),
3010
+ implementationReady,
3011
+ validationResults,
3012
+ };
3013
+ }
3014
+
3015
+ return {
3016
+ kind: 'agents',
3017
+ agentsToResume: eligible.map((target) => ({
3018
+ ...target,
3019
+ reason: 'missing_validation_result',
3020
+ })),
3021
+ implementationReady,
3022
+ validationResults,
3023
+ };
3024
+ }
3025
+
3026
+ _buildCleanResumePlan(clusterId, cluster) {
3027
+ const interrupted = this._findInterruptedResumeTargets(clusterId, cluster);
3028
+ if (interrupted.invalid.length > 0) {
3029
+ const missingContext = interrupted.invalid.some(
3030
+ (entry) => entry.reason === 'missing_durable_context'
3031
+ );
3032
+ return {
3033
+ kind: 'failure',
3034
+ error: missingContext
3035
+ ? `Cannot resume cluster ${clusterId}: interrupted work has no durable workflow context. ` +
3036
+ `No agents were started and the preserved cluster remains stopped. ` +
3037
+ `Inspect it with 'zeroshot export ${clusterId}' before repairing its persisted state; do not launch a duplicate run.`
3038
+ : `Cannot resume cluster ${clusterId}: persisted interrupted-task state is ambiguous. ` +
3039
+ `No agents were started; inspect failureInfo for reconstruction details.`,
3040
+ details: {
3041
+ reason: missingContext ? 'missing_workflow_history' : 'ambiguous_interrupted_task',
3042
+ interrupted: interrupted.invalid,
3043
+ },
3044
+ };
3045
+ }
3046
+
3047
+ const lastTrigger = this._findLastDurableWorkflowTrigger(cluster, clusterId);
3048
+ const partialValidation = this._findPartialValidationTargets(clusterId, cluster, lastTrigger);
3049
+ if (partialValidation?.kind === 'failure') {
3050
+ return {
3051
+ kind: 'failure',
3052
+ error:
3053
+ `Cannot resume cluster ${clusterId}: validators ${partialValidation.missingAgentIds.join(
3054
+ ', '
3055
+ )} are missing results, but not every missing validator has an eligible IMPLEMENTATION_READY handler. ` +
3056
+ `No agents were started.`,
3057
+ details: {
3058
+ reason: 'partial_validation_handlers_ineligible',
3059
+ missingAgentIds: partialValidation.missingAgentIds,
3060
+ matchingAgentIds: partialValidation.matchingAgentIds,
3061
+ },
3062
+ };
3063
+ }
3064
+
3065
+ if (interrupted.targets.length > 0) {
3066
+ const interruptedValidators = interrupted.targets.filter(
3067
+ ({ agent }) => agent.role === 'validator'
3068
+ );
3069
+ if (interruptedValidators.length > 0 && partialValidation?.kind !== 'agents') {
3070
+ return {
3071
+ kind: 'failure',
3072
+ error:
3073
+ `Cannot resume cluster ${clusterId}: interrupted validator state does not match an active validation cycle. ` +
3074
+ `No agents were started.`,
3075
+ details: {
3076
+ reason: 'ambiguous_validation_cycle',
3077
+ interruptedAgentIds: interruptedValidators.map(({ agent }) => agent.id),
3078
+ },
3079
+ };
3080
+ }
3081
+
3082
+ const targets =
3083
+ interruptedValidators.length > 0 ? partialValidation.agentsToResume : interrupted.targets;
3084
+ return {
3085
+ kind: 'agents',
3086
+ reason: interruptedValidators.length > 0 ? 'partial_validation' : 'interrupted_tasks',
3087
+ agentsToResume: targets,
3088
+ lastTrigger,
3089
+ durableMessages: this._collectDurableResumeContext(
3090
+ cluster,
3091
+ clusterId,
3092
+ targets.map(({ message }) => message)
3093
+ ),
3094
+ };
2764
3095
  }
2765
3096
 
2766
- return agentsToResume;
3097
+ if (partialValidation?.kind === 'agents') {
3098
+ return {
3099
+ kind: 'agents',
3100
+ reason: 'partial_validation',
3101
+ agentsToResume: partialValidation.agentsToResume,
3102
+ lastTrigger,
3103
+ durableMessages: this._collectDurableResumeContext(
3104
+ cluster,
3105
+ clusterId,
3106
+ partialValidation.validationResults
3107
+ ),
3108
+ };
3109
+ }
3110
+
3111
+ if (!lastTrigger) {
3112
+ const issueMessage = cluster.messageBus.findLast({
3113
+ cluster_id: clusterId,
3114
+ topic: 'ISSUE_OPENED',
3115
+ });
3116
+ if (issueMessage) {
3117
+ return {
3118
+ kind: 'bootstrap',
3119
+ reason: 'missing_workflow_trigger',
3120
+ issueMessage,
3121
+ agentsToResume: [],
3122
+ lastTrigger: null,
3123
+ durableMessages: [issueMessage],
3124
+ };
3125
+ }
3126
+
3127
+ return {
3128
+ kind: 'failure',
3129
+ error:
3130
+ `Cannot resume cluster ${clusterId}: no workflow trigger or ISSUE_OPENED message exists. ` +
3131
+ `No agents were started and the preserved cluster remains stopped. ` +
3132
+ `Inspect it with 'zeroshot export ${clusterId}' before repairing its persisted state; do not launch a duplicate run.`,
3133
+ details: { reason: 'missing_workflow_history' },
3134
+ };
3135
+ }
3136
+
3137
+ const selection = this._selectAgentsToResume(cluster, lastTrigger);
3138
+ if (selection.eligible.length > 0) {
3139
+ return {
3140
+ kind: 'agents',
3141
+ reason: 'eligible_handlers',
3142
+ agentsToResume: selection.eligible,
3143
+ lastTrigger,
3144
+ durableMessages: this._collectDurableResumeContext(
3145
+ cluster,
3146
+ clusterId,
3147
+ selection.eligible.map(({ message }) => message)
3148
+ ),
3149
+ };
3150
+ }
3151
+
3152
+ const matchingAgentIds = selection.matching.map(({ agent }) => agent.id);
3153
+ if (matchingAgentIds.length > 0) {
3154
+ return {
3155
+ kind: 'failure',
3156
+ error:
3157
+ `Cannot resume cluster ${clusterId}: trigger ${lastTrigger.topic} is registered by ` +
3158
+ `${matchingAgentIds.join(', ')}, but no handler is currently eligible and no interrupted task can be reconstructed. ` +
3159
+ `No agents were started.`,
3160
+ details: {
3161
+ reason: 'registered_handlers_ineligible',
3162
+ trigger: lastTrigger.topic,
3163
+ matchingAgentIds,
3164
+ },
3165
+ };
3166
+ }
3167
+
3168
+ return {
3169
+ kind: 'failure',
3170
+ error:
3171
+ `Cannot resume cluster ${clusterId}: no restored agent registers trigger ${lastTrigger.topic}. ` +
3172
+ `No agents were started; check the persisted agent configuration.`,
3173
+ details: {
3174
+ reason: 'unhandled_trigger',
3175
+ trigger: lastTrigger.topic,
3176
+ },
3177
+ };
3178
+ }
3179
+
3180
+ async _failResumeReconstruction(clusterId, cluster, plan) {
3181
+ cluster.state = 'stopped';
3182
+ cluster.pid = null;
3183
+ cluster.failureInfo = {
3184
+ type: 'resume_reconstruction',
3185
+ error: plan.error,
3186
+ ...plan.details,
3187
+ timestamp: Date.now(),
3188
+ };
3189
+ await this._saveClusters();
3190
+
3191
+ const error = new Error(plan.error);
3192
+ error.code = 'RESUME_RECONSTRUCTION_FAILED';
3193
+ throw error;
3194
+ }
3195
+
3196
+ _clearTransientAgentState(cluster) {
3197
+ for (const agent of cluster.agents) {
3198
+ agent.currentTask = null;
3199
+ agent.currentTaskId = null;
3200
+ agent.processPid = null;
3201
+ agent._currentExecution = null;
3202
+ agent.state = 'idle';
3203
+ }
2767
3204
  }
2768
3205
 
2769
3206
  _resumeAgents(agentsToResume, context) {
@@ -2829,15 +3266,16 @@ class Orchestrator {
2829
3266
  };
2830
3267
  }
2831
3268
 
2832
- async _resumeCleanCluster(clusterId, cluster, recentMessages, prompt) {
3269
+ async _resumeCleanCluster(clusterId, cluster, recentMessages, prompt, plan) {
2833
3270
  this._log(`[Orchestrator] Resuming stopped cluster ${clusterId} (no failure)`);
2834
3271
 
2835
3272
  const context = this._buildResumeContext(recentMessages, prompt, {
2836
3273
  header: 'Resuming cluster from previous session.',
2837
3274
  topics: ['AGENT_OUTPUT', 'VALIDATION_RESULT', 'ISSUE_OPENED'],
3275
+ durableMessages: plan.durableMessages,
2838
3276
  });
2839
3277
 
2840
- const lastTrigger = this._findLastWorkflowTrigger(recentMessages);
3278
+ const lastTrigger = plan.lastTrigger;
2841
3279
  if (lastTrigger) {
2842
3280
  this._log(
2843
3281
  `[Orchestrator] Last workflow trigger: ${lastTrigger.topic} (${new Date(lastTrigger.timestamp).toISOString()})`
@@ -2846,31 +3284,16 @@ class Orchestrator {
2846
3284
  this._log(`[Orchestrator] No workflow triggers found in ledger`);
2847
3285
  }
2848
3286
 
2849
- const agentsToResume = lastTrigger ? this._selectAgentsToResume(cluster, lastTrigger) : [];
2850
- if (agentsToResume.length === 0) {
2851
- if (!lastTrigger) {
2852
- this._log(
2853
- `[Orchestrator] WARNING: No workflow triggers in ledger. Cluster may not have started properly.`
2854
- );
2855
- this._log(`[Orchestrator] Publishing ISSUE_OPENED to bootstrap workflow...`);
2856
-
2857
- if (!this._republishIssue(cluster, clusterId, recentMessages)) {
2858
- throw new Error(
2859
- `Cannot resume cluster ${clusterId}: No workflow triggers found and no ISSUE_OPENED message. ` +
2860
- `The cluster may not have started properly. Try: zeroshot run <issue> instead.`
2861
- );
2862
- }
2863
- } else {
2864
- throw new Error(
2865
- `Cannot resume cluster ${clusterId}: Found trigger ${lastTrigger.topic} but no agents handle it. ` +
2866
- `Check agent trigger configurations.`
2867
- );
2868
- }
3287
+ const agentsToResume = plan.agentsToResume;
3288
+ if (plan.kind === 'bootstrap') {
3289
+ this._log(`[Orchestrator] Publishing ISSUE_OPENED to bootstrap workflow...`);
3290
+ this._republishIssue(cluster, clusterId, [plan.issueMessage]);
2869
3291
  } else {
2870
3292
  this._log(`[Orchestrator] Resuming ${agentsToResume.length} agent(s) based on ledger state`);
2871
3293
  this._resumeAgents(agentsToResume, context);
2872
3294
  }
2873
3295
 
3296
+ cluster.failureInfo = null;
2874
3297
  await this._saveClusters();
2875
3298
  this._log(`[Orchestrator] Cluster ${clusterId} resumed`);
2876
3299
 
@@ -3632,15 +4055,7 @@ Continue from where you left off. Review your previous output to understand what
3632
4055
  * @private
3633
4056
  */
3634
4057
  _isProcessRunning(pid) {
3635
- if (!pid) return false;
3636
- try {
3637
- // Signal 0 doesn't kill, just checks if process exists
3638
- process.kill(pid, 0);
3639
- return true;
3640
- } catch (e) {
3641
- // ESRCH = No such process, EPERM = process exists but no permission
3642
- return e.code === 'EPERM';
3643
- }
4058
+ return isProcessRunning(pid);
3644
4059
  }
3645
4060
 
3646
4061
  /**