@the-open-engine/zeroshot 6.2.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.
- package/README.md +141 -474
- package/cli/event-copy.js +12 -0
- package/cli/index.js +311 -96
- package/cli/message-formatters-normal.js +14 -2
- package/cli/message-formatters-watch.js +9 -2
- package/cluster-hooks/block-ask-user-question.py +53 -0
- package/cluster-hooks/block-dangerous-git.py +145 -0
- package/lib/clusters-registry.js +48 -0
- package/lib/compose-utils.js +101 -0
- package/lib/detached-startup.js +9 -0
- package/lib/id-detector.js +8 -9
- package/lib/path-check.js +63 -0
- package/lib/run-mode.js +22 -0
- package/lib/start-cluster.js +39 -23
- package/package.json +3 -4
- package/scripts/check-path.js +19 -0
- package/scripts/validate-templates.js +11 -29
- package/src/agent/agent-hook-executor.js +1 -1
- package/src/agent/agent-task-executor.js +4 -3
- package/src/agent/pr-verification.js +27 -1
- package/src/agents/git-pusher-template.js +121 -4
- package/src/attach/socket-discovery.js +2 -3
- package/src/claude-credentials.js +75 -0
- package/src/claude-task-runner.js +1 -0
- package/src/isolation-manager.js +12 -9
- package/src/issue-providers/README.md +45 -15
- package/src/issue-providers/index.js +2 -0
- package/src/issue-providers/jira-provider.js +8 -1
- package/src/issue-providers/linear-provider.js +405 -0
- package/src/ledger.js +42 -3
- package/src/lib/gc.js +2 -3
- package/src/message-bus.js +10 -0
- package/src/orchestrator.js +264 -144
- package/src/preflight.js +12 -16
- package/src/providers/base-provider.js +7 -6
- package/src/status-footer.js +13 -1
- package/src/template-validation/index.js +3 -0
- package/src/template-validation/report-formatter.js +55 -0
- package/src/worktree-claude-config.js +2 -13
- package/task-lib/runner.js +1 -0
- package/task-lib/watcher.js +1 -0
package/src/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
//
|
|
1130
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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;
|