@the-open-engine/zeroshot 6.10.0 → 6.10.2
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/index.js +16 -0
- 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 +42 -1
- package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
- package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
- package/lib/agent-cli-provider/contract-options.js +2 -0
- package/lib/agent-cli-provider/contract-options.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +6 -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-lifecycle.js +13 -6
- package/src/agent/agent-task-executor.js +527 -313
- package/src/agent-cli-provider/adapters/claude.ts +46 -1
- package/src/agent-cli-provider/contract-options.ts +6 -0
- package/src/agent-cli-provider/types.ts +9 -6
- package/src/claude-task-runner.js +168 -44
- package/src/darwin-keychain-boundary.js +174 -0
- package/src/task-spawn-cleanup-ownership.js +82 -0
- package/src/worktree-claude-config.js +193 -85
- package/src/worktree-tooling-env.js +3 -0
- package/task-lib/attachable-watcher.js +93 -267
- 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/status.js +1 -0
- package/task-lib/runner.js +61 -4
- package/task-lib/store.js +68 -6
- package/task-lib/watcher-output-runtime.js +284 -0
- package/task-lib/watcher.js +124 -257
|
@@ -15,12 +15,18 @@ 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
|
+
const { applyDarwinKeychainBoundaryToEnv } = require('../darwin-keychain-boundary.js');
|
|
22
23
|
const {
|
|
23
|
-
|
|
24
|
+
CLAUDE_SETTINGS_ENV,
|
|
25
|
+
cleanupClaudeSettingsOverlay,
|
|
26
|
+
ensureAskUserQuestionHook,
|
|
27
|
+
ensureDangerousGitHook,
|
|
28
|
+
prepareClaudeSettingsOverlay,
|
|
29
|
+
resolveContainerMcpConfigPath,
|
|
24
30
|
resolveRepoMcpConfigPath,
|
|
25
31
|
} = require('../worktree-claude-config.js');
|
|
26
32
|
const {
|
|
@@ -28,6 +34,14 @@ const {
|
|
|
28
34
|
wrapTaskRunWithIsolatedSettings,
|
|
29
35
|
} = require('../task-run-model-args.js');
|
|
30
36
|
const { buildRawLogOnlyMetadata } = require('./context-replay-policy');
|
|
37
|
+
const {
|
|
38
|
+
TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
|
|
39
|
+
cleanupCallerOwnedCommand,
|
|
40
|
+
callerOwnsCommandCleanup,
|
|
41
|
+
createTaskSpawnOwnershipToken,
|
|
42
|
+
requireTaskIdFromWrapperResult,
|
|
43
|
+
trackTaskWrapperCleanupOwnership,
|
|
44
|
+
} = require('../task-spawn-cleanup-ownership');
|
|
31
45
|
const {
|
|
32
46
|
providerSessionFromCompletedTask,
|
|
33
47
|
resolveAgentResumeSessionId,
|
|
@@ -378,10 +392,6 @@ function extractErrorContext({ output, statusOutput, taskId, isNotFound = false,
|
|
|
378
392
|
return sanitizeErrorMessage(`Task failed. Output: ${trimmedOutput}`);
|
|
379
393
|
}
|
|
380
394
|
|
|
381
|
-
// Track which config dirs already have zeroshot-installed hooks.
|
|
382
|
-
const askUserQuestionHookInstalledDirs = new Set();
|
|
383
|
-
const dangerousGitHookInstalledDirs = new Set();
|
|
384
|
-
|
|
385
395
|
/**
|
|
386
396
|
* Extract token usage from NDJSON output.
|
|
387
397
|
* Looks for the 'result' event line which contains usage data.
|
|
@@ -435,162 +445,6 @@ function extractTokenUsage(output, providerName = 'claude') {
|
|
|
435
445
|
};
|
|
436
446
|
}
|
|
437
447
|
|
|
438
|
-
/**
|
|
439
|
-
* Ensure the AskUserQuestion blocking hook is installed in user's Claude config.
|
|
440
|
-
* This adds defense-in-depth by blocking the tool at the Claude CLI level.
|
|
441
|
-
* Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
|
|
442
|
-
*
|
|
443
|
-
* Safe to call multiple times - only modifies config once per process.
|
|
444
|
-
*/
|
|
445
|
-
function ensureAskUserQuestionHook(targetClaudeDir = null) {
|
|
446
|
-
const userClaudeDir =
|
|
447
|
-
targetClaudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
448
|
-
if (askUserQuestionHookInstalledDirs.has(userClaudeDir)) {
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
const hooksDir = path.join(userClaudeDir, 'hooks');
|
|
452
|
-
const settingsPath = path.join(userClaudeDir, 'settings.json');
|
|
453
|
-
const hookScriptName = 'block-ask-user-question.py';
|
|
454
|
-
const hookScriptDst = path.join(hooksDir, hookScriptName);
|
|
455
|
-
|
|
456
|
-
// Ensure hooks directory exists
|
|
457
|
-
if (!fs.existsSync(hooksDir)) {
|
|
458
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Copy hook script if not present or outdated
|
|
462
|
-
const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
|
|
463
|
-
if (fs.existsSync(hookScriptSrc)) {
|
|
464
|
-
// Always copy to ensure latest version
|
|
465
|
-
fs.copyFileSync(hookScriptSrc, hookScriptDst);
|
|
466
|
-
fs.chmodSync(hookScriptDst, 0o755);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Read existing settings or create new
|
|
470
|
-
let settings = {};
|
|
471
|
-
if (fs.existsSync(settingsPath)) {
|
|
472
|
-
try {
|
|
473
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
474
|
-
} catch (e) {
|
|
475
|
-
console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
|
|
476
|
-
settings = {};
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// Ensure hooks structure exists
|
|
481
|
-
if (!settings.hooks) {
|
|
482
|
-
settings.hooks = {};
|
|
483
|
-
}
|
|
484
|
-
if (!settings.hooks.PreToolUse) {
|
|
485
|
-
settings.hooks.PreToolUse = [];
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Check if AskUserQuestion hook already exists
|
|
489
|
-
const hasHook = settings.hooks.PreToolUse.some(
|
|
490
|
-
(entry) =>
|
|
491
|
-
entry.matcher === 'AskUserQuestion' ||
|
|
492
|
-
(entry.hooks && entry.hooks.some((h) => h.command && h.command.includes(hookScriptName)))
|
|
493
|
-
);
|
|
494
|
-
|
|
495
|
-
if (!hasHook) {
|
|
496
|
-
// Add the hook
|
|
497
|
-
settings.hooks.PreToolUse.push({
|
|
498
|
-
matcher: 'AskUserQuestion',
|
|
499
|
-
hooks: [
|
|
500
|
-
{
|
|
501
|
-
type: 'command',
|
|
502
|
-
command: hookScriptDst,
|
|
503
|
-
},
|
|
504
|
-
],
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
// Write updated settings
|
|
508
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
509
|
-
console.log(`[AgentTaskExecutor] Installed AskUserQuestion blocking hook in ${settingsPath}`);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
askUserQuestionHookInstalledDirs.add(userClaudeDir);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Ensure the dangerous git blocking hook is installed in user's Claude config.
|
|
517
|
-
* This blocks dangerous git commands like stash, checkout --, reset --hard, etc.
|
|
518
|
-
* Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
|
|
519
|
-
*
|
|
520
|
-
* Only used in worktree mode - Docker isolation mode has its own git-safe.sh wrapper.
|
|
521
|
-
* Safe to call multiple times - only modifies config once per process.
|
|
522
|
-
*/
|
|
523
|
-
function ensureDangerousGitHook(targetClaudeDir = null) {
|
|
524
|
-
const userClaudeDir =
|
|
525
|
-
targetClaudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
526
|
-
if (dangerousGitHookInstalledDirs.has(userClaudeDir)) {
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
const hooksDir = path.join(userClaudeDir, 'hooks');
|
|
530
|
-
const settingsPath = path.join(userClaudeDir, 'settings.json');
|
|
531
|
-
const hookScriptName = 'block-dangerous-git.py';
|
|
532
|
-
const hookScriptDst = path.join(hooksDir, hookScriptName);
|
|
533
|
-
|
|
534
|
-
// Ensure hooks directory exists
|
|
535
|
-
if (!fs.existsSync(hooksDir)) {
|
|
536
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// Copy hook script if not present or outdated
|
|
540
|
-
const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
|
|
541
|
-
if (fs.existsSync(hookScriptSrc)) {
|
|
542
|
-
// Always copy to ensure latest version
|
|
543
|
-
fs.copyFileSync(hookScriptSrc, hookScriptDst);
|
|
544
|
-
fs.chmodSync(hookScriptDst, 0o755);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// Read existing settings or create new
|
|
548
|
-
let settings = {};
|
|
549
|
-
if (fs.existsSync(settingsPath)) {
|
|
550
|
-
try {
|
|
551
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
552
|
-
} catch (e) {
|
|
553
|
-
console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
|
|
554
|
-
settings = {};
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Ensure hooks structure exists
|
|
559
|
-
if (!settings.hooks) {
|
|
560
|
-
settings.hooks = {};
|
|
561
|
-
}
|
|
562
|
-
if (!settings.hooks.PreToolUse) {
|
|
563
|
-
settings.hooks.PreToolUse = [];
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// Check if dangerous git hook already exists
|
|
567
|
-
const hasHook = settings.hooks.PreToolUse.some(
|
|
568
|
-
(entry) =>
|
|
569
|
-
entry.matcher === 'Bash' &&
|
|
570
|
-
entry.hooks &&
|
|
571
|
-
entry.hooks.some((h) => h.command && h.command.includes(hookScriptName))
|
|
572
|
-
);
|
|
573
|
-
|
|
574
|
-
if (!hasHook) {
|
|
575
|
-
// Add the hook - matches Bash tool to check for dangerous git commands
|
|
576
|
-
settings.hooks.PreToolUse.push({
|
|
577
|
-
matcher: 'Bash',
|
|
578
|
-
hooks: [
|
|
579
|
-
{
|
|
580
|
-
type: 'command',
|
|
581
|
-
command: hookScriptDst,
|
|
582
|
-
},
|
|
583
|
-
],
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
// Write updated settings
|
|
587
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
588
|
-
console.log(`[AgentTaskExecutor] Installed dangerous git blocking hook in ${settingsPath}`);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
dangerousGitHookInstalledDirs.add(userClaudeDir);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
448
|
/**
|
|
595
449
|
* Spawn claude-zeroshots process and stream output via message bus
|
|
596
450
|
* @param {Object} agent - Agent instance
|
|
@@ -650,37 +504,43 @@ async function spawnClaudeTask(agent, context) {
|
|
|
650
504
|
return spawnClaudeTaskIsolated(agent, context);
|
|
651
505
|
}
|
|
652
506
|
|
|
653
|
-
// NON-ISOLATION MODE:
|
|
507
|
+
// NON-ISOLATION MODE: Load safety hooks through an additional per-run settings file. Claude
|
|
508
|
+
// still reads the user's normal config and the repository's project/local config.
|
|
654
509
|
// AskUserQuestion blocking handled via:
|
|
655
510
|
// 1. Prompt injection (see agent-context-builder)
|
|
656
511
|
// 2. PreToolUse hook (defense-in-depth) - activated by ZEROSHOT_BLOCK_ASK_USER env var
|
|
657
|
-
const
|
|
512
|
+
const claudeSettingsPath =
|
|
658
513
|
providerName === 'claude'
|
|
659
|
-
?
|
|
660
|
-
|
|
661
|
-
worktreePath: agent.worktree?.path || null,
|
|
514
|
+
? prepareClaudeSettingsOverlay({
|
|
515
|
+
includeDangerousGit: Boolean(agent.worktree?.enabled),
|
|
662
516
|
})
|
|
663
517
|
: null;
|
|
664
518
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
agent,
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
519
|
+
let taskId;
|
|
520
|
+
let pendingLaunch;
|
|
521
|
+
try {
|
|
522
|
+
const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, { claudeSettingsPath });
|
|
523
|
+
taskId = await spawnTaskProcess({
|
|
524
|
+
agent,
|
|
525
|
+
ctPath,
|
|
526
|
+
args,
|
|
527
|
+
cwd,
|
|
528
|
+
spawnEnv,
|
|
529
|
+
});
|
|
530
|
+
pendingLaunch = agent.currentTask;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
cleanupCallerOwnedCommand(error, () => cleanupClaudeSettingsOverlay(claudeSettingsPath));
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
674
535
|
|
|
536
|
+
// The task ID transfers provider and cleanup ownership to the detached
|
|
537
|
+
// watcher. From this point, follower/PID observation failures must not remove
|
|
538
|
+
// files that a live provider still reads.
|
|
675
539
|
agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId}`);
|
|
676
|
-
|
|
677
|
-
// Wait for task to be registered in zeroshot storage (race condition fix)
|
|
678
540
|
await waitForTaskReady(agent, taskId);
|
|
541
|
+
if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
|
|
679
542
|
|
|
680
|
-
|
|
681
|
-
// The watcher spawns the actual CLI and writes PID to SQLite asynchronously.
|
|
682
|
-
// We must poll because the watcher runs in a forked process.
|
|
683
|
-
const MAX_PID_POLLS = 30; // 3 seconds max
|
|
543
|
+
const MAX_PID_POLLS = 30;
|
|
684
544
|
const PID_POLL_DELAY = 100;
|
|
685
545
|
let realPid = null;
|
|
686
546
|
let terminalBeforePidObservation = false;
|
|
@@ -698,6 +558,8 @@ async function spawnClaudeTask(agent, context) {
|
|
|
698
558
|
await new Promise((r) => setTimeout(r, PID_POLL_DELAY));
|
|
699
559
|
}
|
|
700
560
|
|
|
561
|
+
if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
|
|
562
|
+
|
|
701
563
|
if (realPid) {
|
|
702
564
|
agent.processPid = realPid;
|
|
703
565
|
agent._publishLifecycle('PROCESS_SPAWNED', { pid: realPid });
|
|
@@ -708,7 +570,6 @@ async function spawnClaudeTask(agent, context) {
|
|
|
708
570
|
agent._log(`⚠️ Agent ${agent.id}: PID not available (task may use non-standard watcher)`);
|
|
709
571
|
}
|
|
710
572
|
|
|
711
|
-
// Now follow the logs and stream output
|
|
712
573
|
return followClaudeTaskLogs(agent, taskId);
|
|
713
574
|
}
|
|
714
575
|
|
|
@@ -749,9 +610,9 @@ function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
|
|
|
749
610
|
args.push('--json-schema', schema);
|
|
750
611
|
}
|
|
751
612
|
|
|
752
|
-
// MCP servers:
|
|
753
|
-
//
|
|
754
|
-
//
|
|
613
|
+
// MCP servers: explicitly forward the repo config through the task CLI.
|
|
614
|
+
// Claude receives the path; providers such as Copilot receive inlined JSON
|
|
615
|
+
// so it survives container path translation.
|
|
755
616
|
for (const mcpArg of resolveMcpConfigArgs(agent, providerName)) {
|
|
756
617
|
args.push(mcpArg);
|
|
757
618
|
}
|
|
@@ -767,20 +628,28 @@ function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
|
|
|
767
628
|
/**
|
|
768
629
|
* Build the `--mcp-config` args for a task-run invocation, or [] when they don't apply.
|
|
769
630
|
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
* under local, worktree, and Docker isolation without host/container path translation.
|
|
631
|
+
* Claude receives the config path because its CLI supports `--mcp-config`.
|
|
632
|
+
* Other providers whose adapter models an MCP config flag receive inlined
|
|
633
|
+
* content so the identical value works under Docker isolation.
|
|
774
634
|
*/
|
|
775
635
|
function resolveMcpConfigArgs(agent, providerName) {
|
|
776
|
-
if (!providerModelsMcpConfigFlag(providerName)) return [];
|
|
777
|
-
|
|
778
636
|
const mcpPath = resolveRepoMcpConfigPath({
|
|
779
637
|
cwd: agent.config?.cwd || process.cwd(),
|
|
780
638
|
worktreePath: agent.worktree?.path || null,
|
|
781
639
|
});
|
|
782
640
|
if (!mcpPath) return [];
|
|
783
641
|
|
|
642
|
+
if (providerName === 'claude') {
|
|
643
|
+
const forwardedPath = agent.isolation?.enabled
|
|
644
|
+
? resolveContainerMcpConfigPath({
|
|
645
|
+
cwd: agent.config?.cwd || process.cwd(),
|
|
646
|
+
worktreePath: agent.worktree?.path || null,
|
|
647
|
+
})
|
|
648
|
+
: mcpPath;
|
|
649
|
+
return forwardedPath ? ['--mcp-config', forwardedPath] : [];
|
|
650
|
+
}
|
|
651
|
+
if (!providerModelsMcpConfigFlag(providerName)) return [];
|
|
652
|
+
|
|
784
653
|
const content = fs.readFileSync(mcpPath, 'utf8').trim();
|
|
785
654
|
if (content.length === 0) return [];
|
|
786
655
|
|
|
@@ -820,21 +689,11 @@ function buildFinalContext({ agent, context, desiredOutputFormat, runOutputForma
|
|
|
820
689
|
return context;
|
|
821
690
|
}
|
|
822
691
|
|
|
823
|
-
function ensureProviderHooks(agent, providerName, claudeConfigDir = null) {
|
|
824
|
-
if (providerName !== 'claude') {
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
ensureAskUserQuestionHook(claudeConfigDir);
|
|
829
|
-
|
|
830
|
-
// WORKTREE MODE: Install git safety hook (blocks dangerous git commands)
|
|
831
|
-
if (agent.worktree?.enabled) {
|
|
832
|
-
ensureDangerousGitHook(claudeConfigDir);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
692
|
function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
|
|
837
|
-
const {
|
|
693
|
+
const {
|
|
694
|
+
claudeSettingsPath = null,
|
|
695
|
+
applyDarwinKeychainBoundary = applyDarwinKeychainBoundaryToEnv,
|
|
696
|
+
} = options;
|
|
838
697
|
const spawnEnv = { ...process.env };
|
|
839
698
|
const agentCwd = agent.config?.cwd || agent.worktree?.path || process.cwd();
|
|
840
699
|
const clusterId = agent.cluster?.id || agent.cluster_id || process.env.ZEROSHOT_CLUSTER_ID;
|
|
@@ -859,8 +718,8 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
|
|
|
859
718
|
|
|
860
719
|
if (providerName === 'claude') {
|
|
861
720
|
Object.assign(spawnEnv, buildClaudeEnv(modelSpec));
|
|
862
|
-
if (
|
|
863
|
-
spawnEnv
|
|
721
|
+
if (claudeSettingsPath) {
|
|
722
|
+
spawnEnv[CLAUDE_SETTINGS_ENV] = claudeSettingsPath;
|
|
864
723
|
}
|
|
865
724
|
|
|
866
725
|
// WORKTREE MODE: Activate git safety hook via environment variable
|
|
@@ -869,6 +728,13 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
|
|
|
869
728
|
}
|
|
870
729
|
}
|
|
871
730
|
|
|
731
|
+
// KEYCHAIN BOUNDARY (darwin only): non-interactive local/worktree worker
|
|
732
|
+
// descendants must not reach the user's GUI Keychain session (issue #704).
|
|
733
|
+
// Docker isolation never reaches buildSpawnEnv (see spawnClaudeTaskIsolated).
|
|
734
|
+
// Applied before the worktree tool bins so repo-managed tool substitutes
|
|
735
|
+
// stay first on PATH.
|
|
736
|
+
applyDarwinKeychainBoundary(spawnEnv);
|
|
737
|
+
|
|
872
738
|
prependWorktreeToolBinToEnv(spawnEnv, {
|
|
873
739
|
cwd: agentCwd,
|
|
874
740
|
worktreePath: agent.worktree?.path || null,
|
|
@@ -882,21 +748,129 @@ function parseTaskIdFromOutput(stdout) {
|
|
|
882
748
|
return match ? match[1] : null;
|
|
883
749
|
}
|
|
884
750
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
751
|
+
const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed', 'stale']);
|
|
752
|
+
|
|
753
|
+
function assignDurableTaskId(agent, taskId) {
|
|
754
|
+
if (!taskId || agent.currentTaskId === taskId) return;
|
|
755
|
+
agent.currentTaskId = taskId;
|
|
756
|
+
agent._publishLifecycle('TASK_ID_ASSIGNED', {
|
|
757
|
+
pid: agent.processPid,
|
|
758
|
+
taskId,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
888
761
|
|
|
762
|
+
function createPendingTaskLaunchHandle({
|
|
763
|
+
agent,
|
|
764
|
+
proc,
|
|
765
|
+
ctPath,
|
|
766
|
+
findPersistedTaskId,
|
|
767
|
+
waitForWrapperClose,
|
|
768
|
+
isWrapperClosed,
|
|
769
|
+
}) {
|
|
770
|
+
let cancellation = null;
|
|
771
|
+
const handle = {
|
|
772
|
+
pendingLaunch: true,
|
|
773
|
+
cancelled: false,
|
|
774
|
+
kill(reason = 'Task killed') {
|
|
775
|
+
handle.cancelled = true;
|
|
776
|
+
if (cancellation) return cancellation;
|
|
777
|
+
const cancellationAttempt = (async () => {
|
|
778
|
+
let taskId = findPersistedTaskId();
|
|
779
|
+
let commandError = null;
|
|
780
|
+
let commandAttempted = false;
|
|
781
|
+
const cancelTask = async (persistedTaskId) => {
|
|
782
|
+
commandAttempted = true;
|
|
783
|
+
assignDurableTaskId(agent, persistedTaskId);
|
|
784
|
+
try {
|
|
785
|
+
await runCommandWithTimeout(ctPath, ['kill', persistedTaskId], { timeout: 10000 });
|
|
786
|
+
} catch (error) {
|
|
787
|
+
commandError = error;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
if (taskId) await cancelTask(taskId);
|
|
791
|
+
|
|
792
|
+
if (!isWrapperClosed()) {
|
|
793
|
+
proc.kill('SIGKILL');
|
|
794
|
+
await waitForWrapperClose;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
taskId = taskId || findPersistedTaskId();
|
|
798
|
+
if (taskId && !commandAttempted) await cancelTask(taskId);
|
|
799
|
+
if (taskId && commandError === null) {
|
|
800
|
+
const task = getTask(taskId);
|
|
801
|
+
if (!task || !TASK_TERMINAL_STATUSES.has(task.status) || task.commandCleanup) {
|
|
802
|
+
commandError = new Error(
|
|
803
|
+
`Task ${taskId} termination and command cleanup were not confirmed`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
} else if (taskId) {
|
|
807
|
+
const task = getTask(taskId);
|
|
808
|
+
if (task && TASK_TERMINAL_STATUSES.has(task.status) && !task.commandCleanup) {
|
|
809
|
+
commandError = null;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (commandError) {
|
|
814
|
+
return { forced: false, reason: commandError.message };
|
|
815
|
+
}
|
|
816
|
+
agent._log?.(`Cancelled pending task launch${taskId ? ` ${taskId}` : ''}: ${reason}`);
|
|
817
|
+
return { forced: true, taskId: taskId || null };
|
|
818
|
+
})();
|
|
819
|
+
cancellation = cancellationAttempt;
|
|
820
|
+
cancellationAttempt.then(
|
|
821
|
+
(termination) => {
|
|
822
|
+
if (termination?.forced === false && cancellation === cancellationAttempt) {
|
|
823
|
+
cancellation = null;
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
() => {
|
|
827
|
+
if (cancellation === cancellationAttempt) cancellation = null;
|
|
828
|
+
}
|
|
829
|
+
);
|
|
830
|
+
return cancellationAttempt;
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
return handle;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs = 30000 }) {
|
|
837
|
+
// Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it.
|
|
838
|
+
const SPAWN_TIMEOUT_MS = spawnTimeoutMs;
|
|
889
839
|
// spawn() throws on null bytes in argv; strip them before they get there.
|
|
890
840
|
const safeArgs = args.map((arg) => (typeof arg === 'string' ? arg.replace(/\0/g, '') : arg));
|
|
841
|
+
const ownershipToken = createTaskSpawnOwnershipToken();
|
|
842
|
+
const findPersistedTaskId = () => getTaskBySpawnOwnershipToken(ownershipToken)?.id || null;
|
|
891
843
|
|
|
892
844
|
return new Promise((resolve, reject) => {
|
|
893
845
|
const proc = spawn(ctPath, safeArgs, {
|
|
894
846
|
cwd,
|
|
895
847
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
896
|
-
env: spawnEnv,
|
|
848
|
+
env: { ...spawnEnv, [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken },
|
|
897
849
|
windowsHide: true,
|
|
898
850
|
});
|
|
899
851
|
|
|
852
|
+
let wrapperClosed = false;
|
|
853
|
+
let resolveWrapperClose;
|
|
854
|
+
const waitForWrapperClose = new Promise((resolveClose) => {
|
|
855
|
+
resolveWrapperClose = resolveClose;
|
|
856
|
+
});
|
|
857
|
+
const markWrapperClosed = () => {
|
|
858
|
+
if (wrapperClosed) return;
|
|
859
|
+
wrapperClosed = true;
|
|
860
|
+
resolveWrapperClose();
|
|
861
|
+
};
|
|
862
|
+
proc.once('close', markWrapperClosed);
|
|
863
|
+
proc.once('error', markWrapperClosed);
|
|
864
|
+
const pendingLaunch = createPendingTaskLaunchHandle({
|
|
865
|
+
agent,
|
|
866
|
+
proc,
|
|
867
|
+
ctPath,
|
|
868
|
+
findPersistedTaskId,
|
|
869
|
+
waitForWrapperClose,
|
|
870
|
+
isWrapperClosed: () => wrapperClosed,
|
|
871
|
+
});
|
|
872
|
+
agent.currentTask = pendingLaunch;
|
|
873
|
+
|
|
900
874
|
// NOTE: Don't emit PROCESS_SPAWNED here - proc.pid is a wrapper that exits immediately.
|
|
901
875
|
// Real PID comes from task store after watcher spawns the actual CLI process.
|
|
902
876
|
// PROCESS_SPAWNED is emitted in spawnClaudeTask after waitForTaskReady + PID polling.
|
|
@@ -904,18 +878,46 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
|
|
|
904
878
|
let stdout = '';
|
|
905
879
|
let stderr = '';
|
|
906
880
|
let resolved = false;
|
|
881
|
+
let timeoutError = null;
|
|
882
|
+
const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
|
|
883
|
+
const rejectWithOwnership = async (error) => {
|
|
884
|
+
const classifiedError = classifyCleanupOwnership(error);
|
|
885
|
+
if (!callerOwnsCommandCleanup(classifiedError) && agent.currentTask === pendingLaunch) {
|
|
886
|
+
let termination;
|
|
887
|
+
try {
|
|
888
|
+
termination = await pendingLaunch.kill(classifiedError.message);
|
|
889
|
+
} catch (cleanupError) {
|
|
890
|
+
termination = { forced: false, reason: cleanupError.message };
|
|
891
|
+
}
|
|
892
|
+
if (termination?.forced === false) {
|
|
893
|
+
classifiedError.message += ` Task cleanup was not confirmed: ${termination.reason}`;
|
|
894
|
+
classifiedError.retainTaskHandle = true;
|
|
895
|
+
classifiedError.permanent = true;
|
|
896
|
+
classifiedError.restartExhausted = true;
|
|
897
|
+
classifiedError.terminationExhausted = true;
|
|
898
|
+
classifiedError.terminationAttempts = 1;
|
|
899
|
+
classifiedError.taskId = agent.currentTaskId || null;
|
|
900
|
+
} else if (agent.currentTask === pendingLaunch) {
|
|
901
|
+
agent.currentTask = null;
|
|
902
|
+
agent.currentTaskId = null;
|
|
903
|
+
agent.processPid = null;
|
|
904
|
+
agent.lastOutputTime = null;
|
|
905
|
+
agent.taskStartedAt = null;
|
|
906
|
+
}
|
|
907
|
+
} else if (wrapperClosed && agent.currentTask === pendingLaunch) {
|
|
908
|
+
agent.currentTask = null;
|
|
909
|
+
}
|
|
910
|
+
reject(classifiedError);
|
|
911
|
+
};
|
|
907
912
|
|
|
908
913
|
// CRITICAL: Timeout to prevent infinite hang if provider CLI hangs
|
|
909
914
|
const spawnTimeout = setTimeout(() => {
|
|
910
915
|
if (resolved) return;
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
new Error(
|
|
915
|
-
`Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
|
|
916
|
-
`stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
|
|
917
|
-
)
|
|
916
|
+
timeoutError = new Error(
|
|
917
|
+
`Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
|
|
918
|
+
`stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
|
|
918
919
|
);
|
|
920
|
+
proc.kill('SIGKILL');
|
|
919
921
|
}, SPAWN_TIMEOUT_MS);
|
|
920
922
|
|
|
921
923
|
proc.stdout.on('data', (data) => {
|
|
@@ -926,48 +928,53 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
|
|
|
926
928
|
stderr += data.toString();
|
|
927
929
|
});
|
|
928
930
|
|
|
929
|
-
proc.on('close', (code, signal) => {
|
|
931
|
+
proc.on('close', async (code, signal) => {
|
|
930
932
|
clearTimeout(spawnTimeout);
|
|
931
933
|
if (resolved) return;
|
|
932
934
|
resolved = true;
|
|
935
|
+
if (timeoutError) {
|
|
936
|
+
await rejectWithOwnership(timeoutError);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
933
939
|
// Handle process killed by signal (e.g., SIGTERM, SIGKILL, SIGSTOP)
|
|
934
940
|
if (signal) {
|
|
935
|
-
|
|
941
|
+
await rejectWithOwnership(
|
|
942
|
+
new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`)
|
|
943
|
+
);
|
|
936
944
|
return;
|
|
937
945
|
}
|
|
938
946
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
947
|
+
let spawnedTaskId;
|
|
948
|
+
try {
|
|
949
|
+
spawnedTaskId = requireTaskIdFromWrapperResult({
|
|
950
|
+
code,
|
|
951
|
+
stdout,
|
|
952
|
+
stderr,
|
|
953
|
+
parseTaskId: parseTaskIdFromOutput,
|
|
954
|
+
persistedTaskId: findPersistedTaskId(),
|
|
955
|
+
});
|
|
956
|
+
} catch (error) {
|
|
957
|
+
await rejectWithOwnership(error);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
949
960
|
|
|
950
|
-
|
|
951
|
-
if (agent.enableLivenessCheck) {
|
|
952
|
-
agent.taskStartedAt = Date.now();
|
|
953
|
-
agent.lastOutputTime = agent.taskStartedAt;
|
|
954
|
-
agent._startLivenessCheck();
|
|
955
|
-
}
|
|
961
|
+
assignDurableTaskId(agent, spawnedTaskId);
|
|
956
962
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
|
|
963
|
+
// Start liveness monitoring
|
|
964
|
+
if (agent.enableLivenessCheck) {
|
|
965
|
+
agent.taskStartedAt = Date.now();
|
|
966
|
+
agent.lastOutputTime = agent.taskStartedAt;
|
|
967
|
+
agent._startLivenessCheck();
|
|
963
968
|
}
|
|
969
|
+
|
|
970
|
+
resolve(spawnedTaskId);
|
|
964
971
|
});
|
|
965
972
|
|
|
966
973
|
proc.on('error', (error) => {
|
|
967
974
|
clearTimeout(spawnTimeout);
|
|
968
975
|
if (resolved) return;
|
|
969
976
|
resolved = true;
|
|
970
|
-
|
|
977
|
+
rejectWithOwnership(error);
|
|
971
978
|
});
|
|
972
979
|
});
|
|
973
980
|
}
|
|
@@ -1371,10 +1378,26 @@ function handleStatusExecError({ agent, state, ctPath, taskId, error, stderr, re
|
|
|
1371
1378
|
return true;
|
|
1372
1379
|
}
|
|
1373
1380
|
|
|
1381
|
+
function hasPendingCommandCleanup(statusOutput) {
|
|
1382
|
+
return /Cleanup:\s+pending/i.test(stripAnsiCodes(statusOutput));
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function retryHostTerminalCleanup({ agent, taskId, state, ctPath }) {
|
|
1386
|
+
if (state.commandCleanupRecoveryPending) return;
|
|
1387
|
+
state.commandCleanupRecoveryPending = true;
|
|
1388
|
+
runCommandWithTimeout(ctPath, ['kill', taskId], { timeout: 10000 }, (error) => {
|
|
1389
|
+
state.commandCleanupRecoveryPending = false;
|
|
1390
|
+
if (error) {
|
|
1391
|
+
agent._log(`[${agent.id}] Terminal command cleanup recovery will retry: ${error.message}`);
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1374
1396
|
function handleStatusCompletion({
|
|
1375
1397
|
agent,
|
|
1376
1398
|
taskId,
|
|
1377
1399
|
providerName,
|
|
1400
|
+
ctPath,
|
|
1378
1401
|
state,
|
|
1379
1402
|
stdout,
|
|
1380
1403
|
pollLogFile,
|
|
@@ -1387,6 +1410,11 @@ function handleStatusCompletion({
|
|
|
1387
1410
|
return false;
|
|
1388
1411
|
}
|
|
1389
1412
|
|
|
1413
|
+
if (hasPendingCommandCleanup(cleanStdout)) {
|
|
1414
|
+
retryHostTerminalCleanup({ agent, taskId, state, ctPath });
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1390
1418
|
pollLogFile();
|
|
1391
1419
|
|
|
1392
1420
|
let success = isCompleted;
|
|
@@ -1481,6 +1509,7 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
|
|
|
1481
1509
|
state.consecutiveExecFailures = 0;
|
|
1482
1510
|
handleStatusCompletion({
|
|
1483
1511
|
agent,
|
|
1512
|
+
ctPath,
|
|
1484
1513
|
taskId,
|
|
1485
1514
|
providerName,
|
|
1486
1515
|
state,
|
|
@@ -1505,7 +1534,7 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
|
|
|
1505
1534
|
*/
|
|
1506
1535
|
function followClaudeTaskLogs(agent, taskId) {
|
|
1507
1536
|
const fsModule = require('fs');
|
|
1508
|
-
const ctPath = getClaudeTasksPath();
|
|
1537
|
+
const ctPath = agent.taskCliPath || getClaudeTasksPath();
|
|
1509
1538
|
const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
|
|
1510
1539
|
|
|
1511
1540
|
return createLogFollower({ agent, taskId, fsModule, ctPath, providerName });
|
|
@@ -1586,35 +1615,145 @@ async function spawnClaudeTaskIsolated(agent, context) {
|
|
|
1586
1615
|
|
|
1587
1616
|
// STEP 1: Spawn task and extract task ID (same as non-isolated mode)
|
|
1588
1617
|
// Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
|
|
1589
|
-
const SPAWN_TIMEOUT_MS = 30000;
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1618
|
+
const SPAWN_TIMEOUT_MS = agent.spawnTimeoutMs ?? 30000;
|
|
1619
|
+
const ownershipToken = createTaskSpawnOwnershipToken();
|
|
1620
|
+
// Auth env vars are injected by IsolationManager; the launch token is the
|
|
1621
|
+
// only authoritative bridge back to the detached task row in the container.
|
|
1622
|
+
const isolatedEnv = {
|
|
1623
|
+
...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
|
|
1624
|
+
[TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
|
|
1625
|
+
};
|
|
1593
1626
|
|
|
1627
|
+
let isolatedPendingLaunch = null;
|
|
1594
1628
|
const taskId = await new Promise((resolve, reject) => {
|
|
1595
1629
|
const proc = manager.spawnInContainer(clusterId, command, {
|
|
1596
1630
|
env: isolatedEnv,
|
|
1597
1631
|
});
|
|
1598
1632
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1633
|
+
let isolatedTaskId = null;
|
|
1634
|
+
let wrapperClosed = false;
|
|
1635
|
+
let resolveWrapperClose;
|
|
1603
1636
|
let stdout = '';
|
|
1604
1637
|
let stderr = '';
|
|
1605
1638
|
let resolved = false;
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1639
|
+
let timeoutError = null;
|
|
1640
|
+
let spawnTimeout = null;
|
|
1641
|
+
let cancellation = null;
|
|
1642
|
+
const waitForWrapperClose = new Promise((resolveClose) => {
|
|
1643
|
+
resolveWrapperClose = resolveClose;
|
|
1644
|
+
});
|
|
1645
|
+
const markWrapperClosed = () => {
|
|
1646
|
+
if (!wrapperClosed) {
|
|
1647
|
+
wrapperClosed = true;
|
|
1648
|
+
resolveWrapperClose();
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
const findPersistedTaskId = async () => {
|
|
1652
|
+
const persistedTaskId = await resolveIsolatedTaskIdBySpawnToken(
|
|
1653
|
+
manager,
|
|
1654
|
+
clusterId,
|
|
1655
|
+
ownershipToken
|
|
1656
|
+
);
|
|
1657
|
+
if (persistedTaskId) {
|
|
1658
|
+
isolatedTaskId = persistedTaskId;
|
|
1659
|
+
assignDurableTaskId(agent, persistedTaskId);
|
|
1660
|
+
}
|
|
1661
|
+
return persistedTaskId;
|
|
1662
|
+
};
|
|
1663
|
+
const rejectLaunch = (error, { retainHandle = false } = {}) => {
|
|
1609
1664
|
if (resolved) return;
|
|
1610
1665
|
resolved = true;
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1666
|
+
clearTimeout(spawnTimeout);
|
|
1667
|
+
const rejection = error instanceof Error ? error : new Error(String(error));
|
|
1668
|
+
if (retainHandle) {
|
|
1669
|
+
rejection.retainTaskHandle = true;
|
|
1670
|
+
rejection.permanent = true;
|
|
1671
|
+
rejection.restartExhausted = true;
|
|
1672
|
+
rejection.terminationExhausted = true;
|
|
1673
|
+
rejection.terminationAttempts = 1;
|
|
1674
|
+
rejection.taskId = isolatedTaskId || agent.currentTaskId || null;
|
|
1675
|
+
} else if (agent.currentTask === isolatedPendingLaunch) {
|
|
1676
|
+
agent.currentTask = null;
|
|
1677
|
+
}
|
|
1678
|
+
reject(rejection);
|
|
1679
|
+
};
|
|
1680
|
+
|
|
1681
|
+
proc.once('close', markWrapperClosed);
|
|
1682
|
+
proc.once('error', markWrapperClosed);
|
|
1683
|
+
isolatedPendingLaunch = {
|
|
1684
|
+
pendingLaunch: true,
|
|
1685
|
+
cancelled: false,
|
|
1686
|
+
async kill(reason = 'Task killed') {
|
|
1687
|
+
isolatedPendingLaunch.cancelled = true;
|
|
1688
|
+
if (cancellation) return cancellation;
|
|
1689
|
+
cancellation = (async () => {
|
|
1690
|
+
let termination = null;
|
|
1691
|
+
let commandError = null;
|
|
1692
|
+
try {
|
|
1693
|
+
const persistedTaskId = isolatedTaskId || (await findPersistedTaskId());
|
|
1694
|
+
if (persistedTaskId) {
|
|
1695
|
+
termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
|
|
1696
|
+
}
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
commandError = error;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
if (!wrapperClosed) {
|
|
1702
|
+
proc.kill('SIGKILL');
|
|
1703
|
+
await waitForWrapperClose;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
try {
|
|
1707
|
+
const persistedTaskId = isolatedTaskId || (await findPersistedTaskId());
|
|
1708
|
+
if (persistedTaskId && !termination) {
|
|
1709
|
+
termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
|
|
1710
|
+
commandError = null;
|
|
1711
|
+
}
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
commandError ||= error;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (commandError) {
|
|
1717
|
+
return { forced: false, reason: commandError.message };
|
|
1718
|
+
}
|
|
1719
|
+
agent._log?.(`Cancelled pending isolated task launch: ${reason}`);
|
|
1720
|
+
return termination
|
|
1721
|
+
? { ...termination, forced: true, taskId: isolatedTaskId }
|
|
1722
|
+
: { forced: true, taskId: null };
|
|
1723
|
+
})();
|
|
1724
|
+
|
|
1725
|
+
const termination = await cancellation;
|
|
1726
|
+
if (termination?.forced === false) cancellation = null;
|
|
1727
|
+
if (!resolved) {
|
|
1728
|
+
const error =
|
|
1729
|
+
timeoutError ||
|
|
1730
|
+
new Error(
|
|
1731
|
+
termination?.forced === false
|
|
1732
|
+
? `Task launch cancellation was not confirmed: ${termination.reason}`
|
|
1733
|
+
: `Task launch cancelled: ${isolatedTaskId || 'before persistence'}`
|
|
1734
|
+
);
|
|
1735
|
+
rejectLaunch(error, { retainHandle: termination?.forced === false });
|
|
1736
|
+
}
|
|
1737
|
+
return termination;
|
|
1738
|
+
},
|
|
1739
|
+
};
|
|
1740
|
+
agent.currentTask = isolatedPendingLaunch;
|
|
1741
|
+
|
|
1742
|
+
// Track PID for resource monitoring
|
|
1743
|
+
agent.processPid = proc.pid;
|
|
1744
|
+
agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
|
|
1745
|
+
|
|
1746
|
+
// CRITICAL: Timeout to prevent infinite hang if provider CLI hangs. Timeout
|
|
1747
|
+
// uses the same cancellation path so a durable child cannot outlive it.
|
|
1748
|
+
spawnTimeout = setTimeout(() => {
|
|
1749
|
+
if (resolved) return;
|
|
1750
|
+
timeoutError = new Error(
|
|
1751
|
+
`Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
|
|
1752
|
+
`stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
|
|
1617
1753
|
);
|
|
1754
|
+
isolatedPendingLaunch.kill(timeoutError.message).catch((error) => {
|
|
1755
|
+
rejectLaunch(error, { retainHandle: true });
|
|
1756
|
+
});
|
|
1618
1757
|
}, SPAWN_TIMEOUT_MS);
|
|
1619
1758
|
|
|
1620
1759
|
proc.stdout.on('data', (data) => {
|
|
@@ -1625,42 +1764,47 @@ async function spawnClaudeTaskIsolated(agent, context) {
|
|
|
1625
1764
|
stderr += data.toString();
|
|
1626
1765
|
});
|
|
1627
1766
|
|
|
1628
|
-
proc.on('close', (code, signal) => {
|
|
1767
|
+
proc.on('close', async (code, signal) => {
|
|
1629
1768
|
clearTimeout(spawnTimeout);
|
|
1630
|
-
if (resolved) return;
|
|
1631
|
-
resolved = true;
|
|
1632
|
-
// Handle process killed by signal
|
|
1769
|
+
if (resolved || isolatedPendingLaunch.cancelled) return;
|
|
1633
1770
|
if (signal) {
|
|
1634
|
-
|
|
1771
|
+
await isolatedPendingLaunch.kill(
|
|
1772
|
+
`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`
|
|
1773
|
+
);
|
|
1635
1774
|
return;
|
|
1636
1775
|
}
|
|
1637
1776
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
const spawnedTaskId =
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1777
|
+
try {
|
|
1778
|
+
const persistedTaskId = await findPersistedTaskId();
|
|
1779
|
+
const spawnedTaskId = requireTaskIdFromWrapperResult({
|
|
1780
|
+
code,
|
|
1781
|
+
stdout,
|
|
1782
|
+
stderr,
|
|
1783
|
+
parseTaskId: parseTaskIdFromOutput,
|
|
1784
|
+
persistedTaskId,
|
|
1785
|
+
});
|
|
1786
|
+
isolatedTaskId = spawnedTaskId;
|
|
1787
|
+
assignDurableTaskId(agent, spawnedTaskId);
|
|
1788
|
+
resolved = true;
|
|
1789
|
+
resolve(spawnedTaskId);
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
const termination = await isolatedPendingLaunch.kill(error.message);
|
|
1792
|
+
if (!resolved) {
|
|
1793
|
+
rejectLaunch(error, { retainHandle: termination?.forced === false });
|
|
1651
1794
|
}
|
|
1652
|
-
} else {
|
|
1653
|
-
reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
|
|
1654
1795
|
}
|
|
1655
1796
|
});
|
|
1656
1797
|
|
|
1657
|
-
proc.on('error', (error) => {
|
|
1798
|
+
proc.on('error', async (error) => {
|
|
1658
1799
|
clearTimeout(spawnTimeout);
|
|
1659
|
-
if (resolved) return;
|
|
1660
|
-
|
|
1661
|
-
|
|
1800
|
+
if (resolved || isolatedPendingLaunch.cancelled) return;
|
|
1801
|
+
const termination = await isolatedPendingLaunch.kill(error.message);
|
|
1802
|
+
if (termination?.forced === false && !resolved) {
|
|
1803
|
+
rejectLaunch(error, { retainHandle: true });
|
|
1804
|
+
}
|
|
1662
1805
|
});
|
|
1663
1806
|
});
|
|
1807
|
+
if (isolatedPendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
|
|
1664
1808
|
|
|
1665
1809
|
agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId} in container...`);
|
|
1666
1810
|
|
|
@@ -1759,6 +1903,32 @@ function rejectIsolatedFollower({ agent, state, cleanup, reject, error }) {
|
|
|
1759
1903
|
reject(error);
|
|
1760
1904
|
}
|
|
1761
1905
|
|
|
1906
|
+
function rejectIsolatedFollowerRetainingHandle({ state, cleanup, reject, error }) {
|
|
1907
|
+
if (state.resolved || state.failureSettled) return;
|
|
1908
|
+
state.failureSettled = true;
|
|
1909
|
+
cleanup();
|
|
1910
|
+
reject(error);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function resolveIsolatedTaskIdBySpawnToken(manager, clusterId, ownershipToken) {
|
|
1914
|
+
const result = await manager.execInContainer(clusterId, [
|
|
1915
|
+
'zeroshot',
|
|
1916
|
+
'get-task-id-by-spawn-token',
|
|
1917
|
+
ownershipToken,
|
|
1918
|
+
]);
|
|
1919
|
+
if (result.code === 2) return null;
|
|
1920
|
+
if (result.code !== 0) {
|
|
1921
|
+
throw new Error(
|
|
1922
|
+
`Failed to resolve isolated task ownership: ${result.stderr || result.stdout || `exit ${result.code}`}`
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
const taskId = result.stdout.trim();
|
|
1926
|
+
if (!taskId) {
|
|
1927
|
+
throw new Error('Isolated task ownership lookup returned an empty task ID');
|
|
1928
|
+
}
|
|
1929
|
+
return taskId;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1762
1932
|
function parseIsolatedStatus(output) {
|
|
1763
1933
|
return output.match(/Status:\s+(completed|failed|killed|stale|cancelled)/i)?.[1].toLowerCase();
|
|
1764
1934
|
}
|
|
@@ -1766,23 +1936,27 @@ function parseIsolatedStatus(output) {
|
|
|
1766
1936
|
async function terminateIsolatedTask(manager, clusterId, taskId) {
|
|
1767
1937
|
const before = await manager.execInContainer(clusterId, ['zeroshot', 'status', taskId]);
|
|
1768
1938
|
const beforeStatus = before.code === 0 ? parseIsolatedStatus(before.stdout) : null;
|
|
1769
|
-
|
|
1770
|
-
|
|
1939
|
+
const result = await manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
|
|
1940
|
+
if (result.code !== 0) {
|
|
1941
|
+
throw new Error(
|
|
1942
|
+
`Failed to terminate isolated task ${taskId}: ${result.stderr || result.stdout || `exit ${result.code}`}`
|
|
1943
|
+
);
|
|
1771
1944
|
}
|
|
1772
1945
|
|
|
1773
|
-
const result = await manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
|
|
1774
1946
|
const status = await manager.execInContainer(clusterId, ['zeroshot', 'status', taskId]);
|
|
1775
1947
|
const afterStatus = status.code === 0 ? parseIsolatedStatus(status.stdout) : null;
|
|
1776
1948
|
if (!afterStatus) {
|
|
1777
1949
|
throw new Error(
|
|
1778
|
-
`Failed to
|
|
1950
|
+
`Failed to confirm isolated task ${taskId} after cleanup recovery: ${
|
|
1951
|
+
status.stderr || status.stdout || `exit ${status.code}`
|
|
1952
|
+
}`
|
|
1779
1953
|
);
|
|
1780
1954
|
}
|
|
1781
1955
|
|
|
1782
1956
|
return {
|
|
1783
|
-
alreadyTerminal:
|
|
1784
|
-
forced: afterStatus === 'killed',
|
|
1785
|
-
status: afterStatus,
|
|
1957
|
+
alreadyTerminal: Boolean(beforeStatus),
|
|
1958
|
+
forced: !beforeStatus && afterStatus === 'killed',
|
|
1959
|
+
status: beforeStatus || afterStatus,
|
|
1786
1960
|
};
|
|
1787
1961
|
}
|
|
1788
1962
|
|
|
@@ -1952,7 +2126,7 @@ function buildIsolatedLifecycleHandle({
|
|
|
1952
2126
|
terminate,
|
|
1953
2127
|
kill: terminate,
|
|
1954
2128
|
failClosed(error) {
|
|
1955
|
-
|
|
2129
|
+
rejectIsolatedFollowerRetainingHandle({ state, cleanup, reject, error });
|
|
1956
2130
|
},
|
|
1957
2131
|
};
|
|
1958
2132
|
}
|
|
@@ -2054,6 +2228,28 @@ async function checkIsolatedStatus({
|
|
|
2054
2228
|
return;
|
|
2055
2229
|
}
|
|
2056
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
|
+
|
|
2057
2253
|
state.logFilePath = logFilePath;
|
|
2058
2254
|
await settleIsolatedTerminalStatus({
|
|
2059
2255
|
agent,
|
|
@@ -2353,29 +2549,40 @@ async function killTask(agent, termination = 'Task killed') {
|
|
|
2353
2549
|
const currentTask = agent.currentTask;
|
|
2354
2550
|
const taskId = agent.currentTaskId;
|
|
2355
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
|
+
|
|
2356
2564
|
if (agent.isolation?.enabled && taskId) {
|
|
2357
2565
|
return killIsolatedTask(agent, currentTask, taskId, reason, code);
|
|
2358
2566
|
}
|
|
2359
2567
|
|
|
2360
|
-
agent._stopLivenessCheck?.();
|
|
2361
|
-
|
|
2362
2568
|
// Kill the underlying task before resolving the local follower. This keeps
|
|
2363
2569
|
// retries from racing a provider process that is still shutting down.
|
|
2364
2570
|
if (taskId) {
|
|
2365
|
-
const ctPath = getClaudeTasksPath();
|
|
2571
|
+
const ctPath = agent.taskCommandPath || getClaudeTasksPath();
|
|
2366
2572
|
try {
|
|
2367
2573
|
// `kill` is a top-level smart command. `task kill` has never existed.
|
|
2368
2574
|
await runCommandWithTimeout(ctPath, ['kill', taskId], { timeout: 10000 });
|
|
2369
2575
|
agent._log?.(`Killed task ${taskId}`);
|
|
2370
2576
|
} catch (error) {
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
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 };
|
|
2374
2579
|
}
|
|
2375
2580
|
}
|
|
2376
2581
|
|
|
2582
|
+
agent._stopLivenessCheck?.();
|
|
2583
|
+
|
|
2377
2584
|
if (currentTask && typeof currentTask.kill === 'function') {
|
|
2378
|
-
currentTask.kill(reason, { code });
|
|
2585
|
+
await currentTask.kill(reason, { code });
|
|
2379
2586
|
}
|
|
2380
2587
|
|
|
2381
2588
|
agent.currentTask = null;
|
|
@@ -2387,24 +2594,28 @@ async function killTask(agent, termination = 'Task killed') {
|
|
|
2387
2594
|
|
|
2388
2595
|
async function killIsolatedTask(agent, currentTask, taskId, reason, code) {
|
|
2389
2596
|
let termination;
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
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
|
+
}
|
|
2400
2609
|
}
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
return { forced: false, reason: error.message };
|
|
2401
2612
|
}
|
|
2402
2613
|
|
|
2403
|
-
|
|
2404
|
-
if (termination?.forced === false) {
|
|
2614
|
+
if (termination?.forced === false && !termination.alreadyTerminal) {
|
|
2405
2615
|
return termination;
|
|
2406
2616
|
}
|
|
2407
2617
|
|
|
2618
|
+
agent._stopLivenessCheck?.();
|
|
2408
2619
|
agent.currentTask = null;
|
|
2409
2620
|
agent.currentTaskId = null;
|
|
2410
2621
|
agent.processPid = null;
|
|
@@ -2415,6 +2626,9 @@ async function killIsolatedTask(agent, currentTask, taskId, reason, code) {
|
|
|
2415
2626
|
|
|
2416
2627
|
module.exports = {
|
|
2417
2628
|
ensureAskUserQuestionHook,
|
|
2629
|
+
ensureDangerousGitHook,
|
|
2630
|
+
resolveMcpConfigArgs,
|
|
2631
|
+
buildSpawnEnv,
|
|
2418
2632
|
spawnClaudeTask,
|
|
2419
2633
|
spawnTaskProcess,
|
|
2420
2634
|
followClaudeTaskLogs,
|