@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.
- package/README.md +141 -474
- package/cli/event-copy.js +12 -0
- package/cli/index.js +314 -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 +12 -1
- package/lib/id-detector.js +8 -9
- package/lib/path-check.js +63 -0
- package/lib/run-mode.js +34 -0
- package/lib/run-plan.js +32 -0
- package/lib/start-cluster.js +58 -28
- package/package.json +7 -6
- 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-lifecycle.js +2 -0
- 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 +45 -3
- package/src/lib/gc.js +2 -3
- package/src/message-bus.js +22 -2
- package/src/orchestrator.js +271 -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');
|
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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;
|