pi-subagents 0.46.0 → 0.47.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 (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/agents/reviewer.md +3 -4
  3. package/docs/agents.md +1 -1
  4. package/docs/configuration.md +14 -6
  5. package/docs/extension-api.md +1 -1
  6. package/docs/missions.md +5 -3
  7. package/docs/models.md +3 -1
  8. package/docs/observability.md +3 -3
  9. package/docs/tool-reference.md +2 -2
  10. package/docs/watchdog.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/pi-subagents/references/execution-controls.md +4 -4
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  14. package/src/extension/config.ts +13 -0
  15. package/src/extension/fanout-child.ts +5 -4
  16. package/src/extension/index.ts +39 -14
  17. package/src/extension/rpc.ts +7 -6
  18. package/src/extension/schemas.ts +27 -7
  19. package/src/extension/tool-description.ts +26 -8
  20. package/src/inspectors/herdr/project-panes.ts +2 -1
  21. package/src/intercom/native-supervisor-channel.ts +102 -4
  22. package/src/missions/store.ts +2 -1
  23. package/src/missions/workflow-state.ts +19 -13
  24. package/src/runs/background/active-run-index.ts +42 -0
  25. package/src/runs/background/async-execution.ts +10 -5
  26. package/src/runs/background/async-job-tracker.ts +293 -133
  27. package/src/runs/background/async-resume.ts +19 -3
  28. package/src/runs/background/async-status.ts +33 -5
  29. package/src/runs/background/control-channel.ts +91 -17
  30. package/src/runs/background/result-watcher.ts +102 -7
  31. package/src/runs/background/run-status.ts +18 -0
  32. package/src/runs/background/scheduled-runs.ts +25 -1
  33. package/src/runs/background/stale-run-reconciler.ts +12 -24
  34. package/src/runs/background/subagent-runner.ts +68 -10
  35. package/src/runs/foreground/async-dismiss-action.ts +85 -0
  36. package/src/runs/foreground/async-steering-action.ts +6 -7
  37. package/src/runs/foreground/chain-execution.ts +3 -0
  38. package/src/runs/foreground/execution.ts +3 -0
  39. package/src/runs/foreground/subagent-executor.ts +129 -20
  40. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  41. package/src/runs/shared/dynamic-fanout.ts +1 -1
  42. package/src/runs/shared/model-fallback.ts +8 -4
  43. package/src/runs/shared/model-scope.ts +12 -2
  44. package/src/runs/shared/parallel-utils.ts +1 -0
  45. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  46. package/src/runs/shared/worktree.ts +3 -2
  47. package/src/shared/artifacts.ts +15 -15
  48. package/src/shared/display-text.ts +100 -0
  49. package/src/shared/file-coalescer.ts +9 -0
  50. package/src/shared/formatters.ts +4 -6
  51. package/src/shared/settings.ts +15 -2
  52. package/src/shared/types.ts +16 -2
  53. package/src/shared/utils.ts +43 -33
  54. package/src/slash/slash-commands.ts +3 -1
  55. package/src/tui/fleet-status.ts +14 -10
  56. package/src/tui/render.ts +30 -26
  57. package/src/watchdog/change-signature.ts +4 -3
  58. package/src/workflows/scripted-workflow.ts +4 -1
@@ -254,7 +254,7 @@ const ControlOverrides = Type.Object({
254
254
  })),
255
255
  });
256
256
 
