@the-open-engine/zeroshot 6.1.0 → 6.3.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 (42) hide show
  1. package/README.md +141 -474
  2. package/cli/commands/cmdproof.js +180 -6
  3. package/cli/event-copy.js +12 -0
  4. package/cli/index.js +311 -96
  5. package/cli/message-formatters-normal.js +14 -2
  6. package/cli/message-formatters-watch.js +9 -2
  7. package/cluster-hooks/block-ask-user-question.py +53 -0
  8. package/cluster-hooks/block-dangerous-git.py +145 -0
  9. package/lib/clusters-registry.js +48 -0
  10. package/lib/compose-utils.js +101 -0
  11. package/lib/detached-startup.js +9 -0
  12. package/lib/id-detector.js +8 -9
  13. package/lib/path-check.js +63 -0
  14. package/lib/run-mode.js +22 -0
  15. package/lib/start-cluster.js +39 -23
  16. package/package.json +3 -4
  17. package/scripts/check-path.js +19 -0
  18. package/scripts/validate-templates.js +11 -29
  19. package/src/agent/agent-hook-executor.js +1 -1
  20. package/src/agent/agent-task-executor.js +4 -3
  21. package/src/agent/pr-verification.js +27 -1
  22. package/src/agents/git-pusher-template.js +121 -4
  23. package/src/attach/socket-discovery.js +2 -3
  24. package/src/claude-credentials.js +75 -0
  25. package/src/claude-task-runner.js +1 -0
  26. package/src/isolation-manager.js +12 -9
  27. package/src/issue-providers/README.md +45 -15
  28. package/src/issue-providers/index.js +2 -0
  29. package/src/issue-providers/jira-provider.js +8 -1
  30. package/src/issue-providers/linear-provider.js +405 -0
  31. package/src/ledger.js +42 -3
  32. package/src/lib/gc.js +2 -3
  33. package/src/message-bus.js +10 -0
  34. package/src/orchestrator.js +264 -144
  35. package/src/preflight.js +12 -16
  36. package/src/providers/base-provider.js +7 -6
  37. package/src/status-footer.js +13 -1
  38. package/src/template-validation/index.js +3 -0
  39. package/src/template-validation/report-formatter.js +55 -0
  40. package/src/worktree-claude-config.js +2 -13
  41. package/task-lib/runner.js +1 -0
  42. package/task-lib/watcher.js +1 -0
@@ -33,6 +33,7 @@ function cleanStaleLock(lockPath) {
33
33
  // Ignore - another process may have cleaned it
34
34
  }
35
35
  }
36
+ const { readClustersFileSync, writeClustersFileAtomic } = require('../lib/clusters-registry');
36
37
  const AgentWrapper = require('./agent-wrapper');
37
38
  const SubClusterWrapper = require('./sub-cluster-wrapper');
38
39
  const MessageBus = require('./message-bus');
@@ -56,6 +57,24 @@ const {
56
57
  } = require('./command-proofs');
57
58
  const crypto = require('crypto');
58
59
 
60
+ /**
61
+ * Thrown when a run is rejected because the issue already has an active cluster.
62
+ * This is expected control flow (a benign guard), not a crash - callers should
63
+ * check `error.code === 'DUPLICATE_CLUSTER'` and present it without a stack trace.
64
+ */
65
+ class DuplicateClusterError extends Error {
66
+ constructor(message, { issueNumber, existingClusterId, existingState, existingPid, ageMinutes }) {
67
+ super(message);
68
+ this.name = 'DuplicateClusterError';
69
+ this.code = 'DUPLICATE_CLUSTER';
70
+ this.issueNumber = issueNumber;
71
+ this.existingClusterId = existingClusterId;
72
+ this.existingState = existingState;
73
+ this.existingPid = existingPid;
74
+ this.ageMinutes = ageMinutes;
75
+ }
76
+ }
77
+
59
78
  function applyModelOverride(agentConfig, modelOverride) {
60
79
  if (!modelOverride) return;
61
80
 
@@ -193,20 +212,20 @@ function applyPushBlockedRepairTriggers(config) {
193
212
  }
194
213
  }
195
214
 
