pi-subagents 0.47.1 → 0.48.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 +23 -0
- package/README.md +2 -0
- package/docs/configuration.md +46 -2
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +1 -1
- package/package.json +1 -1
- package/src/agents/agents.ts +9 -3
- package/src/extension/config.ts +6 -0
- package/src/extension/doctor.ts +40 -0
- package/src/extension/index.ts +21 -0
- package/src/extension/public-execution.ts +5 -0
- package/src/extension/rpc.ts +3 -9
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +4 -1
- package/src/missions/lifecycle.ts +4 -7
- package/src/missions/store.ts +4 -4
- package/src/missions/workflow-state.ts +6 -2
- package/src/runs/background/active-async-capacity.ts +374 -0
- package/src/runs/background/active-run-index.ts +9 -5
- package/src/runs/background/async-execution.ts +117 -29
- package/src/runs/background/async-resume.ts +7 -1
- package/src/runs/background/async-status.ts +11 -5
- package/src/runs/background/chain-append.ts +33 -15
- package/src/runs/background/owned-process-tree.ts +104 -0
- package/src/runs/background/process-terminal.ts +17 -3
- package/src/runs/background/run-status.ts +5 -1
- package/src/runs/background/stale-run-reconciler.ts +3 -3
- package/src/runs/background/subagent-runner.ts +56 -31
- package/src/runs/foreground/chain-execution.ts +37 -2
- package/src/runs/foreground/execution.ts +90 -19
- package/src/runs/foreground/foreground-control.ts +12 -0
- package/src/runs/foreground/prompt-audit.ts +171 -0
- package/src/runs/foreground/subagent-executor.ts +614 -187
- package/src/runs/shared/acceptance.ts +13 -4
- package/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/src/runs/shared/parallel-utils.ts +2 -0
- package/src/runs/shared/pi-args.ts +44 -1
- package/src/runs/shared/run-fanout-budget.ts +280 -0
- package/src/runs/shared/single-output.ts +4 -2
- package/src/runs/shared/task-intent.ts +19 -3
- package/src/runs/shared/worktree.ts +17 -5
- package/src/shared/types.ts +92 -0
- package/src/shared/utils.ts +3 -1
- package/src/tui/fleet-status.ts +7 -5
- package/src/tui/fleet.ts +225 -12
- package/src/workflows/scripted-workflow.ts +22 -5
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
5
|
+
import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } from "../../shared/types.ts";
|
|
6
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
+
import { readProcessTerminal } from "./process-terminal.ts";
|
|
8
|
+
|
|
9
|
+
export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
|
|
10
|
+
|
|
11
|
+
export interface ActiveAsyncCapacityOwnerV1 {
|
|
12
|
+
version: 1;
|
|
13
|
+
reservationToken: string;
|
|
14
|
+
ownerSessionId: string;
|
|
15
|
+
ownerSessionKey: string;
|
|
16
|
+
slot: number;
|
|
17
|
+
runId: string;
|
|
18
|
+
sourceRunId?: string;
|
|
19
|
+
generation: number;
|
|
20
|
+
kind: "runner" | "workflow";
|
|
21
|
+
asyncDir: string;
|
|
22
|
+
reservedAt: number;
|
|
23
|
+
runnerProcessInstanceId?: string;
|
|
24
|
+
runnerStartedAt?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActiveAsyncCapacityHandle {
|
|
28
|
+
readonly owner: ActiveAsyncCapacityOwnerV1;
|
|
29
|
+
markStarted(runnerProcessInstanceId: string): void;
|
|
30
|
+
markWorkflowStarted(): void;
|
|
31
|
+
rollback(): boolean;
|
|
32
|
+
reconcile(liveWorkflowRunIds?: ReadonlySet<string>): ActiveAsyncCapacitySnapshot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CapacityOptions {
|
|
36
|
+
rootDir?: string;
|
|
37
|
+
now?: () => number;
|
|
38
|
+
token?: () => string;
|
|
39
|
+
afterSlotRename?: (releasedDir: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ActiveAsyncCapacityError extends Error {
|
|
43
|
+
readonly snapshot: ActiveAsyncCapacitySnapshot;
|
|
44
|
+
|
|
45
|
+
constructor(snapshot: ActiveAsyncCapacitySnapshot) {
|
|
46
|
+
super(`Active async run capacity exhausted: ${snapshot.used}/${snapshot.limit} used.`);
|
|
47
|
+
this.name = "ActiveAsyncCapacityError";
|
|
48
|
+
this.snapshot = snapshot;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveMaxActiveAsyncRunsPerSession(value: unknown): number | undefined {
|
|
53
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return undefined;
|
|
54
|
+
return value === 0 ? undefined : value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function activeAsyncCapacitySessionKey(sessionId: string): string {
|
|
58
|
+
return createHash("sha256").update(sessionId).digest("hex");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sessionDir(sessionId: string, rootDir: string): string {
|
|
62
|
+
return path.join(rootDir, activeAsyncCapacitySessionKey(sessionId));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function slotDir(poolDir: string, slot: number): string {
|
|
66
|
+
return path.join(poolDir, `slot-${slot}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseOwner(value: unknown): ActiveAsyncCapacityOwnerV1 | undefined {
|
|
70
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
71
|
+
const owner = value as Partial<ActiveAsyncCapacityOwnerV1>;
|
|
72
|
+
if (owner.version !== 1
|
|
73
|
+
|| typeof owner.reservationToken !== "string" || !owner.reservationToken
|
|
74
|
+
|| typeof owner.ownerSessionId !== "string" || !owner.ownerSessionId
|
|
75
|
+
|| typeof owner.ownerSessionKey !== "string" || !owner.ownerSessionKey
|
|
76
|
+
|| typeof owner.slot !== "number" || !Number.isInteger(owner.slot) || owner.slot < 0
|
|
77
|
+
|| typeof owner.runId !== "string" || !owner.runId
|
|
78
|
+
|| typeof owner.generation !== "number" || !Number.isInteger(owner.generation) || owner.generation < 0
|
|
79
|
+
|| (owner.kind !== "runner" && owner.kind !== "workflow")
|
|
80
|
+
|| typeof owner.asyncDir !== "string" || !owner.asyncDir
|
|
81
|
+
|| typeof owner.reservedAt !== "number" || !Number.isFinite(owner.reservedAt)) return undefined;
|
|
82
|
+
if (owner.sourceRunId !== undefined && typeof owner.sourceRunId !== "string") return undefined;
|
|
83
|
+
if (owner.runnerProcessInstanceId !== undefined && (typeof owner.runnerProcessInstanceId !== "string" || !owner.runnerProcessInstanceId)) return undefined;
|
|
84
|
+
if (owner.runnerStartedAt !== undefined && (typeof owner.runnerStartedAt !== "number" || !Number.isFinite(owner.runnerStartedAt))) return undefined;
|
|
85
|
+
return owner as ActiveAsyncCapacityOwnerV1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readOwner(dir: string): ActiveAsyncCapacityOwnerV1 | undefined {
|
|
89
|
+
try {
|
|
90
|
+
return parseOwner(JSON.parse(fs.readFileSync(path.join(dir, "owner.json"), "utf-8")));
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function occupiedSlots(poolDir: string): string[] {
|
|
97
|
+
try {
|
|
98
|
+
return fs.readdirSync(poolDir, { withFileTypes: true })
|
|
99
|
+
.filter((entry) => entry.isDirectory() && /^slot-\d+$/.test(entry.name))
|
|
100
|
+
.map((entry) => path.join(poolDir, entry.name));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function snapshotFor(sessionId: string, limit: number | undefined, rootDir: string): ActiveAsyncCapacitySnapshot {
|
|
108
|
+
return { used: occupiedSlots(sessionDir(sessionId, rootDir)).length, limit: limit ?? 0 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchingOwner(dir: string, expected: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityOwnerV1 | undefined {
|
|
112
|
+
const owner = readOwner(dir);
|
|
113
|
+
return owner?.reservationToken === expected.reservationToken
|
|
114
|
+
&& owner.runId === expected.runId
|
|
115
|
+
&& owner.generation === expected.generation
|
|
116
|
+
? owner
|
|
117
|
+
: undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; value: T } | { acquired: false } {
|
|
121
|
+
const claimPath = path.join(dir, "capacity.claim");
|
|
122
|
+
const claimToken = randomUUID();
|
|
123
|
+
let claim: number | undefined;
|
|
124
|
+
try {
|
|
125
|
+
claim = fs.openSync(claimPath, "wx", 0o600);
|
|
126
|
+
fs.writeFileSync(claim, claimToken, "utf-8");
|
|
127
|
+
fs.closeSync(claim);
|
|
128
|
+
claim = undefined;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (claim !== undefined) fs.closeSync(claim);
|
|
131
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST" || (error as NodeJS.ErrnoException).code === "ENOENT") return { acquired: false };
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return { acquired: true, value: operation() };
|
|
136
|
+
} finally {
|
|
137
|
+
try {
|
|
138
|
+
if (fs.readFileSync(claimPath, "utf-8") === claimToken) fs.rmSync(claimPath, { force: true });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false): boolean {
|
|
146
|
+
if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
|
|
147
|
+
const claimed = withSlotClaim(dir, () => {
|
|
148
|
+
const current = matchingOwner(dir, expected);
|
|
149
|
+
if (!current || (requireUnstarted && (current.runnerProcessInstanceId || current.runnerStartedAt))) return false;
|
|
150
|
+
const releasedDir = path.join(path.dirname(dir), `.${path.basename(dir)}.released-${randomUUID()}`);
|
|
151
|
+
fs.renameSync(dir, releasedDir);
|
|
152
|
+
options.afterSlotRename?.(releasedDir);
|
|
153
|
+
fs.rmSync(releasedDir, { recursive: true, force: true });
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
return claimed.acquired && claimed.value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function terminalState(state: AsyncStatus["state"]): boolean {
|
|
160
|
+
return state !== "queued" && state !== "running" && state !== "paused";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function runnerCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus): boolean {
|
|
164
|
+
if (!owner.runnerProcessInstanceId
|
|
165
|
+
|| status.sessionId !== owner.ownerSessionId
|
|
166
|
+
|| status.runId !== owner.runId
|
|
167
|
+
|| !terminalState(status.state)) return false;
|
|
168
|
+
if (status.processTerminal?.state === "not-started"
|
|
169
|
+
&& status.processTerminal.runId === owner.runId
|
|
170
|
+
&& status.processTerminal.runnerProcessInstanceId === owner.runnerProcessInstanceId
|
|
171
|
+
&& typeof status.error === "string"
|
|
172
|
+
&& status.error) return true;
|
|
173
|
+
const proof = readProcessTerminal(owner.asyncDir, {
|
|
174
|
+
runId: owner.runId,
|
|
175
|
+
runnerProcessInstanceId: owner.runnerProcessInstanceId,
|
|
176
|
+
});
|
|
177
|
+
return proof?.state === "observed"
|
|
178
|
+
&& proof.runId === owner.runId
|
|
179
|
+
&& proof.runnerProcessInstanceId === owner.runnerProcessInstanceId;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function workflowCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, liveWorkflowRunIds: ReadonlySet<string>): boolean {
|
|
183
|
+
if (status.sessionId !== owner.ownerSessionId
|
|
184
|
+
|| status.runId !== owner.runId
|
|
185
|
+
|| status.mode !== "workflow"
|
|
186
|
+
|| !terminalState(status.state)
|
|
187
|
+
|| liveWorkflowRunIds.has(owner.runId)) return false;
|
|
188
|
+
for (const step of status.steps ?? []) {
|
|
189
|
+
if (step.status === "pending" || step.status === "running" || step.status === "paused") return false;
|
|
190
|
+
if (typeof step.async !== "boolean") return false;
|
|
191
|
+
if (!step.async) continue;
|
|
192
|
+
if (!step.runId) return false;
|
|
193
|
+
const childDir = path.join(path.dirname(owner.asyncDir), step.runId);
|
|
194
|
+
if (!fs.existsSync(childDir)) return false;
|
|
195
|
+
const childStatus = readStatus(childDir);
|
|
196
|
+
if (!childStatus || !terminalState(childStatus.state) || !childStatus.processTerminal?.runnerProcessInstanceId) return false;
|
|
197
|
+
const proof = readProcessTerminal(childDir, {
|
|
198
|
+
runId: step.runId,
|
|
199
|
+
runnerProcessInstanceId: childStatus.processTerminal.runnerProcessInstanceId,
|
|
200
|
+
});
|
|
201
|
+
if (proof?.state !== "observed" || proof.runId !== step.runId) return false;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ownerCanRelease(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): boolean {
|
|
207
|
+
const status = readStatus(owner.asyncDir);
|
|
208
|
+
if (!status) return false;
|
|
209
|
+
return owner.kind === "runner"
|
|
210
|
+
? runnerCanRelease(owner, status)
|
|
211
|
+
: workflowCanRelease(owner, status, liveWorkflowRunIds);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function reconcileActiveAsyncCapacity(
|
|
215
|
+
sessionId: string,
|
|
216
|
+
limit: number | undefined,
|
|
217
|
+
options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
|
|
218
|
+
): ActiveAsyncCapacitySnapshot {
|
|
219
|
+
const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
|
|
220
|
+
const poolDir = sessionDir(sessionId, rootDir);
|
|
221
|
+
const liveWorkflowRunIds = options.liveWorkflowRunIds ?? new Set<string>();
|
|
222
|
+
for (const dir of occupiedSlots(poolDir)) {
|
|
223
|
+
const owner = readOwner(dir);
|
|
224
|
+
if (!owner
|
|
225
|
+
|| owner.ownerSessionId !== sessionId
|
|
226
|
+
|| owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
|
|
227
|
+
|| path.basename(dir) !== `slot-${owner.slot}`
|
|
228
|
+
|| !ownerCanRelease(owner, liveWorkflowRunIds)) continue;
|
|
229
|
+
removeOwnedSlot(dir, owner, options);
|
|
230
|
+
}
|
|
231
|
+
return snapshotFor(sessionId, limit, rootDir);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getActiveAsyncCapacitySnapshot(
|
|
235
|
+
sessionId: string,
|
|
236
|
+
limit: number | undefined,
|
|
237
|
+
options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
|
|
238
|
+
): ActiveAsyncCapacitySnapshot {
|
|
239
|
+
return reconcileActiveAsyncCapacity(sessionId, limit, options);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwnerV1): boolean {
|
|
243
|
+
const destination = slotDir(poolDir, owner.slot);
|
|
244
|
+
fs.mkdirSync(poolDir, { recursive: true, mode: 0o700 });
|
|
245
|
+
try {
|
|
246
|
+
fs.mkdirSync(destination, { mode: 0o700 });
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
// If owner persistence fails, the corrupt occupied directory remains and
|
|
252
|
+
// fails closed instead of becoming available to another admission.
|
|
253
|
+
fs.writeFileSync(path.join(destination, "owner.json"), `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function handleFor(owner: ActiveAsyncCapacityOwnerV1, limit: number, options: CapacityOptions, rollbackOwner?: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityHandle {
|
|
258
|
+
const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
|
|
259
|
+
const dir = slotDir(sessionDir(owner.ownerSessionId, rootDir), owner.slot);
|
|
260
|
+
return {
|
|
261
|
+
owner,
|
|
262
|
+
markStarted(runnerProcessInstanceId) {
|
|
263
|
+
const claimed = withSlotClaim(dir, () => {
|
|
264
|
+
const current = matchingOwner(dir, owner);
|
|
265
|
+
if (!current) return false;
|
|
266
|
+
const next = { ...current, runnerProcessInstanceId, runnerStartedAt: options.now?.() ?? Date.now() };
|
|
267
|
+
// Mark memory first. If persistence fails after the process starts, caller
|
|
268
|
+
// cleanup must retain the occupied slot instead of rolling it back.
|
|
269
|
+
Object.assign(owner, next);
|
|
270
|
+
try {
|
|
271
|
+
writePrivateAtomicJson(path.join(dir, "owner.json"), next);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error(`Failed to bind active async capacity to runner '${runnerProcessInstanceId}'; capacity will remain occupied:`, error);
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
});
|
|
277
|
+
if (!claimed.acquired || !claimed.value) throw new Error(`Active async capacity ownership changed for run '${owner.runId}'.`);
|
|
278
|
+
},
|
|
279
|
+
markWorkflowStarted() {
|
|
280
|
+
const claimed = withSlotClaim(dir, () => {
|
|
281
|
+
const current = matchingOwner(dir, owner);
|
|
282
|
+
if (!current || current.kind !== "workflow") return false;
|
|
283
|
+
const next = { ...current, runnerStartedAt: options.now?.() ?? Date.now() };
|
|
284
|
+
Object.assign(owner, next);
|
|
285
|
+
try {
|
|
286
|
+
writePrivateAtomicJson(path.join(dir, "owner.json"), next);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error(`Failed to mark async workflow '${owner.runId}' started; capacity will remain occupied:`, error);
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
if (!claimed.acquired || !claimed.value) throw new Error(`Active async capacity ownership changed for workflow '${owner.runId}'.`);
|
|
293
|
+
},
|
|
294
|
+
rollback() {
|
|
295
|
+
if (!rollbackOwner) return removeOwnedSlot(dir, owner, options, true);
|
|
296
|
+
if (owner.runnerProcessInstanceId || owner.runnerStartedAt) return false;
|
|
297
|
+
const claimed = withSlotClaim(dir, () => {
|
|
298
|
+
const current = matchingOwner(dir, owner);
|
|
299
|
+
if (!current || current.runnerProcessInstanceId || current.runnerStartedAt) return false;
|
|
300
|
+
writePrivateAtomicJson(path.join(dir, "owner.json"), rollbackOwner);
|
|
301
|
+
Object.assign(owner, rollbackOwner);
|
|
302
|
+
return true;
|
|
303
|
+
});
|
|
304
|
+
return claimed.acquired && claimed.value;
|
|
305
|
+
},
|
|
306
|
+
reconcile(liveWorkflowRunIds) {
|
|
307
|
+
return reconcileActiveAsyncCapacity(owner.ownerSessionId, limit, { ...options, rootDir, liveWorkflowRunIds });
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function acquireActiveAsyncCapacity(
|
|
313
|
+
input: { sessionId: string; limit: number | undefined; runId: string; kind: "runner" | "workflow"; asyncDir: string },
|
|
314
|
+
options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
|
|
315
|
+
): ActiveAsyncCapacityHandle | undefined {
|
|
316
|
+
if (input.limit === undefined) return undefined;
|
|
317
|
+
const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
|
|
318
|
+
const reconciled = reconcileActiveAsyncCapacity(input.sessionId, input.limit, options);
|
|
319
|
+
if (reconciled.used >= input.limit) throw new ActiveAsyncCapacityError(reconciled);
|
|
320
|
+
const poolDir = sessionDir(input.sessionId, rootDir);
|
|
321
|
+
const token = options.token?.() ?? randomUUID();
|
|
322
|
+
for (let slot = 0; slot < input.limit; slot++) {
|
|
323
|
+
const owner: ActiveAsyncCapacityOwnerV1 = {
|
|
324
|
+
version: 1,
|
|
325
|
+
reservationToken: token,
|
|
326
|
+
ownerSessionId: input.sessionId,
|
|
327
|
+
ownerSessionKey: activeAsyncCapacitySessionKey(input.sessionId),
|
|
328
|
+
slot,
|
|
329
|
+
runId: input.runId,
|
|
330
|
+
generation: 0,
|
|
331
|
+
kind: input.kind,
|
|
332
|
+
asyncDir: input.asyncDir,
|
|
333
|
+
reservedAt: options.now?.() ?? Date.now(),
|
|
334
|
+
};
|
|
335
|
+
if (createSlot(poolDir, owner)) return handleFor(owner, input.limit, { ...options, rootDir });
|
|
336
|
+
}
|
|
337
|
+
throw new ActiveAsyncCapacityError(snapshotFor(input.sessionId, input.limit, rootDir));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function transferActiveAsyncCapacity(
|
|
341
|
+
input: { sessionId: string; limit: number | undefined; sourceRunId: string; runId: string; asyncDir: string },
|
|
342
|
+
options: CapacityOptions = {},
|
|
343
|
+
): ActiveAsyncCapacityHandle | undefined {
|
|
344
|
+
const limit = input.limit ?? 0;
|
|
345
|
+
const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
|
|
346
|
+
const poolDir = sessionDir(input.sessionId, rootDir);
|
|
347
|
+
for (const dir of occupiedSlots(poolDir)) {
|
|
348
|
+
const source = readOwner(dir);
|
|
349
|
+
if (!source || source.ownerSessionId !== input.sessionId || source.runId !== input.sourceRunId) continue;
|
|
350
|
+
const claimed = withSlotClaim(dir, () => {
|
|
351
|
+
const current = matchingOwner(dir, source);
|
|
352
|
+
const status = readStatus(source.asyncDir);
|
|
353
|
+
if (!current || !status || status.runId !== input.sourceRunId || status.state === "queued" || status.state === "running") {
|
|
354
|
+
throw new Error(`Active async capacity source '${input.sourceRunId}' is not transferable.`);
|
|
355
|
+
}
|
|
356
|
+
const next: ActiveAsyncCapacityOwnerV1 = {
|
|
357
|
+
...current,
|
|
358
|
+
runId: input.runId,
|
|
359
|
+
sourceRunId: input.sourceRunId,
|
|
360
|
+
generation: current.generation + 1,
|
|
361
|
+
kind: "runner",
|
|
362
|
+
asyncDir: input.asyncDir,
|
|
363
|
+
reservedAt: options.now?.() ?? Date.now(),
|
|
364
|
+
};
|
|
365
|
+
delete next.runnerProcessInstanceId;
|
|
366
|
+
delete next.runnerStartedAt;
|
|
367
|
+
writePrivateAtomicJson(path.join(dir, "owner.json"), next);
|
|
368
|
+
return handleFor(next, limit, { ...options, rootDir }, current);
|
|
369
|
+
});
|
|
370
|
+
if (!claimed.acquired) throw new Error(`Active async capacity transfer is already in progress for run '${input.sourceRunId}'.`);
|
|
371
|
+
return claimed.value;
|
|
372
|
+
}
|
|
373
|
+
return acquireActiveAsyncCapacity({ sessionId: input.sessionId, limit: input.limit, runId: input.runId, kind: "runner", asyncDir: input.asyncDir }, options);
|
|
374
|
+
}
|
|
@@ -16,6 +16,14 @@ export function isActiveAsyncState(state: AsyncStatus["state"]): boolean {
|
|
|
16
16
|
return state === "queued" || state === "running";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function releaseActiveRunIndex(asyncDir: string): void {
|
|
20
|
+
try {
|
|
21
|
+
fs.rmSync(markerPath(asyncDir));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"]): void {
|
|
20
28
|
const marker = markerPath(asyncDir);
|
|
21
29
|
if (isActiveAsyncState(state)) {
|
|
@@ -23,11 +31,7 @@ export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state
|
|
|
23
31
|
fs.writeFileSync(marker, "", { flag: "a" });
|
|
24
32
|
return;
|
|
25
33
|
}
|
|
26
|
-
|
|
27
|
-
fs.rmSync(marker);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
30
|
-
}
|
|
34
|
+
releaseActiveRunIndex(asyncDir);
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export function readActiveRunIndex(asyncDirRoot: string): string[] | undefined {
|