pi-subagents 0.80.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 +66 -0
- package/bundle/agents.js +18 -0
- package/bundle/background-work.js +16 -0
- package/bundle/capability-ceiling.js +22 -0
- package/bundle/child-tool-plan.js +11 -0
- package/bundle/chunk-43SCHIMD.js +57 -0
- package/bundle/chunk-4QFZZV3O.js +57 -0
- package/bundle/chunk-4SDU2BZX.js +1190 -0
- package/bundle/chunk-52UBALJL.js +753 -0
- package/bundle/chunk-5LFLVSTM.js +260 -0
- package/bundle/chunk-5TYMSZX3.js +235 -0
- package/bundle/chunk-62OCY2S4.js +14 -0
- package/bundle/chunk-7F6RKT6W.js +132 -0
- package/bundle/chunk-7IVSUMQQ.js +1337 -0
- package/bundle/chunk-7J4ZXMY7.js +18 -0
- package/bundle/chunk-7PCKZHR5.js +1234 -0
- package/bundle/chunk-7YMXOYEU.js +1373 -0
- package/bundle/chunk-B5EKXDHE.js +16870 -0
- package/bundle/chunk-CIZQXJ5Q.js +8619 -0
- package/bundle/chunk-CTPAE36Y.js +94 -0
- package/bundle/chunk-D4EHT5SJ.js +14 -0
- package/bundle/chunk-GC4DSPMH.js +946 -0
- package/bundle/chunk-GJECHSAR.js +179 -0
- package/bundle/chunk-M2CTFYT3.js +4164 -0
- package/bundle/chunk-MHZ4G23A.js +1296 -0
- package/bundle/chunk-N2BN7BF5.js +159 -0
- package/bundle/chunk-NF7IEKBH.js +490 -0
- package/bundle/chunk-NGGAFDL6.js +742 -0
- package/bundle/chunk-NIXWDZZV.js +9 -0
- package/bundle/chunk-NRQ75MGH.js +8327 -0
- package/bundle/chunk-OYF6AFDY.js +458 -0
- package/bundle/chunk-PLCCBLPY.js +526 -0
- package/bundle/chunk-RMQFMMAT.js +416 -0
- package/bundle/chunk-SIJ4S3EN.js +276 -0
- package/bundle/chunk-UKG4V45O.js +228 -0
- package/bundle/chunk-VH5OAWVG.js +4096 -0
- package/bundle/chunk-W2SZ22EM.js +5462 -0
- package/bundle/chunk-WVD4UEL4.js +9367 -0
- package/bundle/chunk-XSWOTIA3.js +175 -0
- package/bundle/chunk-YFCK7H2S.js +129 -0
- package/bundle/chunk-YKEWIB4M.js +380 -0
- package/bundle/chunk-YYREO77V.js +137 -0
- package/bundle/chunk-Z7KVHQHL.js +173 -0
- package/bundle/chunk-ZLINIYTP.js +8613 -0
- package/bundle/chunk-ZQGSDHL6.js +128 -0
- package/bundle/control-channel.js +11 -0
- package/bundle/delegation.js +14 -0
- package/bundle/external-job-provider.js +20 -0
- package/bundle/external-runs.js +21 -0
- package/bundle/index.js +30 -0
- package/bundle/inspectors.js +8 -0
- package/bundle/intercom-bridge.js +11 -0
- package/bundle/preflight.js +425 -0
- package/bundle/project-panes.js +28 -0
- package/bundle/required-child-extensions.js +6 -0
- package/bundle/shared-types.js +8 -0
- package/bundle/workflow-resources.js +7 -0
- package/docs/agents.md +31 -0
- package/docs/configuration.md +9 -1
- package/docs/extension-api.md +27 -0
- package/docs/observability.md +6 -2
- package/docs/tool-reference.md +3 -1
- package/docs/workflows.md +16 -0
- package/package.json +19 -19
- 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 +4 -0
- package/src/agents/frontmatter.js +24 -3
- package/src/agents/skills.js +2 -1
- package/src/api/delegation.d.ts +20 -0
- package/src/extension/config.js +3 -0
- package/src/extension/herdr-pi-bridge.js +17 -10
- package/src/extension/index.js +109 -9
- 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 +19 -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 +54 -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 +45 -5
- 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/runs/shared/structured-output.d.ts +1 -0
- package/src/runs/shared/structured-output.js +1 -0
- package/src/shared/accessible-dir.js +4 -1
- package/src/shared/completion-owner.d.ts +5 -0
- package/src/shared/completion-owner.js +5 -0
- package/src/shared/provider-diagnostics.d.ts +9 -0
- package/src/shared/provider-diagnostics.js +75 -0
- package/src/shared/session-identity.d.ts +2 -0
- package/src/shared/session-identity.js +26 -0
- package/src/shared/types.d.ts +48 -6
- package/src/slash/delegation-adapters.d.ts +3 -1
- package/src/slash/delegation-adapters.js +7 -0
- package/src/slash/prompt-template-bridge.js +3 -1
- package/src/slash/subagents-admin.d.ts +1 -0
- package/src/slash/subagents-admin.js +1 -1
- package/src/tui/fleet-layout.d.ts +20 -0
- package/src/tui/fleet-layout.js +17 -0
- package/src/tui/fleet-viewport.d.ts +12 -0
- package/src/tui/fleet-viewport.js +35 -0
- package/src/tui/fleet.d.ts +6 -6
- package/src/tui/fleet.js +93 -66
- 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/structured-workflow-scripts.d.ts +1 -0
- package/src/workflows/structured-workflow-scripts.js +5 -4
- 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-resources.js +2 -2
- 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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { stableJsonDigest } from "../shared/launch-contract.js";
|
|
3
3
|
import { createWorkflowResourcePermit, } from "../shared/workflow-child-permit.js";
|
|
4
|
-
import { buildStructuredWorkflowScript, isPlainRecord } from "./structured-workflow-scripts.js";
|
|
4
|
+
import { buildStructuredWorkflowScript, isPlainRecord, TOP_LEVEL_CONTROLS_HINT } from "./structured-workflow-scripts.js";
|
|
5
5
|
const RESOURCE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
6
|
export const MAX_ARGS_BYTES = 16 * 1024;
|
|
7
7
|
export const MAX_ARGS_FIELDS = 16;
|
|
@@ -152,7 +152,7 @@ function resolveRunCi(args) {
|
|
|
152
152
|
function resolveParallel(args) {
|
|
153
153
|
const unsupported = Object.keys(args).filter((key) => key !== "tasks");
|
|
154
154
|
if (unsupported.length > 0)
|
|
155
|
-
return { error: `workflow 'parallel' args contain unsupported fields: ${unsupported.join(", ")}. Pass args.tasks: [{ agent, task, cwd?, model? }, ...]
|
|
155
|
+
return { error: `workflow 'parallel' args contain unsupported fields: ${unsupported.join(", ")}. Pass args.tasks: [{ agent, task, cwd?, model? }, ...].${TOP_LEVEL_CONTROLS_HINT}` };
|
|
156
156
|
try {
|
|
157
157
|
return { script: buildStructuredWorkflowScript("tasks", args.tasks, undefined) };
|
|
158
158
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type PidLiveness, type RecordedPid } from "../runs/background/stale-run-reconciler.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Who continues a stopped workflow run. Claims are generation files `resumed-by.<n>.json` in the
|
|
4
|
+
* stopped run's directory, and the highest n is the current claim. A claimant whose process is dead
|
|
5
|
+
* is superseded by creating the next generation, so a takeover never moves a file another taker
|
|
6
|
+
* may have just created. An owner whose launch did not start renames its own claim to
|
|
7
|
+
* `resumed-by.<n>.json.released-<uuid>`; a released top means no claim, and the next taker creates
|
|
8
|
+
* n+1, so claims below it are never judged again. Only the owner launches; once the claimed new
|
|
9
|
+
* run's status.json exists the claim is permanent.
|
|
10
|
+
*/
|
|
11
|
+
interface WorkflowResumeClaim extends RecordedPid {
|
|
12
|
+
version: 1;
|
|
13
|
+
claimedAt: number;
|
|
14
|
+
newRunId: string;
|
|
15
|
+
trigger: "auto" | "manual" | "reuse";
|
|
16
|
+
}
|
|
17
|
+
interface WorkflowResumeRefusal {
|
|
18
|
+
version: 1;
|
|
19
|
+
reason: string;
|
|
20
|
+
byHand: string;
|
|
21
|
+
newRunId?: string;
|
|
22
|
+
refusedAt: number;
|
|
23
|
+
}
|
|
24
|
+
export type WorkflowResumeClaimOutcome = {
|
|
25
|
+
kind: "owner";
|
|
26
|
+
} | {
|
|
27
|
+
kind: "resumed";
|
|
28
|
+
newRunId: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "busy";
|
|
31
|
+
pid: number;
|
|
32
|
+
}
|
|
33
|
+
/** The current claim file exists but cannot be read, so nothing proves its claimant dead. */
|
|
34
|
+
| {
|
|
35
|
+
kind: "unreadable";
|
|
36
|
+
};
|
|
37
|
+
type WorkflowResumeClaimState = {
|
|
38
|
+
kind: "none";
|
|
39
|
+
} | {
|
|
40
|
+
kind: "resumed";
|
|
41
|
+
newRunId: string;
|
|
42
|
+
} | {
|
|
43
|
+
kind: "busy";
|
|
44
|
+
pid: number;
|
|
45
|
+
newRunId: string;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "unreadable";
|
|
48
|
+
};
|
|
49
|
+
type ClaimLiveness = (claim: WorkflowResumeClaim) => PidLiveness;
|
|
50
|
+
export declare function readWorkflowResumeClaim(asyncDir: string): WorkflowResumeClaim | undefined;
|
|
51
|
+
/** True once the run that resumes the run in `sourceAsyncDir` as `newRunId` has written its first status. */
|
|
52
|
+
export declare function resumedRunStarted(sourceAsyncDir: string, newRunId: string): boolean;
|
|
53
|
+
/** Read-only view for reuse readers and auto-resume: a dead, corrupt, or missing claim blocks nothing. */
|
|
54
|
+
export declare function workflowResumeClaimState(asyncDir: string, options?: {
|
|
55
|
+
liveness?: ClaimLiveness;
|
|
56
|
+
}): WorkflowResumeClaimState;
|
|
57
|
+
/**
|
|
58
|
+
* Claims the right to continue the stopped run in `asyncDir` as `newRunId`. A dead claimant is
|
|
59
|
+
* superseded by the next generation file; of several takers, the one whose exclusive create
|
|
60
|
+
* succeeds owns the run, and the others see its live claim on their next read.
|
|
61
|
+
*/
|
|
62
|
+
export declare function claimWorkflowResume(asyncDir: string, input: {
|
|
63
|
+
newRunId: string;
|
|
64
|
+
trigger: WorkflowResumeClaim["trigger"];
|
|
65
|
+
now?: number;
|
|
66
|
+
liveness?: ClaimLiveness;
|
|
67
|
+
}): WorkflowResumeClaimOutcome;
|
|
68
|
+
/** Gives up this process's claim on a launch that did not start, so the run can be continued again. */
|
|
69
|
+
export declare function releaseWorkflowResumeClaim(asyncDir: string, newRunId: string): void;
|
|
70
|
+
export declare function recordWorkflowResumeRefusal(asyncDir: string, refusal: Omit<WorkflowResumeRefusal, "version" | "refusedAt">, now?: number): void;
|
|
71
|
+
export declare function resumeByHandText(runId: string): string;
|
|
72
|
+
export declare function relaunchByHandText(runId: string): string;
|
|
73
|
+
/** `<reason>. <byHand>`; some reasons quote a message that already ends in a period. */
|
|
74
|
+
export declare function refusalSentence(reason: string, byHand: string): string;
|
|
75
|
+
/** A refusal was recorded for this run; the run is not tried again automatically. */
|
|
76
|
+
export declare function hasWorkflowResumeRefusal(asyncDir: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* The one line an interrupted workflow's notice carries about what happens next: the recorded
|
|
79
|
+
* auto-resume refusal, else who continues the run, else how to continue it by hand.
|
|
80
|
+
*/
|
|
81
|
+
export declare function describeWorkflowResume(asyncDir: string, runId: string): string;
|
|
82
|
+
export {};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { writeAtomicJson } from "../shared/atomic-json.js";
|
|
5
|
+
import { currentBootId, currentRecordedPid } from "../runs/background/boot-id.js";
|
|
6
|
+
import { currentPidNamespaceScope } from "../runs/background/pid-namespace.js";
|
|
7
|
+
import { checkRecordedPidLiveness, isRecordedPid } from "../runs/background/stale-run-reconciler.js";
|
|
8
|
+
import { readWorkflowLaunchRecord } from "./workflow-launch-record.js";
|
|
9
|
+
const CLAIM_FILE_PATTERN = /^resumed-by\.([1-9][0-9]*)\.json$/;
|
|
10
|
+
const RELEASED_CLAIM_FILE_PATTERN = /^resumed-by\.([1-9][0-9]*)\.json\.released-/;
|
|
11
|
+
const REFUSAL_FILE = "resume-refused.json";
|
|
12
|
+
const MAX_CLAIM_ROUNDS = 8;
|
|
13
|
+
// Claims this process holds, shared by every runtime in the process so a reload keeps them.
|
|
14
|
+
const heldClaimsKey = Symbol.for("pi-subagents.workflow-resume-claims.v1");
|
|
15
|
+
const heldClaimsGlobal = globalThis;
|
|
16
|
+
const heldClaims = heldClaimsGlobal[heldClaimsKey] ?? (heldClaimsGlobal[heldClaimsKey] = new Set());
|
|
17
|
+
function claimFilePath(asyncDir, generation) {
|
|
18
|
+
return path.join(asyncDir, `resumed-by.${generation}.json`);
|
|
19
|
+
}
|
|
20
|
+
function isClaim(value) {
|
|
21
|
+
if (!isRecordedPid(value) || Array.isArray(value))
|
|
22
|
+
return false;
|
|
23
|
+
const claim = value;
|
|
24
|
+
return claim.version === 1
|
|
25
|
+
&& typeof claim.claimedAt === "number"
|
|
26
|
+
&& typeof claim.newRunId === "string" && claim.newRunId.length > 0
|
|
27
|
+
&& (claim.trigger === "auto" || claim.trigger === "manual" || claim.trigger === "reuse");
|
|
28
|
+
}
|
|
29
|
+
function readTopClaim(asyncDir, retries = 3) {
|
|
30
|
+
let generation = 0;
|
|
31
|
+
let releasedGeneration = 0;
|
|
32
|
+
try {
|
|
33
|
+
for (const name of fs.readdirSync(asyncDir)) {
|
|
34
|
+
const live = CLAIM_FILE_PATTERN.exec(name);
|
|
35
|
+
if (live)
|
|
36
|
+
generation = Math.max(generation, Number(live[1]));
|
|
37
|
+
const released = RELEASED_CLAIM_FILE_PATTERN.exec(name);
|
|
38
|
+
if (released)
|
|
39
|
+
releasedGeneration = Math.max(releasedGeneration, Number(released[1]));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return error.code === "ENOENT" ? { kind: "open", generation: 0 } : { kind: "unreadable" };
|
|
44
|
+
}
|
|
45
|
+
// A live file and a released one with the same n: the live one was created after the release.
|
|
46
|
+
if (releasedGeneration > generation || generation === 0)
|
|
47
|
+
return { kind: "open", generation: Math.max(releasedGeneration, generation) };
|
|
48
|
+
const filePath = claimFilePath(asyncDir, generation);
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = fs.readFileSync(filePath, "utf-8");
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// A release renamed it after the listing: look again.
|
|
55
|
+
if (error.code === "ENOENT" && retries > 0)
|
|
56
|
+
return readTopClaim(asyncDir, retries - 1);
|
|
57
|
+
return { kind: "unreadable" };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const value = JSON.parse(text);
|
|
61
|
+
// The file is created whole, so a body that does not parse is corrupt, not half-written.
|
|
62
|
+
return isClaim(value) ? { kind: "claim", generation, filePath, claim: value } : { kind: "open", generation };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { kind: "open", generation };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function readWorkflowResumeClaim(asyncDir) {
|
|
69
|
+
const top = readTopClaim(asyncDir);
|
|
70
|
+
return top.kind === "claim" ? top.claim : undefined;
|
|
71
|
+
}
|
|
72
|
+
/** True once the run that resumes the run in `sourceAsyncDir` as `newRunId` has written its first status. */
|
|
73
|
+
export function resumedRunStarted(sourceAsyncDir, newRunId) {
|
|
74
|
+
return path.basename(newRunId) === newRunId
|
|
75
|
+
&& fs.existsSync(path.join(path.dirname(sourceAsyncDir), newRunId, "status.json"));
|
|
76
|
+
}
|
|
77
|
+
function claimantLiveness(filePath, claim, liveness) {
|
|
78
|
+
// A reload keeps the pid, so this process's own claim counts only while one of its runtimes holds
|
|
79
|
+
// it. The same pid in another pid namespace (a container sharing the temp root) is another process.
|
|
80
|
+
if (claim.pid === process.pid && claim.bootId === currentBootId() && claim.pidNamespaceScope === currentPidNamespaceScope())
|
|
81
|
+
return heldClaims.has(filePath) ? "alive" : "dead";
|
|
82
|
+
return liveness ? liveness(claim) : checkRecordedPidLiveness(claim);
|
|
83
|
+
}
|
|
84
|
+
/** Read-only view for reuse readers and auto-resume: a dead, corrupt, or missing claim blocks nothing. */
|
|
85
|
+
export function workflowResumeClaimState(asyncDir, options = {}) {
|
|
86
|
+
const top = readTopClaim(asyncDir);
|
|
87
|
+
if (top.kind === "unreadable")
|
|
88
|
+
return { kind: "unreadable" };
|
|
89
|
+
if (top.kind !== "claim")
|
|
90
|
+
return { kind: "none" };
|
|
91
|
+
if (resumedRunStarted(asyncDir, top.claim.newRunId))
|
|
92
|
+
return { kind: "resumed", newRunId: top.claim.newRunId };
|
|
93
|
+
return claimantLiveness(top.filePath, top.claim, options.liveness) === "dead" ? { kind: "none" } : { kind: "busy", pid: top.claim.pid, newRunId: top.claim.newRunId };
|
|
94
|
+
}
|
|
95
|
+
/** Creates the claim file only if none exists, with its whole body in place at once. */
|
|
96
|
+
function createClaimFile(filePath, claim) {
|
|
97
|
+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
98
|
+
try {
|
|
99
|
+
fs.writeFileSync(tempPath, JSON.stringify(claim, null, 2), "utf-8");
|
|
100
|
+
fs.linkSync(tempPath, filePath);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error.code === "EEXIST")
|
|
105
|
+
return false;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
fs.rmSync(tempPath, { force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Claims the right to continue the stopped run in `asyncDir` as `newRunId`. A dead claimant is
|
|
114
|
+
* superseded by the next generation file; of several takers, the one whose exclusive create
|
|
115
|
+
* succeeds owns the run, and the others see its live claim on their next read.
|
|
116
|
+
*/
|
|
117
|
+
export function claimWorkflowResume(asyncDir, input) {
|
|
118
|
+
const claim = {
|
|
119
|
+
version: 1,
|
|
120
|
+
...currentRecordedPid(),
|
|
121
|
+
claimedAt: input.now ?? Date.now(),
|
|
122
|
+
newRunId: input.newRunId,
|
|
123
|
+
trigger: input.trigger,
|
|
124
|
+
};
|
|
125
|
+
for (let round = 0; round < MAX_CLAIM_ROUNDS; round++) {
|
|
126
|
+
const top = readTopClaim(asyncDir);
|
|
127
|
+
if (top.kind === "unreadable")
|
|
128
|
+
return { kind: "unreadable" };
|
|
129
|
+
if (top.kind === "claim") {
|
|
130
|
+
// Already claimed for this exact new run, as a resume does before it launches.
|
|
131
|
+
if (top.claim.newRunId === input.newRunId && top.claim.pid === process.pid) {
|
|
132
|
+
heldClaims.add(top.filePath);
|
|
133
|
+
return { kind: "owner" };
|
|
134
|
+
}
|
|
135
|
+
if (resumedRunStarted(asyncDir, top.claim.newRunId))
|
|
136
|
+
return { kind: "resumed", newRunId: top.claim.newRunId };
|
|
137
|
+
if (claimantLiveness(top.filePath, top.claim, input.liveness) !== "dead")
|
|
138
|
+
return { kind: "busy", pid: top.claim.pid };
|
|
139
|
+
}
|
|
140
|
+
const filePath = claimFilePath(asyncDir, top.generation + 1);
|
|
141
|
+
if (createClaimFile(filePath, claim)) {
|
|
142
|
+
heldClaims.add(filePath);
|
|
143
|
+
return { kind: "owner" };
|
|
144
|
+
}
|
|
145
|
+
// Another taker created that generation first: decide again from its claim.
|
|
146
|
+
}
|
|
147
|
+
// Every round lost to another taker; fail closed with whatever claim is there now.
|
|
148
|
+
const top = readTopClaim(asyncDir);
|
|
149
|
+
return top.kind === "claim" ? { kind: "busy", pid: top.claim.pid } : { kind: "unreadable" };
|
|
150
|
+
}
|
|
151
|
+
/** Gives up this process's claim on a launch that did not start, so the run can be continued again. */
|
|
152
|
+
export function releaseWorkflowResumeClaim(asyncDir, newRunId) {
|
|
153
|
+
const top = readTopClaim(asyncDir);
|
|
154
|
+
if (top.kind !== "claim" || top.claim.newRunId !== newRunId || top.claim.pid !== process.pid)
|
|
155
|
+
return;
|
|
156
|
+
// The only rename in the protocol: the owner moves its own claim aside.
|
|
157
|
+
try {
|
|
158
|
+
fs.renameSync(top.filePath, `${top.filePath}.released-${randomUUID()}`);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== "ENOENT")
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
heldClaims.delete(top.filePath);
|
|
165
|
+
}
|
|
166
|
+
export function recordWorkflowResumeRefusal(asyncDir, refusal, now = Date.now()) {
|
|
167
|
+
writeAtomicJson(path.join(asyncDir, REFUSAL_FILE), { version: 1, ...refusal, refusedAt: now });
|
|
168
|
+
}
|
|
169
|
+
function readWorkflowResumeRefusal(asyncDir) {
|
|
170
|
+
try {
|
|
171
|
+
const value = JSON.parse(fs.readFileSync(path.join(asyncDir, REFUSAL_FILE), "utf-8"));
|
|
172
|
+
return value?.version === 1 && typeof value.reason === "string" && typeof value.byHand === "string" && typeof value.refusedAt === "number"
|
|
173
|
+
&& (value.newRunId === undefined || typeof value.newRunId === "string")
|
|
174
|
+
? value
|
|
175
|
+
: undefined;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export function resumeByHandText(runId) {
|
|
182
|
+
return `Resume by hand: subagent({ action: "resume", id: "${runId}" })`;
|
|
183
|
+
}
|
|
184
|
+
export function relaunchByHandText(runId) {
|
|
185
|
+
return `Relaunch it by hand with options: { reuseFrom: "${runId}" }.`;
|
|
186
|
+
}
|
|
187
|
+
/** `<reason>. <byHand>`; some reasons quote a message that already ends in a period. */
|
|
188
|
+
export function refusalSentence(reason, byHand) {
|
|
189
|
+
return `${reason.replace(/\.$/, "")}.${byHand ? ` ${byHand}` : ""}`;
|
|
190
|
+
}
|
|
191
|
+
/** A refusal was recorded for this run; the run is not tried again automatically. */
|
|
192
|
+
export function hasWorkflowResumeRefusal(asyncDir) {
|
|
193
|
+
return fs.existsSync(path.join(asyncDir, REFUSAL_FILE));
|
|
194
|
+
}
|
|
195
|
+
const RESUMED_AS = { auto: "Auto-resumed as", manual: "Resumed as", reuse: "Relaunched as" };
|
|
196
|
+
/**
|
|
197
|
+
* The one line an interrupted workflow's notice carries about what happens next: the recorded
|
|
198
|
+
* auto-resume refusal, else who continues the run, else how to continue it by hand.
|
|
199
|
+
*/
|
|
200
|
+
export function describeWorkflowResume(asyncDir, runId) {
|
|
201
|
+
const claim = readWorkflowResumeClaim(asyncDir);
|
|
202
|
+
const refusal = readWorkflowResumeRefusal(asyncDir);
|
|
203
|
+
if (refusal && (!claim || refusal.newRunId === claim.newRunId)) {
|
|
204
|
+
return `Not auto-resumed: ${refusalSentence(refusal.reason, refusal.byHand)}`;
|
|
205
|
+
}
|
|
206
|
+
if (claim)
|
|
207
|
+
return `${RESUMED_AS[claim.trigger]} ${claim.newRunId}.`;
|
|
208
|
+
const launch = readWorkflowLaunchRecord(asyncDir);
|
|
209
|
+
return launch.ok && launch.record.source !== "structured" ? resumeByHandText(runId) : relaunchByHandText(runId);
|
|
210
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
type WorkflowResumeTrigger = "auto" | "manual";
|
|
2
|
+
export type WorkflowResumeRefusalCode = "not-owned" | "not-interrupted" | "claim-busy" | "claim-resumed" | "launch-missing" | "launch-invalid" | "structured" | "digest" | "authority" | "capacity" | "attempts" | "launch-failed";
|
|
3
|
+
export interface WorkflowResumeRefusalReason {
|
|
4
|
+
code: WorkflowResumeRefusalCode;
|
|
5
|
+
reason: string;
|
|
6
|
+
/** How to continue the run by hand; empty when there is nothing to add. */
|
|
7
|
+
byHand: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PreparedWorkflowResume {
|
|
10
|
+
sourceRunId: string;
|
|
11
|
+
sourceAsyncDir: string;
|
|
12
|
+
newRunId: string;
|
|
13
|
+
trigger: WorkflowResumeTrigger;
|
|
14
|
+
/** Public launch params for executePublic: the saved launch with async, reuseFrom and the source's ceiling. */
|
|
15
|
+
params: Record<string, unknown>;
|
|
16
|
+
expectedScriptDigest: string;
|
|
17
|
+
resumeOrigin: {
|
|
18
|
+
sourceRunId: string;
|
|
19
|
+
attempt: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
type WorkflowResumePreparation = {
|
|
23
|
+
ok: true;
|
|
24
|
+
prepared: PreparedWorkflowResume;
|
|
25
|
+
} | ({
|
|
26
|
+
ok: false;
|
|
27
|
+
} & WorkflowResumeRefusalReason);
|
|
28
|
+
export declare function workflowResumeRefusal(code: WorkflowResumeRefusalCode, reason: string, runId: string): WorkflowResumeRefusalReason;
|
|
29
|
+
/** The tool error for a resume by id; the resume form is not repeated to a caller that just tried it. */
|
|
30
|
+
export declare function manualWorkflowResumeRefusalText(runId: string, refusal: Pick<WorkflowResumeRefusalReason, "reason" | "byHand">): string;
|
|
31
|
+
/**
|
|
32
|
+
* The run id a resume action relaunches: no message, task, chain or index, and an id naming a
|
|
33
|
+
* top-level workflow run. Anything else is a child resume.
|
|
34
|
+
*/
|
|
35
|
+
export declare function workflowResumeTargetId(asyncDirRoot: string, params: {
|
|
36
|
+
id?: unknown;
|
|
37
|
+
runId?: unknown;
|
|
38
|
+
message?: unknown;
|
|
39
|
+
task?: unknown;
|
|
40
|
+
chain?: unknown;
|
|
41
|
+
index?: unknown;
|
|
42
|
+
}): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Checks a stopped workflow run can be resumed and claims it for a new run id. Synchronous and
|
|
45
|
+
* cheap: the launch itself happens in the executor.
|
|
46
|
+
* `newRunId` presets the new run's id: a reload claims a run again under the id of the launch it cancelled.
|
|
47
|
+
*/
|
|
48
|
+
export declare function prepareWorkflowResume(input: {
|
|
49
|
+
asyncDirRoot: string;
|
|
50
|
+
sourceRunId: string;
|
|
51
|
+
trigger: WorkflowResumeTrigger;
|
|
52
|
+
now: number;
|
|
53
|
+
currentSessionId: string;
|
|
54
|
+
newRunId?: string;
|
|
55
|
+
}): WorkflowResumePreparation;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { normalizePublicSubagentExecution } from "../extension/public-execution.js";
|
|
4
|
+
import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.js";
|
|
5
|
+
import { ownsRun } from "../runs/shared/run-ownership.js";
|
|
6
|
+
import { readStatus } from "../shared/utils.js";
|
|
7
|
+
import { readWorkflowLaunchRecord } from "./workflow-launch-record.js";
|
|
8
|
+
import { claimWorkflowResume, refusalSentence, relaunchByHandText, resumeByHandText } from "./workflow-resume-claim.js";
|
|
9
|
+
import { workflowScriptDigest } from "./workflow-reuse.js";
|
|
10
|
+
import { isRunId } from "./workflow-revival.js";
|
|
11
|
+
import { isReusableWorkflowStopCause, WORKFLOW_STOP_CAUSE_OWNER_EXITED } from "./workflow-stop-cause.js";
|
|
12
|
+
/** A run may be resumed again after at most this many resumes that followed Pi exits (auto-resume only). */
|
|
13
|
+
const MAX_WORKFLOW_RESUME_ATTEMPTS = 3;
|
|
14
|
+
export function workflowResumeRefusal(code, reason, runId) {
|
|
15
|
+
const byHand = code === "launch-missing" || code === "launch-invalid" || code === "structured" || code === "digest" || code === "authority"
|
|
16
|
+
? relaunchByHandText(runId)
|
|
17
|
+
: code === "capacity" || code === "attempts" || code === "launch-failed" ? resumeByHandText(runId) : "";
|
|
18
|
+
return { code, reason, byHand };
|
|
19
|
+
}
|
|
20
|
+
/** The tool error for a resume by id; the resume form is not repeated to a caller that just tried it. */
|
|
21
|
+
export function manualWorkflowResumeRefusalText(runId, refusal) {
|
|
22
|
+
return `Cannot resume workflow '${runId}': ${refusalSentence(refusal.reason, refusal.byHand === relaunchByHandText(runId) ? refusal.byHand : "")}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The run id a resume action relaunches: no message, task, chain or index, and an id naming a
|
|
26
|
+
* top-level workflow run. Anything else is a child resume.
|
|
27
|
+
*/
|
|
28
|
+
export function workflowResumeTargetId(asyncDirRoot, params) {
|
|
29
|
+
if (params.message !== undefined || params.task !== undefined || params.index !== undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (Array.isArray(params.chain) && params.chain.length > 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const id = params.id ?? params.runId;
|
|
34
|
+
if (!isRunId(id))
|
|
35
|
+
return undefined;
|
|
36
|
+
let status;
|
|
37
|
+
try {
|
|
38
|
+
status = readStatus(path.join(asyncDirRoot, id));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return status?.mode === "workflow" && status.parentWorkflowRunId === undefined ? id : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Checks a stopped workflow run can be resumed and claims it for a new run id. Synchronous and
|
|
47
|
+
* cheap: the launch itself happens in the executor.
|
|
48
|
+
* `newRunId` presets the new run's id: a reload claims a run again under the id of the launch it cancelled.
|
|
49
|
+
*/
|
|
50
|
+
export function prepareWorkflowResume(input) {
|
|
51
|
+
const { asyncDirRoot, sourceRunId, trigger } = input;
|
|
52
|
+
const refuse = (code, reason) => ({ ok: false, ...workflowResumeRefusal(code, reason, sourceRunId) });
|
|
53
|
+
const sourceAsyncDir = path.join(asyncDirRoot, sourceRunId);
|
|
54
|
+
// Only the session that owns the run (itself or one it replaced) may resume it, and nothing is
|
|
55
|
+
// written into another session's run, so this comes before the reconcile.
|
|
56
|
+
const stored = readStatus(sourceAsyncDir);
|
|
57
|
+
if (stored && !ownsRun(input.currentSessionId, stored.sessionId))
|
|
58
|
+
return refuse("not-owned", "it was started in another session; reopen that session to resume it");
|
|
59
|
+
// Session start has already reconciled this session's runs; a resume by id may come later.
|
|
60
|
+
if (trigger === "manual")
|
|
61
|
+
reconcileAsyncRun(sourceAsyncDir);
|
|
62
|
+
const status = readStatus(sourceAsyncDir);
|
|
63
|
+
if (!status || status.mode !== "workflow" || !isReusableWorkflowStopCause(status.workflow?.stopCause))
|
|
64
|
+
return refuse("not-interrupted", "not interrupted; pass message to revive one of its children");
|
|
65
|
+
// The attempt the resumed run records: only resumes that follow a Pi exit count.
|
|
66
|
+
const attempt = (status.workflow?.resumeOrigin?.attempt ?? 0) + (status.workflow?.stopCause === WORKFLOW_STOP_CAUSE_OWNER_EXITED ? 1 : 0);
|
|
67
|
+
if (trigger === "auto" && attempt > MAX_WORKFLOW_RESUME_ATTEMPTS)
|
|
68
|
+
return refuse("attempts", `${MAX_WORKFLOW_RESUME_ATTEMPTS} resumes already followed Pi exits`);
|
|
69
|
+
const read = readWorkflowLaunchRecord(sourceAsyncDir);
|
|
70
|
+
if (!read.ok)
|
|
71
|
+
return read.missing ? refuse("launch-missing", "launch.json is missing (the run started before launches were saved)") : refuse("launch-invalid", `launch.json is invalid: ${read.error}`);
|
|
72
|
+
const record = read.record;
|
|
73
|
+
if (record.source === "structured")
|
|
74
|
+
return refuse("structured", "launched from tasks/chain");
|
|
75
|
+
if (record.source === "script" && (typeof record.params.workflowScript !== "string" || workflowScriptDigest(record.params.workflowScript) !== record.scriptDigest)) {
|
|
76
|
+
return refuse("launch-invalid", "launch.json is invalid: its script does not match its scriptDigest");
|
|
77
|
+
}
|
|
78
|
+
const normalized = normalizePublicSubagentExecution(record.params);
|
|
79
|
+
if (!normalized.ok)
|
|
80
|
+
return refuse("launch-invalid", `launch.json is invalid: ${normalized.error}`);
|
|
81
|
+
const newRunId = input.newRunId ?? randomUUID();
|
|
82
|
+
const claim = claimWorkflowResume(sourceAsyncDir, { newRunId, trigger, now: input.now });
|
|
83
|
+
if (claim.kind === "resumed")
|
|
84
|
+
return refuse("claim-resumed", `resumed as ${claim.newRunId}; wait on it or stop it`);
|
|
85
|
+
if (claim.kind === "busy")
|
|
86
|
+
return refuse("claim-busy", `being resumed by pid ${claim.pid}`);
|
|
87
|
+
if (claim.kind === "unreadable")
|
|
88
|
+
return refuse("claim-busy", "its resume claim cannot be read; wait or remove it");
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
prepared: {
|
|
92
|
+
sourceRunId,
|
|
93
|
+
sourceAsyncDir,
|
|
94
|
+
newRunId,
|
|
95
|
+
trigger,
|
|
96
|
+
params: {
|
|
97
|
+
...record.params,
|
|
98
|
+
async: true,
|
|
99
|
+
reuseFrom: sourceRunId,
|
|
100
|
+
...(status.admissionCapabilityCeiling ? { capabilityCeiling: status.admissionCapabilityCeiling } : {}),
|
|
101
|
+
},
|
|
102
|
+
expectedScriptDigest: record.scriptDigest,
|
|
103
|
+
resumeOrigin: { sourceRunId, attempt },
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { type WorkflowResumeClaimOutcome } from "./workflow-resume-claim.ts";
|
|
1
2
|
import { type WorkflowScriptChildResult } from "./scripted-workflow.ts";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export declare function workflowRuntimeReplacedRelaunchNotice(runId: string): string;
|
|
3
|
+
import { WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED } from "./workflow-stop-cause.ts";
|
|
4
|
+
/** What a runtime replacement left; the notice's resume line says how the run continues. */
|
|
5
|
+
export declare const WORKFLOW_RUNTIME_REPLACED_STOP_NOTICE = "Async children that were still running keep running, and finished children are kept.";
|
|
6
6
|
/** Abort reason for workflow controllers torn down by runtime replacement; carries the cause as data, not text. */
|
|
7
7
|
export declare function runtimeReplacedAbortReason(): Error;
|
|
8
|
-
export declare function workflowStopCause(reason: unknown):
|
|
8
|
+
export declare function workflowStopCause(reason: unknown): typeof WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED | undefined;
|
|
9
9
|
export declare function workflowScriptDigest(script: string): string;
|
|
10
10
|
/** Hash of the canonical launch params the script host uses to detect duplicate keys. */
|
|
11
11
|
export declare function workflowChildFingerprint(params: Record<string, unknown>): string;
|
|
@@ -40,16 +40,22 @@ type WorkflowReuseMatch = {
|
|
|
40
40
|
kind: "started";
|
|
41
41
|
runId: string;
|
|
42
42
|
};
|
|
43
|
+
/** Why a relaunch of `runId` is refused when another relaunch already continues it. */
|
|
44
|
+
export declare function claimedWorkflowRunRefusal(runId: string, claim: Exclude<WorkflowResumeClaimOutcome, {
|
|
45
|
+
kind: "owner";
|
|
46
|
+
}>, named: boolean): string;
|
|
43
47
|
/**
|
|
44
48
|
* The newest terminal workflow of this session with the same script and args,
|
|
45
|
-
* when that run stopped because its runtime was replaced.
|
|
49
|
+
* when that run stopped because its runtime was replaced or its Pi process exited.
|
|
50
|
+
* Throws when another relaunch already continues that run.
|
|
46
51
|
*/
|
|
47
52
|
export declare function findWorkflowReuseSource(asyncDirRoot: string, sessionId: string, scriptDigest: string, argsDigest: string | undefined): WorkflowReuseSource | undefined;
|
|
48
53
|
/**
|
|
49
|
-
* The
|
|
50
|
-
* must match: each child is still reused only when its key and launch params match.
|
|
54
|
+
* The interrupted workflow run named by `options.reuseFrom`, from any session. Only the script
|
|
55
|
+
* must match: each child is still reused only when its key and launch params match. The run is
|
|
56
|
+
* reconciled first, so a run whose Pi process died is marked `owner-exited` before it is read.
|
|
51
57
|
*/
|
|
52
|
-
export declare function readNamedWorkflowReuseSource(asyncDirRoot: string, runId: string, scriptDigest: string, cwd: string): WorkflowReuseSource;
|
|
58
|
+
export declare function readNamedWorkflowReuseSource(asyncDirRoot: string, runId: string, scriptDigest: string, cwd: string, claimOwnerRunId?: string): WorkflowReuseSource;
|
|
53
59
|
/** A settled match is reusable only when that child succeeded; failed or stopped children run again. */
|
|
54
60
|
export declare function matchWorkflowReuse(source: WorkflowReuseSource, key: string, fingerprint: string): WorkflowReuseMatch | undefined;
|
|
55
61
|
export {};
|
|
@@ -2,14 +2,15 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { readRecentTerminalRunIndex } from "../runs/background/terminal-run-index.js";
|
|
5
|
+
import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.js";
|
|
5
6
|
import { readStatus } from "../shared/utils.js";
|
|
7
|
+
import { workflowResumeClaimState } from "./workflow-resume-claim.js";
|
|
8
|
+
import { isRunId } from "./workflow-revival.js";
|
|
6
9
|
import { workflowRunParamsFingerprint } from "./scripted-workflow.js";
|
|
7
|
-
|
|
8
|
-
const WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED = "runtime-replaced";
|
|
10
|
+
import { isReusableWorkflowStopCause, WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED } from "./workflow-stop-cause.js";
|
|
9
11
|
const WORKFLOW_CHILD_JOURNAL_FILE = "workflow-children.jsonl";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
12
|
+
/** What a runtime replacement left; the notice's resume line says how the run continues. */
|
|
13
|
+
export const WORKFLOW_RUNTIME_REPLACED_STOP_NOTICE = "Async children that were still running keep running, and finished children are kept.";
|
|
13
14
|
/** Abort reason for workflow controllers torn down by runtime replacement; carries the cause as data, not text. */
|
|
14
15
|
export function runtimeReplacedAbortReason() {
|
|
15
16
|
return Object.assign(new Error("Workflow stopped because the extension session was replaced or reloaded."), { workflowStopCause: WORKFLOW_STOP_CAUSE_RUNTIME_REPLACED });
|
|
@@ -67,9 +68,63 @@ function readWorkflowChildJournal(runId, workflowAsyncDir) {
|
|
|
67
68
|
}
|
|
68
69
|
return source;
|
|
69
70
|
}
|
|
71
|
+
const WORKFLOW_REUSE_CHAIN_MAX_LINKS = 16;
|
|
72
|
+
/**
|
|
73
|
+
* The head run's journal plus the journals of the runs it was relaunched from (`workflow.reusedFrom`).
|
|
74
|
+
* A child's start record is written only once it gets a concurrency slot, so a key the head never
|
|
75
|
+
* reached is still in an earlier run's journal. For each key the newest run that recorded it wins. A
|
|
76
|
+
* status file is parsed, not validated, so a corrupt `reusedFrom` may be any JSON value.
|
|
77
|
+
*/
|
|
78
|
+
function readWorkflowReuseChain(asyncDirRoot, headRunId, headStatus) {
|
|
79
|
+
const source = readWorkflowChildJournal(headRunId, path.join(asyncDirRoot, headRunId));
|
|
80
|
+
const visited = new Set([headRunId]);
|
|
81
|
+
let next = headStatus.workflow?.reusedFrom;
|
|
82
|
+
for (let links = 0; isRunId(next) && links < WORKFLOW_REUSE_CHAIN_MAX_LINKS && !visited.has(next); links++) {
|
|
83
|
+
visited.add(next);
|
|
84
|
+
const asyncDir = path.join(asyncDirRoot, next);
|
|
85
|
+
let status;
|
|
86
|
+
try {
|
|
87
|
+
status = readStatus(asyncDir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (status?.mode !== "workflow" || status.workflow?.scriptDigest !== headStatus.workflow?.scriptDigest)
|
|
93
|
+
break;
|
|
94
|
+
const link = readWorkflowChildJournal(next, asyncDir);
|
|
95
|
+
for (const key of new Set([...link.started.keys(), ...link.settled.keys()])) {
|
|
96
|
+
if (source.started.has(key) || source.settled.has(key))
|
|
97
|
+
continue;
|
|
98
|
+
const started = link.started.get(key);
|
|
99
|
+
const settled = link.settled.get(key);
|
|
100
|
+
if (started)
|
|
101
|
+
source.started.set(key, started);
|
|
102
|
+
if (settled)
|
|
103
|
+
source.settled.set(key, settled);
|
|
104
|
+
}
|
|
105
|
+
next = status.workflow?.reusedFrom;
|
|
106
|
+
}
|
|
107
|
+
return source;
|
|
108
|
+
}
|
|
109
|
+
/** Why a relaunch of `runId` is refused when another relaunch already continues it. */
|
|
110
|
+
export function claimedWorkflowRunRefusal(runId, claim, named) {
|
|
111
|
+
const run = `${named ? "options.reuseFrom: workflow" : "Workflow"} run '${runId}'`;
|
|
112
|
+
if (claim.kind === "resumed")
|
|
113
|
+
return `${run} was resumed as ${claim.newRunId}; wait on it or stop it.`;
|
|
114
|
+
if (claim.kind === "busy")
|
|
115
|
+
return `${run} is being resumed by pid ${claim.pid}; wait for it.`;
|
|
116
|
+
return `${run} has a resume claim that cannot be read; wait or remove it.`;
|
|
117
|
+
}
|
|
118
|
+
function assertWorkflowRunNotClaimed(asyncDir, runId, named, claimOwnerRunId) {
|
|
119
|
+
const claim = workflowResumeClaimState(asyncDir);
|
|
120
|
+
if (claim.kind === "none" || (claim.kind !== "unreadable" && claim.newRunId === claimOwnerRunId))
|
|
121
|
+
return;
|
|
122
|
+
throw new Error(claimedWorkflowRunRefusal(runId, claim, named));
|
|
123
|
+
}
|
|
70
124
|
/**
|
|
71
125
|
* The newest terminal workflow of this session with the same script and args,
|
|
72
|
-
* when that run stopped because its runtime was replaced.
|
|
126
|
+
* when that run stopped because its runtime was replaced or its Pi process exited.
|
|
127
|
+
* Throws when another relaunch already continues that run.
|
|
73
128
|
*/
|
|
74
129
|
export function findWorkflowReuseSource(asyncDirRoot, sessionId, scriptDigest, argsDigest) {
|
|
75
130
|
let runIds;
|
|
@@ -90,28 +145,36 @@ export function findWorkflowReuseSource(asyncDirRoot, sessionId, scriptDigest, a
|
|
|
90
145
|
}
|
|
91
146
|
if (status?.mode !== "workflow" || status.workflow?.scriptDigest !== scriptDigest || status.workflow.argsDigest !== argsDigest)
|
|
92
147
|
continue;
|
|
93
|
-
|
|
148
|
+
if (!isReusableWorkflowStopCause(status.workflow.stopCause))
|
|
149
|
+
return undefined;
|
|
150
|
+
assertWorkflowRunNotClaimed(asyncDir, runId, false);
|
|
151
|
+
return readWorkflowReuseChain(asyncDirRoot, runId, status);
|
|
94
152
|
}
|
|
95
153
|
return undefined;
|
|
96
154
|
}
|
|
97
155
|
/**
|
|
98
|
-
* The
|
|
99
|
-
* must match: each child is still reused only when its key and launch params match.
|
|
156
|
+
* The interrupted workflow run named by `options.reuseFrom`, from any session. Only the script
|
|
157
|
+
* must match: each child is still reused only when its key and launch params match. The run is
|
|
158
|
+
* reconciled first, so a run whose Pi process died is marked `owner-exited` before it is read.
|
|
100
159
|
*/
|
|
101
|
-
export function readNamedWorkflowReuseSource(asyncDirRoot, runId, scriptDigest, cwd) {
|
|
160
|
+
export function readNamedWorkflowReuseSource(asyncDirRoot, runId, scriptDigest, cwd, claimOwnerRunId) {
|
|
102
161
|
// A user-supplied id: it must name one run directory, never a path.
|
|
103
|
-
const
|
|
162
|
+
const runName = isRunId(runId);
|
|
163
|
+
if (runName)
|
|
164
|
+
reconcileAsyncRun(path.join(asyncDirRoot, runId));
|
|
165
|
+
const status = runName ? readStatus(path.join(asyncDirRoot, runId)) : null;
|
|
104
166
|
if (!status)
|
|
105
167
|
throw new Error(`options.reuseFrom: workflow run '${runId}' was not found.`);
|
|
106
168
|
if (status.mode !== "workflow")
|
|
107
169
|
throw new Error(`options.reuseFrom: run '${runId}' is not a workflow run.`);
|
|
108
|
-
if (status.workflow?.stopCause
|
|
109
|
-
throw new Error(`options.reuseFrom: workflow run '${runId}' did not stop because its session was replaced, so its children cannot be reused.`);
|
|
170
|
+
if (!isReusableWorkflowStopCause(status.workflow?.stopCause))
|
|
171
|
+
throw new Error(`options.reuseFrom: workflow run '${runId}' did not stop because its session was replaced or its Pi process exited, so its children cannot be reused.`);
|
|
110
172
|
if (status.workflow.scriptDigest !== scriptDigest)
|
|
111
173
|
throw new Error(`options.reuseFrom: workflow run '${runId}' ran a different workflow script.`);
|
|
112
174
|
if (status.cwd !== cwd)
|
|
113
175
|
throw new Error(`options.reuseFrom: workflow run '${runId}' ran in '${status.cwd}', not '${cwd}'.`);
|
|
114
|
-
|
|
176
|
+
assertWorkflowRunNotClaimed(path.join(asyncDirRoot, runId), runId, true, claimOwnerRunId);
|
|
177
|
+
return readWorkflowReuseChain(asyncDirRoot, runId, status);
|
|
115
178
|
}
|
|
116
179
|
/** A settled match is reusable only when that child succeeded; failed or stopped children run again. */
|
|
117
180
|
export function matchWorkflowReuse(source, key, fingerprint) {
|