@the-open-engine/zeroshot 6.2.0 → 6.4.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 (43) hide show
  1. package/README.md +141 -474
  2. package/cli/event-copy.js +12 -0
  3. package/cli/index.js +314 -96
  4. package/cli/message-formatters-normal.js +14 -2
  5. package/cli/message-formatters-watch.js +9 -2
  6. package/cluster-hooks/block-ask-user-question.py +53 -0
  7. package/cluster-hooks/block-dangerous-git.py +145 -0
  8. package/lib/clusters-registry.js +48 -0
  9. package/lib/compose-utils.js +101 -0
  10. package/lib/detached-startup.js +12 -1
  11. package/lib/id-detector.js +8 -9
  12. package/lib/path-check.js +63 -0
  13. package/lib/run-mode.js +34 -0
  14. package/lib/run-plan.js +32 -0
  15. package/lib/start-cluster.js +58 -28
  16. package/package.json +7 -6
  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-lifecycle.js +2 -0
  21. package/src/agent/agent-task-executor.js +4 -3
  22. package/src/agent/pr-verification.js +27 -1
  23. package/src/agents/git-pusher-template.js +121 -4
  24. package/src/attach/socket-discovery.js +2 -3
  25. package/src/claude-credentials.js +75 -0
  26. package/src/claude-task-runner.js +1 -0
  27. package/src/isolation-manager.js +12 -9
  28. package/src/issue-providers/README.md +45 -15
  29. package/src/issue-providers/index.js +2 -0
  30. package/src/issue-providers/jira-provider.js +8 -1
  31. package/src/issue-providers/linear-provider.js +405 -0
  32. package/src/ledger.js +45 -3
  33. package/src/lib/gc.js +2 -3
  34. package/src/message-bus.js +22 -2
  35. package/src/orchestrator.js +271 -144
  36. package/src/preflight.js +12 -16
  37. package/src/providers/base-provider.js +7 -6
  38. package/src/status-footer.js +13 -1
  39. package/src/template-validation/index.js +3 -0
  40. package/src/template-validation/report-formatter.js +55 -0
  41. package/src/worktree-claude-config.js +2 -13
  42. package/task-lib/runner.js +1 -0
  43. 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');
@@ -46,6 +47,7 @@ const configValidator = require('./config-validator');
46
47
  const TemplateResolver = require('./template-resolver');
47
48
  const { loadSettings } = require('../lib/settings');
48
49
  const { normalizeProviderName } = require('../lib/provider-names');
50
+ const { resolveRunPlan } = require('../lib/run-plan');
49
51
  const { getProvider } = require('./providers');
50
52
  const StateSnapshotter = require('./state-snapshotter');
51
53
  const { resolveClusterRequiredQualityGates } = require('./quality-gates');
@@ -56,6 +58,24 @@ const {
56
58
  } = require('./command-proofs');
57
59
  const crypto = require('crypto');
58
60
 
61
+ /**
62
+ * Thrown when a run is rejected because the issue already has an active cluster.
63
+ * This is expected control flow (a benign guard), not a crash - callers should
64
+ * check `error.code === 'DUPLICATE_CLUSTER'` and present it without a stack trace.
65
+ */
66
+ class DuplicateClusterError extends Error {
67
+ constructor(message, { issueNumber, existingClusterId, existingState, existingPid, ageMinutes }) {
68
+ super(message);
69
+ this.name = 'DuplicateClusterError';
70
+ this.code = 'DUPLICATE_CLUSTER';
71
+ this.issueNumber = issueNumber;
72
+ this.existingClusterId = existingClusterId;
73
+ this.existingState = existingState;
74
+ this.existingPid = existingPid;
75
+ this.ageMinutes = ageMinutes;
76
+ }
77
+ }
78
+
59
79
  function applyModelOverride(agentConfig, modelOverride) {
60
80
  if (!modelOverride) return;
61
81
 
@@ -194,19 +214,16 @@ function applyPushBlockedRepairTriggers(config) {
194
214
  }
195
215
 
196
216
  function buildPrOptions(options, requiredQualityGates) {
197
- if (
198
- !options.prBase &&
199
- !options.mergeQueue &&
200
- !options.closeIssue &&
201
- requiredQualityGates.length === 0
202
- ) {
203
- return null;
204
- }
217
+ // autoMerge must always be persisted (even when no other PR fields are set) so that
218
+ // `zeroshot run --pr` (autoMerge=false) vs `--ship` (autoMerge=true) survives resume.
219
+ // Derived from the canonical run plan — never recomputed from ship/pr here.
220
+ const autoMerge = resolveRunPlan(options).autoMerge;
205
221
 
206
222
  return {
207
223
  prBase: options.prBase || null,
208
224
  mergeQueue: options.mergeQueue || false,
209
225
  closeIssue: options.closeIssue || null,
226
+ autoMerge,
210
227
  ...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}),
211
228
  cwd: options.cwd || process.cwd(),
212
229
  };
