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.
- package/CHANGELOG.md +38 -0
- package/bundle/agents.js +3 -3
- package/bundle/child-tool-plan.js +2 -2
- package/bundle/{chunk-LSRUTHT7.js → chunk-43SCHIMD.js} +1 -1
- package/bundle/{chunk-G5DKFLLI.js → chunk-4SDU2BZX.js} +15 -10
- package/bundle/{chunk-V2JWDB5U.js → chunk-52UBALJL.js} +2 -2
- package/bundle/{chunk-3DC5KBVP.js → chunk-5TYMSZX3.js} +1 -1
- package/bundle/{chunk-CEKS3TFO.js → chunk-7IVSUMQQ.js} +6 -6
- package/bundle/{chunk-KSWQVGDW.js → chunk-7PCKZHR5.js} +225 -18
- package/bundle/{chunk-563BHAZ5.js → chunk-7YMXOYEU.js} +33 -25
- package/bundle/{chunk-XMFLCKMQ.js → chunk-B5EKXDHE.js} +336 -130
- package/bundle/{chunk-ZBC44JJ3.js → chunk-CIZQXJ5Q.js} +22 -9
- package/bundle/{chunk-L5ZHE4L4.js → chunk-GC4DSPMH.js} +4 -4
- package/bundle/{chunk-ARRKBB2B.js → chunk-GJECHSAR.js} +2 -2
- package/bundle/{chunk-2ZSNH26H.js → chunk-M2CTFYT3.js} +7 -7
- package/bundle/{chunk-TUWLXTAM.js → chunk-MHZ4G23A.js} +681 -320
- package/bundle/{chunk-WSBNENFS.js → chunk-NGGAFDL6.js} +14 -4
- package/bundle/{chunk-VJHOELQP.js → chunk-NRQ75MGH.js} +160 -78
- package/bundle/{chunk-BJN5LUQY.js → chunk-OYF6AFDY.js} +1 -1
- package/bundle/{chunk-EYNJ6VL6.js → chunk-PLCCBLPY.js} +2 -2
- package/bundle/{chunk-74RT5YQE.js → chunk-RMQFMMAT.js} +123 -33
- package/bundle/{chunk-V6MR5TOQ.js → chunk-SIJ4S3EN.js} +25 -19
- package/bundle/{chunk-NNLASTQE.js → chunk-VH5OAWVG.js} +19 -5
- package/bundle/{chunk-BPS2AGBZ.js → chunk-W2SZ22EM.js} +93 -52
- package/bundle/{chunk-V3BIUPLU.js → chunk-WVD4UEL4.js} +256 -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-ZLINIYTP.js} +497 -272
- package/bundle/{chunk-2XYWN7QJ.js → chunk-ZQGSDHL6.js} +2 -2
- 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 +31 -0
- 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/pi-subagents/references/execution-controls.md +5 -4
- 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/schemas.js +3 -3
- 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 +2 -0
- package/src/runs/background/async-status.js +21 -2
- 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 +1 -0
- package/src/runs/background/terminal-run-index.js +4 -2
- 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 +24 -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
|
@@ -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 {};
|
|
@@ -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 ??
|
|
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
|
-
...(
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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;
|