@pi-unipi/subagents 2.6.1 → 2.6.2
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/README.md +89 -78
- package/agents/delegate.md +14 -0
- package/agents/oracle.md +78 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/worker.md +59 -0
- package/package.json +19 -17
- package/prompts/council.md +48 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +43 -0
- package/skills/council-mode/SKILL.md +230 -0
- package/skills/subagents/SKILL.md +49 -0
- package/skills/subagents/references/constraints-and-recipes.md +259 -0
- package/skills/subagents/references/execution-controls.md +454 -0
- package/skills/subagents/references/management-authoring-rpc.md +161 -0
- package/skills/subagents/references/multi-lane-orchestration.md +39 -0
- package/skills/subagents/references/prompting-and-roles.md +267 -0
- package/src/acceptance.ts +331 -0
- package/src/agent-manager.ts +405 -0
- package/src/agent-memory.ts +300 -0
- package/src/agent-overrides.ts +183 -0
- package/src/agent-runner.ts +265 -0
- package/src/async-runner.ts +417 -0
- package/src/authority-policy.ts +47 -0
- package/src/budgets.ts +285 -0
- package/src/child-safety.ts +140 -0
- package/src/config.ts +226 -0
- package/src/conversation-viewer.ts +281 -0
- package/src/core-compat.ts +94 -0
- package/src/custom-agents.ts +323 -0
- package/src/file-system-retry.ts +50 -0
- package/src/fleet-data.ts +61 -0
- package/src/fleet-view.ts +316 -0
- package/src/foreground-detach.ts +59 -0
- package/src/fork-context.ts +285 -0
- package/src/global.d.ts +7 -0
- package/src/guide.ts +129 -0
- package/src/index.ts +1149 -0
- package/src/mission-state.ts +133 -0
- package/src/mission-store.ts +434 -0
- package/src/model-resolver.ts +79 -0
- package/src/output-limits.ts +142 -0
- package/src/parity-types.ts +315 -0
- package/src/pi-args.ts +161 -0
- package/src/pi-spawn.ts +147 -0
- package/src/result-files.ts +232 -0
- package/src/result-watcher.ts +221 -0
- package/src/retained-children.ts +176 -0
- package/src/run-fanout-budget.ts +356 -0
- package/src/scheduled-runs.ts +351 -0
- package/src/schemas.ts +263 -0
- package/src/slash-commands.ts +77 -0
- package/src/supervisor-channel.ts +249 -0
- package/src/tool-handler.ts +1144 -0
- package/src/types.ts +238 -0
- package/src/widget.ts +447 -0
- package/src/workflow-script.ts +737 -0
- package/src/workflow-worker.ts +384 -0
- package/src/worktree.ts +614 -0
- package/dist/agent-manager.d.ts +0 -81
- package/dist/agent-manager.d.ts.map +0 -1
- package/dist/agent-manager.js +0 -295
- package/dist/agent-manager.js.map +0 -1
- package/dist/agent-runner.d.ts +0 -51
- package/dist/agent-runner.d.ts.map +0 -1
- package/dist/agent-runner.js +0 -262
- package/dist/agent-runner.js.map +0 -1
- package/dist/config.d.ts +0 -24
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -132
- package/dist/config.js.map +0 -1
- package/dist/conversation-viewer.d.ts +0 -40
- package/dist/conversation-viewer.d.ts.map +0 -1
- package/dist/conversation-viewer.js +0 -276
- package/dist/conversation-viewer.js.map +0 -1
- package/dist/core-compat.d.ts +0 -21
- package/dist/core-compat.d.ts.map +0 -1
- package/dist/core-compat.js +0 -86
- package/dist/core-compat.js.map +0 -1
- package/dist/custom-agents.d.ts +0 -14
- package/dist/custom-agents.d.ts.map +0 -1
- package/dist/custom-agents.js +0 -113
- package/dist/custom-agents.js.map +0 -1
- package/dist/file-lock.d.ts +0 -42
- package/dist/file-lock.d.ts.map +0 -1
- package/dist/file-lock.js +0 -91
- package/dist/file-lock.js.map +0 -1
- package/dist/index.d.ts +0 -10
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -758
- package/dist/index.js.map +0 -1
- package/dist/model-resolver.d.ts +0 -19
- package/dist/model-resolver.d.ts.map +0 -1
- package/dist/model-resolver.js +0 -61
- package/dist/model-resolver.js.map +0 -1
- package/dist/types.d.ts +0 -98
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -47
- package/dist/types.js.map +0 -1
- package/dist/widget.d.ts +0 -56
- package/dist/widget.d.ts.map +0 -1
- package/dist/widget.js +0 -396
- package/dist/widget.js.map +0 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Retained children + resume
|
|
3
|
+
*
|
|
4
|
+
* Ported from pi-subagents src/runs/background/retained-children.ts (core).
|
|
5
|
+
* Completed workflow children from the current parent session stay
|
|
6
|
+
* addressable: children.list shows up to 10 (newest first, a resumable child
|
|
7
|
+
* retained when available) with explicit resumable/not-resumable state.
|
|
8
|
+
* Resume only rows reported resumable; the revived child keeps its stored
|
|
9
|
+
* agent/model/tool contract and gets the follow-up task.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { ASYNC_DIR } from "./parity-types.js";
|
|
15
|
+
import { readStatus } from "./async-runner.js";
|
|
16
|
+
|
|
17
|
+
const MAX_RETAINED_CHILDREN = 10;
|
|
18
|
+
const MAX_RETAINED_CHILD_CANDIDATES = 100;
|
|
19
|
+
const MAX_TASK_SUMMARY_LENGTH = 120;
|
|
20
|
+
|
|
21
|
+
export type RetainedChildState = "completed" | "failed" | "paused" | "stopped";
|
|
22
|
+
|
|
23
|
+
export type RetainedChildResumability =
|
|
24
|
+
| { state: "resumable"; sessionPath: string }
|
|
25
|
+
| { state: "not-resumable"; reason: string };
|
|
26
|
+
|
|
27
|
+
export interface RetainedChild {
|
|
28
|
+
runId: string;
|
|
29
|
+
state: RetainedChildState;
|
|
30
|
+
agent: string;
|
|
31
|
+
taskSummary: string;
|
|
32
|
+
completedAt: number;
|
|
33
|
+
resumability: RetainedChildResumability;
|
|
34
|
+
sessionPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRetainedChildState(state: unknown): state is RetainedChildState {
|
|
38
|
+
return state === "completed" || state === "failed" || state === "paused" || state === "stopped";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function retainedSessionFile(sessionFile: string | undefined): RetainedChildResumability {
|
|
42
|
+
if (!sessionFile) return { state: "not-resumable", reason: "no persisted session file" };
|
|
43
|
+
if (!sessionFile.endsWith(".jsonl")) return { state: "not-resumable", reason: "persisted session file is not a .jsonl file" };
|
|
44
|
+
try {
|
|
45
|
+
const stat = fs.lstatSync(sessionFile);
|
|
46
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { state: "not-resumable", reason: "persisted session file is not a regular file" };
|
|
47
|
+
return { state: "resumable", sessionPath: sessionFile };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
50
|
+
return { state: "not-resumable", reason: `persisted session file is missing: ${sessionFile}` };
|
|
51
|
+
}
|
|
52
|
+
return { state: "not-resumable", reason: `persisted session file could not be inspected: ${error instanceof Error ? error.message : String(error)}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function boundedTaskSummary(value: string | undefined): string {
|
|
57
|
+
const normalized = value?.replace(/\s+/g, " ").trim() ?? "";
|
|
58
|
+
return normalized.length > MAX_TASK_SUMMARY_LENGTH ? `${normalized.slice(0, MAX_TASK_SUMMARY_LENGTH - 1)}…` : normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** List terminal async runs from this session with their resumability. */
|
|
62
|
+
export function listRetainedChildren(asyncDirRoot: string = ASYNC_DIR, sessionId?: string): RetainedChild[] {
|
|
63
|
+
let entries: fs.Dirent[];
|
|
64
|
+
try {
|
|
65
|
+
entries = fs.readdirSync(asyncDirRoot, { withFileTypes: true });
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const children: RetainedChild[] = [];
|
|
71
|
+
for (const entry of entries.slice(0, MAX_RETAINED_CHILD_CANDIDATES)) {
|
|
72
|
+
if (!entry.isDirectory()) continue;
|
|
73
|
+
const runDir = path.join(asyncDirRoot, entry.name);
|
|
74
|
+
const status = readStatus(runDir);
|
|
75
|
+
if (!isRetainedChildState(status.status)) continue;
|
|
76
|
+
if (sessionId && status.sessionId !== sessionId) continue;
|
|
77
|
+
|
|
78
|
+
const completedAt =
|
|
79
|
+
typeof status.updatedAt === "number" ? status.updatedAt : statMtime(runDir);
|
|
80
|
+
if (completedAt === undefined) continue;
|
|
81
|
+
|
|
82
|
+
// Reference rule: stopped runs are never resumable.
|
|
83
|
+
if (status.status === "stopped") {
|
|
84
|
+
const agent = typeof status.agent === "string" ? status.agent : "unknown";
|
|
85
|
+
const completedAt2 = typeof status.updatedAt === "number" ? status.updatedAt : statMtime(runDir);
|
|
86
|
+
if (completedAt2 === undefined) continue;
|
|
87
|
+
children.push({
|
|
88
|
+
runId: entry.name,
|
|
89
|
+
state: "stopped",
|
|
90
|
+
agent,
|
|
91
|
+
taskSummary: boundedTaskSummary(typeof status.task === "string" ? status.task : undefined),
|
|
92
|
+
completedAt: completedAt2,
|
|
93
|
+
resumability: { state: "not-resumable", reason: "stopped run" },
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const agent = typeof status.agent === "string" ? status.agent : "unknown";
|
|
99
|
+
const taskSummary = boundedTaskSummary(typeof status.task === "string" ? status.task : undefined);
|
|
100
|
+
const resumability = retainedSessionFile(
|
|
101
|
+
typeof status.sessionFile === "string" ? status.sessionFile : undefined,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
children.push({
|
|
105
|
+
runId: entry.name,
|
|
106
|
+
state: status.status,
|
|
107
|
+
agent,
|
|
108
|
+
taskSummary,
|
|
109
|
+
completedAt,
|
|
110
|
+
resumability,
|
|
111
|
+
...(resumability.state === "resumable" ? { sessionPath: resumability.sessionPath } : {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return children.sort((left, right) => right.completedAt - left.completedAt);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function statMtime(dir: string): number | undefined {
|
|
119
|
+
try {
|
|
120
|
+
return fs.statSync(dir).mtimeMs;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatRetainedChildren(children: RetainedChild[]): string {
|
|
127
|
+
if (children.length === 0) {
|
|
128
|
+
return "No retained workflow children in the active parent session. If a retained-writer challenge is required, launch a same-role fallback challenge and label it as fallback.";
|
|
129
|
+
}
|
|
130
|
+
const retained = children.slice(0, MAX_RETAINED_CHILDREN);
|
|
131
|
+
if (!retained.some((child) => child.resumability.state === "resumable")) {
|
|
132
|
+
const resumable = children.slice(MAX_RETAINED_CHILDREN).find((child) => child.resumability.state === "resumable");
|
|
133
|
+
if (resumable && retained.length === MAX_RETAINED_CHILDREN) retained[MAX_RETAINED_CHILDREN - 1] = resumable;
|
|
134
|
+
}
|
|
135
|
+
const hasResumableChild = retained.some((child) => child.resumability.state === "resumable");
|
|
136
|
+
return [
|
|
137
|
+
`Retained workflow children (up to ${MAX_RETAINED_CHILDREN}; newest first, with a resumable child retained when available):`,
|
|
138
|
+
...retained.flatMap((child) => [
|
|
139
|
+
`- ${child.runId} | ${child.agent} | ${child.state} | ${new Date(child.completedAt).toISOString()}`,
|
|
140
|
+
` task: ${child.taskSummary || "(no task summary)"}`,
|
|
141
|
+
child.resumability.state === "resumable"
|
|
142
|
+
? " resumability: resumable"
|
|
143
|
+
: ` resumability: not resumable (${child.resumability.reason})`,
|
|
144
|
+
...(child.resumability.state === "resumable"
|
|
145
|
+
? [
|
|
146
|
+
` session: ${child.resumability.sessionPath}`,
|
|
147
|
+
` resume: spawn_helper({ action: "resume", id: "${child.runId}", message: "..." })`,
|
|
148
|
+
]
|
|
149
|
+
: []),
|
|
150
|
+
]),
|
|
151
|
+
...(hasResumableChild ? [] : ["No resumable retained child is listed. Launch a same-role fallback challenge and label it as fallback."]),
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a resume target: only rows reported resumable may resume. Returns
|
|
157
|
+
* the stored contract (agent name + session file) or an error message.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveResumeTarget(
|
|
160
|
+
asyncDirRoot: string,
|
|
161
|
+
runIdOrPrefix: string,
|
|
162
|
+
): { ok: true; runId: string; agent: string; sessionFile: string } | { ok: false; error: string } {
|
|
163
|
+
const children = listRetainedChildren(asyncDirRoot);
|
|
164
|
+
const matches = children.filter((child) => child.runId.startsWith(runIdOrPrefix));
|
|
165
|
+
if (matches.length === 0) {
|
|
166
|
+
return { ok: false, error: `No retained child matches "${runIdOrPrefix}". Use children.list to see retained runs.` };
|
|
167
|
+
}
|
|
168
|
+
if (matches.length > 1) {
|
|
169
|
+
return { ok: false, error: `"${runIdOrPrefix}" matches ${matches.length} retained children; use a longer prefix.` };
|
|
170
|
+
}
|
|
171
|
+
const target = matches[0]!;
|
|
172
|
+
if (target.resumability.state !== "resumable") {
|
|
173
|
+
return { ok: false, error: `Run ${target.runId} is not resumable (${target.resumability.reason}).` };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, runId: target.runId, agent: target.agent, sessionFile: target.resumability.sessionPath };
|
|
176
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Run fan-out budget (maxSubagentSpawnsPerRun)
|
|
3
|
+
*
|
|
4
|
+
* Ported from pi-subagents src/runs/shared/run-fanout-budget.ts. Durable,
|
|
5
|
+
* file-backed claim directory per top-level run tree; claims are never
|
|
6
|
+
* refunded; groups admit atomically under an admission lock (stale-lock
|
|
7
|
+
* reclaim via pid check). Layout under OUR temp root
|
|
8
|
+
* (unipi-subagents-<scope>/run-fanout-budgets).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS,
|
|
16
|
+
waitForFileSystemRetry,
|
|
17
|
+
} from "./file-system-retry.js";
|
|
18
|
+
import { TEMP_ROOT_DIR } from "./parity-types.js";
|
|
19
|
+
|
|
20
|
+
const RUN_FANOUT_ROOT = path.join(TEMP_ROOT_DIR, "run-fanout-budgets");
|
|
21
|
+
|
|
22
|
+
export interface RunFanoutBudgetDescriptor {
|
|
23
|
+
version: 1;
|
|
24
|
+
rootRunId: string;
|
|
25
|
+
directory: string;
|
|
26
|
+
limit: number;
|
|
27
|
+
parentPath?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RunFanoutBudgetSnapshot {
|
|
31
|
+
used: number;
|
|
32
|
+
limit: number;
|
|
33
|
+
remaining: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunFanoutRejection extends RunFanoutBudgetSnapshot {
|
|
37
|
+
code: "RUN_FANOUT_LIMIT";
|
|
38
|
+
path: string;
|
|
39
|
+
requested: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class RunFanoutLimitError extends Error {
|
|
43
|
+
readonly rejection: RunFanoutRejection;
|
|
44
|
+
readonly snapshot: RunFanoutBudgetSnapshot;
|
|
45
|
+
|
|
46
|
+
constructor(rejection: RunFanoutRejection) {
|
|
47
|
+
super(formatRunFanoutRejection(rejection));
|
|
48
|
+
this.name = "RunFanoutLimitError";
|
|
49
|
+
this.rejection = rejection;
|
|
50
|
+
this.snapshot = { used: rejection.used, limit: rejection.limit, remaining: rejection.remaining };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ManifestV1 {
|
|
55
|
+
version: 1;
|
|
56
|
+
rootRunId: string;
|
|
57
|
+
limit: number;
|
|
58
|
+
createdAt: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ClaimV1 {
|
|
62
|
+
version: 1;
|
|
63
|
+
claimId: string;
|
|
64
|
+
path: string;
|
|
65
|
+
claimedAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeRootRunId(rootRunId: string): string {
|
|
69
|
+
return rootRunId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || randomUUID();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseManifest(value: unknown): ManifestV1 | undefined {
|
|
73
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
74
|
+
const manifest = value as Partial<ManifestV1>;
|
|
75
|
+
if (
|
|
76
|
+
manifest.version !== 1 ||
|
|
77
|
+
typeof manifest.rootRunId !== "string" ||
|
|
78
|
+
!manifest.rootRunId ||
|
|
79
|
+
!Number.isInteger(manifest.limit) ||
|
|
80
|
+
(manifest.limit ?? 0) <= 0 ||
|
|
81
|
+
typeof manifest.createdAt !== "number" ||
|
|
82
|
+
!Number.isFinite(manifest.createdAt)
|
|
83
|
+
) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return manifest as ManifestV1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readManifest(directory: string): ManifestV1 {
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf-8"));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Run fan-out budget manifest is unreadable at '${directory}': ${error instanceof Error ? error.message : String(error)}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const manifest = parseManifest(parsed);
|
|
99
|
+
if (!manifest) throw new Error(`Run fan-out budget manifest is invalid at '${directory}'.`);
|
|
100
|
+
return manifest;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateDirectory(directory: string): string {
|
|
104
|
+
let realDirectory: string;
|
|
105
|
+
let realRoot: string;
|
|
106
|
+
try {
|
|
107
|
+
realDirectory = fs.realpathSync(path.resolve(directory));
|
|
108
|
+
realRoot = fs.realpathSync(path.resolve(RUN_FANOUT_ROOT));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Run fan-out budget directory is unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (realDirectory !== realRoot && !realDirectory.startsWith(`${realRoot}${path.sep}`)) {
|
|
115
|
+
throw new Error("Run fan-out budget directory resolves outside the managed budget root.");
|
|
116
|
+
}
|
|
117
|
+
return realDirectory;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createRunFanoutBudget(rootRunId: string, limit: number): RunFanoutBudgetDescriptor {
|
|
121
|
+
if (!Number.isInteger(limit) || limit <= 0) throw new Error("Run fan-out limit must be a positive integer.");
|
|
122
|
+
fs.mkdirSync(RUN_FANOUT_ROOT, { recursive: true, mode: 0o700 });
|
|
123
|
+
let directory: string;
|
|
124
|
+
do {
|
|
125
|
+
directory = path.join(RUN_FANOUT_ROOT, `${safeRootRunId(rootRunId)}-${randomUUID()}`);
|
|
126
|
+
} while (fs.existsSync(directory));
|
|
127
|
+
fs.mkdirSync(path.join(directory, "claims"), { recursive: true, mode: 0o700 });
|
|
128
|
+
const manifest: ManifestV1 = { version: 1, rootRunId, limit, createdAt: Date.now() };
|
|
129
|
+
fs.writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: "wx" });
|
|
130
|
+
return { version: 1, rootRunId, directory, limit };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function validateRunFanoutBudgetDescriptor(value: unknown): RunFanoutBudgetDescriptor {
|
|
134
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
135
|
+
throw new Error("Run fan-out budget descriptor is missing or invalid.");
|
|
136
|
+
}
|
|
137
|
+
const descriptor = value as Partial<RunFanoutBudgetDescriptor>;
|
|
138
|
+
if (
|
|
139
|
+
descriptor.version !== 1 ||
|
|
140
|
+
typeof descriptor.rootRunId !== "string" ||
|
|
141
|
+
!descriptor.rootRunId ||
|
|
142
|
+
typeof descriptor.directory !== "string" ||
|
|
143
|
+
!descriptor.directory ||
|
|
144
|
+
!Number.isInteger(descriptor.limit) ||
|
|
145
|
+
(descriptor.limit ?? 0) <= 0 ||
|
|
146
|
+
(descriptor.parentPath !== undefined && typeof descriptor.parentPath !== "string")
|
|
147
|
+
) {
|
|
148
|
+
throw new Error("Run fan-out budget descriptor is invalid.");
|
|
149
|
+
}
|
|
150
|
+
const directory = validateDirectory(descriptor.directory);
|
|
151
|
+
const manifest = readManifest(directory);
|
|
152
|
+
if (manifest.rootRunId !== descriptor.rootRunId || manifest.limit !== descriptor.limit) {
|
|
153
|
+
throw new Error("Run fan-out budget descriptor does not match its manifest.");
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
version: 1,
|
|
157
|
+
rootRunId: descriptor.rootRunId,
|
|
158
|
+
directory,
|
|
159
|
+
limit: descriptor.limit,
|
|
160
|
+
...(descriptor.parentPath ? { parentPath: descriptor.parentPath } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function writeRunFanoutBudgetDescriptor(asyncDir: string, descriptor: RunFanoutBudgetDescriptor): void {
|
|
165
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
166
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
167
|
+
fs.writeFileSync(path.join(asyncDir, "run-fanout-budget.json"), `${JSON.stringify(valid)}\n`, { mode: 0o600 });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function readRunFanoutBudgetDescriptor(asyncDir: string | undefined): RunFanoutBudgetDescriptor | undefined {
|
|
171
|
+
if (!asyncDir) return undefined;
|
|
172
|
+
const descriptorPath = path.join(asyncDir, "run-fanout-budget.json");
|
|
173
|
+
if (!fs.existsSync(descriptorPath)) return undefined;
|
|
174
|
+
try {
|
|
175
|
+
return validateRunFanoutBudgetDescriptor(JSON.parse(fs.readFileSync(descriptorPath, "utf-8")));
|
|
176
|
+
} catch (error) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Invalid persisted run fan-out budget '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface AdmissionLockOwner {
|
|
184
|
+
pid: number;
|
|
185
|
+
token: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ADMISSION_LOCK_STALE_MS = 60_000;
|
|
189
|
+
|
|
190
|
+
function readAdmissionLockOwner(lockPath: string): AdmissionLockOwner | undefined {
|
|
191
|
+
try {
|
|
192
|
+
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf-8")) as Partial<AdmissionLockOwner>;
|
|
193
|
+
if (Number.isSafeInteger(owner.pid) && (owner.pid ?? 0) > 0 && typeof owner.token === "string" && owner.token) {
|
|
194
|
+
return owner as AdmissionLockOwner;
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// unreadable = treat as absent
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function admissionLockIsStale(lockPath: string): boolean {
|
|
203
|
+
const owner = readAdmissionLockOwner(lockPath);
|
|
204
|
+
if (owner) {
|
|
205
|
+
try {
|
|
206
|
+
process.kill(owner.pid, 0);
|
|
207
|
+
return false;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return (error as NodeJS.ErrnoException).code !== "EPERM";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return Date.now() - fs.statSync(lockPath).mtimeMs > ADMISSION_LOCK_STALE_MS;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function withAdmissionLock<T>(directory: string, operation: () => T): T {
|
|
221
|
+
const lockPath = path.join(directory, "admission.lock");
|
|
222
|
+
const owner: AdmissionLockOwner = { pid: process.pid, token: randomUUID() };
|
|
223
|
+
for (let attempt = 0; ; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
226
|
+
try {
|
|
227
|
+
fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { mode: 0o600 });
|
|
228
|
+
} catch (error) {
|
|
229
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
235
|
+
if (admissionLockIsStale(lockPath)) {
|
|
236
|
+
const stalePath = path.join(directory, `admission.stale-${randomUUID()}`);
|
|
237
|
+
try {
|
|
238
|
+
fs.renameSync(lockPath, stalePath);
|
|
239
|
+
fs.rmSync(stalePath, { recursive: true, force: true });
|
|
240
|
+
continue;
|
|
241
|
+
} catch (reclaimError) {
|
|
242
|
+
if ((reclaimError as NodeJS.ErrnoException).code !== "ENOENT") throw reclaimError;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const delay = DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt];
|
|
246
|
+
if (delay === undefined) {
|
|
247
|
+
throw new Error(`Timed out acquiring run fan-out admission lock at '${directory}'.`);
|
|
248
|
+
}
|
|
249
|
+
waitForFileSystemRetry(delay);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
return operation();
|
|
254
|
+
} finally {
|
|
255
|
+
if (readAdmissionLockOwner(lockPath)?.token === owner.token) {
|
|
256
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function claimCount(directory: string): number {
|
|
262
|
+
const claimsDir = path.join(directory, "claims");
|
|
263
|
+
let entries: fs.Dirent[];
|
|
264
|
+
try {
|
|
265
|
+
entries = fs.readdirSync(claimsDir, { withFileTypes: true });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Run fan-out claims directory is unreadable at '${claimsDir}': ${error instanceof Error ? error.message : String(error)}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return entries.filter((entry) => /^\d{6}\.json$/.test(entry.name)).length;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function getRunFanoutBudgetSnapshot(descriptor: RunFanoutBudgetDescriptor): RunFanoutBudgetSnapshot {
|
|
275
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
276
|
+
const used = claimCount(valid.directory);
|
|
277
|
+
return { used, limit: valid.limit, remaining: Math.max(0, valid.limit - used) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function qualifyRunFanoutPaths(descriptor: RunFanoutBudgetDescriptor, paths: string[]): string[] {
|
|
281
|
+
const prefix = descriptor.parentPath?.trim();
|
|
282
|
+
return paths.map((item) => (prefix ? `${prefix}/${item}` : item));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function commitRunFanoutBatch<T>(
|
|
286
|
+
descriptor: RunFanoutBudgetDescriptor,
|
|
287
|
+
paths: string[],
|
|
288
|
+
commit: (snapshot: RunFanoutBudgetSnapshot) => T,
|
|
289
|
+
): T {
|
|
290
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
291
|
+
if (paths.length === 0) return commit(getRunFanoutBudgetSnapshot(valid));
|
|
292
|
+
const qualified = qualifyRunFanoutPaths(valid, paths);
|
|
293
|
+
return withAdmissionLock(valid.directory, () => {
|
|
294
|
+
const before = getRunFanoutBudgetSnapshot(valid);
|
|
295
|
+
if (qualified.length > before.remaining) {
|
|
296
|
+
throw new RunFanoutLimitError({
|
|
297
|
+
code: "RUN_FANOUT_LIMIT",
|
|
298
|
+
path: qualified[before.remaining] ?? qualified[0]!,
|
|
299
|
+
requested: qualified.length,
|
|
300
|
+
...before,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const created: string[] = [];
|
|
304
|
+
try {
|
|
305
|
+
for (const claimPath of qualified) {
|
|
306
|
+
for (let slot = 0; slot < valid.limit; slot++) {
|
|
307
|
+
const slotPath = path.join(valid.directory, "claims", `${String(slot).padStart(6, "0")}.json`);
|
|
308
|
+
try {
|
|
309
|
+
const fd = fs.openSync(slotPath, "wx", 0o600);
|
|
310
|
+
created.push(slotPath);
|
|
311
|
+
try {
|
|
312
|
+
const claim: ClaimV1 = { version: 1, claimId: randomUUID(), path: claimPath, claimedAt: Date.now() };
|
|
313
|
+
fs.writeFileSync(fd, `${JSON.stringify(claim)}\n`, "utf-8");
|
|
314
|
+
} finally {
|
|
315
|
+
fs.closeSync(fd);
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return commit(getRunFanoutBudgetSnapshot(valid));
|
|
325
|
+
} catch (error) {
|
|
326
|
+
for (const slotPath of created) {
|
|
327
|
+
try {
|
|
328
|
+
fs.unlinkSync(slotPath);
|
|
329
|
+
} catch {
|
|
330
|
+
// best-effort rollback
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function claimRunFanoutBatch(descriptor: RunFanoutBudgetDescriptor, paths: string[]): RunFanoutBudgetSnapshot {
|
|
339
|
+
return commitRunFanoutBatch(descriptor, paths, (snapshot) => snapshot);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function claimRunFanoutBatchWithCommit<T>(
|
|
343
|
+
descriptor: RunFanoutBudgetDescriptor,
|
|
344
|
+
paths: string[],
|
|
345
|
+
commit: () => T,
|
|
346
|
+
): T {
|
|
347
|
+
return commitRunFanoutBatch(descriptor, paths, commit);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function formatRunFanoutBudget(snapshot: RunFanoutBudgetSnapshot): string {
|
|
351
|
+
return `Run fan-out: ${snapshot.used}/${snapshot.limit} used, ${snapshot.remaining} remaining`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function formatRunFanoutRejection(rejection: RunFanoutRejection): string {
|
|
355
|
+
return `Run fan-out limit reached at ${rejection.path} (${rejection.used}/${rejection.limit} used; ${rejection.requested} requested, ${rejection.remaining} remaining). No children from this admission group were started. Start a new top-level run or raise the maxSubagentSpawnsPerRun config.`;
|
|
356
|
+
}
|