@@ -216,6 +233,11 @@ class Orchestrator {
216
233
  constructor(options = {}) {
217
234
  this.clusters = new Map(); // cluster_id -> cluster object
218
235
  this.quiet = options.quiet || false; // Suppress verbose logging
236
+ // Read-only mode: opens ledgers without write access and skips schema DDL/
237
+ // side-effecting bootstrap (state snapshotter). Used by CLI read commands
238
+ // (list/status/logs) so they can never contend with a live daemon's writer
239
+ // connection or mutate shared state as a side effect of a plain read.
240
+ this.readonly = options.readonly === true;
219
241
 
220
242
  // TaskRunner DI - allows injecting MockTaskRunner for testing
221
243
  // When set, passed to all AgentWrappers to control task execution
@@ -336,14 +358,12 @@ class Orchestrator {
336
358
  },
337
359
  });
338
360
 
339
- const data = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
361
+ const data = readClustersFileSync(this.storageDir);
340
362
  const clusterIds = Object.keys(data);
341
363
  this._log(`[Orchestrator] Found ${clusterIds.length} clusters in file:`, clusterIds);
342
364
 
343
- // Track clusters to remove (missing .db files or 0 messages)
365
+ // Track clusters to remove (missing .db files)
344
366
  const clustersToRemove = [];
345
- // Track clusters with 0 messages (corrupted from SIGINT race condition)
346
- const corruptedClusters = [];
347
367
 
