@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.
@@ -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
- function isProcessAlive(pid) {
28
- if (!pid || !Number.isInteger(pid) || pid <= 0) {
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 };
@@ -185,13 +185,17 @@ function resolveParameterizedConfigFile(config) {
185
185
  return resolver.resolveTemplate(config, {});
186
186
  }
187
187
 
188
- function loadClusterConfig(orchestrator, configPath, settings = {}, providerOverride) {
189
- const config = resolveParameterizedConfigFile(orchestrator.loadConfig(configPath));
190
- ensureConfigProviderDefaults(config, settings);
188
+ function prepareClusterConfig(config, settings = {}, providerOverride) {
189
+ const prepared = resolveParameterizedConfigFile(config);
190
+ ensureConfigProviderDefaults(prepared, settings);
191
191
  if (providerOverride) {
192
- applyProviderOverrideToConfig(config, providerOverride, settings);
192
+ applyProviderOverrideToConfig(prepared, providerOverride, settings);
193
193
  }
194
- return config;
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
- cwd: resolveTargetCwd(),
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: firstTruthy(mergedOptions.dockerImage, process.env.ZEROSHOT_DOCKER_IMAGE),
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(mergedOptions.mounts === false, mergedOptions.noMounts === true),
267
- mounts: resolveMounts(mergedOptions),
268
- containerHome: optionalValue(mergedOptions.containerHome),
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(mergedOptions),
271
- mergeQueue: resolveMergeQueue(mergedOptions),
272
- closeIssue: resolveCloseIssue(mergedOptions),
273
- ship: mergedOptions.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: mergedOptions.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.5.0",
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"