@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.
Files changed (65) hide show
  1. package/cli/commands/inspect.js +1 -0
  2. package/cli/index.js +17 -1
  3. package/cluster-hooks/block-ask-user-question.py +2 -3
  4. package/cluster-hooks/block-dangerous-git.py +2 -3
  5. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/adapters/claude.js +57 -3
  7. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  8. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
  9. package/lib/agent-cli-provider/adapters/codex.js +32 -2
  10. package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
  11. package/lib/agent-cli-provider/adapters/common.js +1 -1
  12. package/lib/agent-cli-provider/adapters/common.js.map +1 -1
  13. package/lib/agent-cli-provider/adapters/index.d.ts +1 -0
  14. package/lib/agent-cli-provider/adapters/index.d.ts.map +1 -1
  15. package/lib/agent-cli-provider/adapters/index.js +8 -0
  16. package/lib/agent-cli-provider/adapters/index.js.map +1 -1
  17. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  18. package/lib/agent-cli-provider/contract-options.js +3 -0
  19. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  20. package/lib/agent-cli-provider/index.d.ts +1 -1
  21. package/lib/agent-cli-provider/index.d.ts.map +1 -1
  22. package/lib/agent-cli-provider/index.js +2 -1
  23. package/lib/agent-cli-provider/index.js.map +1 -1
  24. package/lib/agent-cli-provider/provider-registry.d.ts +9 -0
  25. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  26. package/lib/agent-cli-provider/provider-registry.js +3 -0
  27. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  28. package/lib/agent-cli-provider/types.d.ts +10 -2
  29. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  30. package/lib/agent-cli-provider/types.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/agent/agent-context-builder.js +90 -4
  33. package/src/agent/agent-context-sources.js +20 -2
  34. package/src/agent/agent-lifecycle.js +43 -8
  35. package/src/agent/agent-task-executor.js +557 -314
  36. package/src/agent/guidance-queue.js +29 -7
  37. package/src/agent/provider-session.js +277 -0
  38. package/src/agent-cli-provider/adapters/claude.ts +64 -4
  39. package/src/agent-cli-provider/adapters/codex.ts +47 -3
  40. package/src/agent-cli-provider/adapters/common.ts +1 -1
  41. package/src/agent-cli-provider/adapters/index.ts +10 -0
  42. package/src/agent-cli-provider/contract-options.ts +7 -0
  43. package/src/agent-cli-provider/index.ts +1 -0
  44. package/src/agent-cli-provider/provider-registry.ts +13 -1
  45. package/src/agent-cli-provider/types.ts +13 -6
  46. package/src/agent-wrapper.js +44 -8
  47. package/src/claude-task-runner.js +153 -44
  48. package/src/ledger-sequence.js +63 -0
  49. package/src/ledger.js +114 -29
  50. package/src/orchestrator.js +40 -2
  51. package/src/task-spawn-cleanup-ownership.js +82 -0
  52. package/src/worktree-claude-config.js +193 -85
  53. package/task-lib/attachable-watcher.js +101 -253
  54. package/task-lib/command-spec-cleanup.js +219 -0
  55. package/task-lib/commands/clean.js +35 -5
  56. package/task-lib/commands/get-task-id-by-spawn-token.js +10 -0
  57. package/task-lib/commands/kill.js +117 -4
  58. package/task-lib/commands/resume.js +29 -6
  59. package/task-lib/commands/status.js +4 -0
  60. package/task-lib/provider-helper-runtime.js +1 -0
  61. package/task-lib/provider-session-capture.js +138 -0
  62. package/task-lib/runner.js +70 -5
  63. package/task-lib/store.js +126 -12
  64. package/task-lib/watcher-output-runtime.js +284 -0
  65. 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
- prepareClaudeConfigDir,
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: For Claude, use user's existing Claude config
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 claudeConfigDir =
511
+ const claudeSettingsPath =
653
512
  providerName === 'claude'