348
368
  for (const [clusterId, clusterData] of Object.entries(data)) {
349
369
  if (clusterData?.state === 'setup' || clusterData?.provisional === true) {
@@ -362,58 +382,29 @@ class Orchestrator {
362
382
  }
363
383
 
364
384
  this._log(`[Orchestrator] Loading cluster: ${clusterId}`);
365
- let cluster;
366
385
  try {
367
- cluster = this._loadSingleCluster(clusterId, clusterData);
386
+ this._loadSingleCluster(clusterId, clusterData);
368
387
  } catch (error) {
369
388
  console.warn(
370
389
  `[Orchestrator] Skipping cluster ${clusterId}: ${error.message || String(error)}`
371
390
  );
372
391
  continue;
373
392
  }
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
393
  }
394
394
 
395
- // Clean up orphaned entries from clusters.json
396
- if (clustersToRemove.length > 0) {
395
+ // Clean up orphaned entries from clusters.json. This is a write side effect,
396
+ // so it must never run for read-only (list/status/logs) instances - only the
397
+ // writable orchestrator that owns the registry performs cleanup.
398
+ if (!this.readonly && clustersToRemove.length > 0) {
397
399
  for (const clusterId of clustersToRemove) {
398
400
  delete data[clusterId];
399
401
  }
400
- fs.writeFileSync(clustersFile, JSON.stringify(data, null, 2));
402
+ writeClustersFileAtomic(this.storageDir, data);
401
403
  this._log(
402
404
  `[Orchestrator] Removed ${clustersToRemove.length} orphaned cluster(s) from registry`
403
405
  );
404
406
  }
405
407
 
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
408
  this._log(`[Orchestrator] Total clusters loaded: ${this.clusters.size}`);
418
409
  } catch (error) {
419
410
  console.error('[Orchestrator] Failed to load clusters:', error.message);
@@ -462,6 +453,10 @@ class Orchestrator {
462
453
  getTokensByRole: () => ({
463
454
  _total: { count: 0, inputTokens: 0, outputTokens: 0, totalCostUsd: 0 },
464
455
  }),
456
+ readSnapshot: () => ({
457
+ messageCount: 0,
458
+ tokensByRole: { _total: { count: 0, inputTokens: 0, outputTokens: 0, totalCostUsd: 0 } },
459
+ }),
465
460
  pollForMessages: () => noop,
466
461
  on: noop,
467
462
  off: noop,
@@ -488,6 +483,7 @@ class Orchestrator {
488
483
  since: (params) => ledger.since(params),
489
484
  getAll: (clusterId) => ledger.getAll(clusterId),
490
485
  getTokensByRole: (clusterId) => ledger.getTokensByRole(clusterId),
486
+ readSnapshot: (clusterId) => ledger.readSnapshot(clusterId),
491
487
  addWebSocketClient: noop,
492
488
  removeWebSocketClient: noop,
493
489
  on: noop,
@@ -527,9 +523,11 @@ class Orchestrator {
527
523
  return this.clusters.get(clusterId);
528
524
  }
529
525
 
530
- // Restore ledger and message bus
526
+ // Restore ledger and message bus. Read-only orchestrators (CLI list/status/logs)
527
+ // open the ledger without write access so they can never contend with the live
528
+ // daemon's writer connection or take a write lock on another process's database.
531
529
  const dbPath = path.join(this.storageDir, `${clusterId}.db`);
532
- const ledger = new Ledger(dbPath);
530
+ const ledger = new Ledger(dbPath, { readonly: this.readonly });
533
531
  const messageBus = new MessageBus(ledger);
534
532
 
535
533
  // Restore isolation manager FIRST if cluster was running in isolation mode
@@ -573,7 +571,18 @@ class Orchestrator {
573
571
  Object.assign(clusterContext, cluster);
574
572
 
575
573
  this.clusters.set(clusterId, clusterContext);
576
- this._startSnapshotter(clusterContext);
574
+ // The snapshotter bootstraps by publishing a STATE_SNAPSHOT message if one is
575
+ // missing - a ledger write. Read-only orchestrators must never write, so they
576
+ // skip it; they only need to read existing snapshots, not produce new ones.
577
+ if (!this.readonly) {
578
+ this._registerClusterSubscriptions({
579
+ messageBus,
580
+ clusterId,
581
+ isolationManager,
582
+ containerId: isolation?.containerId || null,
583
+ });
584
+ this._startSnapshotter(clusterContext);
585
+ }
577
586
  this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`);
578
587
 
579
588
  return clusterContext;
@@ -720,7 +729,7 @@ class Orchestrator {
720
729
 
721
730
  const clustersFile = path.join(this.storageDir, 'clusters.json');
722
731
  if (!fs.existsSync(clustersFile)) {
723
- fs.writeFileSync(clustersFile, '{}');
732
+ writeClustersFileAtomic(this.storageDir, {});
724
733
  }
725
734
  return clustersFile;
726
735
  }
@@ -781,6 +790,11 @@ class Orchestrator {
781
790
  return;
782
791
  }
783
792
 
793
+ // Read-only orchestrators (CLI list/status/logs) must never write the registry.
794
+ if (this.readonly) {
795
+ return;
796
+ }
797
+
784
798
  const clustersFile = this._ensureClustersFile();
785
799
  if (!clustersFile) {
786
800
  return;
@@ -807,8 +821,7 @@ class Orchestrator {
807
821
  // Read existing clusters from file (other processes may have added clusters)
808
822
  let existingClusters = {};
809
823
  try {
810
- const content = fs.readFileSync(clustersFile, 'utf8');
811
- existingClusters = JSON.parse(content);
824
+ existingClusters = readClustersFileSync(this.storageDir);
812
825
  } catch (error) {
813
826
  console.error('[Orchestrator] Failed to read existing clusters:', error.message);
814
827
  }
@@ -845,6 +858,8 @@ class Orchestrator {
845
858
  failureInfo: cluster.failureInfo || null,
846
859
  // Persist PR mode for completion agent selection
847
860
  autoPr: cluster.autoPr || false,
861
+ // Persist normalized run mode for status/list display
862
+ runMode: cluster.runMode || null,
848
863
  // Persist PR options for resume
849
864
  prOptions: cluster.prOptions || null,
850
865
  // Persist cluster-scoped command proof configuration for resume and dynamic agents
@@ -880,8 +895,9 @@ class Orchestrator {
880
895
  };
881
896
  }
882
897
 
883
- // Write merged data
884
- fs.writeFileSync(clustersFile, JSON.stringify(existingClusters, null, 2));
898
+ // Write merged data atomically (temp file + rename) so no reader can ever
899
+ // observe a partially-written file.
900
+ writeClustersFileAtomic(this.storageDir, existingClusters);
885
901
  this._log(
886
902
  `[Orchestrator] Saved ${this.clusters.size} cluster(s), file now has ${Object.keys(existingClusters).length} total`
887
903
  );
@@ -933,7 +949,7 @@ class Orchestrator {
933
949
  throw lockErr;
934
950
  }
935
951
 
936
- const data = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
952
+ const data = readClustersFileSync(this.storageDir);
937
953
 
938
954
  for (const [clusterId, clusterData] of Object.entries(data)) {
939
955
  if (!knownClusterIds.has(clusterId)) {
@@ -1029,6 +1045,69 @@ class Orchestrator {
1029
1045
  });
1030
1046
  }
1031
1047
 
1048
+ /**
1049
+ * Resolve input (issue from provider, file, or text) and reject duplicate active
1050
+ * runs on the same issue. Deliberately allocates NOTHING (no ledger, no isolation,
1051
+ * no cluster registration) so a rejection - duplicate or otherwise - has zero side
1052
+ * effects on disk or in-memory state.
1053
+ * @private
1054
+ * @returns {Promise<{ inputData: Object, issueProviderId: string|null }>}
1055
+ */
1056
+ async _resolveClusterInput(input, options, clusterId) {
1057
+ if (input.issue) {
1058
+ const ProviderClass = detectProvider(
1059
+ input.issue,
1060
+ options.settings || {},
1061
+ options.forceProvider
1062
+ );
1063
+ if (!ProviderClass) {
1064
+ throw new Error(`No issue provider matched input: ${input.issue}`);
1065
+ }
1066
+
1067
+ const provider = new ProviderClass();
1068
+ const inputData = await provider.fetchIssue(input.issue, options.settings || {});
1069
+ const issueNumber = inputData.number || null;
1070
+
1071
+ // Check for duplicate active runs on same issue (unless --force)
1072
+ if (issueNumber && !options.force) {
1073
+ const activeClusters = this._getActiveClustersForIssue(issueNumber, clusterId);
1074
+ if (activeClusters.length > 0) {
1075
+ const existing = activeClusters[0];
1076
+ const age = Math.round((Date.now() - existing.createdAt) / 1000 / 60);
1077
+ throw new DuplicateClusterError(
1078
+ `Issue #${issueNumber} already has an active cluster:\n` +
1079
+ ` Cluster: ${existing.id} (state: ${existing.state}, running for ${age}min, pid: ${existing.pid})\n\n` +
1080
+ `Options:\n` +
1081
+ ` 1. Kill existing: zeroshot kill ${existing.id}\n` +
1082
+ ` 2. Override check: zeroshot run ${input.issue} --force\n` +
1083
+ ` 3. View status: zeroshot status ${existing.id}`,
1084
+ {
1085
+ issueNumber,
1086
+ existingClusterId: existing.id,
1087
+ existingState: existing.state,
1088
+ existingPid: existing.pid,
1089
+ ageMinutes: age,
1090
+ }
1091
+ );
1092
+ }
1093
+ }
1094
+
1095
+ // Log clickable issue link
1096
+ if (inputData.url) {
1097
+ this._log(`[Orchestrator] Issue (${ProviderClass.displayName}): ${inputData.url}`);
1098
+ }
1099
+
1100
+ return { inputData, issueProviderId: ProviderClass.id };
1101
+ } else if (input.file) {
1102
+ this._log(`[Orchestrator] File: ${input.file}`);
1103
+ return { inputData: InputHelpers.createFileInput(input.file), issueProviderId: null };
1104
+ } else if (input.text) {
1105
+ return { inputData: InputHelpers.createTextInput(input.text), issueProviderId: null };
1106
+ }
1107
+
1108
+ throw new Error('Either issue, file, or text input is required');
1109
+ }
1110
+
1032
1111
  /**
1033
1112
  * Internal start implementation (shared by start and startWithMock)
1034
1113
  * @private
@@ -1044,6 +1123,15 @@ class Orchestrator {
1044
1123
  config?.dbPath || null
1045
1124
  );
1046
1125
 
1126
+ // Resolve input (issue/file/text) and reject duplicate active runs on the same issue
1127
+ // BEFORE allocating any resources. A duplicate-run rejection must have zero side
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
+ );
1134
+
1047
1135
  // Create ledger and message bus with persistent storage
1048
1136
  const dbPath = config.dbPath || path.join(this.storageDir, `${clusterId}.db`);
1049
1137
  const ledger = new Ledger(dbPath);
@@ -1077,10 +1165,13 @@ class Orchestrator {
1077
1165
  createdAt: Date.now(),
1078
1166
  // Track PID for zombie detection (this process owns the cluster)
1079
1167
  pid: process.pid,
1168
+ // Issue number for heroshot/external tools (avoids log parsing)
1169
+ issue: inputData.number || null,
1080
1170
  // Initialization completion tracking (for safe SIGINT handling)
1081
1171
  initCompletePromise,
1082
1172
  _resolveInitComplete: resolveInitComplete,
1083
1173
  autoPr: options.autoPr || false,
1174
+ runMode: options.runMode || null,
1084
1175
  requiredQualityGates,
1085
1176
  commandProofs,
1086
1177
  // PR configuration options (persisted for resume)
@@ -1088,7 +1179,7 @@ class Orchestrator {
1088
1179
  // Model override for all agents (applied to dynamically added agents)
1089
1180
  modelOverride: options.modelOverride || null,
1090
1181
  // Issue provider tracking (where issue was fetched from)
1091
- issueProvider: null, // Set after fetching issue (github, gitlab, jira, azure-devops)
1182
+ issueProvider: issueProviderId, // null unless input.issue (github, gitlab, jira, azure-devops)
1092
1183
  // Git platform tracking (where PR/MR will be created - independent of issue provider)
1093
1184
  gitPlatform: null, // Set when --pr mode is active
1094
1185
  // Isolation state (only if enabled)
@@ -1126,63 +1217,8 @@ class Orchestrator {
1126
1217
  });
1127
1218
 
1128
1219
  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
-
1220
+ // Input (issue/file/text) was already resolved and duplicate-checked in
1221
+ // _resolveClusterInput() before any resource was allocated (see above).
1186
1222
  commandProofs = mergeCommandProofs(
1187
1223
  commandProofs,
1188
1224
  resolveClusterCommandProofs(config, options, inputData.context)
@@ -1793,6 +1829,7 @@ class Orchestrator {
1793
1829
  mergeQueue: options.mergeQueue,
1794
1830
  closeIssue: options.closeIssue,
1795
1831
  requiredQualityGates: options.requiredQualityGates,
1832
+ autoMerge: resolveRunPlan(options).autoMerge,
1796
1833
  cwd: options.cwd,
1797
1834
  });
1798
1835
 
@@ -1908,22 +1945,29 @@ class Orchestrator {
1908
1945
  * @private
1909
1946
  */
