pi-subagents 0.81.0 → 0.82.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.
- package/CHANGELOG.md +55 -0
- package/README.md +1 -1
- package/bundle/agents.js +3 -3
- package/bundle/child-tool-plan.js +2 -2
- package/bundle/{chunk-L5ZHE4L4.js → chunk-3JCDWF4V.js} +4 -4
- package/bundle/{chunk-LSRUTHT7.js → chunk-43SCHIMD.js} +1 -1
- package/bundle/{chunk-VJHOELQP.js → chunk-46XKCPJR.js} +193 -81
- package/bundle/{chunk-G5DKFLLI.js → chunk-4SDU2BZX.js} +15 -10
- package/bundle/{chunk-2ZSNH26H.js → chunk-4Y2ROR7L.js} +7 -7
- package/bundle/{chunk-V2JWDB5U.js → chunk-52UBALJL.js} +2 -2
- package/bundle/{chunk-3DC5KBVP.js → chunk-5TYMSZX3.js} +1 -1
- package/bundle/{chunk-NNLASTQE.js → chunk-6XJB7PIX.js} +20 -6
- package/bundle/{chunk-ZBC44JJ3.js → chunk-CIZQXJ5Q.js} +22 -9
- package/bundle/{chunk-KSWQVGDW.js → chunk-FXUXCX2K.js} +225 -18
- package/bundle/{chunk-563BHAZ5.js → chunk-G4GZD3OJ.js} +35 -26
- package/bundle/{chunk-ARRKBB2B.js → chunk-GJECHSAR.js} +2 -2
- package/bundle/{chunk-V3BIUPLU.js → chunk-IVAE5Z6Q.js} +256 -52
- package/bundle/{chunk-V6MR5TOQ.js → chunk-JYUKW2SY.js} +25 -19
- package/bundle/{chunk-BJN5LUQY.js → chunk-OYF6AFDY.js} +1 -1
- package/bundle/{chunk-CEKS3TFO.js → chunk-PBK2I7WY.js} +6 -6
- package/bundle/{chunk-TUWLXTAM.js → chunk-PDZWJCHD.js} +681 -320
- package/bundle/{chunk-EYNJ6VL6.js → chunk-PLCCBLPY.js} +2 -2
- package/bundle/{chunk-XMFLCKMQ.js → chunk-QE4AGIMG.js} +339 -132
- package/bundle/{chunk-74RT5YQE.js → chunk-RMQFMMAT.js} +123 -33
- package/bundle/{chunk-WSBNENFS.js → chunk-UCPP4GNU.js} +14 -4
- package/bundle/{chunk-2XYWN7QJ.js → chunk-UTT254JE.js} +2 -2
- package/bundle/{chunk-BPS2AGBZ.js → chunk-VSUSLDNW.js} +93 -52
- package/bundle/{chunk-ZP3HODFH.js → chunk-XSWOTIA3.js} +1 -1
- package/bundle/{chunk-3IF2PGW7.js → chunk-YKEWIB4M.js} +1 -1
- package/bundle/{chunk-26C3WLYQ.js → chunk-ZIV4J4HG.js} +498 -274
- package/bundle/control-channel.js +2 -2
- package/bundle/index.js +1 -1
- package/bundle/intercom-bridge.js +2 -2
- package/bundle/preflight.js +8 -8
- package/bundle/project-panes.js +3 -3
- package/docs/agents.md +37 -7
- package/docs/configuration.md +9 -1
- package/docs/tool-reference.md +3 -1
- package/docs/workflows.md +16 -0
- package/package.json +2 -2
- package/skills/council-mode/SKILL.md +1 -4
- package/skills/pi-subagents/SKILL.md +130 -107
- package/skills/pi-subagents/references/orchestration.md +124 -0
- package/skills/pi-subagents/references/prompting-and-roles.md +96 -295
- package/src/agents/agent-management.js +3 -3
- package/src/agents/agent-memory.js +3 -1
- package/src/agents/agents.js +6 -6
- package/src/agents/frontmatter.d.ts +2 -0
- package/src/agents/frontmatter.js +15 -1
- package/src/agents/skills.js +2 -1
- package/src/extension/config.js +3 -0
- package/src/extension/herdr-pi-bridge.js +17 -10
- package/src/extension/index.js +82 -2
- package/src/extension/reply-workflow-script.js +5 -2
- package/src/extension/schemas.js +3 -3
- package/src/extension/subagent-guide.js +1 -2
- package/src/intercom/native-supervisor-channel.js +20 -0
- package/src/missions/lifecycle.js +7 -1
- package/src/runs/background/active-async-capacity.d.ts +1 -0
- package/src/runs/background/active-async-capacity.js +9 -3
- package/src/runs/background/async-execution.js +20 -10
- package/src/runs/background/async-job-tracker.d.ts +6 -1
- package/src/runs/background/async-job-tracker.js +9 -4
- package/src/runs/background/async-status.d.ts +4 -0
- package/src/runs/background/async-status.js +22 -3
- package/src/runs/background/await-async-run.js +3 -7
- package/src/runs/background/boot-id.d.ts +12 -0
- package/src/runs/background/boot-id.js +47 -0
- package/src/runs/background/interrupted-elsewhere.d.ts +22 -0
- package/src/runs/background/interrupted-elsewhere.js +68 -0
- package/src/runs/background/notify.d.ts +2 -0
- package/src/runs/background/notify.js +13 -0
- package/src/runs/background/result-delivery-ownership.d.ts +6 -0
- package/src/runs/background/result-delivery-ownership.js +19 -1
- package/src/runs/background/result-watcher.d.ts +3 -1
- package/src/runs/background/result-watcher.js +24 -8
- package/src/runs/background/run-child-session.d.ts +1 -6
- package/src/runs/background/run-child-session.js +1 -1
- package/src/runs/background/run-status.js +2 -1
- package/src/runs/background/stale-run-reconciler.d.ts +25 -0
- package/src/runs/background/stale-run-reconciler.js +52 -13
- package/src/runs/background/subagent-runner-bootstrap.js +3 -0
- package/src/runs/background/subagent-runner.d.ts +4 -0
- package/src/runs/background/subagent-runner.js +13 -5
- package/src/runs/background/subagent-wait.d.ts +1 -1
- package/src/runs/background/subagent-wait.js +6 -1
- package/src/runs/background/terminal-run-index.d.ts +2 -0
- package/src/runs/background/terminal-run-index.js +44 -3
- package/src/runs/foreground/async-stop-action.js +3 -1
- package/src/runs/foreground/execution.js +6 -3
- package/src/runs/foreground/foreground-history.d.ts +16 -2
- package/src/runs/foreground/foreground-history.js +133 -31
- package/src/runs/foreground/subagent-executor.d.ts +11 -0
- package/src/runs/foreground/subagent-executor.js +277 -74
- package/src/runs/shared/child-launch.d.ts +2 -2
- package/src/runs/shared/child-session.d.ts +3 -2
- package/src/runs/shared/child-session.js +51 -8
- package/src/runs/shared/child-tool-plan.d.ts +7 -0
- package/src/runs/shared/child-tool-plan.js +21 -6
- package/src/runs/shared/herdr-connection.d.ts +17 -8
- package/src/runs/shared/herdr-connection.js +19 -7
- package/src/runs/shared/herdr-external-adapters.d.ts +3 -2
- package/src/runs/shared/herdr-external-adapters.js +1 -1
- package/src/runs/shared/herdr-machine.d.ts +12 -2
- package/src/runs/shared/herdr-machine.js +55 -9
- package/src/runs/shared/herdr-pane-host.d.ts +88 -0
- package/src/runs/shared/herdr-pane-host.js +155 -0
- package/src/runs/shared/herdr-placed-run.d.ts +27 -150
- package/src/runs/shared/herdr-placed-run.js +48 -395
- package/src/runs/shared/local-machine-access.d.ts +5 -0
- package/src/runs/shared/local-machine-access.js +28 -0
- package/src/runs/shared/orca-progress-tabs.d.ts +0 -1
- package/src/runs/shared/orca-progress-tabs.js +1 -24
- package/src/runs/shared/parallel-utils.d.ts +1 -1
- package/src/runs/shared/placed-run-owner.d.ts +49 -0
- package/src/runs/shared/placed-run-owner.js +93 -0
- package/src/runs/shared/placement-host.d.ts +99 -0
- package/src/runs/shared/placement-host.js +157 -0
- package/src/runs/shared/rex-machine.d.ts +25 -0
- package/src/runs/shared/rex-machine.js +83 -0
- package/src/runs/shared/rex-pane-host.d.ts +62 -0
- package/src/runs/shared/rex-pane-host.js +327 -0
- package/src/shared/completion-owner.d.ts +5 -0
- package/src/shared/completion-owner.js +5 -0
- package/src/shared/session-identity.d.ts +2 -0
- package/src/shared/session-identity.js +26 -0
- package/src/shared/types.d.ts +45 -6
- package/src/tui/fleet.d.ts +1 -0
- package/src/tui/fleet.js +25 -15
- package/src/tui/render.d.ts +6 -0
- package/src/tui/render.js +3 -3
- package/src/tui/subagent-messages.js +1 -0
- package/src/workflows/scripted-workflow.d.ts +1 -1
- package/src/workflows/workflow-auto-resume.d.ts +32 -0
- package/src/workflows/workflow-auto-resume.js +102 -0
- package/src/workflows/workflow-launch-record.d.ts +27 -0
- package/src/workflows/workflow-launch-record.js +48 -0
- package/src/workflows/workflow-resume-claim.d.ts +82 -0
- package/src/workflows/workflow-resume-claim.js +210 -0
- package/src/workflows/workflow-resume.d.ts +56 -0
- package/src/workflows/workflow-resume.js +106 -0
- package/src/workflows/workflow-reuse.d.ts +15 -9
- package/src/workflows/workflow-reuse.js +77 -14
- package/src/workflows/workflow-revival.d.ts +2 -0
- package/src/workflows/workflow-revival.js +2 -1
- package/src/workflows/workflow-stop-cause.d.ts +6 -0
- package/src/workflows/workflow-stop-cause.js +8 -0
- package/skills/pi-subagents/references/constraints-and-recipes.md +0 -74
- package/skills/pi-subagents/references/execution-controls.md +0 -563
- package/skills/pi-subagents/references/management-authoring-rpc.md +0 -165
- package/skills/pi-subagents/references/multi-lane-orchestration.md +0 -61
- package/skills/pi-subagents/references/review-and-validation.md +0 -73
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { type AsyncJobState, type AsyncWidgetLayout, type ControlEvent, type SubagentState } from "../../shared/types.ts";
|
|
4
|
+
import type { RawDrainStatusObserver } from "../shared/readonly-drain-observation.ts";
|
|
5
|
+
import type { ResultDeliveryOwnership } from "./result-delivery-ownership.ts";
|
|
4
6
|
interface AsyncJobTrackerOptions {
|
|
5
7
|
completionRetentionMs?: number;
|
|
6
8
|
/** Slow safety sweep for liveness repair when filesystem events are missed. */
|
|
@@ -12,6 +14,8 @@ interface AsyncJobTrackerOptions {
|
|
|
12
14
|
platform?: NodeJS.Platform;
|
|
13
15
|
onJobTerminal?: (job: AsyncJobState) => void;
|
|
14
16
|
onJobCleanup?: (asyncId: string) => void;
|
|
17
|
+
/** Lets a run whose launching Pi exited hold its publication in the session that owns it. */
|
|
18
|
+
deliveryOwnerId?: ResultDeliveryOwnership["deliveryOwnerId"];
|
|
15
19
|
watch?: typeof fs.watch;
|
|
16
20
|
kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
17
21
|
now?: () => number;
|
|
@@ -26,7 +30,8 @@ export declare function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">,
|
|
|
26
30
|
handleStarted: (data: unknown) => void;
|
|
27
31
|
handleComplete: (data: unknown) => void;
|
|
28
32
|
resetJobs: (ctx?: ExtensionContext) => void;
|
|
29
|
-
|
|
33
|
+
/** `observeStatus` also sees the statuses of other sessions' runs that the listing skips. */
|
|
34
|
+
restoreActiveJobs: (ctx?: ExtensionContext, observeStatus?: RawDrainStatusObserver) => void;
|
|
30
35
|
dispose: () => void;
|
|
31
36
|
};
|
|
32
37
|
export {};
|
|
@@ -15,6 +15,7 @@ import { withWorkflowChildRunModel } from "./workflow-terminal-proof.js";
|
|
|
15
15
|
import { validHostStepNodes } from "../shared/host-step-status.js";
|
|
16
16
|
import { readProcessTerminal } from "./process-terminal.js";
|
|
17
17
|
import { withCachedUiContext } from "../../shared/extension-context.js";
|
|
18
|
+
import { OWNING_SESSION_COMPLETION_OWNER } from "../../shared/completion-owner.js";
|
|
18
19
|
const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
|
|
19
20
|
const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
|
|
20
21
|
const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
|
|
@@ -560,8 +561,11 @@ export function createAsyncJobTracker(pi, state, asyncDirRoot, options = {}) {
|
|
|
560
561
|
if (isTerminalJobStatus(job.status)) {
|
|
561
562
|
let publication = terminalPublications.get(job.asyncId);
|
|
562
563
|
if (!publication && status.mode !== "workflow" && status.processTerminal?.state === "pending"
|
|
563
|
-
&& status.runId === job.asyncId && status.
|
|
564
|
-
&& status.
|
|
564
|
+
&& status.runId === job.asyncId && status.completionOwnerId
|
|
565
|
+
&& ((status.sessionId === state.currentSessionId && status.completionOwnerId === state.completionOwnerId)
|
|
566
|
+
// Also covers a session inherited after /new or /resume: deliveryOwnerId checks run ownership.
|
|
567
|
+
|| (typeof status.sessionId === "string"
|
|
568
|
+
&& options.deliveryOwnerId?.(status.sessionId, status.completionOwnerId, status.completionOwnerProcess, status.parentWorkflowRunId) === OWNING_SESSION_COMPLETION_OWNER))) {
|
|
565
569
|
publication = { instanceId: status.processTerminal.runnerProcessInstanceId, pending: true };
|
|
566
570
|
terminalPublications.set(job.asyncId, publication);
|
|
567
571
|
}
|
|
@@ -883,7 +887,7 @@ export function createAsyncJobTracker(pi, state, asyncDirRoot, options = {}) {
|
|
|
883
887
|
rerenderWidget(ctx, []);
|
|
884
888
|
}
|
|
885
889
|
};
|
|
886
|
-
const restoreActiveJobs = (ctx) => {
|
|
890
|
+
const restoreActiveJobs = (ctx, observeStatus) => {
|
|
887
891
|
if (ctx?.hasUI)
|
|
888
892
|
state.lastUiContext = ctx;
|
|
889
893
|
state.statusProjectionSessionId = null;
|
|
@@ -891,7 +895,8 @@ export function createAsyncJobTracker(pi, state, asyncDirRoot, options = {}) {
|
|
|
891
895
|
return;
|
|
892
896
|
let runs;
|
|
893
897
|
try {
|
|
894
|
-
|
|
898
|
+
// A workflow whose Pi exited is repaired here, in the session that owns it, which then delivers its notice.
|
|
899
|
+
runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], sessionId: state.currentSessionId, resultsDir, kill: options.kill, now: options.now, adoptCompletionOwnerId: state.completionOwnerId }, observeStatus);
|
|
895
900
|
}
|
|
896
901
|
catch (error) {
|
|
897
902
|
console.error(`Failed to restore active async jobs from '${asyncDirRoot}':`, error);
|
|
@@ -142,6 +142,10 @@ interface AsyncRunListOptions {
|
|
|
142
142
|
includeNested?: boolean;
|
|
143
143
|
/** Explicit repair/debug escape hatch. Normal runtime paths must not set this. */
|
|
144
144
|
repairScan?: boolean;
|
|
145
|
+
/** This process's completion owner, adopted by an owner-exited workflow repair of a run the session owns; needs `sessionId`. */
|
|
146
|
+
adoptCompletionOwnerId?: string;
|
|
147
|
+
/** Receives the warning for a skipped corrupt terminal status, so an open TUI can show it as a notice instead of console output. */
|
|
148
|
+
onCorruptStatus?: (warning: string) => void;
|
|
145
149
|
}
|
|
146
150
|
type TargetedAsyncRunResolution = {
|
|
147
151
|
kind: "exact";
|
|
@@ -37,6 +37,9 @@ function isAsyncStatusIsolationError(asyncDir, error) {
|
|
|
37
37
|
|| message.startsWith(`Invalid host step '${statusPath}`)
|
|
38
38
|
|| /^(workflowChildren|Invalid workflowChildren)/.test(message);
|
|
39
39
|
}
|
|
40
|
+
// Status path -> last logged signature. A corrupt run is re-scanned on every
|
|
41
|
+
// status poll, so the line repeats only when the file or the outcome changes.
|
|
42
|
+
const reportedCorruptStatus = new Map();
|
|
40
43
|
function isolateCorruptActiveRun(asyncDir, runId, error, now) {
|
|
41
44
|
const statusPath = path.join(asyncDir, "status.json");
|
|
42
45
|
const processTerminal = readProcessTerminal(asyncDir, { runId });
|
|
@@ -61,7 +64,20 @@ function isolateCorruptActiveRun(asyncDir, runId, error, now) {
|
|
|
61
64
|
markerAction = `failed to release active marker: ${getErrorMessage(releaseError)}`;
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
const line = `[pi-subagents] Skipping corrupt active async run '${runId}' at '${statusPath}': ${getErrorMessage(error)}; ${markerAction}.`;
|
|
68
|
+
let fileSignature = "";
|
|
69
|
+
try {
|
|
70
|
+
const stat = fs.statSync(statusPath);
|
|
71
|
+
fileSignature = `${stat.size}:${stat.mtimeMs}`;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A missing file still gets the line once; its message carries the cause.
|
|
75
|
+
}
|
|
76
|
+
const signature = `${fileSignature}\n${line}`;
|
|
77
|
+
if (reportedCorruptStatus.get(statusPath) === signature)
|
|
78
|
+
return;
|
|
79
|
+
reportedCorruptStatus.set(statusPath, signature);
|
|
80
|
+
console.error(line);
|
|
65
81
|
}
|
|
66
82
|
function isNotFoundError(error) {
|
|
67
83
|
return typeof error === "object"
|
|
@@ -388,7 +404,7 @@ export function listAsyncRuns(asyncDirRoot, options = {}, observeStatus) {
|
|
|
388
404
|
}
|
|
389
405
|
if (wantsTerminal) {
|
|
390
406
|
for (const sessionId of owners ?? [undefined]) {
|
|
391
|
-
for (const entry of readRecentTerminalRunIndex(asyncDirRoot, { sessionId, ...(options.entryLimit !== undefined ? { limit: options.entryLimit } : {}) }))
|
|
407
|
+
for (const entry of readRecentTerminalRunIndex(asyncDirRoot, { sessionId, onCorruptStatus: options.onCorruptStatus, ...(options.entryLimit !== undefined ? { limit: options.entryLimit } : {}) }))
|
|
392
408
|
indexed.add(entry);
|
|
393
409
|
}
|
|
394
410
|
}
|
|
@@ -425,16 +441,19 @@ export function listAsyncRuns(asyncDirRoot, options = {}, observeStatus) {
|
|
|
425
441
|
let status;
|
|
426
442
|
try {
|
|
427
443
|
// Reconciliation can rewrite state; session-scoped discovery does not own foreign runs.
|
|
444
|
+
let owned = false;
|
|
428
445
|
if (owners) {
|
|
429
446
|
const stored = readStatus(asyncDir);
|
|
430
447
|
if (stored && !owners.has(stored.sessionId)) {
|
|
431
448
|
observeStatus?.(stored);
|
|
432
449
|
continue;
|
|
433
450
|
}
|
|
451
|
+
owned = stored !== null;
|
|
434
452
|
}
|
|
453
|
+
// Only a run this session owns may adopt the caller's completion owner.
|
|
435
454
|
const reconciliation = options.reconcile === false
|
|
436
455
|
? undefined
|
|
437
|
-
: reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now }, observeStatus);
|
|
456
|
+
: reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now, adoptCompletionOwnerId: owned ? options.adoptCompletionOwnerId : undefined }, observeStatus);
|
|
438
457
|
status = (reconciliation?.status ?? readStatus(asyncDir));
|
|
439
458
|
if (options.reconcile === false)
|
|
440
459
|
observeStatus?.(status);
|
|
@@ -2,9 +2,8 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import { readStatus } from "../../shared/utils.js";
|
|
3
3
|
import { workflowAwaitedAsyncResultPath } from "./async-execution.js";
|
|
4
4
|
import { waitForImportedAsyncRoot } from "./chain-root-attachment.js";
|
|
5
|
-
import { currentPidNamespaceScope } from "./pid-namespace.js";
|
|
6
5
|
import { resultPayloadPathForSessionRun, resultSnapshotReplaced, withResultRunLease } from "./result-files.js";
|
|
7
|
-
import {
|
|
6
|
+
import { checkRecordedPidLiveness } from "./stale-run-reconciler.js";
|
|
8
7
|
function errorMessage(error) {
|
|
9
8
|
return error instanceof Error ? error.message : String(error);
|
|
10
9
|
}
|
|
@@ -13,11 +12,8 @@ function runnerExitedWithoutResult(asyncDir, runId, resultPath) {
|
|
|
13
12
|
const status = readStatus(asyncDir);
|
|
14
13
|
if (!status || (status.state !== "running" && status.state !== "queued") || typeof status.pid !== "number")
|
|
15
14
|
return false;
|
|
16
|
-
|
|
17
|
-
if (status.pidNamespaceScope
|
|
18
|
-
return false;
|
|
19
|
-
const pidScopeVerified = status.pidNamespaceScope !== undefined && status.pidNamespaceScope === observedScope;
|
|
20
|
-
if (checkPidLiveness(status.pid, undefined, pidScopeVerified) !== "dead")
|
|
15
|
+
// A pid from another namespace never reads as dead here, so the runner is not presumed gone.
|
|
16
|
+
if (checkRecordedPidLiveness({ pid: status.pid, pidNamespaceScope: status.pidNamespaceScope, bootId: status.bootId }) !== "dead")
|
|
21
17
|
return false;
|
|
22
18
|
return !fs.existsSync(resultPath) && !(status.sessionId && resultPayloadPathForSessionRun(asyncDir, status.sessionId, runId));
|
|
23
19
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RecordedPid } from "./stale-run-reconciler.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Identity of the current machine boot, read once per process. A pid recorded under a
|
|
4
|
+
* different boot id cannot still be running. Undefined when the platform has none.
|
|
5
|
+
*/
|
|
6
|
+
export declare function currentBootId(): string | undefined;
|
|
7
|
+
/** This process as a recorded pid, without the fields the platform does not have. */
|
|
8
|
+
export declare function currentRecordedPid(): RecordedPid;
|
|
9
|
+
/** Fixes the boot id without reading the OS; runners take Pi's value from their config. */
|
|
10
|
+
export declare function primeBootId(value: string | undefined): void;
|
|
11
|
+
/** Clears the cache so the next read calls `nextReader` (default: the OS reader). */
|
|
12
|
+
export declare function resetBootIdForTesting(nextReader?: () => string | undefined): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { currentPidNamespaceScope } from "./pid-namespace.js";
|
|
4
|
+
function readBootIdFromOs() {
|
|
5
|
+
try {
|
|
6
|
+
if (process.platform === "linux")
|
|
7
|
+
return fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || undefined;
|
|
8
|
+
if (process.platform === "darwin") {
|
|
9
|
+
return execFileSync("sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf-8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// An unreadable boot id only means liveness falls back to probing the pid.
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
// Process-wide, so the bundled extension and the compiled runner modules share one value.
|
|
18
|
+
const BOOT_ID_KEY = Symbol.for("pi-subagents.boot-id.v1");
|
|
19
|
+
const runtime = globalThis;
|
|
20
|
+
let reader = readBootIdFromOs;
|
|
21
|
+
/**
|
|
22
|
+
* Identity of the current machine boot, read once per process. A pid recorded under a
|
|
23
|
+
* different boot id cannot still be running. Undefined when the platform has none.
|
|
24
|
+
*/
|
|
25
|
+
export function currentBootId() {
|
|
26
|
+
const cached = runtime[BOOT_ID_KEY];
|
|
27
|
+
if (cached)
|
|
28
|
+
return cached.value;
|
|
29
|
+
const value = reader();
|
|
30
|
+
runtime[BOOT_ID_KEY] = { value };
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/** This process as a recorded pid, without the fields the platform does not have. */
|
|
34
|
+
export function currentRecordedPid() {
|
|
35
|
+
const pidNamespaceScope = currentPidNamespaceScope();
|
|
36
|
+
const bootId = currentBootId();
|
|
37
|
+
return { pid: process.pid, ...(pidNamespaceScope ? { pidNamespaceScope } : {}), ...(bootId ? { bootId } : {}) };
|
|
38
|
+
}
|
|
39
|
+
/** Fixes the boot id without reading the OS; runners take Pi's value from their config. */
|
|
40
|
+
export function primeBootId(value) {
|
|
41
|
+
runtime[BOOT_ID_KEY] = { value };
|
|
42
|
+
}
|
|
43
|
+
/** Clears the cache so the next read calls `nextReader` (default: the OS reader). */
|
|
44
|
+
export function resetBootIdForTesting(nextReader) {
|
|
45
|
+
reader = nextReader ?? readBootIdFromOs;
|
|
46
|
+
delete runtime[BOOT_ID_KEY];
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ForegroundResumeRun } from "../../shared/types.ts";
|
|
2
|
+
import type { RawDrainStatusObserver } from "../shared/readonly-drain-observation.ts";
|
|
3
|
+
interface InterruptedElsewhereCollector {
|
|
4
|
+
/** Observer for the async statuses session start already reads; keeps foreign ones that may be interrupted. */
|
|
5
|
+
observeStatus: RawDrainStatusObserver;
|
|
6
|
+
/** Receives the foreground records of other sessions that session start already reads. */
|
|
7
|
+
observeForegroundRun: (run: ForegroundResumeRun) => void;
|
|
8
|
+
/** One line per other session that can be reopened and has runs whose Pi is dead. */
|
|
9
|
+
lines: () => string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Collects runs of other sessions in this folder that were cut off because their Pi died, from what
|
|
13
|
+
* session start reads anyway. Read-only: nothing is written to those runs or sessions. The cheap
|
|
14
|
+
* filters run as records arrive; `lines` reads each session file's header once, then probes each
|
|
15
|
+
* candidate's pid once.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createInterruptedElsewhereCollector(input: {
|
|
18
|
+
currentSessionId: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
now: number;
|
|
21
|
+
}): InterruptedElsewhereCollector;
|
|
22
|
+
export {};
|
|
@@ -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
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 {};
|