@covibes/zeroshot 5.2.1 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +174 -189
- package/README.md +199 -248
- package/cli/commands/providers.js +150 -0
- package/cli/index.js +214 -58
- package/cli/lib/first-run.js +40 -3
- package/cluster-templates/base-templates/debug-workflow.json +24 -78
- package/cluster-templates/base-templates/full-workflow.json +44 -145
- package/cluster-templates/base-templates/single-worker.json +23 -15
- package/cluster-templates/base-templates/worker-validator.json +47 -34
- package/cluster-templates/conductor-bootstrap.json +7 -5
- package/lib/docker-config.js +6 -1
- package/lib/provider-detection.js +59 -0
- package/lib/provider-names.js +56 -0
- package/lib/settings.js +191 -6
- package/lib/stream-json-parser.js +4 -238
- package/package.json +21 -5
- package/scripts/validate-templates.js +100 -0
- package/src/agent/agent-config.js +37 -13
- package/src/agent/agent-context-builder.js +64 -2
- package/src/agent/agent-hook-executor.js +82 -9
- package/src/agent/agent-lifecycle.js +53 -14
- package/src/agent/agent-task-executor.js +196 -194
- package/src/agent/output-extraction.js +200 -0
- package/src/agent/output-reformatter.js +175 -0
- package/src/agent/schema-utils.js +111 -0
- package/src/agent-wrapper.js +102 -30
- package/src/agents/git-pusher-agent.json +1 -1
- package/src/claude-task-runner.js +80 -30
- package/src/config-router.js +13 -13
- package/src/config-validator.js +231 -10
- package/src/github.js +36 -0
- package/src/isolation-manager.js +243 -154
- package/src/ledger.js +28 -6
- package/src/orchestrator.js +391 -96
- package/src/preflight.js +85 -82
- package/src/providers/anthropic/cli-builder.js +45 -0
- package/src/providers/anthropic/index.js +134 -0
- package/src/providers/anthropic/models.js +23 -0
- package/src/providers/anthropic/output-parser.js +159 -0
- package/src/providers/base-provider.js +181 -0
- package/src/providers/capabilities.js +51 -0
- package/src/providers/google/cli-builder.js +55 -0
- package/src/providers/google/index.js +116 -0
- package/src/providers/google/models.js +24 -0
- package/src/providers/google/output-parser.js +92 -0
- package/src/providers/index.js +75 -0
- package/src/providers/openai/cli-builder.js +122 -0
- package/src/providers/openai/index.js +135 -0
- package/src/providers/openai/models.js +21 -0
- package/src/providers/openai/output-parser.js +129 -0
- package/src/sub-cluster-wrapper.js +18 -3
- package/src/task-runner.js +8 -6
- package/src/tui/layout.js +20 -3
- package/task-lib/attachable-watcher.js +80 -78
- package/task-lib/claude-recovery.js +119 -0
- package/task-lib/commands/list.js +1 -1
- package/task-lib/commands/resume.js +3 -2
- package/task-lib/commands/run.js +12 -3
- package/task-lib/runner.js +59 -38
- package/task-lib/scheduler.js +2 -2
- package/task-lib/store.js +43 -30
- package/task-lib/watcher.js +81 -62
package/src/isolation-manager.js
CHANGED
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles:
|
|
5
5
|
* - Container creation with workspace mounts
|
|
6
|
-
* - Credential injection for
|
|
6
|
+
* - Credential injection for provider CLIs
|
|
7
7
|
* - Command execution inside containers
|
|
8
8
|
* - Container cleanup on stop/kill
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const { spawn, execSync } = require('child_process');
|
|
12
12
|
const { Worker } = require('worker_threads');
|
|
13
|
+
const crypto = require('crypto');
|
|
13
14
|
const path = require('path');
|
|
14
15
|
const os = require('os');
|
|
15
16
|
const fs = require('fs');
|
|
16
17
|
const { loadSettings } = require('../lib/settings');
|
|
18
|
+
const { normalizeProviderName } = require('../lib/provider-names');
|
|
17
19
|
const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
|
|
20
|
+
const { getProvider } = require('./providers');
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Escape a string for safe use in shell commands
|
|
@@ -28,6 +31,19 @@ function escapeShell(str) {
|
|
|
28
31
|
return `'${str.replace(/'/g, "'\\''")}'`;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
function expandHomePath(value) {
|
|
35
|
+
if (!value) return value;
|
|
36
|
+
if (value === '~') return os.homedir();
|
|
37
|
+
return value.replace(/^~(?=\/|$)/, os.homedir());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathContains(base, target) {
|
|
41
|
+
const resolvedBase = path.resolve(base);
|
|
42
|
+
const resolvedTarget = path.resolve(target);
|
|
43
|
+
if (resolvedBase === resolvedTarget) return true;
|
|
44
|
+
return resolvedTarget.startsWith(resolvedBase + path.sep);
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
const DEFAULT_IMAGE = 'zeroshot-cluster-base';
|
|
32
48
|
|
|
33
49
|
class IsolationManager {
|
|
@@ -68,6 +84,7 @@ class IsolationManager {
|
|
|
68
84
|
* @param {boolean} [config.reuseExistingWorkspace=false] - If true, reuse existing isolated workspace (for resume)
|
|
69
85
|
* @param {Array<string|object>} [config.mounts] - Override default mounts (preset names or {host, container, readonly})
|
|
70
86
|
* @param {boolean} [config.noMounts=false] - Disable all credential mounts
|
|
87
|
+
* @param {string} [config.provider] - Provider name for credential warnings
|
|
71
88
|
* @returns {Promise<string>} Container ID
|
|
72
89
|
*/
|
|
73
90
|
async createContainer(clusterId, config) {
|
|
@@ -117,6 +134,9 @@ class IsolationManager {
|
|
|
117
134
|
|
|
118
135
|
// Resolve container home directory EARLY - needed for Claude config mount and hooks
|
|
119
136
|
const settings = loadSettings();
|
|
137
|
+
const providerName = normalizeProviderName(
|
|
138
|
+
config.provider || settings.defaultProvider || 'claude'
|
|
139
|
+
);
|
|
120
140
|
const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
|
|
121
141
|
|
|
122
142
|
// Create fresh Claude config dir for this cluster (avoids permission issues from host)
|
|
@@ -145,6 +165,8 @@ class IsolationManager {
|
|
|
145
165
|
`${clusterConfigDir}:${containerHome}/.claude`,
|
|
146
166
|
];
|
|
147
167
|
|
|
168
|
+
const mountedHosts = [];
|
|
169
|
+
|
|
148
170
|
// Add configurable credential mounts
|
|
149
171
|
// Priority: CLI config > env var > settings > defaults
|
|
150
172
|
if (!config.noMounts) {
|
|
@@ -168,9 +190,18 @@ class IsolationManager {
|
|
|
168
190
|
|
|
169
191
|
// Resolve presets to actual mount specs (containerHome already resolved above)
|
|
170
192
|
const mounts = resolveMounts(mountConfig, { containerHome });
|
|
193
|
+
const claudeContainerPath = path.posix.join(containerHome, '.claude');
|
|
171
194
|
|
|
172
195
|
for (const mount of mounts) {
|
|
173
|
-
|
|
196
|
+
if (mount.container === claudeContainerPath) {
|
|
197
|
+
console.warn(
|
|
198
|
+
`[IsolationManager] Skipping mount for ${mount.host} -> ${mount.container} ` +
|
|
199
|
+
'(Claude config is managed by zeroshot).'
|
|
200
|
+
);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const hostPath = expandHomePath(mount.host);
|
|
174
205
|
|
|
175
206
|
// Check path exists and is mountable
|
|
176
207
|
try {
|
|
@@ -188,12 +219,11 @@ class IsolationManager {
|
|
|
188
219
|
? `${hostPath}:${mount.container}:ro`
|
|
189
220
|
: `${hostPath}:${mount.container}`;
|
|
190
221
|
args.push('-v', mountSpec);
|
|
222
|
+
mountedHosts.push(hostPath);
|
|
191
223
|
}
|
|
192
224
|
|
|
193
225
|
// Pass env vars based on enabled presets
|
|
194
|
-
const envSpecs = expandEnvPatterns(
|
|
195
|
-
resolveEnvs(mountConfig, settings.dockerEnvPassthrough)
|
|
196
|
-
);
|
|
226
|
+
const envSpecs = expandEnvPatterns(resolveEnvs(mountConfig, settings.dockerEnvPassthrough));
|
|
197
227
|
for (const spec of envSpecs) {
|
|
198
228
|
if (spec.forced) {
|
|
199
229
|
// Forced value - always pass with specified value
|
|
@@ -205,15 +235,30 @@ class IsolationManager {
|
|
|
205
235
|
}
|
|
206
236
|
}
|
|
207
237
|
|
|
238
|
+
// Warn when provider credentials are likely missing
|
|
239
|
+
if (providerName !== 'claude') {
|
|
240
|
+
const provider = getProvider(providerName);
|
|
241
|
+
const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
|
|
242
|
+
const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
|
|
243
|
+
const hasCredentialMount = mountedHosts.some((hostPath) =>
|
|
244
|
+
expandedCreds.some(
|
|
245
|
+
(credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (!hasCredentialMount && expandedCreds.length > 0) {
|
|
250
|
+
const exampleHost = credentialPaths[0];
|
|
251
|
+
const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
|
|
252
|
+
const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
|
|
253
|
+
console.warn(
|
|
254
|
+
`[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
|
|
255
|
+
`Add one with --mount ${exampleHost}:${exampleContainer}:ro`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
208
260
|
// Finish docker args
|
|
209
|
-
args.push(
|
|
210
|
-
'-w',
|
|
211
|
-
'/workspace',
|
|
212
|
-
image,
|
|
213
|
-
'tail',
|
|
214
|
-
'-f',
|
|
215
|
-
'/dev/null'
|
|
216
|
-
);
|
|
261
|
+
args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
|
|
217
262
|
|
|
218
263
|
return new Promise((resolve, reject) => {
|
|
219
264
|
const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -240,116 +285,7 @@ class IsolationManager {
|
|
|
240
285
|
try {
|
|
241
286
|
console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
|
|
242
287
|
if (fs.existsSync(path.join(workDir, 'package.json'))) {
|
|
243
|
-
|
|
244
|
-
const checkResult = await this.execInContainer(
|
|
245
|
-
clusterId,
|
|
246
|
-
['sh', '-c', 'test -d node_modules && test -f node_modules/.package-lock.json && echo "exists"'],
|
|
247
|
-
{}
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
if (checkResult.code === 0 && checkResult.stdout.trim() === 'exists') {
|
|
251
|
-
console.log(`[IsolationManager] ✓ Dependencies already installed (skipping npm install)`);
|
|
252
|
-
} else {
|
|
253
|
-
// Check if npm is available in container
|
|
254
|
-
const npmCheck = await this.execInContainer(clusterId, ['which', 'npm'], {});
|
|
255
|
-
if (npmCheck.code !== 0) {
|
|
256
|
-
console.log(`[IsolationManager] npm not available in container, skipping dependency install`);
|
|
257
|
-
} else {
|
|
258
|
-
// Issue #20: Try to use pre-baked dependencies first
|
|
259
|
-
// Check if pre-baked deps exist and can satisfy project requirements
|
|
260
|
-
const preBakeCheck = await this.execInContainer(
|
|
261
|
-
clusterId,
|
|
262
|
-
['sh', '-c', 'test -d /pre-baked-deps/node_modules && echo "exists"'],
|
|
263
|
-
{}
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
if (preBakeCheck.code === 0 && preBakeCheck.stdout.trim() === 'exists') {
|
|
267
|
-
console.log(`[IsolationManager] Checking if pre-baked deps satisfy requirements...`);
|
|
268
|
-
|
|
269
|
-
// Copy pre-baked deps, then run npm install to add any missing
|
|
270
|
-
// This is faster than full npm install: copy is ~2s, npm install adds ~5-10s for missing
|
|
271
|
-
const copyResult = await this.execInContainer(
|
|
272
|
-
clusterId,
|
|
273
|
-
['sh', '-c', 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true'],
|
|
274
|
-
{}
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
if (copyResult.code === 0) {
|
|
278
|
-
console.log(`[IsolationManager] ✓ Copied pre-baked dependencies`);
|
|
279
|
-
|
|
280
|
-
// Run npm install to add any missing deps (much faster with pre-baked base)
|
|
281
|
-
const installResult = await this.execInContainer(
|
|
282
|
-
clusterId,
|
|
283
|
-
['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline'],
|
|
284
|
-
{}
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
if (installResult.code === 0) {
|
|
288
|
-
console.log(`[IsolationManager] ✓ Dependencies installed (pre-baked + incremental)`);
|
|
289
|
-
} else {
|
|
290
|
-
// Fallback: full install (pre-baked copy may have caused issues)
|
|
291
|
-
console.warn(`[IsolationManager] Incremental install failed, falling back to full install`);
|
|
292
|
-
await this.execInContainer(
|
|
293
|
-
clusterId,
|
|
294
|
-
['sh', '-c', 'rm -rf node_modules && npm_config_engine_strict=false npm install --no-audit --no-fund'],
|
|
295
|
-
{}
|
|
296
|
-
);
|
|
297
|
-
console.log(`[IsolationManager] ✓ Dependencies installed (full fallback)`);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
} else {
|
|
301
|
-
// No pre-baked deps, full npm install with retries
|
|
302
|
-
console.log(`[IsolationManager] Installing npm dependencies in container...`);
|
|
303
|
-
|
|
304
|
-
// Retry npm install with exponential backoff (network issues are common)
|
|
305
|
-
const maxRetries = 3;
|
|
306
|
-
const baseDelay = 2000; // 2 seconds
|
|
307
|
-
let installResult = null;
|
|
308
|
-
|
|
309
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
310
|
-
try {
|
|
311
|
-
installResult = await this.execInContainer(
|
|
312
|
-
clusterId,
|
|
313
|
-
['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund'],
|
|
314
|
-
{}
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
if (installResult.code === 0) {
|
|
318
|
-
console.log(`[IsolationManager] ✓ Dependencies installed`);
|
|
319
|
-
break; // Success - exit retry loop
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Failed - retry if not last attempt
|
|
323
|
-
// Use stderr if available, otherwise stdout (npm writes some errors to stdout)
|
|
324
|
-
const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
|
|
325
|
-
if (attempt < maxRetries) {
|
|
326
|
-
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
327
|
-
console.warn(
|
|
328
|
-
`[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
|
|
329
|
-
);
|
|
330
|
-
console.warn(`[IsolationManager] Error: ${errorOutput}`);
|
|
331
|
-
await new Promise((_resolve) => setTimeout(_resolve, delay));
|
|
332
|
-
} else {
|
|
333
|
-
console.warn(
|
|
334
|
-
`[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
} catch (execErr) {
|
|
338
|
-
if (attempt < maxRetries) {
|
|
339
|
-
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
340
|
-
console.warn(
|
|
341
|
-
`[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
|
|
342
|
-
);
|
|
343
|
-
console.warn(`[IsolationManager] Error: ${execErr.message}`);
|
|
344
|
-
await new Promise((_resolve) => setTimeout(_resolve, delay));
|
|
345
|
-
} else {
|
|
346
|
-
throw execErr; // Re-throw on last attempt
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
288
|
+
await this._installDependenciesWithRetry(clusterId);
|
|
353
289
|
}
|
|
354
290
|
} catch (err) {
|
|
355
291
|
console.warn(
|
|
@@ -369,6 +305,81 @@ class IsolationManager {
|
|
|
369
305
|
});
|
|
370
306
|
}
|
|
371
307
|
|
|
308
|
+
async _installDependenciesWithRetry(clusterId) {
|
|
309
|
+
console.log(`[IsolationManager] Installing npm dependencies in container...`);
|
|
310
|
+
|
|
311
|
+
const maxRetries = 3;
|
|
312
|
+
const baseDelay = 2000; // 2 seconds
|
|
313
|
+
const installCommand = [
|
|
314
|
+
'sh',
|
|
315
|
+
'-c',
|
|
316
|
+
[
|
|
317
|
+
'if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then',
|
|
318
|
+
'echo "__deps_present__";',
|
|
319
|
+
'exit 0;',
|
|
320
|
+
'fi;',
|
|
321
|
+
'if ! command -v npm >/dev/null 2>&1; then',
|
|
322
|
+
'echo "__npm_missing__";',
|
|
323
|
+
'exit 127;',
|
|
324
|
+
'fi;',
|
|
325
|
+
'if [ -d /pre-baked-deps/node_modules ]; then',
|
|
326
|
+
'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true;',
|
|
327
|
+
'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline;',
|
|
328
|
+
'install_code=$?;',
|
|
329
|
+
'if [ $install_code -ne 0 ]; then',
|
|
330
|
+
'rm -rf node_modules;',
|
|
331
|
+
'npm_config_engine_strict=false npm install --no-audit --no-fund;',
|
|
332
|
+
'fi;',
|
|
333
|
+
'else',
|
|
334
|
+
'npm_config_engine_strict=false npm install --no-audit --no-fund;',
|
|
335
|
+
'fi',
|
|
336
|
+
].join(' '),
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
340
|
+
try {
|
|
341
|
+
const installResult = await this.execInContainer(clusterId, installCommand, {});
|
|
342
|
+
const stdout = installResult.stdout || '';
|
|
343
|
+
|
|
344
|
+
if (installResult.code === 0) {
|
|
345
|
+
if (stdout.includes('__deps_present__')) {
|
|
346
|
+
console.log(
|
|
347
|
+
`[IsolationManager] ✓ Dependencies already installed (skipping npm install)`
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
console.log(`[IsolationManager] ✓ Dependencies installed`);
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
|
|
356
|
+
if (attempt < maxRetries) {
|
|
357
|
+
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
358
|
+
console.warn(
|
|
359
|
+
`[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
|
|
360
|
+
);
|
|
361
|
+
console.warn(`[IsolationManager] Error: ${errorOutput}`);
|
|
362
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
363
|
+
} else {
|
|
364
|
+
console.warn(
|
|
365
|
+
`[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
} catch (execErr) {
|
|
369
|
+
if (attempt < maxRetries) {
|
|
370
|
+
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
371
|
+
console.warn(
|
|
372
|
+
`[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
|
|
373
|
+
);
|
|
374
|
+
console.warn(`[IsolationManager] Error: ${execErr.message}`);
|
|
375
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
376
|
+
} else {
|
|
377
|
+
throw execErr;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
372
383
|
/**
|
|
373
384
|
* Execute a command inside the container
|
|
374
385
|
* @param {string} clusterId - Cluster ID
|
|
@@ -542,7 +553,9 @@ class IsolationManager {
|
|
|
542
553
|
const isolatedInfo = this.isolatedDirs.get(clusterId);
|
|
543
554
|
|
|
544
555
|
if (preserveWorkspace) {
|
|
545
|
-
console.log(
|
|
556
|
+
console.log(
|
|
557
|
+
`[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`
|
|
558
|
+
);
|
|
546
559
|
// Don't delete - but DON'T remove from Map either, resume() needs it
|
|
547
560
|
} else {
|
|
548
561
|
console.log(`[IsolationManager] Cleaning up isolated dir at ${isolatedInfo.path}`);
|
|
@@ -896,7 +909,10 @@ class IsolationManager {
|
|
|
896
909
|
],
|
|
897
910
|
},
|
|
898
911
|
};
|
|
899
|
-
fs.writeFileSync(
|
|
912
|
+
fs.writeFileSync(
|
|
913
|
+
path.join(configDir, 'settings.json'),
|
|
914
|
+
JSON.stringify(clusterSettings, null, 2)
|
|
915
|
+
);
|
|
900
916
|
|
|
901
917
|
// Track for cleanup
|
|
902
918
|
this.clusterConfigDirs = this.clusterConfigDirs || new Map();
|
|
@@ -990,9 +1006,12 @@ class IsolationManager {
|
|
|
990
1006
|
*/
|
|
991
1007
|
_isContainerRunning(containerId) {
|
|
992
1008
|
try {
|
|
993
|
-
const result = execSync(
|
|
994
|
-
|
|
995
|
-
|
|
1009
|
+
const result = execSync(
|
|
1010
|
+
`docker inspect -f '{{.State.Running}}' ${escapeShell(containerId)} 2>/dev/null`,
|
|
1011
|
+
{
|
|
1012
|
+
encoding: 'utf8',
|
|
1013
|
+
}
|
|
1014
|
+
);
|
|
996
1015
|
return result.trim() === 'true';
|
|
997
1016
|
} catch {
|
|
998
1017
|
return false;
|
|
@@ -1017,7 +1036,8 @@ class IsolationManager {
|
|
|
1017
1036
|
*/
|
|
1018
1037
|
static isDockerAvailable() {
|
|
1019
1038
|
try {
|
|
1020
|
-
|
|
1039
|
+
// Require both CLI binary and a reachable daemon.
|
|
1040
|
+
execSync('docker info', { encoding: 'utf8', stdio: 'pipe' });
|
|
1021
1041
|
return true;
|
|
1022
1042
|
} catch {
|
|
1023
1043
|
return false;
|
|
@@ -1146,14 +1166,16 @@ class IsolationManager {
|
|
|
1146
1166
|
|
|
1147
1167
|
/**
|
|
1148
1168
|
* Create worktree-based isolation for a cluster (lightweight alternative to Docker)
|
|
1149
|
-
* Creates a git worktree at /
|
|
1169
|
+
* Creates a git worktree at {os.tmpdir()}/zeroshot-worktrees/{clusterId}
|
|
1150
1170
|
* @param {string} clusterId - Cluster ID
|
|
1151
1171
|
* @param {string} workDir - Original working directory (must be a git repo)
|
|
1152
1172
|
* @returns {{ path: string, branch: string, repoRoot: string }}
|
|
1153
1173
|
*/
|
|
1154
1174
|
createWorktreeIsolation(clusterId, workDir) {
|
|
1155
1175
|
if (!this._isGitRepo(workDir)) {
|
|
1156
|
-
throw new Error(
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
`Worktree isolation requires a git repository. ${workDir} is not a git repo.`
|
|
1178
|
+
);
|
|
1157
1179
|
}
|
|
1158
1180
|
|
|
1159
1181
|
const worktreeInfo = this.createWorktree(clusterId, workDir);
|
|
@@ -1196,7 +1218,8 @@ class IsolationManager {
|
|
|
1196
1218
|
}
|
|
1197
1219
|
|
|
1198
1220
|
// Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87)
|
|
1199
|
-
const
|
|
1221
|
+
const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
|
|
1222
|
+
let branchName = baseBranchName;
|
|
1200
1223
|
|
|
1201
1224
|
// Worktree path in tmp
|
|
1202
1225
|
const worktreePath = path.join(os.tmpdir(), 'zeroshot-worktrees', clusterId);
|
|
@@ -1207,34 +1230,73 @@ class IsolationManager {
|
|
|
1207
1230
|
fs.mkdirSync(parentDir, { recursive: true });
|
|
1208
1231
|
}
|
|
1209
1232
|
|
|
1210
|
-
//
|
|
1233
|
+
// Best-effort cleanup of stale worktree metadata and directory.
|
|
1234
|
+
// IMPORTANT: If a previous run deleted the directory without deregistering the worktree,
|
|
1235
|
+
// git may keep the branch "checked out" and block deletion/reuse.
|
|
1211
1236
|
try {
|
|
1212
|
-
execSync(`git worktree remove --force
|
|
1237
|
+
execSync(`git worktree remove --force ${escapeShell(worktreePath)}`, {
|
|
1213
1238
|
cwd: repoRoot,
|
|
1214
1239
|
encoding: 'utf8',
|
|
1215
1240
|
stdio: 'pipe',
|
|
1216
1241
|
});
|
|
1217
1242
|
} catch {
|
|
1218
|
-
//
|
|
1243
|
+
// ignore
|
|
1219
1244
|
}
|
|
1220
|
-
|
|
1221
|
-
// Delete the branch if it exists (from previous run)
|
|
1222
1245
|
try {
|
|
1223
|
-
execSync(
|
|
1224
|
-
cwd: repoRoot,
|
|
1225
|
-
encoding: 'utf8',
|
|
1226
|
-
stdio: 'pipe',
|
|
1227
|
-
});
|
|
1246
|
+
execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
|
|
1228
1247
|
} catch {
|
|
1229
|
-
//
|
|
1248
|
+
// ignore
|
|
1249
|
+
}
|
|
1250
|
+
try {
|
|
1251
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
1252
|
+
} catch {
|
|
1253
|
+
// ignore
|
|
1230
1254
|
}
|
|
1231
1255
|
|
|
1232
|
-
// Create worktree with new branch based on HEAD
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1256
|
+
// Create worktree with new branch based on HEAD (retry on branch collision/in-use)
|
|
1257
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1258
|
+
// Best-effort delete if branch exists and is not in use by another worktree.
|
|
1259
|
+
try {
|
|
1260
|
+
execSync(`git branch -D ${escapeShell(branchName)}`, {
|
|
1261
|
+
cwd: repoRoot,
|
|
1262
|
+
encoding: 'utf8',
|
|
1263
|
+
stdio: 'pipe',
|
|
1264
|
+
});
|
|
1265
|
+
} catch {
|
|
1266
|
+
// ignore
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
try {
|
|
1270
|
+
execSync(
|
|
1271
|
+
`git worktree add -b ${escapeShell(branchName)} ${escapeShell(worktreePath)} HEAD`,
|
|
1272
|
+
{
|
|
1273
|
+
cwd: repoRoot,
|
|
1274
|
+
encoding: 'utf8',
|
|
1275
|
+
stdio: 'pipe',
|
|
1276
|
+
}
|
|
1277
|
+
);
|
|
1278
|
+
break;
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
const stderr = (
|
|
1281
|
+
err && (err.stderr || err.message) ? String(err.stderr || err.message) : ''
|
|
1282
|
+
).toLowerCase();
|
|
1283
|
+
const isBranchCollision =
|
|
1284
|
+
stderr.includes('already exists') ||
|
|
1285
|
+
stderr.includes('cannot delete branch') ||
|
|
1286
|
+
stderr.includes('checked out');
|
|
1287
|
+
|
|
1288
|
+
if (attempt < 9 && isBranchCollision) {
|
|
1289
|
+
branchName = `${baseBranchName}-${crypto.randomBytes(3).toString('hex')}`;
|
|
1290
|
+
try {
|
|
1291
|
+
execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
|
|
1292
|
+
} catch {
|
|
1293
|
+
// ignore
|
|
1294
|
+
}
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
throw err;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1238
1300
|
|
|
1239
1301
|
return {
|
|
1240
1302
|
path: worktreePath,
|
|
@@ -1250,19 +1312,46 @@ class IsolationManager {
|
|
|
1250
1312
|
* @param {boolean} [options.deleteBranch=false] - Also delete the branch
|
|
1251
1313
|
*/
|
|
1252
1314
|
removeWorktree(worktreeInfo, _options = {}) {
|
|
1315
|
+
// Remove the worktree (prefer git so metadata is cleaned up).
|
|
1253
1316
|
try {
|
|
1254
|
-
|
|
1255
|
-
execSync(`git worktree remove --force "${worktreeInfo.path}" 2>/dev/null`, {
|
|
1317
|
+
execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
|
|
1256
1318
|
cwd: worktreeInfo.repoRoot,
|
|
1257
1319
|
encoding: 'utf8',
|
|
1258
1320
|
stdio: 'pipe',
|
|
1259
1321
|
});
|
|
1260
1322
|
} catch {
|
|
1261
|
-
//
|
|
1323
|
+
// If git worktree metadata is stale, prune and retry once.
|
|
1262
1324
|
try {
|
|
1263
|
-
|
|
1325
|
+
execSync('git worktree prune', {
|
|
1326
|
+
cwd: worktreeInfo.repoRoot,
|
|
1327
|
+
encoding: 'utf8',
|
|
1328
|
+
stdio: 'pipe',
|
|
1329
|
+
});
|
|
1330
|
+
} catch {
|
|
1331
|
+
// ignore
|
|
1332
|
+
}
|
|
1333
|
+
try {
|
|
1334
|
+
execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
|
|
1335
|
+
cwd: worktreeInfo.repoRoot,
|
|
1336
|
+
encoding: 'utf8',
|
|
1337
|
+
stdio: 'pipe',
|
|
1338
|
+
});
|
|
1264
1339
|
} catch {
|
|
1265
|
-
//
|
|
1340
|
+
// Last resort: delete directory, then prune stale worktree entries.
|
|
1341
|
+
try {
|
|
1342
|
+
fs.rmSync(worktreeInfo.path, { recursive: true, force: true });
|
|
1343
|
+
} catch {
|
|
1344
|
+
// ignore
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
execSync('git worktree prune', {
|
|
1348
|
+
cwd: worktreeInfo.repoRoot,
|
|
1349
|
+
encoding: 'utf8',
|
|
1350
|
+
stdio: 'pipe',
|
|
1351
|
+
});
|
|
1352
|
+
} catch {
|
|
1353
|
+
// ignore
|
|
1354
|
+
}
|
|
1266
1355
|
}
|
|
1267
1356
|
}
|
|
1268
1357
|
|
package/src/ledger.js
CHANGED
|
@@ -19,6 +19,7 @@ class Ledger extends EventEmitter {
|
|
|
19
19
|
this.cache = new Map(); // LRU cache for queries
|
|
20
20
|
this.cacheLimit = 1000;
|
|
21
21
|
this._closed = false; // Track closed state to prevent write-after-close
|
|
22
|
+
this._lastTimestamp = 0;
|
|
22
23
|
this._initSchema();
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -52,6 +53,7 @@ class Ledger extends EventEmitter {
|
|
|
52
53
|
`);
|
|
53
54
|
|
|
54
55
|
this._prepareStatements();
|
|
56
|
+
this._loadLastTimestamp();
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
_prepareStatements() {
|
|
@@ -69,6 +71,13 @@ class Ledger extends EventEmitter {
|
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
_loadLastTimestamp() {
|
|
75
|
+
const row = this.db.prepare('SELECT MAX(timestamp) AS max_timestamp FROM messages').get();
|
|
76
|
+
if (row && Number.isFinite(row.max_timestamp)) {
|
|
77
|
+
this._lastTimestamp = row.max_timestamp;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
/**
|
|
73
82
|
* Append a message to the ledger
|
|
74
83
|
* @param {Object} message - Message object
|
|
@@ -83,7 +92,10 @@ class Ledger extends EventEmitter {
|
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
const id = message.id || `msg_${crypto.randomBytes(16).toString('hex')}`;
|
|
86
|
-
const
|
|
95
|
+
const baseTimestamp = Math.max(Date.now(), this._lastTimestamp + 1);
|
|
96
|
+
const requestedTimestamp = typeof message.timestamp === 'number' ? message.timestamp : null;
|
|
97
|
+
const timestamp =
|
|
98
|
+
requestedTimestamp !== null ? Math.max(requestedTimestamp, baseTimestamp) : baseTimestamp;
|
|
87
99
|
|
|
88
100
|
const record = {
|
|
89
101
|
id,
|
|
@@ -113,6 +125,8 @@ class Ledger extends EventEmitter {
|
|
|
113
125
|
// Invalidate cache
|
|
114
126
|
this.cache.clear();
|
|
115
127
|
|
|
128
|
+
this._lastTimestamp = Math.max(this._lastTimestamp, timestamp);
|
|
129
|
+
|
|
116
130
|
// Emit event for subscriptions
|
|
117
131
|
const fullMessage = this._deserializeMessage(record);
|
|
118
132
|
this.emit('message', fullMessage);
|
|
@@ -149,13 +163,13 @@ class Ledger extends EventEmitter {
|
|
|
149
163
|
// Create transaction function - all inserts happen atomically
|
|
150
164
|
const insertMany = this.db.transaction((msgs) => {
|
|
151
165
|
const results = [];
|
|
152
|
-
const baseTimestamp = Date.now();
|
|
166
|
+
const baseTimestamp = Math.max(Date.now(), this._lastTimestamp + 1);
|
|
153
167
|
|
|
154
168
|
for (let i = 0; i < msgs.length; i++) {
|
|
155
169
|
const message = msgs[i];
|
|
156
170
|
const id = message.id || `msg_${crypto.randomBytes(16).toString('hex')}`;
|
|
157
171
|
// Use incrementing timestamps to preserve order within batch
|
|
158
|
-
const timestamp =
|
|
172
|
+
const timestamp = baseTimestamp + i;
|
|
159
173
|
|
|
160
174
|
const record = {
|
|
161
175
|
id,
|
|
@@ -184,16 +198,18 @@ class Ledger extends EventEmitter {
|
|
|
184
198
|
results.push(this._deserializeMessage(record));
|
|
185
199
|
}
|
|
186
200
|
|
|
187
|
-
return results;
|
|
201
|
+
return { results, baseTimestamp };
|
|
188
202
|
});
|
|
189
203
|
|
|
190
204
|
try {
|
|
191
205
|
// Execute transaction (atomic - all or nothing)
|
|
192
|
-
const appendedMessages = insertMany(messages);
|
|
206
|
+
const { results: appendedMessages, baseTimestamp } = insertMany(messages);
|
|
193
207
|
|
|
194
208
|
// Invalidate cache
|
|
195
209
|
this.cache.clear();
|
|
196
210
|
|
|
211
|
+
this._lastTimestamp = Math.max(this._lastTimestamp, baseTimestamp + messages.length - 1);
|
|
212
|
+
|
|
197
213
|
// Emit events for subscriptions AFTER transaction commits
|
|
198
214
|
// This ensures listeners see consistent state
|
|
199
215
|
for (const fullMessage of appendedMessages) {
|
|
@@ -248,7 +264,13 @@ class Ledger extends EventEmitter {
|
|
|
248
264
|
params.push(typeof until === 'number' ? until : new Date(until).getTime());
|
|
249
265
|
}
|
|
250
266
|
|
|
251
|
-
|
|
267
|
+
// Defend against prototype pollution affecting default query ordering.
|
|
268
|
+
// Only treat `criteria.order` as set if it's an own property.
|
|
269
|
+
const orderValue = Object.prototype.hasOwnProperty.call(criteria, 'order')
|
|
270
|
+
? criteria.order
|
|
271
|
+
: undefined;
|
|
272
|
+
const direction = String(orderValue ?? 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
273
|
+
let sql = `SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY timestamp ${direction}`;
|
|
252
274
|
|
|
253
275
|
if (limit) {
|
|
254
276
|
sql += ` LIMIT ?`;
|