1910
1947
  _teardownWorktreeCompose(worktreePath) {
1911
- const composePath = path.join(worktreePath, 'docker-compose.yml');
1912
- if (!fs.existsSync(composePath)) return;
1948
+ // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
1949
+ // Compose project — only a project scoped to the worktree directory basename, which is
1950
+ // the only kind zeroshot could itself have created, is touched.
1951
+ const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
1952
+ const teardown = resolveWorktreeComposeTeardown(worktreePath);
1953
+ if (!teardown.shouldTeardown) {
1954
+ if (teardown.composePath) {
1955
+ this._log(
1956
+ `[Orchestrator] Skipping Docker Compose teardown in ${worktreePath}: ${teardown.reason}`
1957
+ );
1958
+ }
1959
+ return;
1960
+ }
1913
1961
 
1914
1962
  try {
1915
1963
  this._log(`[Orchestrator] Tearing down Docker Compose services in ${worktreePath}...`);
1916
1964
  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
- );
1965
+ const result = spawnSync('docker', teardown.args, {
1966
+ cwd: worktreePath,
1967
+ encoding: 'utf8',
1968
+ stdio: 'pipe',
1969
+ timeout: 30000,
1970
+ });
1927
1971
  if (result.status !== 0 || result.error) {
1928
1972
  const detail = result.error?.message || result.stderr || 'no stderr';
1929
1973
  throw new Error(`Docker Compose teardown failed in ${worktreePath}: ${detail}`);
@@ -2276,6 +2320,9 @@ class Orchestrator {
2276
2320
  * Call before deleting storageDir to prevent ENOENT race conditions during cleanup
2277
2321
  */
2278
2322
  close() {
2323
+ if (this.closed) {
2324
+ return;
2325
+ }
2279
2326
  this.closed = true;
2280
2327
  }
2281
2328
 
@@ -3583,6 +3630,24 @@ Continue from where you left off. Review your previous output to understand what
3583
3630
  }
3584
3631
  }
3585
3632
 
3633
+ /**
3634
+ * Read messageCount for a cluster without fabricating a value on failure.
3635
+ * A confirmed zero (ledger read succeeded, cluster has no messages) and a
3636
+ * failed read (ledger unavailable) must stay distinguishable - returning 0
3637
+ * for both is what produced the phantom "failed/msgs=0" state in list/status.
3638
+ * @private
3639
+ */
3640
+ _readMessageCount(cluster, clusterId) {
3641
+ try {
3642
+ return cluster.messageBus.readSnapshot(clusterId).messageCount;
3643
+ } catch (error) {
3644
+ console.error(
3645
+ `[Orchestrator] Failed to read message count for cluster ${clusterId}: ${error.message || String(error)}`
3646
+ );
3647
+ return null;
3648
+ }
3649
+ }
3650
+
3586
3651
  /**
3587
3652
  * Get cluster status
3588
3653
  * @param {String} clusterId - Cluster ID
@@ -3625,18 +3690,11 @@ Continue from where you left off. Review your previous output to understand what
3625
3690
  pid: cluster.pid || null,
3626
3691
  createdAt: cluster.createdAt,
3627
3692
  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
- })(),
3693
+ messageCount: this._readMessageCount(cluster, clusterId),
3637
3694
  setupLogPath: cluster.setupLogPath || null,
3638
3695
  setupStage: cluster.setupStage || null,
3639
3696
  failureInfo: cluster.failureInfo || null,
3697
+ runMode: cluster.runMode || null,
3640
3698
  };
3641
3699
  }
3642
3700
 
@@ -3665,19 +3723,12 @@ Continue from where you left off. Review your previous output to understand what
3665
3723
  createdAt: cluster.createdAt,
3666
3724
  issue: cluster.issue || null,
3667
3725
  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
- })(),
3726
+ messageCount: this._readMessageCount(cluster, cluster.id),
3677
3727
  pid: cluster.pid || null,
3678
3728
  setupLogPath: cluster.setupLogPath || null,
3679
3729
  setupStage: cluster.setupStage || null,
3680
3730
  failureInfo: cluster.failureInfo || null,
3731
+ runMode: cluster.runMode || null,
3681
3732
  };
3682
3733
  });
3683
3734
  }
@@ -3984,6 +4035,82 @@ Continue from where you left off. Review your previous output to understand what
3984
4035
  dryRun: options.dryRun || false,
3985
4036
  });
3986
4037
  }
4038
+
4039
+ /**
4040
+ * Explicitly detect clusters corrupted by SIGINT during initialization
4041
+ * (state='running' but the ledger never received its first message).
4042
+ *
4043
+ * This replaces the old unconditional 0-message check that used to run inside
4044
+ * _loadClusters() on every list/status call: that check mutated cluster.state
4045
+ * as a side effect of a plain read, and a single racy count() (e.g. against a
4046
+ * writer's just-opened connection) was enough to misclassify a healthy running
4047
+ * cluster as corrupted. This method is only ever invoked explicitly (from the
4048
+ * `gc` CLI flow), never from getStatus()/listClusters(), and requires:
4049
+ * - the cluster has been running longer than `graceMs` (default 60s), so a
4050
+ * brand-new cluster whose first message hasn't landed yet isn't flagged, and
4051
+ * - two independent reads 250ms apart both observe 0 messages, so a single
4052
+ * transient/racy read can't trigger a false positive.
4053
+ *
4054
+ * @param {object} [options]
4055
+ * @param {number} [options.graceMs=60000]
4056
+ * @returns {Promise<string[]>} IDs newly marked corrupted
4057
+ */
4058
+ async detectCorruptedClusters(options = {}) {
4059
+ const graceMs = typeof options.graceMs === 'number' ? options.graceMs : 60000;
4060
+ const now = Date.now();
4061
+ const corrupted = [];
4062
+
4063
+ for (const cluster of this.clusters.values()) {
4064
+ if (this._isSetupCluster(cluster)) continue;
4065
+ if (cluster.state !== 'running') continue;
4066
+ if (!cluster.createdAt || now - cluster.createdAt < graceMs) continue;
4067
+ if (!cluster.messageBus) continue;
4068
+
4069
+ let firstCount;
4070
+ try {
4071
+ firstCount = cluster.messageBus.count({ cluster_id: cluster.id });
4072
+ } catch (error) {
4073
+ console.warn(
4074
+ `[Orchestrator] Skipping corruption check for ${cluster.id}: ${error.message || String(error)}`
4075
+ );
4076
+ continue;
4077
+ }
4078
+ if (firstCount !== 0) continue;
4079
+
4080
+ await new Promise((resolve) => setTimeout(resolve, 250));
4081
+
4082
+ let secondCount;
4083
+ try {
4084
+ secondCount = cluster.messageBus.count({ cluster_id: cluster.id });
4085
+ } catch (error) {
4086
+ console.warn(
4087
+ `[Orchestrator] Skipping corruption check for ${cluster.id}: ${error.message || String(error)}`
4088
+ );
4089
+ continue;
4090
+ }
4091
+ if (secondCount !== 0) continue;
4092
+
4093
+ console.warn(`[Orchestrator] ⚠️ Cluster ${cluster.id} has 0 messages (corrupted)`);
4094
+ console.warn(`[Orchestrator] This likely occurred from SIGINT during initialization.`);
4095
+ console.warn(
4096
+ `[Orchestrator] Marking as 'corrupted' - use 'zeroshot kill ${cluster.id}' to remove.`
4097
+ );
4098
+ cluster.state = 'corrupted';
4099
+ cluster.corruptedReason = 'SIGINT during initialization (0 messages in ledger)';
4100
+ corrupted.push(cluster.id);
4101
+ }
4102
+
4103
+ if (corrupted.length > 0) {
4104
+ await this._saveClusters();
4105
+ }
4106
+
4107
+ return corrupted;
4108
+ }
3987
4109
  }
3988
4110
 
4111
+ // Exported for testing (PR options persistence, e.g. autoMerge for --pr vs --ship).
4112
+ Orchestrator.buildPrOptions = buildPrOptions;
4113
+ Orchestrator.resolveRunPlan = resolveRunPlan;
4114
+
3989
4115
  module.exports = Orchestrator;
4116
+ module.exports.DuplicateClusterError = DuplicateClusterError;