215
+ function resolveAutoMerge(options) {
216
+ return Boolean(options.ship) || Boolean(options.autoMerge);
217
+ }
218
+
196
219
  function buildPrOptions(options, requiredQualityGates) {
197
- if (
198
- !options.prBase &&
199
- !options.mergeQueue &&
200
- !options.closeIssue &&
201
- requiredQualityGates.length === 0
202
- ) {
203
- return null;
204
- }
220
+ // autoMerge must always be persisted (even when no other PR fields are set) so that
221
+ // `zeroshot run --pr` (autoMerge=false) vs `--ship` (autoMerge=true) survives resume.
222
+ const autoMerge = resolveAutoMerge(options);
205
223
 
206
224
  return {
207
225
  prBase: options.prBase || null,
208
226
  mergeQueue: options.mergeQueue || false,
209
227
  closeIssue: options.closeIssue || null,
228
+ autoMerge,
210
229
  ...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}),
211
230
  cwd: options.cwd || process.cwd(),
212
231
  };
@@ -216,6 +235,11 @@ class Orchestrator {
216
235
  constructor(options = {}) {
217
236
  this.clusters = new Map(); // cluster_id -> cluster object
218
237
  this.quiet = options.quiet || false; // Suppress verbose logging
238
+ // Read-only mode: opens ledgers without write access and skips schema DDL/
239
+ // side-effecting bootstrap (state snapshotter). Used by CLI read commands
240
+ // (list/status/logs) so they can never contend with a live daemon's writer
241
+ // connection or mutate shared state as a side effect of a plain read.
242
+ this.readonly = options.readonly === true;
219
243
 
220
244
  // TaskRunner DI - allows injecting MockTaskRunner for testing
221
245
  // When set, passed to all AgentWrappers to control task execution
@@ -336,14 +360,12 @@ class Orchestrator {
336
360
  },
337
361
  });
338
362
 
339
- const data = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
363
+ const data = readClustersFileSync(this.storageDir);
340
364
  const clusterIds = Object.keys(data);
341
365
  this._log(`[Orchestrator] Found ${clusterIds.length} clusters in file:`, clusterIds);
342
366
 
343
- // Track clusters to remove (missing .db files or 0 messages)
367
+ // Track clusters to remove (missing .db files)
344
368
  const clustersToRemove = [];
345
- // Track clusters with 0 messages (corrupted from SIGINT race condition)
346
- const corruptedClusters = [];
347
369
 