257
- const SubagentParamsSchema = Type.Object({
257
+ const SubagentParamProperties = {
258
258
  agent: Type.Optional(Type.String({ description: "Agent target for management actions such as get, update, delete, and models." })),
259
259
  resume: Type.Optional(Type.String({ description: "Retained child run id for a workflowScript runs.run/runs.all item. Mutually exclusive with agent; task supplies the follow-up." })),
260
260
  // Management action (when present, tool operates in management mode)
@@ -263,10 +263,10 @@ const SubagentParamsSchema = Type.Object({
263
263
  })),
264
264
  name: Type.Optional(Type.String({ description: "Human-readable name for action='schedule.create'." })),
265
265
  id: Type.Optional(Type.String({
266
- description: "Run id or prefix for status, interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, mission.attach-run, or the decision id for mission.resolve-decision."
266
+ description: "Run id or prefix for status, interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, mission.attach-run, or the decision id for mission.resolve-decision."
267
267
  })),
268
268
  runId: Type.Optional(Type.String({
269
- description: "Target run ID for interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
269
+ description: "Target run ID for interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
270
270
  })),
271
271
  dir: Type.Optional(Type.String({
272
272
  description: "Async run directory for action='status', action='stop', action='resume', or action='steer'."
@@ -296,7 +296,7 @@ const SubagentParamsSchema = Type.Object({
296
296
  overlap: Type.Optional(Type.String({ enum: ["skip"], description: "Overlap policy. This slice supports skip only." })),
297
297
  catchUp: Type.Optional(Type.String({ enum: ["none", "latest"], description: "Missed occurrence policy for recurring schedules. Defaults to latest." })),
298
298
  missionId: Type.Optional(Type.String({ description: "Mission id." })),
299
- mission: Type.Optional(Type.Unsafe({ ...MissionLaunchOverride, description: "Mission object, or false for no mission. Use objective for intent; goal:true with budget.tokens enables turn-end continuation notices." })),
299
+ mission: Type.Optional(Type.Unsafe({ ...MissionLaunchOverride, description: "Mission object, or false for no mission. Set exactly one non-empty title or summary; objective and labels are optional. goal may only be true and then requires budget.tokens." })),
300
300
  missionUpdate: Type.Optional(Type.Unsafe({ ...MissionUpdateOverride, description: "Mission update: objective, goal false or {paused:boolean}, budget, summary, labels, decisions, artifacts, or delivery receipts." })),
301
301
  missionStatus: Type.Optional(Type.String({ description: "Mission status." })),
302
302
  missionScope: Type.Optional(Type.String({ description: "Mission list scope: project (default) or global pointer index." })),
@@ -324,8 +324,8 @@ const SubagentParamsSchema = Type.Object({
324
324
  description: "'fresh' or 'fork' to branch from parent session. Explicit context overrides every child in the invocation. If omitted, each requested agent uses its own defaultContext; agents without defaultContext: 'fork' run fresh.",
325
325
  })),
326
326
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
327
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Optional timeout for foreground and async/background runs. Foreground workflows default to 30m; async workflows have no default timeout. Alias maxRuntimeMs." })),
328
- maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs for foreground and async/background runs. Foreground workflows default to 30m; async workflows have no default timeout." })),
327
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Run timeout. Foreground runs and async children default to 30m; async composites have no default parent deadline. Alias maxRuntimeMs." })),
328
+ maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs. Foreground runs and async children default to 30m; async composites have no default parent deadline." })),
329
329
  turnBudget: Type.Optional(TurnBudgetOverride),
330
330
  toolBudget: Type.Optional(ToolBudgetOverride),
331
331
  usageBudget: Type.Optional(UsageBudgetOverride),
@@ -353,9 +353,29 @@ const SubagentParamsSchema = Type.Object({
353
353
  agentContract: Type.Optional(AgentContractOverride),
354
354
  acceptance: Type.Optional(AcceptanceOverride),
355
355
  gate: Type.Optional(Type.String({ minLength: 1, description: "Host gate command. Cannot be combined with acceptance." })),
356
- });
356
+ };
357
+
358
+ const { step: _legacyChainStep, ...subagentParamPropertiesWithoutStep } = SubagentParamProperties;
359
+ const trimmedSubagentParamProperties = {
360
+ ...subagentParamPropertiesWithoutStep,
361
+ id: Type.Optional(Type.String({
362
+ description: "Run id or prefix for status, interrupt, stop, resume, steer, mission.attach-run, or the decision id for mission.resolve-decision."
363
+ })),
364
+ runId: Type.Optional(Type.String({
365
+ description: "Target run ID for interrupt, stop, resume, steer, or mission.attach-run. Prefer id for new calls."
366
+ })),
367
+ };
368
+ const SubagentParamsSchema = Type.Object(SubagentParamProperties);
369
+ const TrimmedSubagentParamsSchema = Type.Object(trimmedSubagentParamProperties);
357
370
 
358
371
  export const SubagentParams = keepTopLevelParameterDescriptions(SubagentParamsSchema);
372
+ export const SubagentParamsWithoutLegacyChainControls = keepTopLevelParameterDescriptions(TrimmedSubagentParamsSchema);
373
+
374
+ export function createSubagentParamsSchema(options: { legacyChainControls?: boolean } = {}): typeof SubagentParams | typeof SubagentParamsWithoutLegacyChainControls {
375
+ return options.legacyChainControls === true
376
+ ? SubagentParams
377
+ : SubagentParamsWithoutLegacyChainControls;
378
+ }
359
379
 
360
380
  const SubagentWaitParamsSchema = Type.Object({
361
381
  id: Type.Optional(Type.String({
@@ -22,7 +22,7 @@ EXECUTION:
22
22
  • Sequential example: { workflowScript: "const a = await runs.run('analyze', {agent:'agent-a', task:'Analyze the request'}); return (await runs.run('plan', {agent:'agent-b', task:'Plan from: '+a.output})).output" }
23
23
  • Parallel example: { workflowScript: "const [a,b] = await runs.all([{key:'correctness',agent:'agent-a',task:'Review correctness'},{key:'tests',agent:'agent-b',task:'Review tests'}]); return {correctness:a.output,tests:b.output}" }
24
24
  • Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
25
- • Durable mission attachment is automatic by default. Use missionId to attach an existing mission, mission:{...} to override auto-create, or mission:false for ephemeral work.
25
+ • Durable mission attachment is automatic by default. Use missionId to attach an existing mission, mission:{...} to override auto-create, or mission:false for ephemeral work. A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
26
26
 
27
27
  MANAGEMENT / CONTROL (use action; omit execution fields):
28
28
  • list, get, models, guide, children.list, create, update, delete, eject, disable, enable, reset, doctor, grant-spawn-budget, worktree.discard, refine/refine.show/refine.rollback, mission.create/list/show/update/resolve-decision/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available. Use {action:"guide", topic:"overview"} for packaged current-version help; topics are overview, workflows, agents, missions, observability, tool-reference, configuration, models, watchdog, and extension-api.
@@ -44,6 +44,7 @@ EXECUTE:
44
44
  MANAGE / CONTROL:
45
45
  • Use action without execution fields for list/get/models/guide/authoring, refine/refine.show/refine.rollback, mission, watchdog, status, interrupt, stop, resume, steer, script-only scheduling, diagnostics, and other management actions. guide reads shipped current-version docs by topic.
46
46
  • append-step uses step:{...} only for an already-running durable legacy chain; step is not an execution mode.
47
+ • A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
47
48
 
48
49
  ASYNC / SAFETY:
49
50
  • Omitted async detaches background work. Do not sleep or poll merely to wait; use subagent_wait only when this turn must receive results.
@@ -152,13 +153,30 @@ function withMandatorySafetyGuidance(description: string): string {
152
153
  : SUBAGENT_SAFETY_GUIDANCE;
153
154
  }
154
155
 
155
- export function buildSubagentToolDescription(config: Pick<ExtensionConfig, "toolDescriptionMode"> = {}, options?: ToolDescriptionOptions): string {
156
+ const LEGACY_CHAIN_CONTROL_GUIDANCE_LINES = new Set([
157
+ '• { action: "append-step", id: "...", step: {agent:"agent-c", task:"Use {previous}"} } appends one step to an already-running durable legacy chain. step is control-only, not an execution mode.',
158
+ "• approve-checkpoint and reject-checkpoint decide a paused durable legacy chain checkpoint.",
159
+ "• append-step uses step:{...} only for an already-running durable legacy chain; step is not an execution mode.",
160
+ ]);
161
+
162
+ function withoutLegacyChainControlGuidance(description: string): string {
163
+ return description
164
+ .split("\n")
165
+ .filter((line) => !LEGACY_CHAIN_CONTROL_GUIDANCE_LINES.has(line.trim()))
166
+ .join("\n");
167
+ }
168
+
169
+ export function buildSubagentToolDescription(config: Pick<ExtensionConfig, "toolDescriptionMode" | "legacyChainControls"> = {}, options?: ToolDescriptionOptions): string {
156
170
  const mode = resolveToolDescriptionMode(config, options);
157
- if (mode === "compact") return COMPACT_SUBAGENT_TOOL_DESCRIPTION;
158
- if (mode === "custom") {
171
+ let description: string;
172
+ if (mode === "compact") description = COMPACT_SUBAGENT_TOOL_DESCRIPTION;
173
+ else if (mode === "custom") {
159
174
  const custom = loadCustomToolDescription(options);
160
- if (custom) return withMandatorySafetyGuidance(custom);
161
- warn(options, `${CUSTOM_TOOL_DESCRIPTION_FILE} was not found or valid for toolDescriptionMode "custom"; using full description.`);
162
- }
163
- return FULL_SUBAGENT_TOOL_DESCRIPTION;
175
+ if (custom) description = withMandatorySafetyGuidance(custom);
176
+ else {
177
+ warn(options, `${CUSTOM_TOOL_DESCRIPTION_FILE} was not found or valid for toolDescriptionMode "custom"; using full description.`);
178
+ description = FULL_SUBAGENT_TOOL_DESCRIPTION;
179
+ }
180
+ } else description = FULL_SUBAGENT_TOOL_DESCRIPTION;
181
+ return config.legacyChainControls === true ? description : withoutLegacyChainControlGuidance(description);
164
182
  }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { getPiSpawnCommand } from "../../runs/shared/pi-spawn.ts";
5
+ import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
5
6
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
6
7
  import type { Details } from "../../shared/types.ts";
7
8
  import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode } from "./client.ts";
@@ -153,7 +154,7 @@ function formatProjectPaneError(input: { code: ProjectPaneErrorCode; message: st
153
154
  }
154
155
 
155
156
  function projectPaneDir(projectRoot: string): string {
156
- return path.join(projectRoot, ".pi-subagents", "project-panes");
157
+ return path.join(getProjectSubagentsDir(projectRoot), "project-panes");
157
158
  }
158
159
 
159
160
  export function projectPaneBindingPath(projectRoot: string): string {
@@ -22,6 +22,7 @@ export const NATIVE_SUPERVISOR_TOOL_NAME = "subagent_supervisor";
22
22
  const MAX_MESSAGE_BYTES = 64 * 1024;
23
23
  const DEFAULT_ASK_TIMEOUT_MS = 10 * 60 * 1000;
24
24
  const CHANNEL_POLL_MS = Math.min(POLL_INTERVAL_MS, 500);
25
+ const CHANNEL_SAFETY_POLL_MS = 5000;
25
26
  const STALE_EMPTY_CHANNEL_AGE_MS = 60 * 1000;
26
27
  const STALE_EMPTY_CHANNEL_CLEANUP_INTERVAL_MS = 60 * 1000;
27
28
 
@@ -69,6 +70,13 @@ interface IntercomParams {
69
70
  replyTo?: string;
70
71
  }
71
72
 
73
+ type SupervisorWatch = (filename: fs.PathLike, listener: fs.WatchListener<string>) => fs.FSWatcher;
74
+
75
+ interface NativeSupervisorChannelDeps {
76
+ platform?: NodeJS.Platform;
77
+ watch?: SupervisorWatch;
78
+ }
79
+
72
80
  const ContactSupervisorParamsSchema = Type.Object({
73
81
  reason: Type.String({ enum: ["need_decision", "interview_request", "progress_update"] }),
74
82
  message: Type.Optional(Type.String()),
@@ -626,10 +634,16 @@ function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>,
626
634
  };
627
635
  }
628
636
 
629
- export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState): { start: () => void; dispose: () => void; pending: Map<string, PendingSupervisorRequest> } {
637
+ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): { start: () => void; dispose: () => void; pending: Map<string, PendingSupervisorRequest> } {
638
+ const watch = deps.watch ?? fs.watch;
630
639
  const pending = new Map<string, PendingSupervisorRequest>();
631
640
  const seenFiles = new Set<string>();
641
+ const requestWatchers = new Map<string, fs.FSWatcher>();
642
+ let rootWatcher: fs.FSWatcher | undefined;
632
643
  let poller: ReturnType<typeof setInterval> | undefined;
644
+ let safetyPoller: ReturnType<typeof setInterval> | undefined;
645
+ let deferredWatcherRefresh: ReturnType<typeof setImmediate> | undefined;
646
+ let started = false;
633
647
  let lastStaleCleanupAt = 0;
634
648
 
635
649
  const registerParentTools = (): void => {
@@ -696,17 +710,101 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
696
710
  }
697
711
  };
698
712
 
713
+ const startPolling = (): void => {
714
+ if (poller) return;
715
+ poller = setInterval(poll, CHANNEL_POLL_MS);
716
+ poller.unref?.();
717
+ };
718
+ const startSafetyPolling = (): void => {
719
+ if (safetyPoller) return;
720
+ safetyPoller = setInterval(() => {
721
+ watchExistingRequestDirs();
722
+ poll();
723
+ }, CHANNEL_SAFETY_POLL_MS);
724
+ safetyPoller.unref?.();
725
+ };
726
+ const watchRequestDir = (requestsDir: string): void => {
727
+ if (requestWatchers.has(requestsDir)) return;
728
+ try {
729
+ const watcher = watch(requestsDir, () => poll());
730
+ watcher.on("error", () => {
731
+ try { watcher.close(); } catch {}
732
+ requestWatchers.delete(requestsDir);
733
+ startPolling();
734
+ });
735
+ watcher.unref?.();
736
+ requestWatchers.set(requestsDir, watcher);
737
+ } catch (error) {
738
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") startPolling();
739
+ }
740
+ };
741
+ const watchExistingRequestDirs = (): void => {
742
+ let channelEntries: fs.Dirent[];
743
+ try {
744
+ channelEntries = fs.readdirSync(SUPERVISOR_CHANNEL_ROOT, { withFileTypes: true });
745
+ } catch (error) {
746
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
747
+ startPolling();
748
+ return;
749
+ }
750
+ for (const entry of channelEntries) {
751
+ if (entry.isDirectory()) watchRequestDir(path.join(SUPERVISOR_CHANNEL_ROOT, entry.name, REQUESTS_DIR));
752
+ }
753
+ };
754
+ const scheduleWatcherRefresh = (): void => {
755
+ if (deferredWatcherRefresh) return;
756
+ deferredWatcherRefresh = setImmediate(() => {
757
+ deferredWatcherRefresh = undefined;
758
+ if (!started) return;
759
+ watchExistingRequestDirs();
760
+ poll();
761
+ });
762
+ deferredWatcherRefresh.unref?.();
763
+ };
764
+
699
765
  return {
700
766
  start: () => {
701
- if (poller) return;
767
+ if (started) return;
768
+ started = true;
702
769
  registerParentTools();
703
770
  poll();
704
- poller = setInterval(poll, CHANNEL_POLL_MS);
705
- poller.unref?.();
771
+ try {
772
+ fs.mkdirSync(SUPERVISOR_CHANNEL_ROOT, { recursive: true });
773
+ if ((deps.platform ?? process.platform) === "win32") {
774
+ startPolling();
775
+ return;
776
+ }
777
+ watchExistingRequestDirs();
778
+ rootWatcher = watch(SUPERVISOR_CHANNEL_ROOT, () => {
779
+ watchExistingRequestDirs();
780
+ poll();
781
+ scheduleWatcherRefresh();
782
+ });
783
+ rootWatcher.on("error", startPolling);
784
+ startSafetyPolling();
785
+ scheduleWatcherRefresh();
786
+ } catch {
787
+ startPolling();
788
+ }
706
789
  },
707
790
  dispose: () => {
791
+ started = false;
792
+ try {
793
+ rootWatcher?.close();
794
+ } catch {
795
+ // Best effort during shutdown.
796
+ }
797
+ rootWatcher = undefined;
798
+ for (const watcher of requestWatchers.values()) {
799
+ try { watcher.close(); } catch {}
800
+ }
801
+ requestWatchers.clear();
708
802
  if (poller) clearInterval(poller);
709
803
  poller = undefined;
804
+ if (safetyPoller) clearInterval(safetyPoller);
805
+ safetyPoller = undefined;
806
+ if (deferredWatcherRefresh) clearImmediate(deferredWatcherRefresh);
807
+ deferredWatcherRefresh = undefined;
710
808
  pending.clear();
711
809
  seenFiles.clear();
712
810
  },
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { getProjectSubagentsDir } from "../shared/artifacts.ts";
5
6
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
6
7
  import { getAgentDir } from "../shared/utils.ts";
7
8
  import {
@@ -290,7 +291,7 @@ export function resolveMissionStoreLocation(input: {
290
291
  const projectRoot = path.resolve(input.projectRoot);
291
292
  const missionDir = input.config?.directory
292
293
  ? expandConfiguredPath(input.config.directory, projectRoot)
293
- : path.join(projectRoot, ".pi-subagents", "missions");
294
+ : path.join(getProjectSubagentsDir(projectRoot), "missions");
294
295
  const globalIndexDir = input.config?.globalIndexDir
295
296
  ? expandConfiguredPath(input.config.globalIndexDir, projectRoot)
296
297
  : path.join(input.agentDir ?? getAgentDir(), "missions", "index");
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
6
- import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
6
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, isRetryableFileSystemError, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
7
7
  import { assertWorkflowJsonValue } from "../workflows/scripted-workflow.ts";
8
8
  import type { MissionStoreLocation } from "./types.ts";
9
9
  import { validateMissionId } from "./store.ts";
@@ -162,24 +162,30 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
162
162
  waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
163
163
  continue;
164
164
  }
165
+ let acquired = false;
165
166
  try {
166
- fs.mkdirSync(lockPath, { mode: 0o700 });
167
- owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
168
- try {
169
- fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
170
- } catch (error) {
171
- removeOwnedStateLock(lockPath, owner);
172
- owner = undefined;
173
- throw error;
174
- }
175
- break;
167
+ acquired = tryMakeDirectory(lockPath, 0o700);
176
168
  } catch (error) {
177
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
178
- throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
169
+ if (isRetryableFileSystemError(error)) {
170
+ waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
171
+ continue;
179
172
  }
173
+ throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
174
+ }
175
+ if (!acquired) {
180
176
  if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
181
177
  waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
178
+ continue;
179
+ }
180
+ owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
181
+ try {
182
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
183
+ } catch (error) {
184
+ fs.rmSync(lockPath, { recursive: true, force: true });
185
+ owner = undefined;
186
+ throw error;
182
187
  }
188
+ break;
183
189
  }
184
190
  try {
185
191
  return operation();
@@ -0,0 +1,42 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AsyncStatus } from "../../shared/types.ts";
4
+
5
+ export const ACTIVE_RUN_INDEX_DIR = ".active-runs";
6
+
7
+ function indexDir(asyncDirRoot: string): string {
8
+ return path.join(asyncDirRoot, ACTIVE_RUN_INDEX_DIR);
9
+ }
10
+
11
+ function markerPath(asyncDir: string): string {
12
+ return path.join(indexDir(path.dirname(asyncDir)), path.basename(asyncDir));
13
+ }
14
+
15
+ export function isActiveAsyncState(state: AsyncStatus["state"]): boolean {
16
+ return state === "queued" || state === "running";
17
+ }
18
+
19
+ export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"]): void {
20
+ const marker = markerPath(asyncDir);
21
+ if (isActiveAsyncState(state)) {
22
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
23
+ fs.writeFileSync(marker, "", { flag: "a" });
24
+ return;
25
+ }
26
+ try {
27
+ fs.rmSync(marker);
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
30
+ }
31
+ }
32
+
33
+ export function readActiveRunIndex(asyncDirRoot: string): string[] | undefined {
34
+ try {
35
+ return fs.readdirSync(indexDir(asyncDirRoot), { withFileTypes: true })
36
+ .filter((entry) => entry.isFile())
37
+ .map((entry) => entry.name);
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
40
+ throw error;
41
+ }
42
+ }
@@ -15,7 +15,7 @@ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts"
15
15
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
16
16
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
17
17
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
18
- import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
18
+ import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveExistingReadPaths, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
19
19
  import type { RunnerStep } from "../shared/parallel-utils.ts";
20
20
  import type { ContextMode } from "../shared/context-mode.ts";
21
21
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
@@ -128,6 +128,8 @@ interface AsyncExecutionContext {
128
128
  interactive?: boolean;
129
129
  }
130
130
 
131
+ export const DEFAULT_ASYNC_TIMEOUT_MS = 30 * 60 * 1000;
132
+
131
133
  interface AsyncChainParams {
132
134
  chain: ChainStep[];
133
135
  task?: string;
@@ -668,6 +670,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
668
670
  if (resolvedToolBudget.error) throw new AsyncStartValidationError(resolvedToolBudget.error);
669
671
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
670
672
  const instructionCwd = behaviorCwd ?? stepCwd;
673
+ const readExistenceCwd = behaviorCwd ? stepCwd : instructionCwd;
671
674
  let behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
672
675
  const inheritedRelativeParallelOutput = parallelOutputNamespace && s.output === undefined && typeof behavior.output === "string" && !path.isAbsolute(behavior.output);
673
676
  if (inheritedRelativeParallelOutput && parallelOutputNamespace.taskIndex !== undefined) {
@@ -698,7 +701,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
698
701
  }
699
702
  systemPrompt = appendAgentRefinementOverlay(systemPrompt, { cwd: stepCwd, agentName: a.name });
700
703
 
701
- const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
704
+ const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false, undefined, readExistenceCwd);
702
705
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
703
706
  if (behavior.progress) progressInstructionCreated = true;
704
707
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
@@ -776,6 +779,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
776
779
  outputMode: behavior.outputMode,
777
780
  sessionFile,
778
781
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
782
+ timeoutMs: a.defaultTimeoutMs ?? DEFAULT_ASYNC_TIMEOUT_MS,
779
783
  waitToolEnabled: params.waitToolEnabled,
780
784
  effectiveAcceptance: resolveEffectiveAcceptance({
781
785
  explicit: s.acceptance,
@@ -1296,8 +1300,9 @@ export function executeAsyncSingle(
1296
1300
  // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
1297
1301
  // absolute paths pass through; relative paths resolve against the child cwd.
1298
1302
  const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
1299
- const readsInstruction = Array.isArray(reads) && reads.length > 0
1300
- ? `[Read from: ${reads.map((f) => resolveChainPath(f, runnerCwd)).join(", ")}]\n\n`
1303
+ const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, runnerCwd) : [];
1304
+ const readsInstruction = readPaths.length > 0
1305
+ ? `[Read from: ${readPaths.join(", ")}]\n\n`
1301
1306
  : "";
1302
1307
  const taskText = readsInstruction + taskWithOutputInstruction;
1303
1308
  const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
@@ -1399,7 +1404,7 @@ export function executeAsyncSingle(
1399
1404
  ...(params.acceptance !== undefined ? { acceptance: params.acceptance } : {}),
1400
1405
  ...(controlConfig ? { controlConfig } : {}),
1401
1406
  ...(deadlineAt !== undefined ? { absoluteDeadlineAt: deadlineAt } : {}),
1402
- ...(initialTurnBudget ? { initialTurnBudget } : {}),
1407
+ ...(initialTurnBudget ? { initialTurnBudget: { maxTurns: initialTurnBudget.maxTurns, graceTurns: initialTurnBudget.graceTurns } } : {}),
1403
1408
  ...(resolvedToolBudget.budget ? { initialToolBudget: resolvedToolBudget.budget } : {}),
1404
1409
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
1405
1410
  ...(maxOutput ? { maxOutput } : {}),