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,68 @@
1
+ import { resolvePiSessionId } from "../../shared/session-identity.js";
2
+ import { ownerConfirmedDead } from "../foreground/foreground-history.js";
3
+ import { ownerSessionIds } from "../shared/run-ownership.js";
4
+ import { checkRecordedPidLiveness } from "./stale-run-reconciler.js";
5
+ /** Runs interrupted longer ago than this are not pointed to. */
6
+ const INTERRUPTED_ELSEWHERE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
7
+ function interruptedElsewhereLine(reopenId, count) {
8
+ const command = `\`pi --session ${reopenId}\``;
9
+ return count === 1
10
+ ? `1 subagent run was interrupted in another session. Reopen it to resume it: ${command}`
11
+ : `${count} subagent runs were interrupted in another session. Reopen it to resume them: ${command}`;
12
+ }
13
+ /**
14
+ * Collects runs of other sessions in this folder that were cut off because their Pi died, from what
15
+ * session start reads anyway. Read-only: nothing is written to those runs or sessions. The cheap
16
+ * filters run as records arrive; `lines` reads each session file's header once, then probes each
17
+ * candidate's pid once.
18
+ */
19
+ export function createInterruptedElsewhereCollector(input) {
20
+ const ownSessions = ownerSessionIds(input.currentSessionId);
21
+ // sessionId -> runId -> whether its owner is dead; a run seen from both sources counts once.
22
+ const candidates = new Map();
23
+ const recent = (at) => typeof at === "number" && input.now - at <= INTERRUPTED_ELSEWHERE_MAX_AGE_MS;
24
+ const add = (sessionId, runId, ownerDead) => {
25
+ if (ownSessions.has(sessionId))
26
+ return;
27
+ const runs = candidates.get(sessionId) ?? new Map();
28
+ if (!runs.has(runId))
29
+ runs.set(runId, ownerDead);
30
+ candidates.set(sessionId, runs);
31
+ };
32
+ return {
33
+ observeStatus(observed) {
34
+ // The listing passes the status.json values it read.
35
+ const status = observed;
36
+ if (!status || typeof status.sessionId !== "string" || typeof status.runId !== "string" || (status.state !== "running" && status.state !== "queued"))
37
+ return;
38
+ if (status.parentWorkflowRunId !== undefined || status.cwd !== input.cwd || !recent(status.lastUpdate ?? status.startedAt))
39
+ return;
40
+ const { pid, pidNamespaceScope, bootId } = status;
41
+ if (typeof pid !== "number")
42
+ return;
43
+ add(status.sessionId, status.runId, () => checkRecordedPidLiveness({ pid, pidNamespaceScope, bootId }) === "dead");
44
+ },
45
+ observeForegroundRun(run) {
46
+ if (!run.sessionId || run.parentWorkflowRunId !== undefined || run.cwd !== input.cwd || !recent(run.updatedAt))
47
+ return;
48
+ add(run.sessionId, run.runId, () => ownerConfirmedDead(run));
49
+ },
50
+ lines() {
51
+ const lines = [];
52
+ for (const [sessionId, runs] of candidates) {
53
+ // Only a persisted session can be reopened; its id is then its file path, whose header names
54
+ // the id `pi --session` resolves, so the command needs no quoting in any shell.
55
+ const reopenId = resolvePiSessionId(sessionId);
56
+ if (!reopenId)
57
+ continue;
58
+ let interrupted = 0;
59
+ for (const ownerDead of runs.values())
60
+ if (ownerDead())
61
+ interrupted += 1;
62
+ if (interrupted > 0)
63
+ lines.push(interruptedElsewhereLine(reopenId, interrupted));
64
+ }
65
+ return lines;
66
+ },
67
+ };
68
+ }
@@ -43,6 +43,8 @@ export interface SubagentNotifyDetails {
43
43
  /** The finished run's id; for a workflow it equals workflowRunId. */
44
44
  runId?: string;
45
45
  workflowRunId?: string;
46
+ /** What happens next to a workflow that Pi's exit or a runtime replacement stopped; decided once at delivery. */
47
+ resumeNotice?: string;
46
48
  childRuns?: Array<{
47
49
  runId: string;
48
50
  workflowKey?: string;
@@ -15,6 +15,8 @@ import { COMPLETION_ACTION } from "../../shared/parent-wake.js";
15
15
  import { resolveSubagentResultStatus } from "../../intercom/result-intercom.js";
16
16
  import { isUnexplainedProcessSignal } from "../shared/process-signal.js";
17
17
  import { capSummaryOutputs } from "../shared/single-output.js";
18
+ import { describeWorkflowResume } from "../../workflows/workflow-resume-claim.js";
19
+ import { isReusableWorkflowStopCause } from "../../workflows/workflow-stop-cause.js";
18
20
  const CHILD_OUTPUT_PREVIEW_MAX_BYTES = 2_000;
19
21
  const ERROR_LINE_MAX_BYTES = 300;
20
22
  const CHILD_OUTPUT_PREVIEW_COUNT = 8;
@@ -187,10 +189,13 @@ function formatCorrelationLines(details) {
187
189
  return [
188
190
  details.asyncDir ? `Retention-managed async directory: ${boundedSafeText(details.asyncDir)}` : undefined,
189
191
  details.workflowRunId ? `Workflow run: ${details.workflowRunId}` : undefined,
192
+ details.resumeNotice,
190
193
  details.childRuns?.length ? `Child runs: ${details.childRuns.map(formatChildRun).join(", ")}` : undefined,
191
194
  details.reconciledFromDetachedChild ? `Reconciled detached child: ${details.reconciledFromDetachedChild}` : undefined,
192
195
  ].filter((line) => line !== undefined);
193
196
  }
197
+ // The forms of describeWorkflowResume, so a re-render keeps the line out of the result preview.
198
+ const RESUME_NOTICE_PREFIXES = ["Auto-resumed as ", "Resumed as ", "Relaunched as ", "Not auto-resumed: ", "Resume by hand: ", "Relaunch it by hand with "];
194
199
  const WATCHDOG_BLOCKERS_HEADING = "Watchdog blockers:";
195
200
  function formatWatchdogBlockerLines(details) {
196
201
  if (!details.watchdogBlockers?.length)
@@ -295,6 +300,9 @@ export function parseSubagentNotifyContent(content) {
295
300
  const workflowRunIndex = body.findIndex((line) => line.startsWith("Workflow run: "));
296
301
  const childRunsIndex = body.findIndex((line) => line.startsWith("Child runs: "));
297
302
  const reconciledIndex = body.findIndex((line) => line.startsWith("Reconciled detached child: "));
303
+ // Only the slot right after `Workflow run:` carries the resume line; the same text elsewhere is output.
304
+ const resumeNoticeLine = workflowRunIndex >= 0 ? body[workflowRunIndex + 1] : undefined;
305
+ const resumeNotice = resumeNoticeLine && RESUME_NOTICE_PREFIXES.some((prefix) => resumeNoticeLine.startsWith(prefix)) ? resumeNoticeLine : undefined;
298
306
  const watchdogIndex = body.findIndex((line) => line === WATCHDOG_BLOCKERS_HEADING);
299
307
  const watchdogBlockers = watchdogIndex >= 0 ? parseWatchdogBlockerLines(body.slice(watchdogIndex + 1)) : [];
300
308
  const metadataIndexes = [sessionIndex, handoffIndex, workflowRunIndex, childRunsIndex, reconciledIndex, watchdogIndex].filter((index) => index >= 0);
@@ -332,6 +340,7 @@ export function parseSubagentNotifyContent(content) {
332
340
  resultPreview,
333
341
  ...(handoffPath ? { handoffPath } : {}),
334
342
  ...(workflowRunId ? { workflowRunId } : {}),
343
+ ...(resumeNotice ? { resumeNotice } : {}),
335
344
  ...(childRuns?.length ? { childRuns } : {}),
336
345
  ...(reconciledFromDetachedChild ? { reconciledFromDetachedChild } : {}),
337
346
  ...(watchdogBlockers.length ? { watchdogBlockers } : {}),
@@ -614,6 +623,9 @@ export function buildCompletionDetails(result) {
614
623
  : undefined;
615
624
  const reconciledFromDetachedChild = typeof result.reconciledFromDetachedChild === "string" ? result.reconciledFromDetachedChild : undefined;
616
625
  const asyncDir = nonemptyPath(result.asyncDir);
626
+ // Read here, once per delivery; a re-render parses the line back instead of reading the run again.
627
+ const stopCause = result.workflow?.stopCause;
628
+ const resumeNotice = workflowRunId && asyncDir && isReusableWorkflowStopCause(stopCause) ? describeWorkflowResume(asyncDir, workflowRunId) : undefined;
617
629
  const watchdogBlockers = [];
618
630
  const watchdogConcerns = [];
619
631
  const collectWatchdogFindings = (owner, progress) => {
@@ -653,6 +665,7 @@ export function buildCompletionDetails(result) {
653
665
  ...(handoffPath ? { handoffPath } : {}),
654
666
  ...(rawRunId ? { runId: rawRunId } : {}),
655
667
  ...(workflowRunId ? { workflowRunId } : {}),
668
+ ...(resumeNotice ? { resumeNotice } : {}),
656
669
  ...(childRuns.length ? { childRuns } : {}),
657
670
  ...(childOutputs?.length ? { childOutputs } : {}),
658
671
  ...(reconciledFromDetachedChild ? { reconciledFromDetachedChild } : {}),
@@ -2,6 +2,12 @@ import type { SubagentState } from "../../shared/types.ts";
2
2
  type ResultDeliveryState = Pick<SubagentState, "currentSessionId" | "completionOwnerId">;
3
3
  export interface ResultDeliveryOwnership {
4
4
  owns(sessionId: string, completionOwnerId: unknown): boolean;
5
+ /**
6
+ * The completion owner a result is delivered under: `owning-session` when its own owner is another Pi
7
+ * process that is confirmed dead and this session owns the run, so nothing else can ever deliver it;
8
+ * otherwise `completionOwnerId`.
9
+ */
10
+ deliveryOwnerId(sessionId: string | undefined, completionOwnerId: string | null | undefined, ownerProcess: unknown, parentWorkflowRunId: unknown): string | null | undefined;
5
11
  claimedSessionIds(): readonly string[];
6
12
  }
7
13
  export declare function createResultDeliveryOwnership(state: ResultDeliveryState): ResultDeliveryOwnership;
@@ -1,5 +1,9 @@
1
1
  import { ownsRun } from "../shared/run-ownership.js";
2
2
  import { inheritedSessionIds } from "../../shared/session-succession.js";
3
+ import { OWNING_SESSION_COMPLETION_OWNER } from "../../shared/completion-owner.js";
4
+ import { checkRecordedPidLiveness, isRecordedPid } from "./stale-run-reconciler.js";
5
+ // A dead process cannot come back, so only that verdict is kept.
6
+ const deadCompletionOwnerIds = new Set();
3
7
  export function createResultDeliveryOwnership(state) {
4
8
  const currentOwner = () => typeof state.completionOwnerId === "string" && state.completionOwnerId
5
9
  ? state.completionOwnerId
@@ -7,10 +11,24 @@ export function createResultDeliveryOwnership(state) {
7
11
  return {
8
12
  owns(sessionId, completionOwnerId) {
9
13
  const owner = currentOwner();
10
- if (!owner || completionOwnerId !== owner)
14
+ if (!owner || (completionOwnerId !== owner && completionOwnerId !== OWNING_SESSION_COMPLETION_OWNER))
11
15
  return false;
12
16
  return ownsRun(state.currentSessionId, sessionId);
13
17
  },
18
+ deliveryOwnerId(sessionId, completionOwnerId, ownerProcess, parentWorkflowRunId) {
19
+ const owner = currentOwner();
20
+ // A workflow child is reported by its workflow, which has its own owner-exited notice.
21
+ if (!owner || typeof completionOwnerId !== "string" || completionOwnerId === owner
22
+ || completionOwnerId === OWNING_SESSION_COMPLETION_OWNER || parentWorkflowRunId
23
+ || !ownsRun(state.currentSessionId, sessionId))
24
+ return completionOwnerId;
25
+ if (!deadCompletionOwnerIds.has(completionOwnerId)) {
26
+ if (!isRecordedPid(ownerProcess) || checkRecordedPidLiveness(ownerProcess) !== "dead")
27
+ return completionOwnerId;
28
+ deadCompletionOwnerIds.add(completionOwnerId);
29
+ }
30
+ return OWNING_SESSION_COMPLETION_OWNER;
31
+ },
14
32
  claimedSessionIds() {
15
33
  if (!currentOwner() || !state.currentSessionId)
16
34
  return [];
@@ -31,7 +31,7 @@ type ResultWatcherDeps = {
31
31
  resultScanLogging?: "all" | "activity" | "off";
32
32
  platform?: NodeJS.Platform;
33
33
  /** Shared current/predecessor session ownership used by the notifier. */
34
- ownership?: Pick<ResultDeliveryOwnership, "owns" | "claimedSessionIds">;
34
+ ownership?: Pick<ResultDeliveryOwnership, "owns" | "claimedSessionIds" | "deliveryOwnerId">;
35
35
  /** Called once the parent has this run's result: the notifier accepted it, or an earlier delivery already did. */
36
36
  onResultDelivered?: (runId: string) => void;
37
37
  };
@@ -69,6 +69,8 @@ type ResultFileData = CompletionNotification & {
69
69
  intercomTarget?: string;
70
70
  parallelHandoff?: ParallelHandoffReference;
71
71
  notificationDeliveredAt?: unknown;
72
+ completionOwnerProcess?: unknown;
73
+ parentWorkflowRunId?: unknown;
72
74
  };
73
75
  /**
74
76
  * Watches persisted async results for the session currently owned by this
@@ -19,25 +19,31 @@ const RETRY_DELAY_MS = 100;
19
19
  // lease attempt instead of parking this event loop while a runner publishes the same run.
20
20
  const CLEANUP_LEASE_WAIT_MS = 0;
21
21
  const SLOW_RESULT_SCAN_MS = 500;
22
- function jsonStringProperty(raw, property) {
23
- const matches = raw.matchAll(new RegExp(`"${property}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, "g"));
22
+ /** The last `"property": <value>` whose value matches `valuePattern`, parsed without reading the whole file; malformed reads as none. */
23
+ function jsonPropertyValue(raw, property, valuePattern) {
24
24
  let encoded;
25
- for (const match of matches)
25
+ for (const match of raw.matchAll(new RegExp(`"${property}"\\s*:\\s*(${valuePattern})`, "g")))
26
26
  encoded = match[1];
27
27
  if (!encoded)
28
28
  return undefined;
29
29
  try {
30
- const value = JSON.parse(encoded);
31
- return typeof value === "string" && value ? value : undefined;
30
+ return JSON.parse(encoded);
32
31
  }
33
32
  catch {
34
33
  return undefined;
35
34
  }
36
35
  }
36
+ function jsonStringProperty(raw, property) {
37
+ const value = jsonPropertyValue(raw, property, `"(?:\\\\.|[^"\\\\])*"`);
38
+ return typeof value === "string" && value ? value : undefined;
39
+ }
37
40
  function resultFileIdentity(raw, file) {
38
41
  return {
39
42
  sessionId: jsonStringProperty(raw, "sessionId"),
40
43
  completionOwnerId: jsonStringProperty(raw, "completionOwnerId"),
44
+ // Recorded as a flat object, so a brace-free match is the whole value.
45
+ completionOwnerProcess: jsonPropertyValue(raw, "completionOwnerProcess", `\\{[^{}]*\\}`),
46
+ parentWorkflowRunId: jsonStringProperty(raw, "parentWorkflowRunId"),
41
47
  runId: file.replace(/\.json$/i, ""),
42
48
  };
43
49
  }
@@ -105,6 +111,9 @@ export function createResultWatcher(pi, state, resultsDir, completionTtlMs, deps
105
111
  // While deliverPendingResults runs, completions skip the batch delay.
106
112
  let deliveringPending = 0;
107
113
  const identityCache = new Map();
114
+ // Result file -> last logged mission-sync error. A failed sync is retried
115
+ // every pass, so the same error is logged once per result file.
116
+ const reportedMissionSyncFailures = new Map();
108
117
  let deliveryActive = true;
109
118
  let deliveryEpoch = 0;
110
119
  let resultScanTimer = null;
@@ -116,6 +125,7 @@ export function createResultWatcher(pi, state, resultsDir, completionTtlMs, deps
116
125
  && typeof completionOwnerId === "string"
117
126
  && completionOwnerId === state.completionOwnerId);
118
127
  const claimedSessionIds = () => deps.ownership?.claimedSessionIds() ?? [];
128
+ const deliveryOwnerId = deps.ownership?.deliveryOwnerId ?? ((_sessionId, completionOwnerId) => completionOwnerId);
119
129
  const ownsCompletion = (sessionId, completionOwnerId, epoch) => {
120
130
  if (!deliveryActive || epoch !== deliveryEpoch)
121
131
  return false;
@@ -223,7 +233,7 @@ export function createResultWatcher(pi, state, resultsDir, completionTtlMs, deps
223
233
  // files keep their existing diagnostics and compatibility behavior.
224
234
  if (!identity.sessionId)
225
235
  return true;
226
- if (ownsResult(identity.sessionId, identity.completionOwnerId))
236
+ if (ownsResult(identity.sessionId, deliveryOwnerId(identity.sessionId, identity.completionOwnerId, identity.completionOwnerProcess, identity.parentWorkflowRunId)))
227
237
  return true;
228
238
  // Acknowledgment removes the observer index; gating on the mission binding instead
229
239
  // re-ran mission sync for an unowned result on every scan.
@@ -306,7 +316,7 @@ export function createResultWatcher(pi, state, resultsDir, completionTtlMs, deps
306
316
  if (typeof data.sessionId !== "string" || !data.sessionId)
307
317
  return;
308
318
  const sessionId = data.sessionId;
309
- const completionOwnerId = data.completionOwnerId;
319
+ const completionOwnerId = deliveryOwnerId(sessionId, data.completionOwnerId, data.completionOwnerProcess, data.parentWorkflowRunId);
310
320
  const runId = typeof data.runId === "string" && data.runId
311
321
  ? data.runId
312
322
  : typeof data.id === "string" && data.id ? data.id : file.replace(/\.json$/i, "");
@@ -329,10 +339,15 @@ export function createResultWatcher(pi, state, resultsDir, completionTtlMs, deps
329
339
  let observerSucceeded = true;
330
340
  try {
331
341
  syncMissionFromAsyncCompletion({ ...data, runId });
342
+ reportedMissionSyncFailures.delete(file);
332
343
  }
333
344
  catch (error) {
334
345
  observerSucceeded = false;
335
- console.error(`Mission completion sync failed for '${resultPath}':`, error);
346
+ const message = error instanceof Error ? error.message : String(error);
347
+ if (reportedMissionSyncFailures.get(file) !== message) {
348
+ reportedMissionSyncFailures.set(file, message);
349
+ console.error(`Mission completion sync failed for '${resultPath}':`, error);
350
+ }
336
351
  }
337
352
  try {
338
353
  deps.observeCompletion?.({ ...data, runId });
@@ -484,6 +499,7 @@ export function createResultWatcher(pi, state, resultsDir, completionTtlMs, deps
484
499
  ...data,
485
500
  id: data.id ?? runId,
486
501
  runId,
502
+ completionOwnerId,
487
503
  triggerTurn,
488
504
  intercomDelivered,
489
505
  ...(nestedChildren?.length ? { nestedChildren } : {}),
@@ -91,12 +91,7 @@ export interface RunChildSessionResult {
91
91
  toolCount: number;
92
92
  durationMs: number;
93
93
  model?: string;
94
- nativeMachine?: {
95
- provider: "herdr";
96
- machineId: string;
97
- initialGit?: import("../../shared/types.ts").HerdrRemoteGitStatus;
98
- finalGit?: import("../../shared/types.ts").HerdrRemoteGitStatus;
99
- };
94
+ nativeMachine?: import("../../shared/types.ts").SingleResult["nativeMachine"];
100
95
  error?: string;
101
96
  finalOutput: string;
102
97
  outputState: SubagentOutputState;
@@ -526,7 +526,7 @@ export function runChildSession(input) {
526
526
  toolCount,
527
527
  durationMs: Date.now() - startedAt,
528
528
  model,
529
- nativeMachine: session?.machineEvidence ? { provider: "herdr", machineId: session.machineEvidence.machineId, ...(session.machineEvidence.initial ? { initialGit: session.machineEvidence.initial } : {}), ...(session.machineEvidence.final ? { finalGit: session.machineEvidence.final } : {}) } : undefined,
529
+ nativeMachine: session?.machineEvidence ? { provider: session.machineEvidence.provider, machineId: session.machineEvidence.machineId, ...(session.machineEvidence.initial ? { initialGit: session.machineEvidence.initial } : {}), ...(session.machineEvidence.final ? { finalGit: session.machineEvidence.final } : {}) } : undefined,
530
530
  error: stopped ? stopMessage() : timedOut ? (error ?? timeoutMessage()) : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal && !commandError) ? undefined : finalError,
531
531
  finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage() : error ?? timeoutMessage()) : finalOutput,
532
532
  outputState: finalOutput.trim() ? "present" : "absent",
@@ -36,6 +36,7 @@ import { workflowAsyncChildSteeringGuidance } from "../shared/workflow-async-chi
36
36
  import { formatWorkflowKeyRevival, projectWorkflowKeyRevival } from "../../workflows/workflow-revival.js";
37
37
  import { capSummaryOutputs } from "../shared/single-output.js";
38
38
  import { ownsRun } from "../shared/run-ownership.js";
39
+ import { foregroundChildStateLabel } from "../foreground/foreground-history.js";
39
40
  /** A step whose structured output has a path line needs only a short inline preview. */
40
41
  const STRUCTURED_PREVIEW_WITH_PATH_CHARS = 500;
41
42
  function formatProcessTerminal(value) {
@@ -200,7 +201,7 @@ function formatRememberedForegroundStatus(run) {
200
201
  for (const child of run.children) {
201
202
  const output = rememberedForegroundChildOutput(child).trim().split(/\r?\n/).find((line) => line.trim());
202
203
  const parts = [
203
- `${child.index + 1}. ${child.sessionName?.trim() || child.agent} ${child.status}`,
204
+ `${child.index + 1}. ${child.sessionName?.trim() || child.agent} ${foregroundChildStateLabel(run, child)}`,
204
205
  child.exitCode !== undefined ? `exit ${child.exitCode}` : undefined,
205
206
  child.detachedReason ? `detached: ${child.detachedReason}` : undefined,
206
207
  child.acceptance ? `acceptance: ${child.acceptance.status}` : undefined,
@@ -24,6 +24,12 @@ interface ReconcileAsyncRunOptions {
24
24
  missingStatusGraceMs?: number;
25
25
  staleAlivePidMs?: number;
26
26
  pidNamespaceScope?: () => string | undefined;
27
+ bootId?: () => string | undefined;
28
+ /**
29
+ * This process's completion owner, for an owner-exited workflow repair to adopt so the session that
30
+ * reopened it delivers its notice. Only callers whose session owns the run pass it.
31
+ */
32
+ adoptCompletionOwnerId?: string;
27
33
  }
28
34
  interface ReconcileAsyncRunResult {
29
35
  status: AsyncStatus | null;
@@ -33,5 +39,24 @@ interface ReconcileAsyncRunResult {
33
39
  }
34
40
  export declare function reconcileNestedAsyncDescendants(route: NestedRoute, options?: ReconcileAsyncRunOptions): void;
35
41
  export declare function checkPidLiveness(pid: number, kill?: KillFn, probeZombie?: boolean): PidLiveness;
42
+ export interface RecordedPid {
43
+ pid: number;
44
+ pidNamespaceScope?: string;
45
+ bootId?: string;
46
+ }
47
+ export declare function isRecordedPid(value: unknown): value is RecordedPid;
48
+ export interface RecordedPidObservation {
49
+ observedScope: string | undefined;
50
+ observedBootId: string | undefined;
51
+ kill?: KillFn;
52
+ /** Replaces the default `checkPidLiveness` probe. */
53
+ probe?: (pid: number) => PidLiveness;
54
+ }
55
+ /**
56
+ * Liveness of a pid saved in a status file. A pid saved under another boot is dead without a
57
+ * probe, but only when both sides are in the same pid namespace or neither records one: a
58
+ * macOS host reading a Linux container's runs must not compare their boot ids.
59
+ */
60
+ export declare function checkRecordedPidLiveness(recorded: RecordedPid, observation?: RecordedPidObservation): PidLiveness;
36
61
  export declare function reconcileAsyncRun(asyncDir: string, options?: ReconcileAsyncRunOptions, observeStatus?: RawDrainStatusObserver): ReconcileAsyncRunResult;
37
62
  export {};
@@ -4,6 +4,7 @@ import { writeAtomicJson } from "../../shared/atomic-json.js";
4
4
  import { resultFilePath, resultPayloadPathForSessionRun, writeAsyncResultFile } from "./result-files.js";
5
5
  import { updateActiveRunIndex } from "./active-run-index.js";
6
6
  import { readProcessTerminal } from "./process-terminal.js";
7
+ import { OWNING_SESSION_COMPLETION_OWNER } from "../../shared/completion-owner.js";
7
8
  import { readStatus } from "../../shared/utils.js";
8
9
  import { DIRS } from "../../shared/types.js";
9
10
  import { resolveEffectiveThinking } from "../../shared/model-info.js";
@@ -11,6 +12,8 @@ import { normalizeParallelGroups } from "./parallel-groups.js";
11
12
  import { nestedSummaryFromAsyncStatus, projectNestedEvents, resolveNestedAsyncDir, writeNestedEvent } from "../shared/nested-events.js";
12
13
  import { assertWorkflowGraphHostSteps } from "../shared/host-step-status.js";
13
14
  import { currentPidNamespaceScope } from "./pid-namespace.js";
15
+ import { currentBootId } from "./boot-id.js";
16
+ import { WORKFLOW_STOP_CAUSE_OWNER_EXITED } from "../../workflows/workflow-stop-cause.js";
14
17
  import { isTerminalAsyncState } from "./workflow-terminal-proof.js";
15
18
  function getErrorMessage(error) {
16
19
  return error instanceof Error ? error.message : String(error);
@@ -215,9 +218,14 @@ function settledWorkflowChildStatus(asyncDir, workflowRunId, step, now) {
215
218
  delete settled.stopped;
216
219
  return settled;
217
220
  }
218
- function buildFailedRepair(status, asyncDir, now, reason) {
221
+ function buildFailedRepair(status, asyncDir, now, reason, ownerExited = false, adoptCompletionOwnerId) {
219
222
  const runId = status.runId || path.basename(asyncDir);
220
223
  const pid = typeof status.pid === "number" ? status.pid : "unknown";
224
+ // A dead workflow host is the Pi process itself; its journal keeps the finished children for a relaunch.
225
+ const exitedWorkflow = ownerExited && status.mode === "workflow" ? status.workflow : undefined;
226
+ // Its owner process is dead, so nothing else can deliver its notice: an owning session that repairs it
227
+ // adopts it, and a repair elsewhere leaves it to whichever session owns the run.
228
+ const completionOwnerId = exitedWorkflow ? adoptCompletionOwnerId || OWNING_SESSION_COMPLETION_OWNER : status.completionOwnerId;
221
229
  const terminal = readProcessTerminal(asyncDir, { runId }) ?? status.processTerminal;
222
230
  const runnerExit = terminal?.state === "observed" || terminal?.state === "unknown"
223
231
  ? terminal.instances?.find((instance) => instance.kind === "runner" && instance.processInstanceId === terminal.runnerProcessInstanceId)
@@ -225,7 +233,9 @@ function buildFailedRepair(status, asyncDir, now, reason) {
225
233
  const exitText = runnerExit?.kind === "runner"
226
234
  ? `exited with code ${runnerExit.exitCode ?? "none"}${runnerExit.signal ? ` (signal ${runnerExit.signal})` : ""}`
227
235
  : "exited or disappeared";
228
- const baseMessage = reason ?? `Async runner process ${pid} ${exitText} before writing a result. Marked run failed by stale-run reconciliation.`;
236
+ const baseMessage = reason ?? (exitedWorkflow
237
+ ? `The Pi process (PID ${pid}) that ran workflow ${runId} exited before the workflow finished. Marked run failed by stale-run reconciliation. Its finished children are kept.`
238
+ : `Async runner process ${pid} ${exitText} before writing a result. Marked run failed by stale-run reconciliation.`);
229
239
  const diagnostics = readRunnerStartupDiagnostics(asyncDir);
230
240
  const message = diagnostics ? `${baseMessage}\n\nRunner stderr tail:\n${diagnostics}` : baseMessage;
231
241
  const steps = status.steps?.length ? status.steps : [{ agent: "subagent", status: "running" }];
@@ -263,6 +273,9 @@ function buildFailedRepair(status, asyncDir, now, reason) {
263
273
  lastUpdate: now,
264
274
  endedAt: now,
265
275
  steps: repairedSteps,
276
+ // Read from the pre-repair status: the repair time would make an old crash look new.
277
+ ...(exitedWorkflow ? { workflow: { ...exitedWorkflow, stopCause: WORKFLOW_STOP_CAUSE_OWNER_EXITED, ownerExitedAt: status.lastUpdate ?? status.startedAt } } : {}),
278
+ ...(completionOwnerId ? { completionOwnerId } : {}),
266
279
  };
267
280
  const resultAgent = repairedSteps[status.currentStep ?? 0]?.agent ?? repairedSteps[0]?.agent ?? "subagent";
268
281
  return {
@@ -272,7 +285,9 @@ function buildFailedRepair(status, asyncDir, now, reason) {
272
285
  id: runId,
273
286
  agent: resultAgent,
274
287
  mode: status.mode,
275
- ...(status.completionOwnerId ? { completionOwnerId: status.completionOwnerId } : {}),
288
+ ...(exitedWorkflow ? { workflow: { stopCause: WORKFLOW_STOP_CAUSE_OWNER_EXITED } } : {}),
289
+ ...(completionOwnerId ? { completionOwnerId } : {}),
290
+ ...(status.completionOwnerProcess ? { completionOwnerProcess: status.completionOwnerProcess } : {}),
276
291
  ...(status.parentWorkflowRunId ? { parentWorkflowRunId: status.parentWorkflowRunId } : {}),
277
292
  ...(status.workflowKey ? { workflowKey: status.workflowKey } : {}),
278
293
  success: false,
@@ -298,8 +313,8 @@ function buildFailedRepair(status, asyncDir, now, reason) {
298
313
  },
299
314
  };
300
315
  }
301
- function writeFailedRepair(asyncDir, status, resultPath, now, reason) {
302
- const repair = buildFailedRepair(status, asyncDir, now, reason);
316
+ function writeFailedRepair(asyncDir, status, resultPath, now, reason, ownerExited = false, adoptCompletionOwnerId) {
317
+ const repair = buildFailedRepair(status, asyncDir, now, reason, ownerExited, adoptCompletionOwnerId);
303
318
  if (repair.result.sessionId)
304
319
  writeAsyncResultFile(resultPath, repair.result);
305
320
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
@@ -386,6 +401,32 @@ export function checkPidLiveness(pid, kill = process.kill, probeZombie = false)
386
401
  return "unknown";
387
402
  }
388
403
  }
404
+ export function isRecordedPid(value) {
405
+ if (!value || typeof value !== "object")
406
+ return false;
407
+ const { pid, pidNamespaceScope, bootId } = value;
408
+ return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0
409
+ && (pidNamespaceScope === undefined || typeof pidNamespaceScope === "string")
410
+ && (bootId === undefined || typeof bootId === "string");
411
+ }
412
+ /**
413
+ * Liveness of a pid saved in a status file. A pid saved under another boot is dead without a
414
+ * probe, but only when both sides are in the same pid namespace or neither records one: a
415
+ * macOS host reading a Linux container's runs must not compare their boot ids.
416
+ */
417
+ export function checkRecordedPidLiveness(recorded, observation = { observedScope: currentPidNamespaceScope(), observedBootId: currentBootId() }) {
418
+ const { observedScope, observedBootId } = observation;
419
+ if (recorded.pidNamespaceScope === observedScope && recorded.bootId && observedBootId && recorded.bootId !== observedBootId)
420
+ return "dead";
421
+ // An observer without a scope (macOS/Windows host sharing a container's temp root) cannot match a recorded one.
422
+ const scopeMismatch = recorded.pidNamespaceScope !== undefined && recorded.pidNamespaceScope !== observedScope;
423
+ // A local zombie is evidence about this pid only when its namespace is known to match.
424
+ const scopeVerified = recorded.pidNamespaceScope !== undefined && recorded.pidNamespaceScope === observedScope;
425
+ const liveness = observation.probe
426
+ ? observation.probe(recorded.pid)
427
+ : checkPidLiveness(recorded.pid, observation.kill, scopeVerified);
428
+ return liveness === "dead" && scopeMismatch ? "unknown" : liveness;
429
+ }
389
430
  export function reconcileAsyncRun(asyncDir, options = {}, observeStatus) {
390
431
  const now = options.now?.() ?? Date.now();
391
432
  const status = readStatus(asyncDir);
@@ -450,13 +491,11 @@ export function reconcileAsyncRun(asyncDir, options = {}, observeStatus) {
450
491
  return { status: null, repaired: false, resultPath };
451
492
  }
452
493
  }
453
- const observedScope = options.pidNamespaceScope ? options.pidNamespaceScope() : currentPidNamespaceScope();
454
- // An observer without a scope (macOS/Windows host sharing a container's temp root) cannot match a recorded one.
455
- const pidScopeMismatch = effectiveStatus.pidNamespaceScope !== undefined && effectiveStatus.pidNamespaceScope !== observedScope;
456
- // A local zombie is evidence about this runner only when its namespace is known to match.
457
- const pidScopeVerified = effectiveStatus.pidNamespaceScope !== undefined && effectiveStatus.pidNamespaceScope === observedScope;
458
- const observedLiveness = checkPidLiveness(effectiveStatus.pid, options.kill, pidScopeVerified);
459
- const liveness = observedLiveness === "dead" && pidScopeMismatch ? "unknown" : observedLiveness;
494
+ const liveness = checkRecordedPidLiveness({ pid: effectiveStatus.pid, pidNamespaceScope: effectiveStatus.pidNamespaceScope, bootId: effectiveStatus.bootId }, {
495
+ kill: options.kill,
496
+ observedScope: options.pidNamespaceScope ? options.pidNamespaceScope() : currentPidNamespaceScope(),
497
+ observedBootId: options.bootId ? options.bootId() : currentBootId(),
498
+ });
460
499
  if (liveness !== "dead") {
461
500
  const staleAfterMs = options.staleAlivePidMs ?? 24 * 60 * 60 * 1000;
462
501
  const lastUpdate = effectiveStatus.lastUpdate ?? effectiveStatus.startedAt;
@@ -466,5 +505,5 @@ export function reconcileAsyncRun(asyncDir, options = {}, observeStatus) {
466
505
  const message = `Async runner PID ${effectiveStatus.pid} ${probe}; status has not updated for ${now - lastUpdate}ms, so stale-run reconciliation marked the run failed because PID ownership is unverified.`;
467
506
  return writeFailedRepair(asyncDir, effectiveStatus, resultPath, now, message);
468
507
  }
469
- return writeFailedRepair(asyncDir, effectiveStatus, resultPath, now);
508
+ return writeFailedRepair(asyncDir, effectiveStatus, resultPath, now, undefined, true, options.adoptCompletionOwnerId);
470
509
  }
@@ -14,6 +14,7 @@ import { acquireSessionLease } from "../shared/session-lease.js";
14
14
  import { readProcessTerminalCandidate, writeProcessTerminalCandidate, markProcessTerminalCandidateLeaseRelease } from "./process-terminal-candidate.js";
15
15
  import { persistRunnerStartupFailure } from "./runner-startup-failure.js";
16
16
  import { currentPidNamespaceScope } from "./pid-namespace.js";
17
+ import { primeBootId } from "./boot-id.js";
17
18
  function isRecord(value) {
18
19
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
19
20
  }
@@ -103,6 +104,8 @@ async function waitForStartupControl(controlPath, token, action, timeoutMs = 30_
103
104
  export async function runConfiguredSubagent(rawConfig, options = {}) {
104
105
  validateSubagentRunConfig(rawConfig);
105
106
  const config = rawConfig;
107
+ // The runner never reads the boot id from the OS (sysctl on macOS); Pi passes it to direct runners only.
108
+ primeBootId(typeof config.bootId === "string" && config.bootId ? config.bootId : undefined);
106
109
  let lease;
107
110
  let startupCommitted = config.revivalLease === undefined && config.launchBarrierToken === undefined;
108
111
  let executionLoaded = false;
@@ -4,6 +4,7 @@ import { type RunnerSubagentStep as SubagentStep, type RunnerStep } from "../sha
4
4
  import type { InheritedChildRuntime } from "../shared/child-launch.ts";
5
5
  import type { ChildSessionFactory, DefaultChildSessionFactoryOptions } from "../shared/child-session.ts";
6
6
  import { type ChildEvent, type SteerDelivery, type StepSteerHandler } from "./run-child-session.ts";
7
+ import type { RecordedPid } from "./stale-run-reconciler.ts";
7
8
  import type { SessionLeaseRequest } from "../shared/session-lease.ts";
8
9
  import { createHerdrExternalAdapter, type HerdrExternalAdapterId, type HerdrExternalResult } from "../shared/herdr-external-adapters.ts";
9
10
  import { type OrcaProgressTab } from "../shared/orca-progress-tabs.ts";
@@ -25,6 +26,7 @@ export interface SubagentRunConfig {
25
26
  asyncDir: string;
26
27
  sessionId?: string | null;
27
28
  completionOwnerId?: string;
29
+ completionOwnerProcess?: RecordedPid;
28
30
  piPackageRoot?: string;
29
31
  /** Test seam: module the runner imports its `ChildSessionFactory` from. */
30
32
  childSessionFactoryModule?: string;
@@ -76,6 +78,8 @@ export interface SubagentRunConfig {
76
78
  runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
77
79
  runnerProcessInstanceId?: string;
78
80
  launchBarrierToken?: string;
81
+ /** Pi's boot id, set only for a direct (non-launcher) runner; the runner never reads its own. */
82
+ bootId?: string;
79
83
  parentWorkflowRunId?: string;
80
84
  workflowKey?: string;
81
85
  lane?: WorkflowLaneMetadata;