@the-open-engine/zeroshot 6.9.1 → 6.10.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/cli/commands/inspect.js +1 -0
- package/cli/index.js +17 -1
- package/cluster-hooks/block-ask-user-question.py +2 -3
- package/cluster-hooks/block-dangerous-git.py +2 -3
- package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/claude.js +57 -3
- 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 +32 -2
- package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
- package/lib/agent-cli-provider/adapters/common.js +1 -1
- package/lib/agent-cli-provider/adapters/common.js.map +1 -1
- package/lib/agent-cli-provider/adapters/index.d.ts +1 -0
- package/lib/agent-cli-provider/adapters/index.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/index.js +8 -0
- package/lib/agent-cli-provider/adapters/index.js.map +1 -1
- package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
- package/lib/agent-cli-provider/contract-options.js +3 -0
- 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 +2 -1
- package/lib/agent-cli-provider/index.js.map +1 -1
- package/lib/agent-cli-provider/provider-registry.d.ts +9 -0
- package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
- package/lib/agent-cli-provider/provider-registry.js +3 -0
- package/lib/agent-cli-provider/provider-registry.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +10 -2
- package/lib/agent-cli-provider/types.d.ts.map +1 -1
- package/lib/agent-cli-provider/types.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/agent-context-builder.js +90 -4
- package/src/agent/agent-context-sources.js +20 -2
- package/src/agent/agent-lifecycle.js +43 -8
- package/src/agent/agent-task-executor.js +557 -314
- package/src/agent/guidance-queue.js +29 -7
- package/src/agent/provider-session.js +277 -0
- package/src/agent-cli-provider/adapters/claude.ts +64 -4
- package/src/agent-cli-provider/adapters/codex.ts +47 -3
- package/src/agent-cli-provider/adapters/common.ts +1 -1
- package/src/agent-cli-provider/adapters/index.ts +10 -0
- package/src/agent-cli-provider/contract-options.ts +7 -0
- package/src/agent-cli-provider/index.ts +1 -0
- package/src/agent-cli-provider/provider-registry.ts +13 -1
- package/src/agent-cli-provider/types.ts +13 -6
- package/src/agent-wrapper.js +44 -8
- package/src/claude-task-runner.js +153 -44
- package/src/ledger-sequence.js +63 -0
- package/src/ledger.js +114 -29
- package/src/orchestrator.js +40 -2
- package/src/task-spawn-cleanup-ownership.js +82 -0
- package/src/worktree-claude-config.js +193 -85
- package/task-lib/attachable-watcher.js +101 -253
- package/task-lib/command-spec-cleanup.js +219 -0
- package/task-lib/commands/clean.js +35 -5
- package/task-lib/commands/get-task-id-by-spawn-token.js +10 -0
- package/task-lib/commands/kill.js +117 -4
- package/task-lib/commands/resume.js +29 -6
- package/task-lib/commands/status.js +4 -0
- package/task-lib/provider-helper-runtime.js +1 -0
- package/task-lib/provider-session-capture.js +138 -0
- package/task-lib/runner.js +70 -5
- package/task-lib/store.js +126 -12
- package/task-lib/watcher-output-runtime.js +284 -0
- package/task-lib/watcher.js +131 -242
|
@@ -15,12 +15,17 @@ const path = require('path');
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const os = require('os');
|
|
17
17
|
const { parseProviderChunk, getProvider } = require('../providers');
|
|
18
|
-
const { getTask } = require('../../task-lib/store.js');
|
|
18
|
+
const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.js');
|
|
19
19
|
const { loadSettings } = require('../../lib/settings.js');
|
|
20
20
|
const { resolveClaudeAuth } = require('../../lib/settings/claude-auth.js');
|
|
21
21
|
const { prependWorktreeToolBinToEnv } = require('../worktree-tooling-env.js');
|
|
22
22
|
const {
|
|
23
|
-
|
|
23
|
+
CLAUDE_SETTINGS_ENV,
|
|
24
|
+
cleanupClaudeSettingsOverlay,
|
|
25
|
+
ensureAskUserQuestionHook,
|
|
26
|
+
ensureDangerousGitHook,
|
|
27
|
+
prepareClaudeSettingsOverlay,
|
|
28
|
+
resolveContainerMcpConfigPath,
|
|
24
29
|
resolveRepoMcpConfigPath,
|
|
25
30
|
} = require('../worktree-claude-config.js');
|
|
26
31
|
const {
|
|
@@ -28,6 +33,19 @@ const {
|
|
|
28
33
|
wrapTaskRunWithIsolatedSettings,
|
|
29
34
|
} = require('../task-run-model-args.js');
|
|
30
35
|
const { buildRawLogOnlyMetadata } = require('./context-replay-policy');
|
|
36
|
+
const {
|
|
37
|
+
TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
|
|
38
|
+
cleanupCallerOwnedCommand,
|
|
39
|
+
callerOwnsCommandCleanup,
|
|
40
|
+
createTaskSpawnOwnershipToken,
|
|
41
|
+
requireTaskIdFromWrapperResult,
|
|
42
|
+
trackTaskWrapperCleanupOwnership,
|
|
43
|
+
} = require('../task-spawn-cleanup-ownership');
|
|
44
|
+
const {
|
|
45
|
+
providerSessionFromCompletedTask,
|
|
46
|
+
resolveAgentResumeSessionId,
|
|
47
|
+
validateCompletedResumeIdentity,
|
|
48
|
+
} = require('./provider-session');
|
|
31
49
|
const { extractClaudeVertexModelError } = require('./output-extraction');
|
|
32
50
|
|
|
33
51
|
function runCommandWithTimeout(command, args, options = {}, callback = null) {
|
|
@@ -373,10 +391,6 @@ function extractErrorContext({ output, statusOutput, taskId, isNotFound = false,
|
|
|
373
391
|
return sanitizeErrorMessage(`Task failed. Output: ${trimmedOutput}`);
|
|
374
392
|
}
|
|
375
393
|
|
|
376
|
-
// Track which config dirs already have zeroshot-installed hooks.
|
|
377
|
-
const askUserQuestionHookInstalledDirs = new Set();
|
|
378
|
-
const dangerousGitHookInstalledDirs = new Set();
|
|
379
|
-
|
|
380
394
|
/**
|
|
381
395
|
* Extract token usage from NDJSON output.
|
|
382
396
|
* Looks for the 'result' event line which contains usage data.
|
|
@@ -430,162 +444,6 @@ function extractTokenUsage(output, providerName = 'claude') {
|
|
|
430
444
|
};
|
|
431
445
|
}
|
|
432
446
|
|
|
433
|
-
/**
|
|
434
|
-
* Ensure the AskUserQuestion blocking hook is installed in user's Claude config.
|
|
435
|
-
* This adds defense-in-depth by blocking the tool at the Claude CLI level.
|
|
436
|
-
* Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
|
|
437
|
-
*
|
|
438
|
-
* Safe to call multiple times - only modifies config once per process.
|
|
439
|
-
*/
|
|
440
|
-
function ensureAskUserQuestionHook(targetClaudeDir = null) {
|
|
441
|
-
const userClaudeDir =
|
|
442
|
-
targetClaudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
443
|
-
if (askUserQuestionHookInstalledDirs.has(userClaudeDir)) {
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
const hooksDir = path.join(userClaudeDir, 'hooks');
|
|
447
|
-
const settingsPath = path.join(userClaudeDir, 'settings.json');
|
|
448
|
-
const hookScriptName = 'block-ask-user-question.py';
|
|
449
|
-
const hookScriptDst = path.join(hooksDir, hookScriptName);
|
|
450
|
-
|
|
451
|
-
// Ensure hooks directory exists
|
|
452
|
-
if (!fs.existsSync(hooksDir)) {
|
|
453
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Copy hook script if not present or outdated
|
|
457
|
-
const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
|
|
458
|
-
if (fs.existsSync(hookScriptSrc)) {
|
|
459
|
-
// Always copy to ensure latest version
|
|
460
|
-
fs.copyFileSync(hookScriptSrc, hookScriptDst);
|
|
461
|
-
fs.chmodSync(hookScriptDst, 0o755);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Read existing settings or create new
|
|
465
|
-
let settings = {};
|
|
466
|
-
if (fs.existsSync(settingsPath)) {
|
|
467
|
-
try {
|
|
468
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
469
|
-
} catch (e) {
|
|
470
|
-
console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
|
|
471
|
-
settings = {};
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Ensure hooks structure exists
|
|
476
|
-
if (!settings.hooks) {
|
|
477
|
-
settings.hooks = {};
|
|
478
|
-
}
|
|
479
|
-
if (!settings.hooks.PreToolUse) {
|
|
480
|
-
settings.hooks.PreToolUse = [];
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Check if AskUserQuestion hook already exists
|
|
484
|
-
const hasHook = settings.hooks.PreToolUse.some(
|
|
485
|
-
(entry) =>
|
|
486
|
-
entry.matcher === 'AskUserQuestion' ||
|
|
487
|
-
(entry.hooks && entry.hooks.some((h) => h.command && h.command.includes(hookScriptName)))
|
|
488
|
-
);
|
|
489
|
-
|
|
490
|
-
if (!hasHook) {
|
|
491
|
-
// Add the hook
|
|
492
|
-
settings.hooks.PreToolUse.push({
|
|
493
|
-
matcher: 'AskUserQuestion',
|
|
494
|
-
hooks: [
|
|
495
|
-
{
|
|
496
|
-
type: 'command',
|
|
497
|
-
command: hookScriptDst,
|
|
498
|
-
},
|
|
499
|
-
],
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
// Write updated settings
|
|
503
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
504
|
-
console.log(`[AgentTaskExecutor] Installed AskUserQuestion blocking hook in ${settingsPath}`);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
askUserQuestionHookInstalledDirs.add(userClaudeDir);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Ensure the dangerous git blocking hook is installed in user's Claude config.
|
|
512
|
-
* This blocks dangerous git commands like stash, checkout --, reset --hard, etc.
|
|
513
|
-
* Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
|
|
514
|
-
*
|
|
515
|
-
* Only used in worktree mode - Docker isolation mode has its own git-safe.sh wrapper.
|
|
516
|
-
* Safe to call multiple times - only modifies config once per process.
|
|
517
|
-
*/
|
|
518
|
-
function ensureDangerousGitHook(targetClaudeDir = null) {
|
|
519
|
-
const userClaudeDir =
|
|
520
|
-
targetClaudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
521
|
-
if (dangerousGitHookInstalledDirs.has(userClaudeDir)) {
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
const hooksDir = path.join(userClaudeDir, 'hooks');
|
|
525
|
-
const settingsPath = path.join(userClaudeDir, 'settings.json');
|
|
526
|
-
const hookScriptName = 'block-dangerous-git.py';
|
|
527
|
-
const hookScriptDst = path.join(hooksDir, hookScriptName);
|
|
528
|
-
|
|
529
|
-
// Ensure hooks directory exists
|
|
530
|
-
if (!fs.existsSync(hooksDir)) {
|
|
531
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Copy hook script if not present or outdated
|
|
535
|
-
const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
|
|
536
|
-
if (fs.existsSync(hookScriptSrc)) {
|
|
537
|
-
// Always copy to ensure latest version
|
|
538
|
-
fs.copyFileSync(hookScriptSrc, hookScriptDst);
|
|
539
|
-
fs.chmodSync(hookScriptDst, 0o755);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// Read existing settings or create new
|
|
543
|
-
let settings = {};
|
|
544
|
-
if (fs.existsSync(settingsPath)) {
|
|
545
|
-
try {
|
|
546
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
547
|
-
} catch (e) {
|
|
548
|
-
console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
|
|
549
|
-
settings = {};
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Ensure hooks structure exists
|
|
554
|
-
if (!settings.hooks) {
|
|
555
|
-
settings.hooks = {};
|
|
556
|
-
}
|
|
557
|
-
if (!settings.hooks.PreToolUse) {
|
|
558
|
-
settings.hooks.PreToolUse = [];
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Check if dangerous git hook already exists
|
|
562
|
-
const hasHook = settings.hooks.PreToolUse.some(
|
|
563
|
-
(entry) =>
|
|
564
|
-
entry.matcher === 'Bash' &&
|
|
565
|
-
entry.hooks &&
|
|
566
|
-
entry.hooks.some((h) => h.command && h.command.includes(hookScriptName))
|
|
567
|
-
);
|
|
568
|
-
|
|
569
|
-
if (!hasHook) {
|
|
570
|
-
// Add the hook - matches Bash tool to check for dangerous git commands
|
|
571
|
-
settings.hooks.PreToolUse.push({
|
|
572
|
-
matcher: 'Bash',
|
|
573
|
-
hooks: [
|
|
574
|
-
{
|
|
575
|
-
type: 'command',
|
|
576
|
-
command: hookScriptDst,
|
|
577
|
-
},
|
|
578
|
-
],
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
// Write updated settings
|
|
582
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
583
|
-
console.log(`[AgentTaskExecutor] Installed dangerous git blocking hook in ${settingsPath}`);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
dangerousGitHookInstalledDirs.add(userClaudeDir);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
447
|
/**
|
|
590
448
|
* Spawn claude-zeroshots process and stream output via message bus
|
|
591
449
|
* @param {Object} agent - Agent instance
|
|
@@ -645,37 +503,43 @@ async function spawnClaudeTask(agent, context) {
|
|
|
645
503
|
return spawnClaudeTaskIsolated(agent, context);
|
|
646
504
|
}
|
|
647
505
|
|
|
648
|
-
// NON-ISOLATION MODE:
|
|
506
|
+
// NON-ISOLATION MODE: Load safety hooks through an additional per-run settings file. Claude
|
|
507
|
+
// still reads the user's normal config and the repository's project/local config.
|
|
649
508
|
// AskUserQuestion blocking handled via:
|
|
650
509
|
// 1. Prompt injection (see agent-context-builder)
|
|
651
510
|
// 2. PreToolUse hook (defense-in-depth) - activated by ZEROSHOT_BLOCK_ASK_USER env var
|
|
652
|
-
const
|
|
511
|
+
const claudeSettingsPath =
|
|
653
512
|
providerName === 'claude'
|
|
654
|
-
?
|
|
655
|
-
|
|
656
|
-
worktreePath: agent.worktree?.path || null,
|
|
513
|
+
? prepareClaudeSettingsOverlay({
|
|
514
|
+
includeDangerousGit: Boolean(agent.worktree?.enabled),
|
|
657
515
|
})
|
|
658
516
|
: null;
|
|
659
517
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
agent,
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
518
|
+
let taskId;
|
|
519
|
+
let pendingLaunch;
|
|
520
|
+
try {
|
|
521
|
+
const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, { claudeSettingsPath });
|
|
522
|
+
taskId = await spawnTaskProcess({
|
|
523
|
+
agent,
|
|
524
|
+
ctPath,
|
|
525
|
+
args,
|
|
526
|
+
cwd,
|
|
527
|
+
spawnEnv,
|
|
528
|
+
});
|
|
529
|
+
pendingLaunch = agent.currentTask;
|
|
530
|
+
} catch (error) {
|
|
531
|
+
cleanupCallerOwnedCommand(error, () => cleanupClaudeSettingsOverlay(claudeSettingsPath));
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
669
534
|
|
|
535
|
+
// The task ID transfers provider and cleanup ownership to the detached
|
|
536
|
+
// watcher. From this point, follower/PID observation failures must not remove
|
|
537
|
+
// files that a live provider still reads.
|
|
670
538
|
agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId}`);
|
|
671
|
-
|
|
672
|
-
// Wait for task to be registered in zeroshot storage (race condition fix)
|
|
673
539
|
await waitForTaskReady(agent, taskId);
|
|
540
|
+
if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
|
|
674
541
|
|
|
675
|
-
|
|
676
|
-
// The watcher spawns the actual CLI and writes PID to SQLite asynchronously.
|
|
677
|
-
// We must poll because the watcher runs in a forked process.
|
|
678
|
-
const MAX_PID_POLLS = 30; // 3 seconds max
|
|
542
|
+
const MAX_PID_POLLS = 30;
|
|
679
543
|
const PID_POLL_DELAY = 100;
|
|
680
544
|
let realPid = null;
|
|
681
545
|
let terminalBeforePidObservation = false;
|
|
@@ -693,6 +557,8 @@ async function spawnClaudeTask(agent, context) {
|
|
|
693
557
|
await new Promise((r) => setTimeout(r, PID_POLL_DELAY));
|
|
694
558
|
}
|
|
695
559
|
|
|
560
|
+
if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
|
|
561
|
+
|
|
696
562
|
if (realPid) {
|
|
697
563
|
agent.processPid = realPid;
|
|
698
564
|
agent._publishLifecycle('PROCESS_SPAWNED', { pid: realPid });
|
|
@@ -703,7 +569,6 @@ async function spawnClaudeTask(agent, context) {
|
|
|
703
569
|
agent._log(`⚠️ Agent ${agent.id}: PID not available (task may use non-standard watcher)`);
|
|
704
570
|
}
|
|
705
571
|
|
|
706
|
-
// Now follow the logs and stream output
|
|
707
572
|
return followClaudeTaskLogs(agent, taskId);
|
|
708
573
|
}
|
|
709
574
|
|
|
@@ -744,33 +609,46 @@ function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
|
|
|
744
609
|
args.push('--json-schema', schema);
|
|
745
610
|
}
|
|
746
611
|
|
|
747
|
-
// MCP servers:
|
|
748
|
-
//
|
|
749
|
-
//
|
|
612
|
+
// MCP servers: explicitly forward the repo config through the task CLI.
|
|
613
|
+
// Claude receives the path; providers such as Copilot receive inlined JSON
|
|
614
|
+
// so it survives container path translation.
|
|
750
615
|
for (const mcpArg of resolveMcpConfigArgs(agent, providerName)) {
|
|
751
616
|
args.push(mcpArg);
|
|
752
617
|
}
|
|
753
618
|
|
|
619
|
+
const resumeSessionId = resolveAgentResumeSessionId(agent, providerName);
|
|
620
|
+
if (resumeSessionId) {
|
|
621
|
+
args.push('--resume', resumeSessionId);
|
|
622
|
+
}
|
|
623
|
+
|
|
754
624
|
return args;
|
|
755
625
|
}
|
|
756
626
|
|
|
757
627
|
/**
|
|
758
628
|
* Build the `--mcp-config` args for a task-run invocation, or [] when they don't apply.
|
|
759
629
|
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
* under local, worktree, and Docker isolation without host/container path translation.
|
|
630
|
+
* Claude receives the config path because its CLI supports `--mcp-config`.
|
|
631
|
+
* Other providers whose adapter models an MCP config flag receive inlined
|
|
632
|
+
* content so the identical value works under Docker isolation.
|
|
764
633
|
*/
|
|
765
634
|
function resolveMcpConfigArgs(agent, providerName) {
|
|
766
|
-
if (!providerModelsMcpConfigFlag(providerName)) return [];
|
|
767
|
-
|
|
768
635
|
const mcpPath = resolveRepoMcpConfigPath({
|
|
769
636
|
cwd: agent.config?.cwd || process.cwd(),
|
|
770
637
|
worktreePath: agent.worktree?.path || null,
|
|
771
638
|
});
|
|
772
639
|
if (!mcpPath) return [];
|
|
773
640
|
|
|
641
|
+
if (providerName === 'claude') {
|
|
642
|
+
const forwardedPath = agent.isolation?.enabled
|
|
643
|
+
? resolveContainerMcpConfigPath({
|
|
644
|
+
cwd: agent.config?.cwd || process.cwd(),
|
|
645
|
+
worktreePath: agent.worktree?.path || null,
|
|
646
|
+
})
|
|
647
|
+
: mcpPath;
|
|
648
|
+
return forwardedPath ? ['--mcp-config', forwardedPath] : [];
|
|
649
|
+
}
|
|
650
|
+
if (!providerModelsMcpConfigFlag(providerName)) return [];
|
|
651
|
+
|
|
774
652
|
const content = fs.readFileSync(mcpPath, 'utf8').trim();
|
|
775
653
|
if (content.length === 0) return [];
|
|
776
654
|
|
|
@@ -810,21 +688,8 @@ function buildFinalContext({ agent, context, desiredOutputFormat, runOutputForma
|
|
|
810
688
|
return context;
|
|
811
689
|
}
|
|
812
690
|
|
|
813
|
-
function ensureProviderHooks(agent, providerName, claudeConfigDir = null) {
|
|
814
|
-
if (providerName !== 'claude') {
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
ensureAskUserQuestionHook(claudeConfigDir);
|
|
819
|
-
|
|
820
|
-
// WORKTREE MODE: Install git safety hook (blocks dangerous git commands)
|
|
821
|
-
if (agent.worktree?.enabled) {
|
|
822
|
-
ensureDangerousGitHook(claudeConfigDir);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
691
|
function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
|
|
827
|
-
const {
|
|
692
|
+
const { claudeSettingsPath = null } = options;
|
|
828
693
|
const spawnEnv = { ...process.env };
|
|
829
694
|
const agentCwd = agent.config?.cwd || agent.worktree?.path || process.cwd();
|
|
830
695
|
const clusterId = agent.cluster?.id || agent.cluster_id || process.env.ZEROSHOT_CLUSTER_ID;
|
|
@@ -849,8 +714,8 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
|
|
|
849
714
|
|
|
850
715
|
if (providerName === 'claude') {
|
|
851
716
|
Object.assign(spawnEnv, buildClaudeEnv(modelSpec));
|
|
852
|
-
if (
|
|
853
|
-
spawnEnv
|
|
717
|
+
if (claudeSettingsPath) {
|
|
718
|
+
spawnEnv[CLAUDE_SETTINGS_ENV] = claudeSettingsPath;
|
|
854
719
|
}
|
|
855
720
|
|
|
856
721
|
// WORKTREE MODE: Activate git safety hook via environment variable
|
|
@@ -872,21 +737,136 @@ function parseTaskIdFromOutput(stdout) {
|
|
|
872
737
|
return match ? match[1] : null;
|
|
873
738
|
}
|
|
874
739
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
740
|
+
const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed', 'stale']);
|
|
741
|
+
|
|
742
|
+
function assignDurableTaskId(agent, taskId) {
|
|
743
|
+
if (!taskId || agent.currentTaskId === taskId) return;
|
|
744
|
+
agent.currentTaskId = taskId;
|
|
745
|
+
agent._publishLifecycle('TASK_ID_ASSIGNED', {
|
|
746
|
+
pid: agent.processPid,
|
|
747
|
+
taskId,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function createPendingTaskLaunchHandle({
|
|
752
|
+
agent,
|
|
753
|
+
proc,
|
|
754
|
+
ctPath,
|
|
755
|
+
findPersistedTaskId,
|
|
756
|
+
waitForWrapperClose,
|
|
757
|
+
isWrapperClosed,
|
|
758
|
+
}) {
|
|
759
|
+
let cancellation = null;
|
|
760
|
+
const handle = {
|
|
761
|
+
pendingLaunch: true,
|
|
762
|
+
cancelled: false,
|
|
763
|
+
kill(reason = 'Task killed') {
|
|
764
|
+
handle.cancelled = true;
|
|
765
|
+
if (cancellation) return cancellation;
|
|
766
|
+
const cancellationAttempt = (async () => {
|
|
767
|
+
let taskId = findPersistedTaskId();
|
|
768
|
+
let commandError = null;
|
|
769
|
+
let commandAttempted = false;
|
|
770
|
+
const cancelTask = async (persistedTaskId) => {
|
|
771
|
+
commandAttempted = true;
|
|
772
|
+
assignDurableTaskId(agent, persistedTaskId);
|
|
773
|
+
try {
|
|
774
|
+
await runCommandWithTimeout(ctPath, ['kill', persistedTaskId], { timeout: 10000 });
|
|
775
|
+
} catch (error) {
|
|
776
|
+
commandError = error;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
if (taskId) await cancelTask(taskId);
|
|
878
780
|
|
|
781
|
+
if (!isWrapperClosed()) {
|
|
782
|
+
proc.kill('SIGKILL');
|
|
783
|
+
await waitForWrapperClose;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
taskId = taskId || findPersistedTaskId();
|
|
787
|
+
if (taskId && !commandAttempted) await cancelTask(taskId);
|
|
788
|
+
if (taskId && commandError === null) {
|
|
789
|
+
const task = getTask(taskId);
|
|
790
|
+
if (!task || !TASK_TERMINAL_STATUSES.has(task.status) || task.commandCleanup) {
|
|
791
|
+
commandError = new Error(
|
|
792
|
+
`Task ${taskId} termination and command cleanup were not confirmed`
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
} else if (taskId) {
|
|
796
|
+
const task = getTask(taskId);
|
|
797
|
+
if (task && TASK_TERMINAL_STATUSES.has(task.status) && !task.commandCleanup) {
|
|
798
|
+
commandError = null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (commandError) {
|
|
803
|
+
return { forced: false, reason: commandError.message };
|
|
804
|
+
}
|
|
805
|
+
agent._log?.(`Cancelled pending task launch${taskId ? ` ${taskId}` : ''}: ${reason}`);
|
|
806
|
+
return { forced: true, taskId: taskId || null };
|
|
807
|
+
})();
|
|
808
|
+
cancellation = cancellationAttempt;
|
|
809
|
+
cancellationAttempt.then(
|
|
810
|
+
(termination) => {
|
|
811
|
+
if (termination?.forced === false && cancellation === cancellationAttempt) {
|
|
812
|
+
cancellation = null;
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
() => {
|
|
816
|
+
if (cancellation === cancellationAttempt) cancellation = null;
|
|
817
|
+
}
|
|
818
|
+
);
|
|
819
|
+
return cancellationAttempt;
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
return handle;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function spawnTaskProcess({
|
|
826
|
+
agent,
|
|
827
|
+
ctPath,
|
|
828
|
+
args,
|
|
829
|
+
cwd,
|
|
830
|
+
spawnEnv,
|
|
831
|
+
spawnTimeoutMs = 30000,
|
|
832
|
+
}) {
|
|
833
|
+
// Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it.
|
|
834
|
+
const SPAWN_TIMEOUT_MS = spawnTimeoutMs;
|
|
879
835
|
// spawn() throws on null bytes in argv; strip them before they get there.
|
|
880
836
|
const safeArgs = args.map((arg) => (typeof arg === 'string' ? arg.replace(/\0/g, '') : arg));
|
|
837
|
+
const ownershipToken = createTaskSpawnOwnershipToken();
|
|
838
|
+
const findPersistedTaskId = () => getTaskBySpawnOwnershipToken(ownershipToken)?.id || null;
|
|
881
839
|
|
|
882
840
|
return new Promise((resolve, reject) => {
|
|
883
841
|
const proc = spawn(ctPath, safeArgs, {
|
|
884
842
|
cwd,
|
|
885
843
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
886
|
-
env: spawnEnv,
|
|
844
|
+
env: { ...spawnEnv, [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken },
|
|
887
845
|
windowsHide: true,
|
|
888
846
|
});
|
|
889
847
|
|
|
848
|
+
let wrapperClosed = false;
|
|
849
|
+
let resolveWrapperClose;
|
|
850
|
+
const waitForWrapperClose = new Promise((resolveClose) => {
|
|
851
|
+
resolveWrapperClose = resolveClose;
|
|
852
|
+
});
|
|
853
|
+
const markWrapperClosed = () => {
|
|
854
|
+
if (wrapperClosed) return;
|
|
855
|
+
wrapperClosed = true;
|
|
856
|
+
resolveWrapperClose();
|
|
857
|
+
};
|
|
858
|
+
proc.once('close', markWrapperClosed);
|
|
859
|
+
proc.once('error', markWrapperClosed);
|
|
860
|
+
const pendingLaunch = createPendingTaskLaunchHandle({
|
|
861
|
+
agent,
|
|
862
|
+
proc,
|
|
863
|
+
ctPath,
|
|
864
|
+
findPersistedTaskId,
|
|
865
|
+
waitForWrapperClose,
|
|
866
|
+
isWrapperClosed: () => wrapperClosed,
|
|
867
|
+
});
|
|
868
|
+
agent.currentTask = pendingLaunch;
|
|
869
|
+
|
|
890
870
|
// NOTE: Don't emit PROCESS_SPAWNED here - proc.pid is a wrapper that exits immediately.
|
|
891
871
|
// Real PID comes from task store after watcher spawns the actual CLI process.
|
|
892
872
|
// PROCESS_SPAWNED is emitted in spawnClaudeTask after waitForTaskReady + PID polling.
|
|
@@ -894,18 +874,49 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
|
|
|
894
874
|
let stdout = '';
|
|
895
875
|
let stderr = '';
|
|
896
876
|
let resolved = false;
|
|
877
|
+
let timeoutError = null;
|
|
878
|
+
const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
|
|
879
|
+
const rejectWithOwnership = async (error) => {
|
|
880
|
+
const classifiedError = classifyCleanupOwnership(error);
|
|
881
|
+
if (
|
|
882
|
+
!callerOwnsCommandCleanup(classifiedError) &&
|
|
883
|
+
agent.currentTask === pendingLaunch
|
|
884
|
+
) {
|
|
885
|
+
let termination;
|
|
886
|
+
try {
|
|
887
|
+
termination = await pendingLaunch.kill(classifiedError.message);
|
|
888
|
+
} catch (cleanupError) {
|
|
889
|
+
termination = { forced: false, reason: cleanupError.message };
|
|
890
|
+
}
|
|
891
|
+
if (termination?.forced === false) {
|
|
892
|
+
classifiedError.message += ` Task cleanup was not confirmed: ${termination.reason}`;
|
|
893
|
+
classifiedError.retainTaskHandle = true;
|
|
894
|
+
classifiedError.permanent = true;
|
|
895
|
+
classifiedError.restartExhausted = true;
|
|
896
|
+
classifiedError.terminationExhausted = true;
|
|
897
|
+
classifiedError.terminationAttempts = 1;
|
|
898
|
+
classifiedError.taskId = agent.currentTaskId || null;
|
|
899
|
+
} else if (agent.currentTask === pendingLaunch) {
|
|
900
|
+
agent.currentTask = null;
|
|
901
|
+
agent.currentTaskId = null;
|
|
902
|
+
agent.processPid = null;
|
|
903
|
+
agent.lastOutputTime = null;
|
|
904
|
+
agent.taskStartedAt = null;
|
|
905
|
+
}
|
|
906
|
+
} else if (wrapperClosed && agent.currentTask === pendingLaunch) {
|
|
907
|
+
agent.currentTask = null;
|
|
908
|
+
}
|
|
909
|
+
reject(classifiedError);
|
|
910
|
+
};
|
|
897
911
|
|
|
898
912
|
// CRITICAL: Timeout to prevent infinite hang if provider CLI hangs
|
|
899
913
|
const spawnTimeout = setTimeout(() => {
|
|
900
914
|
if (resolved) return;
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
new Error(
|
|
905
|
-
`Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
|
|
906
|
-
`stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
|
|
907
|
-
)
|
|
915
|
+
timeoutError = new Error(
|
|
916
|
+
`Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
|
|
917
|
+
`stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
|
|
908
918
|
);
|
|
919
|
+
proc.kill('SIGKILL');
|
|
909
920
|
}, SPAWN_TIMEOUT_MS);
|
|
910
921
|
|
|
911
922
|
proc.stdout.on('data', (data) => {
|
|
@@ -916,48 +927,53 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
|
|
|
916
927
|
stderr += data.toString();
|
|
917
928
|
});
|
|
918
929
|
|
|
919
|
-
proc.on('close', (code, signal) => {
|
|
930
|
+
proc.on('close', async (code, signal) => {
|
|
920
931
|
clearTimeout(spawnTimeout);
|
|
921
932
|
if (resolved) return;
|
|
922
933
|
resolved = true;
|
|
934
|
+
if (timeoutError) {
|
|
935
|
+
await rejectWithOwnership(timeoutError);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
923
938
|
// Handle process killed by signal (e.g., SIGTERM, SIGKILL, SIGSTOP)
|
|
924
939
|
if (signal) {
|
|
925
|
-
|
|
940
|
+
await rejectWithOwnership(
|
|
941
|
+
new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`)
|
|
942
|
+
);
|
|
926
943
|
return;
|
|
927
944
|
}
|
|
928
945
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
946
|
+
let spawnedTaskId;
|
|
947
|
+
try {
|
|
948
|
+
spawnedTaskId = requireTaskIdFromWrapperResult({
|
|
949
|
+
code,
|
|
950
|
+
stdout,
|
|
951
|
+
stderr,
|
|
952
|
+
parseTaskId: parseTaskIdFromOutput,
|
|
953
|
+
persistedTaskId: findPersistedTaskId(),
|
|
954
|
+
});
|
|
955
|
+
} catch (error) {
|
|
956
|
+
await rejectWithOwnership(error);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
939
959
|
|
|
940
|
-
|
|
941
|
-
if (agent.enableLivenessCheck) {
|
|
942
|
-
agent.taskStartedAt = Date.now();
|
|
943
|
-
agent.lastOutputTime = agent.taskStartedAt;
|
|
944
|
-
agent._startLivenessCheck();
|
|
945
|
-
}
|
|
960
|
+
assignDurableTaskId(agent, spawnedTaskId);
|
|
946
961
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
|
|
962
|
+
// Start liveness monitoring
|
|
963
|
+
if (agent.enableLivenessCheck) {
|
|
964
|
+
agent.taskStartedAt = Date.now();
|
|
965
|
+
agent.lastOutputTime = agent.taskStartedAt;
|
|
966
|
+
agent._startLivenessCheck();
|
|
953
967
|
}
|
|
968
|
+
|
|
969
|
+
resolve(spawnedTaskId);
|
|
954
970
|
});
|
|
955
971
|
|
|
956
972
|
proc.on('error', (error) => {
|
|
957
973
|
clearTimeout(spawnTimeout);
|
|
958
974
|
if (resolved) return;
|
|
959
975
|
resolved = true;
|
|
960
|
-
|
|
976
|
+
rejectWithOwnership(error);
|
|
961
977
|
});
|
|
962
978
|
});
|
|
963
979
|
}
|
|
@@ -1212,8 +1228,21 @@ function buildFailureContext({ agent, taskId, providerName, state, stdout }) {
|
|
|
1212
1228
|
});
|
|
1213
1229
|
}
|
|
1214
1230
|
|
|
1215
|
-
async function buildCompletionResult({
|
|
1231
|
+
async function buildCompletionResult({
|
|
1232
|
+
agent,
|
|
1233
|
+
taskId,
|
|
1234
|
+
providerName,
|
|
1235
|
+
state,
|
|
1236
|
+
stdout,
|
|
1237
|
+
success,
|
|
1238
|
+
taskInfo = getTask(taskId),
|
|
1239
|
+
}) {
|
|
1216
1240
|
const classified = await evaluateStructuredSuccess({ agent, taskId, state, success });
|
|
1241
|
+
const resumeIdentityError = classified.success ? validateCompletedResumeIdentity(taskInfo) : null;
|
|
1242
|
+
if (resumeIdentityError) {
|
|
1243
|
+
classified.success = false;
|
|
1244
|
+
classified.error = resumeIdentityError;
|
|
1245
|
+
}
|
|
1217
1246
|
const vertexModelError =
|
|
1218
1247
|
providerName === 'claude'
|
|
1219
1248
|
? extractClaudeVertexModelError(state.output, {
|
|
@@ -1233,6 +1262,12 @@ async function buildCompletionResult({ agent, taskId, providerName, state, stdou
|
|
|
1233
1262
|
output: state.output,
|
|
1234
1263
|
error: errorContext,
|
|
1235
1264
|
tokenUsage: extractTokenUsage(state.output, providerName),
|
|
1265
|
+
providerSession: providerSessionFromCompletedTask({
|
|
1266
|
+
agent,
|
|
1267
|
+
providerName,
|
|
1268
|
+
taskInfo,
|
|
1269
|
+
logicalSuccess: classified.success,
|
|
1270
|
+
}),
|
|
1236
1271
|
vertexModelError,
|
|
1237
1272
|
};
|
|
1238
1273
|
}
|
|
@@ -1342,10 +1377,26 @@ function handleStatusExecError({ agent, state, ctPath, taskId, error, stderr, re
|
|
|
1342
1377
|
return true;
|
|
1343
1378
|
}
|
|
1344
1379
|
|
|
1380
|
+
function hasPendingCommandCleanup(statusOutput) {
|
|
1381
|
+
return /Cleanup:\s+pending/i.test(stripAnsiCodes(statusOutput));
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function retryHostTerminalCleanup({ agent, taskId, state, ctPath }) {
|
|
1385
|
+
if (state.commandCleanupRecoveryPending) return;
|
|
1386
|
+
state.commandCleanupRecoveryPending = true;
|
|
1387
|
+
runCommandWithTimeout(ctPath, ['kill', taskId], { timeout: 10000 }, (error) => {
|
|
1388
|
+
state.commandCleanupRecoveryPending = false;
|
|
1389
|
+
if (error) {
|
|
1390
|
+
agent._log(`[${agent.id}] Terminal command cleanup recovery will retry: ${error.message}`);
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1345
1395
|
function handleStatusCompletion({
|
|
1346
1396
|
agent,
|
|
1347
1397
|
taskId,
|
|
1348
1398
|
providerName,
|
|
1399
|
+
ctPath,
|
|
1349
1400
|
state,
|
|
1350
1401
|
stdout,
|
|
1351
1402
|
pollLogFile,
|
|
@@ -1358,6 +1409,11 @@ function handleStatusCompletion({
|
|
|
1358
1409
|
return false;
|
|
1359
1410
|
}
|
|
1360
1411
|
|
|
1412
|
+
if (hasPendingCommandCleanup(cleanStdout)) {
|
|
1413
|
+
retryHostTerminalCleanup({ agent, taskId, state, ctPath });
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1361
1417
|
pollLogFile();
|
|
1362
1418
|
|
|
1363
1419
|
let success = isCompleted;
|
|
@@ -1452,6 +1508,7 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
|
|
|
1452
1508
|
state.consecutiveExecFailures = 0;
|
|
1453
1509
|
handleStatusCompletion({
|
|
1454
1510
|
agent,
|
|
1511
|
+
ctPath,
|
|
1455
1512
|
taskId,
|
|
1456
1513
|
providerName,
|
|
1457
1514
|
state,
|
|
@@ -1476,7 +1533,7 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
|
|
|
1476
1533
|
*/
|
|
1477
1534
|
function followClaudeTaskLogs(agent, taskId) {
|
|
1478
1535
|
const fsModule = require('fs');
|
|
1479
|
-
const ctPath = getClaudeTasksPath();
|
|
1536
|
+
const ctPath = agent.taskCliPath || getClaudeTasksPath();
|
|
1480
1537
|
const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
|
|
1481
1538
|
|
|
1482
1539
|
return createLogFollower({ agent, taskId, fsModule, ctPath, providerName });
|
|
@@ -1557,35 +1614,145 @@ async function spawnClaudeTaskIsolated(agent, context) {
|
|
|
1557
1614
|
|
|
1558
1615
|
// STEP 1: Spawn task and extract task ID (same as non-isolated mode)
|
|
1559
1616
|
// Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
|
|
1560
|
-
const SPAWN_TIMEOUT_MS = 30000;
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1617
|
+
const SPAWN_TIMEOUT_MS = agent.spawnTimeoutMs ?? 30000;
|
|
1618
|
+
const ownershipToken = createTaskSpawnOwnershipToken();
|
|
1619
|
+
// Auth env vars are injected by IsolationManager; the launch token is the
|
|
1620
|
+
// only authoritative bridge back to the detached task row in the container.
|
|
1621
|
+
const isolatedEnv = {
|
|
1622
|
+
...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
|
|
1623
|
+
[TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
|
|
1624
|
+
};
|
|
1564
1625
|
|
|
1626
|
+
let isolatedPendingLaunch = null;
|
|
1565
1627
|
const taskId = await new Promise((resolve, reject) => {
|
|
1566
1628
|
const proc = manager.spawnInContainer(clusterId, command, {
|
|
1567
1629
|
env: isolatedEnv,
|
|
1568
1630
|
});
|
|
1569
1631
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1632
|
+
let isolatedTaskId = null;
|
|
1633
|
+
let wrapperClosed = false;
|
|
1634
|
+
let resolveWrapperClose;
|
|
1574
1635
|
let stdout = '';
|
|
1575
1636
|
let stderr = '';
|
|
1576
1637
|
let resolved = false;
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1638
|
+
let timeoutError = null;
|
|
1639
|
+
let spawnTimeout = null;
|
|
1640
|
+
let cancellation = null;
|
|
1641
|
+
const waitForWrapperClose = new Promise((resolveClose) => {
|
|
1642
|
+
resolveWrapperClose = resolveClose;
|
|
1643
|
+
});
|
|
1644
|
+
const markWrapperClosed = () => {
|
|
1645
|
+
if (!wrapperClosed) {
|
|
1646
|
+
wrapperClosed = true;
|
|
1647
|
+
resolveWrapperClose();
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
const findPersistedTaskId = async () => {
|
|
1651
|
+
const persistedTaskId = await resolveIsolatedTaskIdBySpawnToken(
|
|
1652
|
+
manager,
|
|
1653
|
+
clusterId,
|
|
1654
|
+
ownershipToken
|
|
1655
|
+
);
|
|
1656
|
+
if (persistedTaskId) {
|
|
1657
|
+
isolatedTaskId = persistedTaskId;
|
|
1658
|
+
assignDurableTaskId(agent, persistedTaskId);
|
|
1659
|
+
}
|
|
1660
|
+
return persistedTaskId;
|
|
1661
|
+
};
|
|
1662
|
+
const rejectLaunch = (error, { retainHandle = false } = {}) => {
|
|
1580
1663
|
if (resolved) return;
|
|
1581
1664
|
resolved = true;
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1665
|
+
clearTimeout(spawnTimeout);
|
|
1666
|
+
const rejection = error instanceof Error ? error : new Error(String(error));
|
|
1667
|
+
if (retainHandle) {
|
|
1668
|
+
rejection.retainTaskHandle = true;
|
|
1669
|
+
rejection.permanent = true;
|
|
1670
|
+
rejection.restartExhausted = true;
|
|
1671
|
+
rejection.terminationExhausted = true;
|
|
1672
|
+
rejection.terminationAttempts = 1;
|
|
1673
|
+
rejection.taskId = isolatedTaskId || agent.currentTaskId || null;
|
|
1674
|
+
} else if (agent.currentTask === isolatedPendingLaunch) {
|
|
1675
|
+
agent.currentTask = null;
|
|
1676
|
+
}
|
|
1677
|
+
reject(rejection);
|
|
1678
|
+
};
|
|
1679
|
+
|
|
1680
|
+
proc.once('close', markWrapperClosed);
|
|
1681
|
+
proc.once('error', markWrapperClosed);
|
|
1682
|
+
isolatedPendingLaunch = {
|
|
1683
|
+
pendingLaunch: true,
|
|
1684
|
+
cancelled: false,
|
|
1685
|
+
async kill(reason = 'Task killed') {
|
|
1686
|
+
isolatedPendingLaunch.cancelled = true;
|
|
1687
|
+
if (cancellation) return cancellation;
|
|
1688
|
+
cancellation = (async () => {
|
|
1689
|
+
let termination = null;
|
|
1690
|
+
let commandError = null;
|
|
1691
|
+
try {
|
|
1692
|
+
const persistedTaskId = isolatedTaskId || (await findPersistedTaskId());
|
|
1693
|
+
if (persistedTaskId) {
|
|
1694
|
+
termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
|
|
1695
|
+
}
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
commandError = error;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (!wrapperClosed) {
|
|
1701
|
+
proc.kill('SIGKILL');
|
|
1702
|
+
await waitForWrapperClose;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
try {
|
|
1706
|
+
const persistedTaskId = isolatedTaskId || (await findPersistedTaskId());
|
|
1707
|
+
if (persistedTaskId && !termination) {
|
|
1708
|
+
termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
|
|
1709
|
+
commandError = null;
|
|
1710
|
+
}
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
commandError ||= error;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (commandError) {
|
|
1716
|
+
return { forced: false, reason: commandError.message };
|
|
1717
|
+
}
|
|
1718
|
+
agent._log?.(`Cancelled pending isolated task launch: ${reason}`);
|
|
1719
|
+
return termination
|
|
1720
|
+
? { ...termination, forced: true, taskId: isolatedTaskId }
|
|
1721
|
+
: { forced: true, taskId: null };
|
|
1722
|
+
})();
|
|
1723
|
+
|
|
1724
|
+
const termination = await cancellation;
|
|
1725
|
+
if (termination?.forced === false) cancellation = null;
|
|
1726
|
+
if (!resolved) {
|
|
1727
|
+
const error =
|
|
1728
|
+
timeoutError ||
|
|
1729
|
+
new Error(
|
|
1730
|
+
termination?.forced === false
|
|
1731
|
+
? `Task launch cancellation was not confirmed: ${termination.reason}`
|
|
1732
|
+
: `Task launch cancelled: ${isolatedTaskId || 'before persistence'}`
|
|
1733
|
+
);
|
|
1734
|
+
rejectLaunch(error, { retainHandle: termination?.forced === false });
|
|
1735
|
+
}
|
|
1736
|
+
return termination;
|
|
1737
|
+
},
|
|
1738
|
+
};
|
|
1739
|
+
agent.currentTask = isolatedPendingLaunch;
|
|
1740
|
+
|
|
1741
|
+
// Track PID for resource monitoring
|
|
1742
|
+
agent.processPid = proc.pid;
|
|
1743
|
+
agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
|
|
1744
|
+
|
|
1745
|
+
// CRITICAL: Timeout to prevent infinite hang if provider CLI hangs. Timeout
|
|
1746
|
+
// uses the same cancellation path so a durable child cannot outlive it.
|
|
1747
|
+
spawnTimeout = setTimeout(() => {
|
|
1748
|
+
if (resolved) return;
|
|
1749
|
+
timeoutError = new Error(
|
|
1750
|
+
`Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
|
|
1751
|
+
`stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
|
|
1588
1752
|
);
|
|
1753
|
+
isolatedPendingLaunch.kill(timeoutError.message).catch((error) => {
|
|
1754
|
+
rejectLaunch(error, { retainHandle: true });
|
|
1755
|
+
});
|
|
1589
1756
|
}, SPAWN_TIMEOUT_MS);
|
|
1590
1757
|
|
|
1591
1758
|
proc.stdout.on('data', (data) => {
|
|
@@ -1596,42 +1763,47 @@ async function spawnClaudeTaskIsolated(agent, context) {
|
|
|
1596
1763
|
stderr += data.toString();
|
|
1597
1764
|
});
|
|
1598
1765
|
|
|
1599
|
-
proc.on('close', (code, signal) => {
|
|
1766
|
+
proc.on('close', async (code, signal) => {
|
|
1600
1767
|
clearTimeout(spawnTimeout);
|
|
1601
|
-
if (resolved) return;
|
|
1602
|
-
resolved = true;
|
|
1603
|
-
// Handle process killed by signal
|
|
1768
|
+
if (resolved || isolatedPendingLaunch.cancelled) return;
|
|
1604
1769
|
if (signal) {
|
|
1605
|
-
|
|
1770
|
+
await isolatedPendingLaunch.kill(
|
|
1771
|
+
`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`
|
|
1772
|
+
);
|
|
1606
1773
|
return;
|
|
1607
1774
|
}
|
|
1608
1775
|
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
const spawnedTaskId =
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1776
|
+
try {
|
|
1777
|
+
const persistedTaskId = await findPersistedTaskId();
|
|
1778
|
+
const spawnedTaskId = requireTaskIdFromWrapperResult({
|
|
1779
|
+
code,
|
|
1780
|
+
stdout,
|
|
1781
|
+
stderr,
|
|
1782
|
+
parseTaskId: parseTaskIdFromOutput,
|
|
1783
|
+
persistedTaskId,
|
|
1784
|
+
});
|
|
1785
|
+
isolatedTaskId = spawnedTaskId;
|
|
1786
|
+
assignDurableTaskId(agent, spawnedTaskId);
|
|
1787
|
+
resolved = true;
|
|
1788
|
+
resolve(spawnedTaskId);
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
const termination = await isolatedPendingLaunch.kill(error.message);
|
|
1791
|
+
if (!resolved) {
|
|
1792
|
+
rejectLaunch(error, { retainHandle: termination?.forced === false });
|
|
1622
1793
|
}
|
|
1623
|
-
} else {
|
|
1624
|
-
reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
|
|
1625
1794
|
}
|
|
1626
1795
|
});
|
|
1627
1796
|
|
|
1628
|
-
proc.on('error', (error) => {
|
|
1797
|
+
proc.on('error', async (error) => {
|
|
1629
1798
|
clearTimeout(spawnTimeout);
|
|
1630
|
-
if (resolved) return;
|
|
1631
|
-
|
|
1632
|
-
|
|
1799
|
+
if (resolved || isolatedPendingLaunch.cancelled) return;
|
|
1800
|
+
const termination = await isolatedPendingLaunch.kill(error.message);
|
|
1801
|
+
if (termination?.forced === false && !resolved) {
|
|
1802
|
+
rejectLaunch(error, { retainHandle: true });
|
|
1803
|
+
}
|
|
1633
1804
|
});
|
|
1634
1805
|
});
|
|
1806
|
+
if (isolatedPendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
|
|
1635
1807
|
|
|
1636
1808
|
agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId} in container...`);
|
|
1637
1809
|
|
|
@@ -1730,6 +1902,32 @@ function rejectIsolatedFollower({ agent, state, cleanup, reject, error }) {
|
|
|
1730
1902
|
reject(error);
|
|
1731
1903
|
}
|
|
1732
1904
|
|
|
1905
|
+
function rejectIsolatedFollowerRetainingHandle({ state, cleanup, reject, error }) {
|
|
1906
|
+
if (state.resolved || state.failureSettled) return;
|
|
1907
|
+
state.failureSettled = true;
|
|
1908
|
+
cleanup();
|
|
1909
|
+
reject(error);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
async function resolveIsolatedTaskIdBySpawnToken(manager, clusterId, ownershipToken) {
|
|
1913
|
+
const result = await manager.execInContainer(clusterId, [
|
|
1914
|
+
'zeroshot',
|
|
1915
|
+
'get-task-id-by-spawn-token',
|
|
1916
|
+
ownershipToken,
|
|
1917
|
+
]);
|
|
1918
|
+
if (result.code === 2) return null;
|
|
1919
|
+
if (result.code !== 0) {
|
|
1920
|
+
throw new Error(
|
|
1921
|
+
`Failed to resolve isolated task ownership: ${result.stderr || result.stdout || `exit ${result.code}`}`
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
const taskId = result.stdout.trim();
|
|
1925
|
+
if (!taskId) {
|
|
1926
|
+
throw new Error('Isolated task ownership lookup returned an empty task ID');
|
|
1927
|
+
}
|
|
1928
|
+
return taskId;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1733
1931
|
function parseIsolatedStatus(output) {
|
|
1734
1932
|
return output.match(/Status:\s+(completed|failed|killed|stale|cancelled)/i)?.[1].toLowerCase();
|
|
1735
1933
|
}
|
|
@@ -1737,23 +1935,27 @@ function parseIsolatedStatus(output) {
|
|
|
1737
1935
|
async function terminateIsolatedTask(manager, clusterId, taskId) {
|
|
1738
1936
|
const before = await manager.execInContainer(clusterId, ['zeroshot', 'status', taskId]);
|
|
1739
1937
|
const beforeStatus = before.code === 0 ? parseIsolatedStatus(before.stdout) : null;
|
|
1740
|
-
|
|
1741
|
-
|
|
1938
|
+
const result = await manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
|
|
1939
|
+
if (result.code !== 0) {
|
|
1940
|
+
throw new Error(
|
|
1941
|
+
`Failed to terminate isolated task ${taskId}: ${result.stderr || result.stdout || `exit ${result.code}`}`
|
|
1942
|
+
);
|
|
1742
1943
|
}
|
|
1743
1944
|
|
|
1744
|
-
const result = await manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
|
|
1745
1945
|
const status = await manager.execInContainer(clusterId, ['zeroshot', 'status', taskId]);
|
|
1746
1946
|
const afterStatus = status.code === 0 ? parseIsolatedStatus(status.stdout) : null;
|
|
1747
1947
|
if (!afterStatus) {
|
|
1748
1948
|
throw new Error(
|
|
1749
|
-
`Failed to
|
|
1949
|
+
`Failed to confirm isolated task ${taskId} after cleanup recovery: ${
|
|
1950
|
+
status.stderr || status.stdout || `exit ${status.code}`
|
|
1951
|
+
}`
|
|
1750
1952
|
);
|
|
1751
1953
|
}
|
|
1752
1954
|
|
|
1753
1955
|
return {
|
|
1754
|
-
alreadyTerminal:
|
|
1755
|
-
forced: afterStatus === 'killed',
|
|
1756
|
-
status: afterStatus,
|
|
1956
|
+
alreadyTerminal: Boolean(beforeStatus),
|
|
1957
|
+
forced: !beforeStatus && afterStatus === 'killed',
|
|
1958
|
+
status: beforeStatus || afterStatus,
|
|
1757
1959
|
};
|
|
1758
1960
|
}
|
|
1759
1961
|
|
|
@@ -1923,7 +2125,7 @@ function buildIsolatedLifecycleHandle({
|
|
|
1923
2125
|
terminate,
|
|
1924
2126
|
kill: terminate,
|
|
1925
2127
|
failClosed(error) {
|
|
1926
|
-
|
|
2128
|
+
rejectIsolatedFollowerRetainingHandle({ state, cleanup, reject, error });
|
|
1927
2129
|
},
|
|
1928
2130
|
};
|
|
1929
2131
|
}
|
|
@@ -1996,6 +2198,7 @@ function startIsolatedTail({ agent, manager, clusterId, logFilePath, state, onLi
|
|
|
1996
2198
|
});
|
|
1997
2199
|
}
|
|
1998
2200
|
|
|
2201
|
+
|
|
1999
2202
|
async function checkIsolatedStatus({
|
|
2000
2203
|
agent,
|
|
2001
2204
|
manager,
|
|
@@ -2025,6 +2228,28 @@ async function checkIsolatedStatus({
|
|
|
2025
2228
|
return;
|
|
2026
2229
|
}
|
|
2027
2230
|
|
|
2231
|
+
if (status && hasPendingCommandCleanup(statusOutput)) {
|
|
2232
|
+
if (!state.commandCleanupRecoveryPromise) {
|
|
2233
|
+
const recovery = manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
|
|
2234
|
+
state.commandCleanupRecoveryPromise = recovery;
|
|
2235
|
+
try {
|
|
2236
|
+
const result = await recovery;
|
|
2237
|
+
if (result.code !== 0) {
|
|
2238
|
+
agent._log(
|
|
2239
|
+
`[${agent.id}] Isolated terminal command cleanup recovery will retry: ${
|
|
2240
|
+
result.stderr || result.stdout || `exit ${result.code}`
|
|
2241
|
+
}`
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
} finally {
|
|
2245
|
+
if (state.commandCleanupRecoveryPromise === recovery) {
|
|
2246
|
+
state.commandCleanupRecoveryPromise = null;
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2028
2253
|
state.logFilePath = logFilePath;
|
|
2029
2254
|
await settleIsolatedTerminalStatus({
|
|
2030
2255
|
agent,
|
|
@@ -2324,29 +2549,40 @@ async function killTask(agent, termination = 'Task killed') {
|
|
|
2324
2549
|
const currentTask = agent.currentTask;
|
|
2325
2550
|
const taskId = agent.currentTaskId;
|
|
2326
2551
|
|
|
2552
|
+
if (currentTask?.pendingLaunch && typeof currentTask.kill === 'function') {
|
|
2553
|
+
const pendingTermination = await currentTask.kill(reason, { code });
|
|
2554
|
+
if (pendingTermination?.forced === false) return pendingTermination;
|
|
2555
|
+
agent._stopLivenessCheck?.();
|
|
2556
|
+
agent.currentTask = null;
|
|
2557
|
+
agent.currentTaskId = null;
|
|
2558
|
+
agent.processPid = null;
|
|
2559
|
+
agent.lastOutputTime = null;
|
|
2560
|
+
agent.taskStartedAt = null;
|
|
2561
|
+
return pendingTermination;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2327
2564
|
if (agent.isolation?.enabled && taskId) {
|
|
2328
2565
|
return killIsolatedTask(agent, currentTask, taskId, reason, code);
|
|
2329
2566
|
}
|
|
2330
2567
|
|
|
2331
|
-
agent._stopLivenessCheck?.();
|
|
2332
|
-
|
|
2333
2568
|
// Kill the underlying task before resolving the local follower. This keeps
|
|
2334
2569
|
// retries from racing a provider process that is still shutting down.
|
|
2335
2570
|
if (taskId) {
|
|
2336
|
-
const ctPath = getClaudeTasksPath();
|
|
2571
|
+
const ctPath = agent.taskCommandPath || getClaudeTasksPath();
|
|
2337
2572
|
try {
|
|
2338
2573
|
// `kill` is a top-level smart command. `task kill` has never existed.
|
|
2339
2574
|
await runCommandWithTimeout(ctPath, ['kill', taskId], { timeout: 10000 });
|
|
2340
2575
|
agent._log?.(`Killed task ${taskId}`);
|
|
2341
2576
|
} catch (error) {
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
agent._log?.(`Note: Could not kill task ${taskId}: ${error.message}`);
|
|
2577
|
+
agent._log?.(`Note: Could not confirm termination for task ${taskId}: ${error.message}`);
|
|
2578
|
+
return { forced: false, reason: error.message };
|
|
2345
2579
|
}
|
|
2346
2580
|
}
|
|
2347
2581
|
|
|
2582
|
+
agent._stopLivenessCheck?.();
|
|
2583
|
+
|
|
2348
2584
|
if (currentTask && typeof currentTask.kill === 'function') {
|
|
2349
|
-
currentTask.kill(reason, { code });
|
|
2585
|
+
await currentTask.kill(reason, { code });
|
|
2350
2586
|
}
|
|
2351
2587
|
|
|
2352
2588
|
agent.currentTask = null;
|
|
@@ -2358,24 +2594,28 @@ async function killTask(agent, termination = 'Task killed') {
|
|
|
2358
2594
|
|
|
2359
2595
|
async function killIsolatedTask(agent, currentTask, taskId, reason, code) {
|
|
2360
2596
|
let termination;
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
currentTask.kill
|
|
2597
|
+
try {
|
|
2598
|
+
if (currentTask && typeof currentTask.terminate === 'function') {
|
|
2599
|
+
termination = await currentTask.terminate(reason, { code });
|
|
2600
|
+
} else {
|
|
2601
|
+
termination = await terminateIsolatedTask(
|
|
2602
|
+
agent.isolation.manager,
|
|
2603
|
+
agent.isolation.clusterId,
|
|
2604
|
+
taskId
|
|
2605
|
+
);
|
|
2606
|
+
if (currentTask && typeof currentTask.kill === 'function') {
|
|
2607
|
+
currentTask.kill(reason, { code });
|
|
2608
|
+
}
|
|
2371
2609
|
}
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
return { forced: false, reason: error.message };
|
|
2372
2612
|
}
|
|
2373
2613
|
|
|
2374
|
-
|
|
2375
|
-
if (termination?.forced === false) {
|
|
2614
|
+
if (termination?.forced === false && !termination.alreadyTerminal) {
|
|
2376
2615
|
return termination;
|
|
2377
2616
|
}
|
|
2378
2617
|
|
|
2618
|
+
agent._stopLivenessCheck?.();
|
|
2379
2619
|
agent.currentTask = null;
|
|
2380
2620
|
agent.currentTaskId = null;
|
|
2381
2621
|
agent.processPid = null;
|
|
@@ -2386,6 +2626,8 @@ async function killIsolatedTask(agent, currentTask, taskId, reason, code) {
|
|
|
2386
2626
|
|
|
2387
2627
|
module.exports = {
|
|
2388
2628
|
ensureAskUserQuestionHook,
|
|
2629
|
+
ensureDangerousGitHook,
|
|
2630
|
+
resolveMcpConfigArgs,
|
|
2389
2631
|
spawnClaudeTask,
|
|
2390
2632
|
spawnTaskProcess,
|
|
2391
2633
|
followClaudeTaskLogs,
|
|
@@ -2397,5 +2639,6 @@ module.exports = {
|
|
|
2397
2639
|
broadcastIsolatedLine,
|
|
2398
2640
|
parseResultOutput,
|
|
2399
2641
|
buildCompletionResult,
|
|
2642
|
+
buildTaskRunArgs,
|
|
2400
2643
|
killTask,
|
|
2401
2644
|
};
|