pi-subagents 0.81.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bundle/agents.js +3 -3
  3. package/bundle/child-tool-plan.js +2 -2
  4. package/bundle/{chunk-LSRUTHT7.js → chunk-43SCHIMD.js} +1 -1
  5. package/bundle/{chunk-G5DKFLLI.js → chunk-4SDU2BZX.js} +15 -10
  6. package/bundle/{chunk-V2JWDB5U.js → chunk-52UBALJL.js} +2 -2
  7. package/bundle/{chunk-3DC5KBVP.js → chunk-5TYMSZX3.js} +1 -1
  8. package/bundle/{chunk-CEKS3TFO.js → chunk-7IVSUMQQ.js} +6 -6
  9. package/bundle/{chunk-KSWQVGDW.js → chunk-7PCKZHR5.js} +225 -18
  10. package/bundle/{chunk-563BHAZ5.js → chunk-7YMXOYEU.js} +33 -25
  11. package/bundle/{chunk-XMFLCKMQ.js → chunk-B5EKXDHE.js} +336 -130
  12. package/bundle/{chunk-ZBC44JJ3.js → chunk-CIZQXJ5Q.js} +22 -9
  13. package/bundle/{chunk-L5ZHE4L4.js → chunk-GC4DSPMH.js} +4 -4
  14. package/bundle/{chunk-ARRKBB2B.js → chunk-GJECHSAR.js} +2 -2
  15. package/bundle/{chunk-2ZSNH26H.js → chunk-M2CTFYT3.js} +7 -7
  16. package/bundle/{chunk-TUWLXTAM.js → chunk-MHZ4G23A.js} +681 -320
  17. package/bundle/{chunk-WSBNENFS.js → chunk-NGGAFDL6.js} +14 -4
  18. package/bundle/{chunk-VJHOELQP.js → chunk-NRQ75MGH.js} +160 -78
  19. package/bundle/{chunk-BJN5LUQY.js → chunk-OYF6AFDY.js} +1 -1
  20. package/bundle/{chunk-EYNJ6VL6.js → chunk-PLCCBLPY.js} +2 -2
  21. package/bundle/{chunk-74RT5YQE.js → chunk-RMQFMMAT.js} +123 -33
  22. package/bundle/{chunk-V6MR5TOQ.js → chunk-SIJ4S3EN.js} +25 -19
  23. package/bundle/{chunk-NNLASTQE.js → chunk-VH5OAWVG.js} +19 -5
  24. package/bundle/{chunk-BPS2AGBZ.js → chunk-W2SZ22EM.js} +93 -52
  25. package/bundle/{chunk-V3BIUPLU.js → chunk-WVD4UEL4.js} +256 -52
  26. package/bundle/{chunk-ZP3HODFH.js → chunk-XSWOTIA3.js} +1 -1
  27. package/bundle/{chunk-3IF2PGW7.js → chunk-YKEWIB4M.js} +1 -1
  28. package/bundle/{chunk-26C3WLYQ.js → chunk-ZLINIYTP.js} +497 -272
  29. package/bundle/{chunk-2XYWN7QJ.js → chunk-ZQGSDHL6.js} +2 -2
  30. package/bundle/control-channel.js +2 -2
  31. package/bundle/index.js +1 -1
  32. package/bundle/intercom-bridge.js +2 -2
  33. package/bundle/preflight.js +8 -8
  34. package/bundle/project-panes.js +3 -3
  35. package/docs/agents.md +31 -0
  36. package/docs/configuration.md +9 -1
  37. package/docs/tool-reference.md +3 -1
  38. package/docs/workflows.md +16 -0
  39. package/package.json +2 -2
  40. package/skills/pi-subagents/references/execution-controls.md +5 -4
  41. package/src/agents/agent-management.js +3 -3
  42. package/src/agents/agent-memory.js +3 -1
  43. package/src/agents/agents.js +6 -6
  44. package/src/agents/frontmatter.d.ts +2 -0
  45. package/src/agents/frontmatter.js +15 -1
  46. package/src/agents/skills.js +2 -1
  47. package/src/extension/config.js +3 -0
  48. package/src/extension/herdr-pi-bridge.js +17 -10
  49. package/src/extension/index.js +82 -2
  50. package/src/extension/schemas.js +3 -3
  51. package/src/intercom/native-supervisor-channel.js +20 -0
  52. package/src/missions/lifecycle.js +7 -1
  53. package/src/runs/background/active-async-capacity.d.ts +1 -0
  54. package/src/runs/background/active-async-capacity.js +9 -3
  55. package/src/runs/background/async-execution.js +20 -10
  56. package/src/runs/background/async-job-tracker.d.ts +6 -1
  57. package/src/runs/background/async-job-tracker.js +9 -4
  58. package/src/runs/background/async-status.d.ts +2 -0
  59. package/src/runs/background/async-status.js +21 -2
  60. package/src/runs/background/await-async-run.js +3 -7
  61. package/src/runs/background/boot-id.d.ts +12 -0
  62. package/src/runs/background/boot-id.js +47 -0
  63. package/src/runs/background/interrupted-elsewhere.d.ts +22 -0
  64. package/src/runs/background/interrupted-elsewhere.js +68 -0
  65. package/src/runs/background/notify.d.ts +2 -0
  66. package/src/runs/background/notify.js +13 -0
  67. package/src/runs/background/result-delivery-ownership.d.ts +6 -0
  68. package/src/runs/background/result-delivery-ownership.js +19 -1
  69. package/src/runs/background/result-watcher.d.ts +3 -1
  70. package/src/runs/background/result-watcher.js +24 -8
  71. package/src/runs/background/run-child-session.d.ts +1 -6
  72. package/src/runs/background/run-child-session.js +1 -1
  73. package/src/runs/background/run-status.js +2 -1
  74. package/src/runs/background/stale-run-reconciler.d.ts +25 -0
  75. package/src/runs/background/stale-run-reconciler.js +52 -13
  76. package/src/runs/background/subagent-runner-bootstrap.js +3 -0
  77. package/src/runs/background/subagent-runner.d.ts +4 -0
  78. package/src/runs/background/subagent-runner.js +13 -5
  79. package/src/runs/background/subagent-wait.d.ts +1 -1
  80. package/src/runs/background/subagent-wait.js +6 -1
  81. package/src/runs/background/terminal-run-index.d.ts +1 -0
  82. package/src/runs/background/terminal-run-index.js +4 -2
  83. package/src/runs/foreground/async-stop-action.js +3 -1
  84. package/src/runs/foreground/execution.js +6 -3
  85. package/src/runs/foreground/foreground-history.d.ts +16 -2
  86. package/src/runs/foreground/foreground-history.js +133 -31
  87. package/src/runs/foreground/subagent-executor.d.ts +11 -0
  88. package/src/runs/foreground/subagent-executor.js +277 -74
  89. package/src/runs/shared/child-launch.d.ts +2 -2
  90. package/src/runs/shared/child-session.d.ts +3 -2
  91. package/src/runs/shared/child-session.js +51 -8
  92. package/src/runs/shared/child-tool-plan.d.ts +7 -0
  93. package/src/runs/shared/child-tool-plan.js +21 -6
  94. package/src/runs/shared/herdr-connection.d.ts +17 -8
  95. package/src/runs/shared/herdr-connection.js +19 -7
  96. package/src/runs/shared/herdr-external-adapters.d.ts +3 -2
  97. package/src/runs/shared/herdr-external-adapters.js +1 -1
  98. package/src/runs/shared/herdr-machine.d.ts +12 -2
  99. package/src/runs/shared/herdr-machine.js +55 -9
  100. package/src/runs/shared/herdr-pane-host.d.ts +88 -0
  101. package/src/runs/shared/herdr-pane-host.js +155 -0
  102. package/src/runs/shared/herdr-placed-run.d.ts +27 -150
  103. package/src/runs/shared/herdr-placed-run.js +48 -395
  104. package/src/runs/shared/local-machine-access.d.ts +5 -0
  105. package/src/runs/shared/local-machine-access.js +28 -0
  106. package/src/runs/shared/orca-progress-tabs.d.ts +0 -1
  107. package/src/runs/shared/orca-progress-tabs.js +1 -24
  108. package/src/runs/shared/parallel-utils.d.ts +1 -1
  109. package/src/runs/shared/placed-run-owner.d.ts +49 -0
  110. package/src/runs/shared/placed-run-owner.js +93 -0
  111. package/src/runs/shared/placement-host.d.ts +99 -0
  112. package/src/runs/shared/placement-host.js +157 -0
  113. package/src/runs/shared/rex-machine.d.ts +25 -0
  114. package/src/runs/shared/rex-machine.js +83 -0
  115. package/src/runs/shared/rex-pane-host.d.ts +62 -0
  116. package/src/runs/shared/rex-pane-host.js +327 -0
  117. package/src/shared/completion-owner.d.ts +5 -0
  118. package/src/shared/completion-owner.js +5 -0
  119. package/src/shared/session-identity.d.ts +2 -0
  120. package/src/shared/session-identity.js +26 -0
  121. package/src/shared/types.d.ts +45 -6
  122. package/src/tui/fleet.d.ts +1 -0
  123. package/src/tui/fleet.js +24 -15
  124. package/src/tui/render.d.ts +6 -0
  125. package/src/tui/render.js +3 -3
  126. package/src/tui/subagent-messages.js +1 -0
  127. package/src/workflows/scripted-workflow.d.ts +1 -1
  128. package/src/workflows/workflow-auto-resume.d.ts +32 -0
  129. package/src/workflows/workflow-auto-resume.js +102 -0
  130. package/src/workflows/workflow-launch-record.d.ts +27 -0
  131. package/src/workflows/workflow-launch-record.js +48 -0
  132. package/src/workflows/workflow-resume-claim.d.ts +82 -0
  133. package/src/workflows/workflow-resume-claim.js +210 -0
  134. package/src/workflows/workflow-resume.d.ts +56 -0
  135. package/src/workflows/workflow-resume.js +106 -0
  136. package/src/workflows/workflow-reuse.d.ts +15 -9
  137. package/src/workflows/workflow-reuse.js +77 -14
  138. package/src/workflows/workflow-revival.d.ts +2 -0
  139. package/src/workflows/workflow-revival.js +2 -1
  140. package/src/workflows/workflow-stop-cause.d.ts +6 -0
  141. package/src/workflows/workflow-stop-cause.js +8 -0