348
370
  for (const [clusterId, clusterData] of Object.entries(data)) {
349
371
  if (clusterData?.state === 'setup' || clusterData?.provisional === true) {
@@ -362,58 +384,29 @@ class Orchestrator {
362
384
  }
363
385
 
364
386
  this._log(`[Orchestrator] Loading cluster: ${clusterId}`);
365
- let cluster;
366
387
  try {
367
- cluster = this._loadSingleCluster(clusterId, clusterData);
388
+ this._loadSingleCluster(clusterId, clusterData);
368
389
  } catch (error) {
369
390
  console.warn(
370
391
  `[Orchestrator] Skipping cluster ${clusterId}: ${error.message || String(error)}`
371
392
  );
372
393
  continue;
373
394
  }
374
-
375
- // VALIDATION: Detect 0-message clusters (corrupted from SIGINT during initialization)
376
- // These clusters were created before the initCompletePromise fix was applied
377
- if (cluster && cluster.messageBus) {
378
- const messageCount = cluster.messageBus.count({ cluster_id: clusterId });
379
- if (messageCount === 0) {
380
- console.warn(`[Orchestrator] ⚠️ Cluster ${clusterId} has 0 messages (corrupted)`);
381
- console.warn(
382
- `[Orchestrator] This likely occurred from SIGINT during initialization.`
383
- );
384
- console.warn(
385
- `[Orchestrator] Marking as 'corrupted' - use 'zeroshot kill ${clusterId}' to remove.`
386
- );
387
- corruptedClusters.push(clusterId);
388
- // Mark cluster as corrupted for visibility in status/list commands
389
- cluster.state = 'corrupted';
390
- cluster.corruptedReason = 'SIGINT during initialization (0 messages in ledger)';
391
- }
392
- }
393
395
  }
394
396
 
395
- // Clean up orphaned entries from clusters.json
396
- if (clustersToRemove.length > 0) {
397
+ // Clean up orphaned entries from clusters.json. This is a write side effect,
398
+ // so it must never run for read-only (list/status/logs) instances - only the
399
+ // writable orchestrator that owns the registry performs cleanup.
400
+ if (!this.readonly && clustersToRemove.length > 0) {
397
401
  for (const clusterId of clustersToRemove) {
398
402
  delete data[clusterId];
399
403
  }
400
- fs.writeFileSync(clustersFile, JSON.stringify(data, null, 2));
404
+ writeClustersFileAtomic(this.storageDir, data);
401
405
  this._log(
402
406
  `[Orchestrator] Removed ${clustersToRemove.length} orphaned cluster(s) from registry`
403
407
  );
404
408
  }
405
409
 
406
- // Log summary of corrupted clusters
407
- if (corruptedClusters.length > 0) {
408
- console.warn(
409
- `\n[Orchestrator] ⚠️ Found ${corruptedClusters.length} corrupted cluster(s):`
410
- );
411
- for (const clusterId of corruptedClusters) {
412
- console.warn(` - ${clusterId}`);
413
- }
414
- console.warn(`[Orchestrator] Run 'zeroshot clear' to remove all corrupted clusters.\n`);
415
- }
416
-
417
410
  this._log(`[Orchestrator] Total clusters loaded: ${this.clusters.size}`);
418
411
  } catch (error) {
419
412
  console.error('[Orchestrator] Failed to load clusters:', error.message);
@@ -462,6 +455,10 @@ class Orchestrator {
462
455
  getTokensByRole: () => ({
463
456
  _total: { count: 0, inputTokens: 0, outputTokens: 0, totalCostUsd: 0 },
464
457
  }),
458
+ readSnapshot: () => ({
459
+ messageCount: 0,
460
+ tokensByRole: { _total: { count: 0, inputTokens: 0, outputTokens: 0, totalCostUsd: 0 } },
461
+ }),
465
462
  pollForMessages: () => noop,
466
463
  on: noop,
467
464
  off: noop,
@@ -488,6 +485,7 @@ class Orchestrator {
488
485
  since: (params) => ledger.since(params),
489
486
  getAll: (clusterId) => ledger.getAll(clusterId),
490
487
  getTokensByRole: (clusterId) => ledger.getTokensByRole(clusterId),
488
+ readSnapshot: (clusterId) => ledger.readSnapshot(clusterId),
491
489
  addWebSocketClient: noop,
492
490
  removeWebSocketClient: noop,
493
491
  on: noop,
@@ -527,9 +525,11 @@ class Orchestrator {
527
525
  return this.clusters.get(clusterId);
528
526
  }
529
527
 
530
- // Restore ledger and message bus
528
+ // Restore ledger and message bus. Read-only orchestrators (CLI list/status/logs)
529
+ // open the ledger without write access so they can never contend with the live
530
+ // daemon's writer connection or take a write lock on another process's database.
531
531
  const dbPath = path.join(this.storageDir, `${clusterId}.db`);
532
- const ledger = new Ledger(dbPath);
532
+ const ledger = new Ledger(dbPath, { readonly: this.readonly });
533
533
  const messageBus = new MessageBus(ledger);
534
534
 
535
535
  // Restore isolation manager FIRST if cluster was running in isolation mode
@@ -573,7 +573,12 @@ class Orchestrator {
573
573
  Object.assign(clusterContext, cluster);
574
574
 
575
575
  this.clusters.set(clusterId, clusterContext);
576
- this._startSnapshotter(clusterContext);
576
+ // The snapshotter bootstraps by publishing a STATE_SNAPSHOT message if one is
577
+ // missing - a ledger write. Read-only orchestrators must never write, so they
578
+ // skip it; they only need to read existing snapshots, not produce new ones.
579
+ if (!this.readonly) {
580
+ this._startSnapshotter(clusterContext);
581
+ }
577
582
  this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`);
578
583
 
579
584
  return clusterContext;
@@ -720,7 +725,7 @@ class Orchestrator {
720
725
 
721
726
  const clustersFile = path.join(this.storageDir, 'clusters.json');
722
727
  if (!fs.existsSync(clustersFile)) {
723
- fs.writeFileSync(clustersFile, '{}');
728
+ writeClustersFileAtomic(this.storageDir, {});
724
729
  }
725
730
  return clustersFile;
726
731
  }
@@ -781,6 +786,11 @@ class Orchestrator {
781
786
  return;
782
787
  }
783
788
 
789
+ // Read-only orchestrators (CLI list/status/logs) must never write the registry.
790
+ if (this.readonly) {
791
+ return;
792
+ }
793
+
784
794
  const clustersFile = this._ensureClustersFile();
785
795
  if (!clustersFile) {
786
796
  return;
@@ -807,8 +817,7 @@ class Orchestrator {
807
817
  // Read existing clusters from file (other processes may have added clusters)
808
818
  let existingClusters = {};
809
819
  try {
810
- const content = fs.readFileSync(clustersFile, 'utf8');
811
- existingClusters = JSON.parse(content);
820
+ existingClusters = readClustersFileSync(this.storageDir);
812
821
  } catch (error) {
813
822
  console.error('[Orchestrator] Failed to read existing clusters:', error.message);
814
823
  }
@@ -845,6 +854,8 @@ class Orchestrator {
845
854
  failureInfo: cluster.failureInfo || null,
846
855
  // Persist PR mode for completion agent selection
847
856
  autoPr: cluster.autoPr || false,
857
+ // Persist normalized run mode for status/list display
858
+ runMode: cluster.runMode || null,
848
859
  // Persist PR options for resume
849
860
  prOptions: cluster.prOptions || null,
850
861
  // Persist cluster-scoped command proof configuration for resume and dynamic agents
@@ -880,8 +891,9 @@ class Orchestrator {
880
891
  };
881
892
  }
882
893
 
883
- // Write merged data
884
- fs.writeFileSync(clustersFile, JSON.stringify(existingClusters, null, 2));
894
+ // Write merged data atomically (temp file + rename) so no reader can ever
895
+ // observe a partially-written file.
896
+ writeClustersFileAtomic(this.storageDir, existingClusters);
885
897
  this._log(
886
898
  `[Orchestrator] Saved ${this.clusters.size} cluster(s), file now has ${Object.keys(existingClusters).length} total`
887
899
  );
@@ -933,7 +945,7 @@ class Orchestrator {
933
945
  throw lockErr;
934
946
  }
935
947
 
936
- const data = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
948
+ const data = readClustersFileSync(this.storageDir);
937
949
 
938
950
  for (const [clusterId, clusterData] of Object.entries(data)) {
939
951
  if (!knownClusterIds.has(clusterId)) {
@@ -1029,6 +1041,69 @@ class Orchestrator {
1029
1041
  });
1030
1042
  }
1031
1043
 
1044
+ /**
1045
+ * Resolve input (issue from provider, file, or text) and reject duplicate active
1046
+ * runs on the same issue. Deliberately allocates NOTHING (no ledger, no isolation,
1047
+ * no cluster registration) so a rejection - duplicate or otherwise - has zero side
1048
+ * effects on disk or in-memory state.
1049
+ * @private
1050
+ * @returns {Promise<{ inputData: Object, issueProviderId: string|null }>}
1051
+ */
1052
+ async _resolveClusterInput(input, options, clusterId) {
1053
+ if (input.issue) {
1054
+ const ProviderClass = detectProvider(
1055
+ input.issue,
1056
+ options.settings || {},
1057
+ options.forceProvider
1058
+ );
1059
+ if (!ProviderClass) {
1060
+ throw new Error(`No issue provider matched input: ${input.issue}`);
1061
+ }
1062
+
1063
+ const provider = new ProviderClass();
1064
+ const inputData = await provider.fetchIssue(input.issue, options.settings || {});
1065
+ const issueNumber = inputData.number || null;
1066
+
1067
+ // Check for duplicate active runs on same issue (unless --force)
1068
+ if (issueNumber && !options.force) {
1069
+ const activeClusters = this._getActiveClustersForIssue(issueNumber, clusterId);
1070
+ if (activeClusters.length > 0) {
1071
+ const existing = activeClusters[0];
1072
+ const age = Math.round((Date.now() - existing.createdAt) / 1000 / 60);
1073
+ throw new DuplicateClusterError(
1074
+ `Issue #${issueNumber} already has an active cluster:\n` +
1075
+ ` Cluster: ${existing.id} (state: ${existing.state}, running for ${age}min, pid: ${existing.pid})\n\n` +
1076
+ `Options:\n` +
1077
+ ` 1. Kill existing: zeroshot kill ${existing.id}\n` +
1078
+ ` 2. Override check: zeroshot run ${input.issue} --force\n` +
1079
+ ` 3. View status: zeroshot status ${existing.id}`,
1080
+ {
1081
+ issueNumber,
1082
+ existingClusterId: existing.id,
1083
+ existingState: existing.state,
1084
+ existingPid: existing.pid,
1085
+ ageMinutes: age,
1086
+ }
1087
+ );
1088
+ }
1089
+ }
1090
+
1091
+ // Log clickable issue link
1092
+ if (inputData.url) {
1093
+ this._log(`[Orchestrator] Issue (${ProviderClass.displayName}): ${inputData.url}`);
1094
+ }
1095
+
1096
+ return { inputData, issueProviderId: ProviderClass.id };
1097
+ } else if (input.file) {
1098
+ this._log(`[Orchestrator] File: ${input.file}`);
1099
+ return { inputData: InputHelpers.createFileInput(input.file), issueProviderId: null };
1100
+ } else if (input.text) {
1101
+ return { inputData: InputHelpers.createTextInput(input.text), issueProviderId: null };
1102
+ }
1103
+
1104
+ throw new Error('Either issue, file, or text input is required');
1105
+ }
1106
+
1032
1107
  /**
1033
1108
  * Internal start implementation (shared by start and startWithMock)
1034
1109
  * @private
@@ -1044,6 +1119,15 @@ class Orchestrator {
1044
1119
  config?.dbPath || null
1045
1120
  );
1046
1121
 
1122
+ // Resolve input (issue/file/text) and reject duplicate active runs on the same issue
1123
+ // BEFORE allocating any resources. A duplicate-run rejection must have zero side
1124
+ // effects: no ledger db file, no worktree/container, no clusters.json entry.
1125
+ const { inputData, issueProviderId } = await this._resolveClusterInput(
1126
+ input,
1127
+ options,
1128
+ clusterId
1129
+ );
1130
+
1047
1131
  // Create ledger and message bus with persistent storage
1048
1132
  const dbPath = config.dbPath || path.join(this.storageDir, `${clusterId}.db`);
1049
1133
  const ledger = new Ledger(dbPath);
@@ -1077,10 +1161,13 @@ class Orchestrator {
1077
1161
  createdAt: Date.now(),
1078
1162
  // Track PID for zombie detection (this process owns the cluster)
1079
1163
  pid: process.pid,
1164
+ // Issue number for heroshot/external tools (avoids log parsing)
1165
+ issue: inputData.number || null,
1080
1166
  // Initialization completion tracking (for safe SIGINT handling)
1081
1167
  initCompletePromise,
1082
1168
  _resolveInitComplete: resolveInitComplete,
1083
1169
  autoPr: options.autoPr || false,
1170
+ runMode: options.runMode || null,
1084
1171
  requiredQualityGates,
1085
1172
  commandProofs,
1086
1173
  // PR configuration options (persisted for resume)
@@ -1088,7 +1175,7 @@ class Orchestrator {
1088
1175
  // Model override for all agents (applied to dynamically added agents)
1089
1176
  modelOverride: options.modelOverride || null,
1090
1177
  // Issue provider tracking (where issue was fetched from)
1091
- issueProvider: null, // Set after fetching issue (github, gitlab, jira, azure-devops)
1178
+ issueProvider: issueProviderId, // null unless input.issue (github, gitlab, jira, azure-devops)
1092
1179
  // Git platform tracking (where PR/MR will be created - independent of issue provider)
1093
1180
  gitPlatform: null, // Set when --pr mode is active
1094
1181
  // Isolation state (only if enabled)
@@ -1126,63 +1213,8 @@ class Orchestrator {
1126
1213
  });
1127
1214
 
1128
1215
  try {
1129
- // Fetch input (issue from provider, file, or text)
1130
- let inputData;
1131
- if (input.issue) {
1132
- // Detect provider and fetch issue
1133
- const ProviderClass = detectProvider(
1134
- input.issue,
1135
- options.settings || {},
1136
- options.forceProvider
1137
- );
1138
- if (!ProviderClass) {
1139
- throw new Error(`No issue provider matched input: ${input.issue}`);
1140
- }
1141
-
1142
- const provider = new ProviderClass();
1143
- inputData = await provider.fetchIssue(input.issue, options.settings || {});
1144
-
1145
- // Store issue provider for logging/debugging and cross-provider workflows
1146
- cluster.issueProvider = ProviderClass.id;
1147
-
1148
- // Store issue number for heroshot/external tools (avoids log parsing)
1149
- cluster.issue = inputData.number || null;
1150
-
1151
- // Persist issue number early so supervisors can associate this cluster with the issue even if start fails.
1152
- await this._saveClusters().catch((err) => {
1153
- console.warn(`[Orchestrator] Failed to persist issue number for ${clusterId}:`, err);
1154
- });
1155
-
1156
- // Check for duplicate active runs on same issue (unless --force)
1157
- if (cluster.issue && !options.force) {
1158
- const activeClusters = this._getActiveClustersForIssue(cluster.issue, clusterId);
1159
- if (activeClusters.length > 0) {
1160
- const existing = activeClusters[0];
1161
- const age = Math.round((Date.now() - existing.createdAt) / 1000 / 60);
1162
- throw new Error(
1163
- `Issue #${cluster.issue} already has an active cluster:\n` +
1164
- ` Cluster: ${existing.id} (state: ${existing.state}, running for ${age}min, pid: ${existing.pid})\n\n` +
1165
- `Options:\n` +
1166
- ` 1. Kill existing: zeroshot kill ${existing.id}\n` +
1167
- ` 2. Override check: zeroshot run ${input.issue} --force\n` +
1168
- ` 3. View status: zeroshot status ${existing.id}`
1169
- );
1170
- }
1171
- }
1172
-
1173
- // Log clickable issue link
1174
- if (inputData.url) {
1175
- this._log(`[Orchestrator] Issue (${ProviderClass.displayName}): ${inputData.url}`);
1176
- }
1177
- } else if (input.file) {
1178
- inputData = InputHelpers.createFileInput(input.file);
1179
- this._log(`[Orchestrator] File: ${input.file}`);
1180
- } else if (input.text) {
1181
- inputData = InputHelpers.createTextInput(input.text);
1182
- } else {
1183
- throw new Error('Either issue, file, or text input is required');
1184
- }
1185
-
1216
+ // Input (issue/file/text) was already resolved and duplicate-checked in
1217
+ // _resolveClusterInput() before any resource was allocated (see above).
1186
1218
  commandProofs = mergeCommandProofs(
1187
1219
  commandProofs,
1188
1220
  resolveClusterCommandProofs(config, options, inputData.context)
@@ -1793,6 +1825,7 @@ class Orchestrator {
1793
1825
  mergeQueue: options.mergeQueue,
1794
1826
  closeIssue: options.closeIssue,
1795
1827
  requiredQualityGates: options.requiredQualityGates,
1828
+ autoMerge: resolveAutoMerge(options),
1796
1829
  cwd: options.cwd,
1797
1830
  });
1798
1831
 
@@ -1908,22 +1941,29 @@ class Orchestrator {
1908
1941
  * @private
1909
1942
  */
