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