@@ -0,0 +1,106 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as path from "node:path";
3
+ import { normalizePublicSubagentExecution } from "../extension/public-execution.js";
4
+ import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.js";
5
+ import { ownsRun } from "../runs/shared/run-ownership.js";
6
+ import { readStatus } from "../shared/utils.js";
7
+ import { readWorkflowLaunchRecord } from "./workflow-launch-record.js";
8
+ import { claimWorkflowResume, refusalSentence, relaunchByHandText, resumeByHandText } from "./workflow-resume-claim.js";
9
+ import { workflowScriptDigest } from "./workflow-reuse.js";
10
+ import { isRunId } from "./workflow-revival.js";
11
+ import { isReusableWorkflowStopCause, WORKFLOW_STOP_CAUSE_OWNER_EXITED } from "./workflow-stop-cause.js";
12
+ /** A run may be resumed again after at most this many resumes that followed Pi exits (auto-resume only). */
13
+ const MAX_WORKFLOW_RESUME_ATTEMPTS = 3;
14
+ export function workflowResumeRefusal(code, reason, runId) {
15
+ const byHand = code === "launch-missing" || code === "launch-invalid" || code === "structured" || code === "digest" || code === "authority"
16
+ ? relaunchByHandText(runId)
17
+ : code === "capacity" || code === "attempts" || code === "launch-failed" ? resumeByHandText(runId) : "";
18
+ return { code, reason, byHand };
19
+ }
20
+ /** The tool error for a resume by id; the resume form is not repeated to a caller that just tried it. */
21
+ export function manualWorkflowResumeRefusalText(runId, refusal) {
22
+ return `Cannot resume workflow '${runId}': ${refusalSentence(refusal.reason, refusal.byHand === relaunchByHandText(runId) ? refusal.byHand : "")}`;
23
+ }
24
+ /**
25
+ * The run id a resume action relaunches: no message, task, chain or index, and an id naming a
26
+ * top-level workflow run. Anything else is a child resume.
27
+ */
28
+ export function workflowResumeTargetId(asyncDirRoot, params) {
29
+ if (params.message !== undefined || params.task !== undefined || params.index !== undefined)
30
+ return undefined;
31
+ if (Array.isArray(params.chain) && params.chain.length > 0)
32
+ return undefined;
33
+ const id = params.id ?? params.runId;
34
+ if (!isRunId(id))
35
+ return undefined;
36
+ let status;
37
+ try {
38
+ status = readStatus(path.join(asyncDirRoot, id));
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ return status?.mode === "workflow" && status.parentWorkflowRunId === undefined ? id : undefined;
44
+ }
45
+ /**
46
+ * Checks a stopped workflow run can be resumed and claims it for a new run id. Synchronous and
47
+ * cheap: the launch itself happens in the executor.
48
+ * `newRunId` presets the new run's id: a reload claims a run again under the id of the launch it cancelled.
49
+ */
50
+ export function prepareWorkflowResume(input) {
51
+ const { asyncDirRoot, sourceRunId, trigger } = input;
52
+ const refuse = (code, reason) => ({ ok: false, ...workflowResumeRefusal(code, reason, sourceRunId) });
53
+ const sourceAsyncDir = path.join(asyncDirRoot, sourceRunId);
54
+ // Only the session that owns the run (itself or one it replaced) may resume it, and nothing is
55
+ // written into another session's run, so this comes before the reconcile.
56
+ const stored = readStatus(sourceAsyncDir);
57
+ if (stored && !ownsRun(input.currentSessionId, stored.sessionId))
58
+ return refuse("not-owned", "it was started in another session; reopen that session to resume it");
59
+ // Session start has already reconciled this session's runs; a resume by id may come later.
60
+ if (trigger === "manual")
61
+ reconcileAsyncRun(sourceAsyncDir);
62
+ const status = readStatus(sourceAsyncDir);
63
+ if (!status || status.mode !== "workflow" || !isReusableWorkflowStopCause(status.workflow?.stopCause))
64
+ return refuse("not-interrupted", "not interrupted; pass message to revive one of its children");
65
+ // The attempt the resumed run records: only resumes that follow a Pi exit count.
66
+ const attempt = (status.workflow?.resumeOrigin?.attempt ?? 0) + (status.workflow?.stopCause === WORKFLOW_STOP_CAUSE_OWNER_EXITED ? 1 : 0);
67
+ if (trigger === "auto" && attempt > MAX_WORKFLOW_RESUME_ATTEMPTS)
68
+ return refuse("attempts", `${MAX_WORKFLOW_RESUME_ATTEMPTS} resumes already followed Pi exits`);
69
+ const read = readWorkflowLaunchRecord(sourceAsyncDir);
70
+ if (!read.ok)
71
+ return read.missing ? refuse("launch-missing", "launch.json is missing (the run started before launches were saved)") : refuse("launch-invalid", `launch.json is invalid: ${read.error}`);
72
+ const record = read.record;
73
+ if (record.source === "structured")
74
+ return refuse("structured", "launched from tasks/chain");
75
+ if (record.source === "script" && (typeof record.params.workflowScript !== "string" || workflowScriptDigest(record.params.workflowScript) !== record.scriptDigest)) {
76
+ return refuse("launch-invalid", "launch.json is invalid: its script does not match its scriptDigest");
77
+ }
78
+ const normalized = normalizePublicSubagentExecution(record.params);
79
+ if (!normalized.ok)
80
+ return refuse("launch-invalid", `launch.json is invalid: ${normalized.error}`);
81
+ const newRunId = input.newRunId ?? randomUUID();
82
+ const claim = claimWorkflowResume(sourceAsyncDir, { newRunId, trigger, now: input.now });
83
+ if (claim.kind === "resumed")
84
+ return refuse("claim-resumed", `resumed as ${claim.newRunId}; wait on it or stop it`);
85
+ if (claim.kind === "busy")
86
+ return refuse("claim-busy", `being resumed by pid ${claim.pid}`);
87
+ if (claim.kind === "unreadable")
88
+ return refuse("claim-busy", "its resume claim cannot be read; wait or remove it");
89
+ return {
90
+ ok: true,
91
+ prepared: {
92
+ sourceRunId,
93
+ sourceAsyncDir,
94
+ newRunId,
95
+ trigger,
96
+ params: {
97
+ ...record.params,
98
+ async: true,
99
+ reuseFrom: sourceRunId,
100
+ ...(status.admissionCapabilityCeiling ? { capabilityCeiling: status.admissionCapabilityCeiling } : {}),
101
+ },
102
+ expectedScriptDigest: record.scriptDigest,
103
+ resumeOrigin: { sourceRunId, attempt },
104
+ },
105
+ };
106
+ }
@@ -1,11 +1,11 @@
1
+ import { type WorkflowResumeClaimOutcome } from "./workflow-resume-claim.ts";
1
2
  import { type WorkflowScriptChildResult } from "./scripted-workflow.ts";
2
- /** Stop cause recorded when the extension runtime that owned the workflow was replaced (/reload, resume, project switch). */
3
- declare const WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED = "runtime-replaced";
4
- type WorkflowStopCause = typeof WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED;
5
- export declare function workflowRuntimeReplacedRelaunchNotice(runId: string): string;
3
+ import { WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED } from "./workflow-stop-cause.ts";
4
+ /** What a runtime replacement left; the notice's resume line says how the run continues. */
5
+ export declare const WORKFLOW_RUNTIME_REPLACED_STOP_NOTICE = "Async children that were still running keep running, and finished children are kept.";
6
6
  /** Abort reason for workflow controllers torn down by runtime replacement; carries the cause as data, not text. */
7
7
  export declare function runtimeReplacedAbortReason(): Error;
8
- export declare function workflowStopCause(reason: unknown): WorkflowStopCause | undefined;
8
+ export declare function workflowStopCause(reason: unknown): typeof WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED | undefined;
9
9
  export declare function workflowScriptDigest(script: string): string;
10
10
  /** Hash of the canonical launch params the script host uses to detect duplicate keys. */
11
11
  export declare function workflowChildFingerprint(params: Record<string, unknown>): string;
@@ -40,16 +40,22 @@ type WorkflowReuseMatch = {
40
40
  kind: "started";
41
41
  runId: string;
42
42
  };
43
+ /** Why a relaunch of `runId` is refused when another relaunch already continues it. */
44
+ export declare function claimedWorkflowRunRefusal(runId: string, claim: Exclude<WorkflowResumeClaimOutcome, {
45
+ kind: "owner";
46
+ }>, named: boolean): string;
43
47
  /**
44
48
  * The newest terminal workflow of this session with the same script and args,
45
- * when that run stopped because its runtime was replaced.
49
+ * when that run stopped because its runtime was replaced or its Pi process exited.
50
+ * Throws when another relaunch already continues that run.
46
51
  */
47
52
  export declare function findWorkflowReuseSource(asyncDirRoot: string, sessionId: string, scriptDigest: string, argsDigest: string | undefined): WorkflowReuseSource | undefined;
48
53
  /**
49
- * The runtime-replaced workflow run named by `options.reuseFrom`, from any session. Only the script
50
- * must match: each child is still reused only when its key and launch params match.
54
+ * The interrupted workflow run named by `options.reuseFrom`, from any session. Only the script
55
+ * must match: each child is still reused only when its key and launch params match. The run is
56
+ * reconciled first, so a run whose Pi process died is marked `owner-exited` before it is read.
51
57
  */
52
- export declare function readNamedWorkflowReuseSource(asyncDirRoot: string, runId: string, scriptDigest: string, cwd: string): WorkflowReuseSource;
58
+ export declare function readNamedWorkflowReuseSource(asyncDirRoot: string, runId: string, scriptDigest: string, cwd: string, claimOwnerRunId?: string): WorkflowReuseSource;
53
59
  /** A settled match is reusable only when that child succeeded; failed or stopped children run again. */
54
60
  export declare function matchWorkflowReuse(source: WorkflowReuseSource, key: string, fingerprint: string): WorkflowReuseMatch | undefined;
55
61
  export {};
@@ -2,14 +2,15 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { readRecentTerminalRunIndex } from "../runs/background/terminal-run-index.js";
5
+ import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.js";
5
6
  import { readStatus } from "../shared/utils.js";
7
+ import { workflowResumeClaimState } from "./workflow-resume-claim.js";
8
+ import { isRunId } from "./workflow-revival.js";
6
9
  import { workflowRunParamsFingerprint } from "./scripted-workflow.js";
7
- /** Stop cause recorded when the extension runtime that owned the workflow was replaced (/reload, resume, project switch). */
8
- const WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED = "runtime-replaced";
10
+ import { isReusableWorkflowStopCause, WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED } from "./workflow-stop-cause.js";
9
11
  const WORKFLOW_CHILD_JOURNAL_FILE = "workflow-children.jsonl";
10
- export function workflowRuntimeReplacedRelaunchNotice(runId) {
11
- return `Async children that were still running keep running. Relaunching the same workflow script with the same args from this session reuses finished children and re-attaches to running ones; from another session, add options: { reuseFrom: "${runId}" }.`;
12
- }
12
+ /** What a runtime replacement left; the notice's resume line says how the run continues. */
13
+ export const WORKFLOW_RUNTIME_REPLACED_STOP_NOTICE = "Async children that were still running keep running, and finished children are kept.";
13
14
  /** Abort reason for workflow controllers torn down by runtime replacement; carries the cause as data, not text. */
14
15
  export function runtimeReplacedAbortReason() {
15
16
  return Object.assign(new Error("Workflow stopped because the extension session was replaced or reloaded."), { workflowStopCause: WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED });
@@ -67,9 +68,63 @@ function readWorkflowChildJournal(runId, workflowAsyncDir) {
67
68
  }
68
69
  return source;
69
70
  }
71
+ const WORKFLOW_REUSE_CHAIN_MAX_LINKS = 16;
72
+ /**
73
+ * The head run's journal plus the journals of the runs it was relaunched from (`workflow.reusedFrom`).
74
+ * A child's start record is written only once it gets a concurrency slot, so a key the head never
75
+ * reached is still in an earlier run's journal. For each key the newest run that recorded it wins. A
76
+ * status file is parsed, not validated, so a corrupt `reusedFrom` may be any JSON value.
77
+ */
78
+ function readWorkflowReuseChain(asyncDirRoot, headRunId, headStatus) {
79
+ const source = readWorkflowChildJournal(headRunId, path.join(asyncDirRoot, headRunId));
80
+ const visited = new Set([headRunId]);
81
+ let next = headStatus.workflow?.reusedFrom;
82
+ for (let links = 0; isRunId(next) && links < WORKFLOW_REUSE_CHAIN_MAX_LINKS && !visited.has(next); links++) {
83
+ visited.add(next);
84
+ const asyncDir = path.join(asyncDirRoot, next);
85
+ let status;
86
+ try {
87
+ status = readStatus(asyncDir);
88
+ }
89
+ catch {
90
+ break;
91
+ }
92
+ if (status?.mode !== "workflow" || status.workflow?.scriptDigest !== headStatus.workflow?.scriptDigest)
93
+ break;
94
+ const link = readWorkflowChildJournal(next, asyncDir);
95
+ for (const key of new Set([...link.started.keys(), ...link.settled.keys()])) {
96
+ if (source.started.has(key) || source.settled.has(key))
97
+ continue;
98
+ const started = link.started.get(key);
99
+ const settled = link.settled.get(key);
100
+ if (started)
101
+ source.started.set(key, started);
102
+ if (settled)
103
+ source.settled.set(key, settled);
104
+ }
105
+ next = status.workflow?.reusedFrom;
106
+ }
107
+ return source;
108
+ }
109
+ /** Why a relaunch of `runId` is refused when another relaunch already continues it. */
110
+ export function claimedWorkflowRunRefusal(runId, claim, named) {
111
+ const run = `${named ? "options.reuseFrom: workflow" : "Workflow"} run '${runId}'`;
112
+ if (claim.kind === "resumed")
113
+ return `${run} was resumed as ${claim.newRunId}; wait on it or stop it.`;
114
+ if (claim.kind === "busy")
115
+ return `${run} is being resumed by pid ${claim.pid}; wait for it.`;
116
+ return `${run} has a resume claim that cannot be read; wait or remove it.`;
117
+ }
118
+ function assertWorkflowRunNotClaimed(asyncDir, runId, named, claimOwnerRunId) {
119
+ const claim = workflowResumeClaimState(asyncDir);
120
+ if (claim.kind === "none" || (claim.kind !== "unreadable" && claim.newRunId === claimOwnerRunId))
121
+ return;
122
+ throw new Error(claimedWorkflowRunRefusal(runId, claim, named));
123
+ }
70
124
  /**
71
125
  * The newest terminal workflow of this session with the same script and args,
72
- * when that run stopped because its runtime was replaced.
126
+ * when that run stopped because its runtime was replaced or its Pi process exited.
127
+ * Throws when another relaunch already continues that run.
73
128
  */
74
129
  export function findWorkflowReuseSource(asyncDirRoot, sessionId, scriptDigest, argsDigest) {
75
130
  let runIds;
@@ -90,28 +145,36 @@ export function findWorkflowReuseSource(asyncDirRoot, sessionId, scriptDigest, a
90
145
  }
91
146
  if (status?.mode !== "workflow" || status.workflow?.scriptDigest !== scriptDigest || status.workflow.argsDigest !== argsDigest)
92
147
  continue;
93
- return status.workflow.stopCause === WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED ? readWorkflowChildJournal(runId, asyncDir) : undefined;
148
+ if (!isReusableWorkflowStopCause(status.workflow.stopCause))
149
+ return undefined;
150
+ assertWorkflowRunNotClaimed(asyncDir, runId, false);
151
+ return readWorkflowReuseChain(asyncDirRoot, runId, status);
94
152
  }
95
153
  return undefined;
96
154
  }
