@the-open-engine/zeroshot 6.5.0 → 6.7.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/bin/zeroshot-cluster-worker.js +16 -0
- package/cli/index.js +135 -12
- package/docs/openengine-cluster-protocol/v1/legacy-worker.md +117 -0
- 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/start-cluster.js +93 -18
- package/package.json +10 -2
- package/protocol/openengine-cluster/v1/worker.schema.json +1174 -0
- package/scripts/assert-release-published.js +107 -0
- package/scripts/release-preflight.js +236 -0
- package/src/orchestrator.js +464 -61
package/lib/detached-startup.js
CHANGED
|
@@ -3,10 +3,12 @@ const os = require('os');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const lockfile = require('proper-lockfile');
|
|
5
5
|
const { resolveRunPlan } = require('./run-plan');
|
|
6
|
+
const { isProcessRunning } = require('./process-liveness');
|
|
6
7
|
|
|
7
8
|
const DEFAULT_WAIT_TIMEOUT_SECONDS = 180;
|
|
8
9
|
const DEFAULT_WAIT_POLL_MS = 1000;
|
|
9
10
|
const CLUSTERS_LOCK_STALE_MS = 5000;
|
|
11
|
+
const DEFAULT_OWNERSHIP_TIMEOUT_MS = 10000;
|
|
10
12
|
|
|
11
13
|
function getStorageDir(storageDir) {
|
|
12
14
|
return storageDir || path.join(os.homedir(), '.zeroshot');
|
|
@@ -24,17 +26,8 @@ function resolveWaitTimeoutMs(waitTimeoutSeconds) {
|
|
|
24
26
|
return Math.floor(parsed * 1000);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
try {
|
|
32
|
-
process.kill(pid, 0);
|
|
33
|
-
return true;
|
|
34
|
-
} catch (error) {
|
|
35
|
-
return error && error.code === 'EPERM';
|
|
36
|
-
}
|
|
37
|
-
}
|
|
29
|
+
// Kept as an alias: this module used to carry its own copy of this check.
|
|
30
|
+
const isProcessAlive = isProcessRunning;
|
|
38
31
|
|
|
39
32
|
function isClusterRegistered(clusterId, storageDir) {
|
|
40
33
|
const clustersFile = getClustersFilePath(storageDir);
|
|
@@ -218,14 +211,137 @@ async function waitForClusterRegistration({
|
|
|
218
211
|
);
|
|
219
212
|
}
|
|
220
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Atomically claim the right to resume-daemon an *existing* cluster record.
|
|
216
|
+
*
|
|
217
|
+
* This deliberately does NOT touch cluster.pid/state - those are the fields
|
|
218
|
+
* orchestrator.resume()'s own eligibility guard inspects to decide "is
|
|
219
|
+
* someone else already running this?". If this function stamped its PID onto
|
|
220
|
+
* `cluster.pid` up front, the daemon's own subsequent resume() call would see
|
|
221
|
+
* state:'running' with a PID that (trivially) belongs to itself and is
|
|
222
|
+
* therefore alive, and reject itself as "still running" - which is exactly
|
|
223
|
+
* what happened in early revisions of this fix. Ownership for the handoff is
|
|
224
|
+
* tracked in a separate `resumeDaemonPid` field instead; orchestrator.resume()
|
|
225
|
+
* never looks at it, and _restartClusterAgents sets the real cluster.pid once
|
|
226
|
+
* resume() actually decides to proceed.
|
|
227
|
+
*
|
|
228
|
+
* Unlike registerDetachedSetupCluster (which writes a brand-new placeholder
|
|
229
|
+
* record for a cluster ID that doesn't exist yet), this only adds
|
|
230
|
+
* resumeDaemonPid to the existing record - config/agents/worktree/isolation
|
|
231
|
+
* survive untouched, or the daemon's orchestrator.resume() would reload a
|
|
232
|
+
* blank record and reject it as an unfinished setup cluster.
|
|
233
|
+
*
|
|
234
|
+
* The liveness re-check happens inside the same updateClustersFile lock that
|
|
235
|
+
* performs the write, so two concurrent resume --detach invocations can't both
|
|
236
|
+
* observe no live claimant and both win: whichever's write lands second sees
|
|
237
|
+
* either the first daemon's (still-live) resumeDaemonPid, or - once that gets
|
|
238
|
+
* overwritten by the first daemon's own early _saveClusters() call - its real
|
|
239
|
+
* state:'running' + live pid, and aborts instead of clobbering it.
|
|
240
|
+
*/
|
|
241
|
+
async function patchDetachedResumeCluster({ clusterId, daemonPid, storageDir }) {
|
|
242
|
+
await updateClustersFile(storageDir, (clusters) => {
|
|
243
|
+
const existing = clusters[clusterId];
|
|
244
|
+
if (!existing) {
|
|
245
|
+
throw new Error(`Cannot start resume daemon: cluster "${clusterId}" not found in registry`);
|
|
246
|
+
}
|
|
247
|
+
if (existing.resumeDaemonPid && isProcessRunning(existing.resumeDaemonPid)) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Cluster "${clusterId}" already has a live resume daemon (PID ${existing.resumeDaemonPid}); refusing to start a second one`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
// resumeDaemonPid only covers the window up to orchestrator.resume()
|
|
253
|
+
// actually proceeding - _restartClusterAgents sets the real cluster.pid,
|
|
254
|
+
// and _resumeFailedCluster/_resumeCleanCluster save that almost
|
|
255
|
+
// immediately (well before the resumed work itself finishes), which wipes
|
|
256
|
+
// resumeDaemonPid since it isn't in _saveClusters' persisted field list.
|
|
257
|
+
// Falling back to state+pid here closes that second window: a first
|
|
258
|
+
// daemon that has already progressed to actually running is just as much
|
|
259
|
+
// a live claimant as one still mid-handoff.
|
|
260
|
+
if (existing.state === 'running' && existing.pid && isProcessRunning(existing.pid)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Cluster "${clusterId}" is already running (PID ${existing.pid}); refusing to start a second resume daemon`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
clusters[clusterId] = { ...existing, resumeDaemonPid: daemonPid };
|
|
266
|
+
return clusters;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Undo a resume handoff that didn't pan out (daemon died before completing
|
|
272
|
+
* ownership, or orchestrator.resume() itself rejected it) so the cluster
|
|
273
|
+
* doesn't end up stuck at state:'running' with a dead PID - the exact zombie
|
|
274
|
+
* this whole mechanism exists to prevent.
|
|
275
|
+
*/
|
|
276
|
+
async function revertDetachedResumeCluster({ clusterId, storageDir, error }) {
|
|
277
|
+
await updateClustersFile(storageDir, (clusters) => {
|
|
278
|
+
const existing = clusters[clusterId];
|
|
279
|
+
if (!existing) return clusters;
|
|
280
|
+
clusters[clusterId] = {
|
|
281
|
+
...existing,
|
|
282
|
+
state: 'failed',
|
|
283
|
+
pid: null,
|
|
284
|
+
resumeDaemonPid: null,
|
|
285
|
+
failureInfo: {
|
|
286
|
+
type: 'resume-daemon',
|
|
287
|
+
error: error instanceof Error ? error.message : String(error),
|
|
288
|
+
timestamp: Date.now(),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
return clusters;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function getRegisteredResumeDaemonPid(clusterId, storageDir) {
|
|
296
|
+
const clustersFile = getClustersFilePath(storageDir);
|
|
297
|
+
if (!fs.existsSync(clustersFile)) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
|
|
302
|
+
return parsed?.[clusterId]?.resumeDaemonPid ?? null;
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Daemon-side half of the resume handoff: block until the registry shows
|
|
310
|
+
* *this process's* PID as resumeDaemonPid before touching the cluster.
|
|
311
|
+
* Closes the window between spawn() returning a PID and the parent's
|
|
312
|
+
* patchDetachedResumeCluster() write landing - without it, a losing daemon
|
|
313
|
+
* from a concurrent resume --detach could start running orchestrator.resume()
|
|
314
|
+
* before its parent's CAS check ever gets a chance to reject it.
|
|
315
|
+
*/
|
|
316
|
+
async function waitForResumeOwnership({
|
|
317
|
+
clusterId,
|
|
318
|
+
daemonPid,
|
|
319
|
+
storageDir,
|
|
320
|
+
timeoutMs = DEFAULT_OWNERSHIP_TIMEOUT_MS,
|
|
321
|
+
pollMs = DEFAULT_WAIT_POLL_MS,
|
|
322
|
+
}) {
|
|
323
|
+
const deadline = Date.now() + timeoutMs;
|
|
324
|
+
while (Date.now() < deadline) {
|
|
325
|
+
if (getRegisteredResumeDaemonPid(clusterId, storageDir) === daemonPid) {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
await sleep(pollMs);
|
|
329
|
+
}
|
|
330
|
+
return getRegisteredResumeDaemonPid(clusterId, storageDir) === daemonPid;
|
|
331
|
+
}
|
|
332
|
+
|
|
221
333
|
module.exports = {
|
|
222
334
|
DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
223
335
|
getClustersFilePath,
|
|
336
|
+
getRegisteredResumeDaemonPid,
|
|
224
337
|
isClusterRegistered,
|
|
225
338
|
isProcessAlive,
|
|
226
339
|
markDetachedSetupFailed,
|
|
340
|
+
patchDetachedResumeCluster,
|
|
227
341
|
registerDetachedSetupCluster,
|
|
228
342
|
removeDetachedSetupCluster,
|
|
229
343
|
resolveWaitTimeoutMs,
|
|
344
|
+
revertDetachedResumeCluster,
|
|
230
345
|
waitForClusterRegistration,
|
|
346
|
+
waitForResumeOwnership,
|
|
231
347
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for "is this PID actually alive?"
|
|
3
|
+
*
|
|
4
|
+
* Previously this check was reimplemented separately in src/orchestrator.js
|
|
5
|
+
* (_isProcessRunning) and lib/detached-startup.js (isProcessAlive), plus an
|
|
6
|
+
* inline copy in _getActiveClustersForIssue. Four of those call sites agreed
|
|
7
|
+
* on the answer; the resume() eligibility guard trusted persisted cluster
|
|
8
|
+
* state instead of asking the OS, which is what let a dead-PID cluster be
|
|
9
|
+
* simultaneously reported as "zombie" by status and "still running" by
|
|
10
|
+
* resume. Consolidating avoids a sixth call site reintroducing that split.
|
|
11
|
+
*/
|
|
12
|
+
function isProcessRunning(pid) {
|
|
13
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
// Signal 0 doesn't kill, just checks if the process exists.
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// ESRCH = no such process, EPERM = process exists but we lack permission.
|
|
22
|
+
return Boolean(error && error.code === 'EPERM');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { isProcessRunning };
|
package/lib/start-cluster.js
CHANGED
|
@@ -185,13 +185,17 @@ function resolveParameterizedConfigFile(config) {
|
|
|
185
185
|
return resolver.resolveTemplate(config, {});
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
function
|
|
189
|
-
const
|
|
190
|
-
ensureConfigProviderDefaults(
|
|
188
|
+
function prepareClusterConfig(config, settings = {}, providerOverride) {
|
|
189
|
+
const prepared = resolveParameterizedConfigFile(config);
|
|
190
|
+
ensureConfigProviderDefaults(prepared, settings);
|
|
191
191
|
if (providerOverride) {
|
|
192
|
-
applyProviderOverrideToConfig(
|
|
192
|
+
applyProviderOverrideToConfig(prepared, providerOverride, settings);
|
|
193
193
|
}
|
|
194
|
-
return
|
|
194
|
+
return prepared;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loadClusterConfig(orchestrator, configPath, settings = {}, providerOverride) {
|
|
198
|
+
return prepareClusterConfig(orchestrator.loadConfig(configPath), settings, providerOverride);
|
|
195
199
|
}
|
|
196
200
|
|
|
197
201
|
function mergeRunOptions(options) {
|
|
@@ -252,29 +256,98 @@ function buildStartOptions({
|
|
|
252
256
|
}) {
|
|
253
257
|
const mergedOptions = mergeRunOptions(options);
|
|
254
258
|
const plan = resolveEffectiveRunPlan(mergedOptions, settings);
|
|
255
|
-
return {
|
|
259
|
+
return buildStartOptionsFromPlan({
|
|
256
260
|
clusterId,
|
|
257
|
-
|
|
261
|
+
plan,
|
|
262
|
+
options: mergedOptions,
|
|
263
|
+
settings,
|
|
264
|
+
providerOverride,
|
|
265
|
+
modelOverride,
|
|
266
|
+
forceProvider,
|
|
267
|
+
environment: true,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildStartOptionsFromPlan({
|
|
272
|
+
clusterId,
|
|
273
|
+
plan,
|
|
274
|
+
options,
|
|
275
|
+
settings,
|
|
276
|
+
providerOverride,
|
|
277
|
+
modelOverride,
|
|
278
|
+
forceProvider,
|
|
279
|
+
environment,
|
|
280
|
+
}) {
|
|
281
|
+
const targetCwd = environment ? resolveTargetCwd() : options.cwd;
|
|
282
|
+
return Object.freeze({
|
|
283
|
+
clusterId,
|
|
284
|
+
cwd: targetCwd,
|
|
258
285
|
isolation: plan.isolation === 'docker',
|
|
259
|
-
isolationImage:
|
|
286
|
+
isolationImage: environment
|
|
287
|
+
? firstTruthy(options.dockerImage, process.env.ZEROSHOT_DOCKER_IMAGE)
|
|
288
|
+
: optionalValue(options.dockerImage),
|
|
260
289
|
worktree: plan.isolation === 'worktree',
|
|
261
290
|
autoPr: plan.delivery !== 'none',
|
|
262
291
|
autoMerge: plan.autoMerge,
|
|
263
|
-
autoPush: process.env.ZEROSHOT_PUSH === '1',
|
|
292
|
+
autoPush: environment ? process.env.ZEROSHOT_PUSH === '1' : options.autoPush === true,
|
|
264
293
|
modelOverride: optionalValue(modelOverride),
|
|
265
294
|
providerOverride: optionalValue(providerOverride),
|
|
266
|
-
noMounts: anyTruthy(
|
|
267
|
-
mounts: resolveMounts(
|
|
268
|
-
containerHome: optionalValue(
|
|
295
|
+
noMounts: anyTruthy(options.mounts === false, options.noMounts === true),
|
|
296
|
+
mounts: resolveMounts(options),
|
|
297
|
+
containerHome: optionalValue(options.containerHome),
|
|
269
298
|
forceProvider: optionalValue(forceProvider),
|
|
270
|
-
prBase: resolvePrBase(
|
|
271
|
-
mergeQueue: resolveMergeQueue(
|
|
272
|
-
closeIssue: resolveCloseIssue(
|
|
273
|
-
ship:
|
|
299
|
+
prBase: environment ? resolvePrBase(options) : optionalValue(options.prBase),
|
|
300
|
+
mergeQueue: environment ? resolveMergeQueue(options) : optionalValue(options.mergeQueue),
|
|
301
|
+
closeIssue: environment ? resolveCloseIssue(options) : optionalValue(options.closeIssue),
|
|
302
|
+
ship: plan.delivery === 'ship',
|
|
274
303
|
runMode: runModeFromPlan(plan),
|
|
275
|
-
requiredQualityGates:
|
|
304
|
+
requiredQualityGates: options.requiredQualityGates,
|
|
305
|
+
settings,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Build start options from a registry-owned canonical plan. Unlike the CLI
|
|
311
|
+
* builder, this function never reads ZEROSHOT_RUN_OPTIONS or mode-related
|
|
312
|
+
* environment variables. The caller must resolve and validate the deployment
|
|
313
|
+
* profile before invoking it.
|
|
314
|
+
*/
|
|
315
|
+
function buildTrustedStartOptions({
|
|
316
|
+
clusterId,
|
|
317
|
+
plan,
|
|
318
|
+
options = {},
|
|
319
|
+
settings = {},
|
|
320
|
+
providerOverride,
|
|
321
|
+
forceProvider,
|
|
322
|
+
}) {
|
|
323
|
+
if (!plan || !Object.isFrozen(plan)) {
|
|
324
|
+
throw new Error('Trusted start requires a frozen canonical run plan');
|
|
325
|
+
}
|
|
326
|
+
const canonical = resolveRunPlan({
|
|
327
|
+
docker: plan.isolation === 'docker',
|
|
328
|
+
worktree: plan.isolation === 'worktree',
|
|
329
|
+
pr: plan.delivery === 'pr',
|
|
330
|
+
ship: plan.delivery === 'ship',
|
|
331
|
+
});
|
|
332
|
+
if (
|
|
333
|
+
canonical.isolation !== plan.isolation ||
|
|
334
|
+
canonical.delivery !== plan.delivery ||
|
|
335
|
+
canonical.autoMerge !== plan.autoMerge
|
|
336
|
+
) {
|
|
337
|
+
throw new Error('Trusted start plan is not canonical');
|
|
338
|
+
}
|
|
339
|
+
if (canonical.isolation === 'none') {
|
|
340
|
+
throw new Error('Trusted worker start requires worktree or docker isolation');
|
|
341
|
+
}
|
|
342
|
+
return buildStartOptionsFromPlan({
|
|
343
|
+
clusterId,
|
|
344
|
+
plan: canonical,
|
|
345
|
+
options,
|
|
276
346
|
settings,
|
|
277
|
-
|
|
347
|
+
providerOverride,
|
|
348
|
+
forceProvider,
|
|
349
|
+
environment: false,
|
|
350
|
+
});
|
|
278
351
|
}
|
|
279
352
|
|
|
280
353
|
function resolveConfigOrThrow({
|
|
@@ -352,8 +425,10 @@ module.exports = {
|
|
|
352
425
|
decodeStdinEnv,
|
|
353
426
|
resolveProviderOverride,
|
|
354
427
|
resolveConfigPath,
|
|
428
|
+
prepareClusterConfig,
|
|
355
429
|
loadClusterConfig,
|
|
356
430
|
buildStartOptions,
|
|
431
|
+
buildTrustedStartOptions,
|
|
357
432
|
resolveEffectiveRunPlan,
|
|
358
433
|
startClusterFromText,
|
|
359
434
|
startClusterFromIssue,
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@the-open-engine/zeroshot",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.0",
|
|
4
4
|
"description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
|
|
5
5
|
"main": "src/orchestrator.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"zeroshot": "./cli/index.js",
|
|
8
|
-
"zeroshot-agent-provider": "./lib/agent-cli-provider/executable.js"
|
|
8
|
+
"zeroshot-agent-provider": "./lib/agent-cli-provider/executable.js",
|
|
9
|
+
"zeroshot-cluster-worker": "./bin/zeroshot-cluster-worker.js"
|
|
9
10
|
},
|
|
10
11
|
"directories": {
|
|
11
12
|
"example": "examples",
|
|
@@ -63,6 +64,8 @@
|
|
|
63
64
|
"validate:templates": "node scripts/validate-templates.js",
|
|
64
65
|
"format": "prettier --write .",
|
|
65
66
|
"format:check": "prettier --check .",
|
|
67
|
+
"protocol:check": "cargo run -p openengine-cluster-testkit --bin generate-cluster-protocol -- --check",
|
|
68
|
+
"rust:check": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace",
|
|
66
69
|
"deadcode": "ts-prune --skip node_modules",
|
|
67
70
|
"deadcode:files": "unimported",
|
|
68
71
|
"deadcode:deps": "depcheck",
|
|
@@ -71,6 +74,8 @@
|
|
|
71
74
|
"check": "npm run typecheck && npm run lint",
|
|
72
75
|
"check:all": "npm run check && npm run deadcode:all",
|
|
73
76
|
"release": "semantic-release",
|
|
77
|
+
"release:preflight": "node scripts/release-preflight.js",
|
|
78
|
+
"release:assert-published": "node scripts/assert-release-published.js",
|
|
74
79
|
"prepublishOnly": "npm run lint && npm run typecheck && npm run check:agent-cli-provider:ci",
|
|
75
80
|
"prepare": "npm run build:agent-cli-provider && husky"
|
|
76
81
|
},
|
|
@@ -121,12 +126,15 @@
|
|
|
121
126
|
"files": [
|
|
122
127
|
"src/",
|
|
123
128
|
"lib/",
|
|
129
|
+
"bin/",
|
|
124
130
|
"cli/",
|
|
125
131
|
"task-lib/",
|
|
126
132
|
"cluster-templates/",
|
|
127
133
|
"cluster-hooks/",
|
|
128
134
|
"docker/",
|
|
129
135
|
"scripts/",
|
|
136
|
+
"protocol/openengine-cluster/v1/worker.schema.json",
|
|
137
|
+
"docs/openengine-cluster-protocol/v1/legacy-worker.md",
|
|
130
138
|
"README.md",
|
|
131
139
|
"LICENSE",
|
|
132
140
|
"CHANGELOG.md"
|