654
- ? prepareClaudeConfigDir({
655
- cwd,
656
- worktreePath: agent.worktree?.path || null,
513
+ ? prepareClaudeSettingsOverlay({
514
+ includeDangerousGit: Boolean(agent.worktree?.enabled),
657
515
  })
658
516
  : null;
659
517
 
660
- ensureProviderHooks(agent, providerName, claudeConfigDir);
661
- const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, { claudeConfigDir });
662
- const taskId = await spawnTaskProcess({
663
- agent,
664
- ctPath,
665
- args,
666
- cwd,
667
- spawnEnv,
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
- // CRITICAL: Poll for REAL process PID from task store
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: providers whose CLI accepts an MCP config flag (e.g. Copilot's
748
- // --additional-mcp-config) cannot use the Claude config-dir overlay, so forward the repo's
749
- // `.mcp.json` (the same MCP source Claude consumes) inline via `--mcp-config`.
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
- * Only providers whose adapter models an MCP config CLI flag receive it — Claude consumes MCP via
761
- * the config-dir `.mcp.json` overlay (see prepareClaudeConfigDir) and needs no flag. The repo
762
- * `.mcp.json` content is inlined (not passed as an @<path> reference) so the identical value works
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 { claudeConfigDir = null } = options;
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 (claudeConfigDir) {
853
- spawnEnv.CLAUDE_CONFIG_DIR = claudeConfigDir;
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
- function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
876
- // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
877
- const SPAWN_TIMEOUT_MS = 30000; // 30 seconds to spawn task
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
- resolved = true;
902
- proc.kill('SIGKILL');
903
- reject(
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
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
940
+ await rejectWithOwnership(
941
+ new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`)
942
+ );
926
943
  return;
927
944
  }
928
945
 
929
- if (code === 0) {
930
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
931
- // Format: <adjective>-<noun>-<digits> (may or may not have task- prefix)
932
- const spawnedTaskId = parseTaskIdFromOutput(stdout);
933
- if (spawnedTaskId) {
934
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
935
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
936
- pid: agent.processPid,
937
- taskId: spawnedTaskId,
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
- // Start liveness monitoring
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
- resolve(spawnedTaskId);
948
- } else {
949
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
950
- }
951
- } else {
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
- reject(error);
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({ agent, taskId, providerName, state, stdout, success }) {
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; // 30 seconds to spawn task
1561
- // Note: Auth env vars are injected by IsolationManager, we only need model mapping here
1562
- const isolatedEnv =
1563
- providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {};
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
- // Track PID for resource monitoring
1571
- agent.processPid = proc.pid;
1572
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
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
- // CRITICAL: Timeout to prevent infinite hang if provider CLI hangs
1579
- const spawnTimeout = setTimeout(() => {
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
- proc.kill('SIGKILL');
1583
- reject(
1584
- new Error(
1585
- `Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
1586
- `stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
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
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
1770
+ await isolatedPendingLaunch.kill(
1771
+ `Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`
1772
+ );
1606
1773
  return;
1607
1774
  }
1608
1775
 
1609
- if (code === 0) {
1610
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
1611
- const spawnedTaskId = parseTaskIdFromOutput(stdout);
1612
- if (spawnedTaskId) {
1613
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
1614
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
1615
- pid: agent.processPid,
1616
- taskId: spawnedTaskId,
1617
- });
1618
-
1619
- resolve(spawnedTaskId);
1620
- } else {
1621
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
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
- resolved = true;
1632
- reject(error);
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
- if (beforeStatus) {
1741
- return { alreadyTerminal: true, forced: false, status: beforeStatus };
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 terminate isolated task ${taskId}: ${result.stderr || result.stdout || `exit ${result.code}`}`
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: false,
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
- rejectIsolatedFollower({ agent, state, cleanup, reject, error });
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
- // Resolve the local follower even if the task is already terminal or the
2343
- // management CLI is unavailable; shutdown state must still reconcile.
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
- if (currentTask && typeof currentTask.terminate === 'function') {
2362
- termination = await currentTask.terminate(reason, { code });
2363
- } else {
2364
- termination = await terminateIsolatedTask(
2365
- agent.isolation.manager,
2366
- agent.isolation.clusterId,
2367
- taskId
2368
- );
2369
- if (currentTask && typeof currentTask.kill === 'function') {
2370
- currentTask.kill(reason, { code });
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
- agent._stopLivenessCheck?.();
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
  };