97
155
  /**
98
- * The runtime-replaced workflow run named by `options.reuseFrom`, from any session. Only the script
99
- * must match: each child is still reused only when its key and launch params match.
156
+ * The interrupted workflow run named by `options.reuseFrom`, from any session. Only the script
157
+ * must match: each child is still reused only when its key and launch params match. The run is
158
+ * reconciled first, so a run whose Pi process died is marked `owner-exited` before it is read.
100
159
  */
101
- export function readNamedWorkflowReuseSource(asyncDirRoot, runId, scriptDigest, cwd) {
160
+ export function readNamedWorkflowReuseSource(asyncDirRoot, runId, scriptDigest, cwd, claimOwnerRunId) {
102
161
  // A user-supplied id: it must name one run directory, never a path.
103
- const status = runId === path.basename(runId) && runId !== "." && runId !== ".." ? readStatus(path.join(asyncDirRoot, runId)) : null;
162
+ const runName = isRunId(runId);
163
+ if (runName)
164
+ reconcileAsyncRun(path.join(asyncDirRoot, runId));
165
+ const status = runName ? readStatus(path.join(asyncDirRoot, runId)) : null;
104
166
  if (!status)
105
167
  throw new Error(`options.reuseFrom: workflow run '${runId}' was not found.`);
106
168
  if (status.mode !== "workflow")
107
169
  throw new Error(`options.reuseFrom: run '${runId}' is not a workflow run.`);
108
- if (status.workflow?.stopCause !== WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED)
109
- throw new Error(`options.reuseFrom: workflow run '${runId}' did not stop because its session was replaced, so its children cannot be reused.`);
170
+ if (!isReusableWorkflowStopCause(status.workflow?.stopCause))
171
+ throw new Error(`options.reuseFrom: workflow run '${runId}' did not stop because its session was replaced or its Pi process exited, so its children cannot be reused.`);
110
172
  if (status.workflow.scriptDigest !== scriptDigest)
111
173
  throw new Error(`options.reuseFrom: workflow run '${runId}' ran a different workflow script.`);
112
174
  if (status.cwd !== cwd)
113
175
  throw new Error(`options.reuseFrom: workflow run '${runId}' ran in '${status.cwd}', not '${cwd}'.`);
114
- return readWorkflowChildJournal(runId, path.join(asyncDirRoot, runId));
176
+ assertWorkflowRunNotClaimed(path.join(asyncDirRoot, runId), runId, true, claimOwnerRunId);
177
+ return readWorkflowReuseChain(asyncDirRoot, runId, status);
115
178
  }
116
179
  /** A settled match is reusable only when that child succeeded; failed or stopped children run again. */
117
180
  export function matchWorkflowReuse(source, key, fingerprint) {
@@ -9,6 +9,8 @@ export interface WorkflowKeyRevival {
9
9
  /** State of the latest revived run; undefined when its status is unreadable. */
10
10
  state?: AsyncStatus["state"];
11
11
  }
12
+ /** A run directory name, never a path. */
13
+ export declare function isRunId(value: unknown): value is string;
12
14
  /**
13
15
  * Links a detached revival to the workflow key of its source run when the source failed and is
14
16
  * an async workflow child or an earlier revival of one. Throws when the source status cannot be
@@ -13,7 +13,8 @@ export const REVIVAL_ORIGIN_FILE = "workflow-revival-origin.json";
13
13
  /** Recording refuses a deeper link, so readers bounded by this always reach the chain's end. */
14
14
  const MAX_REVIVAL_DEPTH = 16;
15
15
  const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
16
- function isRunId(value) {
16
+ /** A run directory name, never a path. */
17
+ export function isRunId(value) {
17
18
  return typeof value === "string" && value.length > 0 && value.length <= 255 && path.basename(value) === value && value !== "." && value !== "..";
18
19
  }
19
20
  function readLink(file) {
@@ -0,0 +1,6 @@
1
+ /** The extension runtime that owned the workflow was replaced (/reload, resume, project switch). */
2
+ export declare const WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED = "runtime-replaced";
3
+ /** Stale-run reconciliation found the Pi process that ran the workflow dead (crash, kill, reboot). */
4
+ export declare const WORKFLOW_STOP_CAUSE_OWNER_EXITED = "owner-exited";
5
+ export type WorkflowStopCause = typeof WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED | typeof WORKFLOW_STOP_CAUSE_OWNER_EXITED;
6
+ export declare function isReusableWorkflowStopCause(cause: unknown): cause is WorkflowStopCause;
@@ -0,0 +1,8 @@
1
+ // Leaf module with no imports, so any module can use it without an import cycle.
2
+ /** The extension runtime that owned the workflow was replaced (/reload, resume, project switch). */
3
+ export const WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED = "runtime-replaced";
4
+ /** Stale-run reconciliation found the Pi process that ran the workflow dead (crash, kill, reboot). */
5
+ export const WORKFLOW_STOP_CAUSE_OWNER_EXITED = "owner-exited";
6
+ export function isReusableWorkflowStopCause(cause) {
7
+ return cause === WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED || cause === WORKFLOW_STOP_CAUSE_OWNER_EXITED;
8
+ }