1910
1943
  _teardownWorktreeCompose(worktreePath) {
1911
- const composePath = path.join(worktreePath, 'docker-compose.yml');
1912
- if (!fs.existsSync(composePath)) return;
1944
+ // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
1945
+ // Compose project — only a project scoped to the worktree directory basename, which is
1946
+ // the only kind zeroshot could itself have created, is touched.
1947
+ const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
1948
+ const teardown = resolveWorktreeComposeTeardown(worktreePath);
1949
+ if (!teardown.shouldTeardown) {
1950
+ if (teardown.composePath) {
1951
+ this._log(
1952
+ `[Orchestrator] Skipping Docker Compose teardown in ${worktreePath}: ${teardown.reason}`
1953
+ );
1954
+ }
1955
+ return;
1956
+ }
1913
1957
 
1914
1958
  try {
1915
1959
  this._log(`[Orchestrator] Tearing down Docker Compose services in ${worktreePath}...`);
1916
1960
  const { spawnSync } = require('child_process');
1917
- const result = spawnSync(
1918
- 'docker',
1919
- ['compose', 'down', '--remove-orphans', '--volumes', '--timeout', '10'],
1920
- {
1921
- cwd: worktreePath,
1922
- encoding: 'utf8',
1923
- stdio: 'pipe',
1924
- timeout: 30000,
1925
- }
1926
- );
1961
+ const result = spawnSync('docker', teardown.args, {
1962
+ cwd: worktreePath,
1963
+ encoding: 'utf8',
1964
+ stdio: 'pipe',
1965
+ timeout: 30000,
1966
+ });
1927
1967
  if (result.status !== 0 || result.error) {
1928
1968
  const detail = result.error?.message || result.stderr || 'no stderr';
1929
1969
  throw new Error(`Docker Compose teardown failed in ${worktreePath}: ${detail}`);
@@ -3583,6 +3623,24 @@ Continue from where you left off. Review your previous output to understand what
3583
3623
  }
3584
3624
  }
3585
3625
 
3626
+ /**
3627
+ * Read messageCount for a cluster without fabricating a value on failure.
3628
+ * A confirmed zero (ledger read succeeded, cluster has no messages) and a
3629
+ * failed read (ledger unavailable) must stay distinguishable - returning 0
3630
+ * for both is what produced the phantom "failed/msgs=0" state in list/status.
3631
+ * @private
3632
+ */
3633
+ _readMessageCount(cluster, clusterId) {
3634
+ try {
3635
+ return cluster.messageBus.readSnapshot(clusterId).messageCount;
3636
+ } catch (error) {
3637
+ console.error(
3638
+ `[Orchestrator] Failed to read message count for cluster ${clusterId}: ${error.message || String(error)}`
3639
+ );
3640
+ return null;
3641
+ }
3642
+ }
3643
+
3586
3644
  /**
3587
3645
  * Get cluster status
3588
3646
  * @param {String} clusterId - Cluster ID
@@ -3625,18 +3683,11 @@ Continue from where you left off. Review your previous output to understand what
3625
3683
  pid: cluster.pid || null,
3626
3684
  createdAt: cluster.createdAt,
3627
3685
  agents: cluster.agents.map((a) => a.getState()),
3628
- messageCount: (() => {
3629
- try {
3630
- return cluster.messageBus.count({ cluster_id: clusterId });
3631
- } catch {
3632
- // Cluster may have closed its ledger during startup failure cleanup.
3633
- // Status/list should remain safe to call for visibility + supervisor cleanup.
3634
- return 0;
3635
- }
3636
- })(),
3686
+ messageCount: this._readMessageCount(cluster, clusterId),
3637
3687
  setupLogPath: cluster.setupLogPath || null,
3638
3688
  setupStage: cluster.setupStage || null,
3639
3689
  failureInfo: cluster.failureInfo || null,
3690
+ runMode: cluster.runMode || null,
3640
3691
  };
3641
3692
  }
3642
3693
 
@@ -3665,19 +3716,12 @@ Continue from where you left off. Review your previous output to understand what
3665
3716
  createdAt: cluster.createdAt,
3666
3717
  issue: cluster.issue || null,
3667
3718
  agentCount: cluster.agents.length,
3668
- messageCount: (() => {
3669
- try {
3670
- return cluster.messageBus.count({ cluster_id: cluster.id });
3671
- } catch {
3672
- // Cluster may have closed its ledger during startup failure cleanup.
3673
- // List should remain safe to call for cleanup routines.
3674
- return 0;
3675
- }
3676
- })(),
3719
+ messageCount: this._readMessageCount(cluster, cluster.id),
3677
3720
  pid: cluster.pid || null,
3678
3721
  setupLogPath: cluster.setupLogPath || null,
3679
3722
  setupStage: cluster.setupStage || null,
3680
3723
  failureInfo: cluster.failureInfo || null,
3724
+ runMode: cluster.runMode || null,
3681
3725
  };
3682
3726
  });
3683
3727
  }
@@ -3984,6 +4028,82 @@ Continue from where you left off. Review your previous output to understand what
3984
4028
  dryRun: options.dryRun || false,
3985
4029
  });
3986
4030
  }
4031
+
4032
+ /**
4033
+ * Explicitly detect clusters corrupted by SIGINT during initialization
4034
+ * (state='running' but the ledger never received its first message).
4035
+ *
4036
+ * This replaces the old unconditional 0-message check that used to run inside
4037
+ * _loadClusters() on every list/status call: that check mutated cluster.state
4038
+ * as a side effect of a plain read, and a single racy count() (e.g. against a
4039
+ * writer's just-opened connection) was enough to misclassify a healthy running
4040
+ * cluster as corrupted. This method is only ever invoked explicitly (from the
4041
+ * `gc` CLI flow), never from getStatus()/listClusters(), and requires:
4042
+ * - the cluster has been running longer than `graceMs` (default 60s), so a
4043
+ * brand-new cluster whose first message hasn't landed yet isn't flagged, and
4044
+ * - two independent reads 250ms apart both observe 0 messages, so a single
4045
+ * transient/racy read can't trigger a false positive.
4046
+ *
4047
+ * @param {object} [options]
4048
+ * @param {number} [options.graceMs=60000]
4049
+ * @returns {Promise<string[]>} IDs newly marked corrupted
4050
+ */
4051
+ async detectCorruptedClusters(options = {}) {
4052
+ const graceMs = typeof options.graceMs === 'number' ? options.graceMs : 60000;
4053
+ const now = Date.now();
4054
+ const corrupted = [];
4055
+
4056
+ for (const cluster of this.clusters.values()) {
4057
+ if (this._isSetupCluster(cluster)) continue;
4058
+ if (cluster.state !== 'running') continue;
4059
+ if (!cluster.createdAt || now - cluster.createdAt < graceMs) continue;
4060
+ if (!cluster.messageBus) continue;
4061
+
4062
+ let firstCount;
4063
+ try {
4064
+ firstCount = cluster.messageBus.count({ cluster_id: cluster.id });
4065
+ } catch (error) {
4066
+ console.warn(
4067
+ `[Orchestrator] Skipping corruption check for ${cluster.id}: ${error.message || String(error)}`
4068
+ );
4069
+ continue;
4070
+ }
4071
+ if (firstCount !== 0) continue;
4072
+
4073
+ await new Promise((resolve) => setTimeout(resolve, 250));
4074
+
4075
+ let secondCount;
4076
+ try {
4077
+ secondCount = cluster.messageBus.count({ cluster_id: cluster.id });
4078
+ } catch (error) {
4079
+ console.warn(
4080
+ `[Orchestrator] Skipping corruption check for ${cluster.id}: ${error.message || String(error)}`
4081
+ );
4082
+ continue;
4083
+ }
4084
+ if (secondCount !== 0) continue;
4085
+
4086
+ console.warn(`[Orchestrator] ⚠️ Cluster ${cluster.id} has 0 messages (corrupted)`);
4087
+ console.warn(`[Orchestrator] This likely occurred from SIGINT during initialization.`);
4088
+ console.warn(
4089
+ `[Orchestrator] Marking as 'corrupted' - use 'zeroshot kill ${cluster.id}' to remove.`
4090
+ );
4091
+ cluster.state = 'corrupted';
4092
+ cluster.corruptedReason = 'SIGINT during initialization (0 messages in ledger)';
4093
+ corrupted.push(cluster.id);
4094
+ }
4095
+
4096
+ if (corrupted.length > 0) {
4097
+ await this._saveClusters();
4098
+ }
4099
+
4100
+ return corrupted;
4101
+ }
3987
4102
  }
3988
4103
 
4104
+ // Exported for testing (PR options persistence, e.g. autoMerge for --pr vs --ship).
4105
+ Orchestrator.buildPrOptions = buildPrOptions;
4106
+ Orchestrator.resolveAutoMerge = resolveAutoMerge;
4107
+
3989
4108
  module.exports = Orchestrator;
4109
+ module.exports.DuplicateClusterError = DuplicateClusterError;