@fitchmultz/pi-subagents 0.52.17 → 0.52.19
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 +51 -0
- package/README.md +4 -0
- package/dist/agent/host.js +12 -0
- package/dist/agent/index.js +70 -56
- package/dist/agent/launch.js +12 -3
- package/dist/agent/notifications.js +5 -5
- package/dist/agent/parking.js +26 -15
- package/dist/agent/runner.js +18 -16
- package/dist/agent/tool-schemas.js +2 -0
- package/dist/agent/usage.js +42 -3
- package/dist/agent/view.js +13 -13
- package/dist/agent/wake.js +17 -15
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
- package/dist/pi-intercom/broker/snapshots.js +56 -25
- package/dist/pi-intercom/broker/spawn-claim.js +89 -0
- package/dist/pi-intercom/broker/spawn.js +31 -78
- package/dist/pi-intercom/inbound-delivery.js +2 -6
- package/dist/pi-intercom/lifecycle.js +26 -26
- package/dist/pi-intercom/reply-tracker.js +47 -55
- package/dist/pi-intercom/session-events.js +2 -0
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +19 -2
- package/docs/reference.md +16 -5
- package/package.json +1 -1
- package/scripts/build.mjs +41 -14
- package/skills/pi-subagents/SKILL.md +8 -4
- package/src/agent/contracts.ts +17 -0
- package/src/agent/host.ts +19 -0
- package/src/agent/index.ts +91 -62
- package/src/agent/launch.ts +16 -3
- package/src/agent/notifications.ts +5 -5
- package/src/agent/parking.ts +39 -17
- package/src/agent/runner.ts +20 -21
- package/src/agent/tool-schemas.ts +2 -0
- package/src/agent/usage.ts +48 -3
- package/src/agent/view.ts +15 -13
- package/src/agent/wake.ts +19 -15
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/snapshots.ts +82 -37
- package/src/pi-intercom/broker/spawn-claim.ts +98 -0
- package/src/pi-intercom/broker/spawn.ts +42 -82
- package/src/pi-intercom/inbound-delivery.ts +2 -6
- package/src/pi-intercom/lifecycle.ts +38 -27
- package/src/pi-intercom/reply-tracker.ts +50 -58
- package/src/pi-intercom/session-events.ts +3 -1
- package/src/shared/post-exit-stdio-guard.ts +48 -40
package/src/agent/usage.ts
CHANGED
|
@@ -8,12 +8,49 @@ import { Assert } from "./typebox.ts";
|
|
|
8
8
|
import {
|
|
9
9
|
contributionsSchema,
|
|
10
10
|
resultSchema,
|
|
11
|
+
type AccountingState,
|
|
11
12
|
type Contribution,
|
|
12
13
|
type Launch,
|
|
13
14
|
type Result,
|
|
14
15
|
} from "./contracts.ts";
|
|
15
16
|
import { read, runDirectory, write } from "./storage.ts";
|
|
16
17
|
|
|
18
|
+
/** Pending repair remains authoritative until its native terminal is published and validated. */
|
|
19
|
+
export function accountingState(result: Result | undefined): AccountingState {
|
|
20
|
+
if (result?.accountingPending !== undefined) {
|
|
21
|
+
return { kind: "pending", pending: result.accountingPending, error: result.accountingError };
|
|
22
|
+
}
|
|
23
|
+
const terminalEntryId = result?.terminalEntryId;
|
|
24
|
+
if (result?.contributions !== undefined) {
|
|
25
|
+
return { kind: "verified", contributions: result.contributions, terminalEntryId };
|
|
26
|
+
}
|
|
27
|
+
return result?.accountingError === undefined
|
|
28
|
+
? { kind: "unrecorded", terminalEntryId }
|
|
29
|
+
: { kind: "unavailable", error: result.accountingError, terminalEntryId };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function accountingFields(
|
|
33
|
+
state: AccountingState,
|
|
34
|
+
): Pick<Result, "contributions" | "accountingPending" | "accountingError" | "terminalEntryId"> {
|
|
35
|
+
const terminal =
|
|
36
|
+
state.kind !== "pending" && state.terminalEntryId !== undefined
|
|
37
|
+
? { terminalEntryId: state.terminalEntryId }
|
|
38
|
+
: {};
|
|
39
|
+
switch (state.kind) {
|
|
40
|
+
case "unrecorded":
|
|
41
|
+
return terminal;
|
|
42
|
+
case "verified":
|
|
43
|
+
return { contributions: state.contributions, ...terminal };
|
|
44
|
+
case "pending":
|
|
45
|
+
return {
|
|
46
|
+
accountingPending: state.pending,
|
|
47
|
+
...(state.error === undefined ? {} : { accountingError: state.error }),
|
|
48
|
+
};
|
|
49
|
+
case "unavailable":
|
|
50
|
+
return { accountingError: state.error, ...terminal };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
/** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
|
|
18
55
|
function billing(entry: UnknownRecord): UnknownRecord | "missing" | undefined {
|
|
19
56
|
if (entry.checkpoint === true) {
|
|
@@ -171,10 +208,11 @@ export function readResult(base: string, launch: Launch): Result | undefined {
|
|
|
171
208
|
if (!result) {
|
|
172
209
|
return;
|
|
173
210
|
}
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
211
|
+
const accounting = accountingState(result);
|
|
212
|
+
if (accounting.kind !== "pending") {
|
|
176
213
|
return result;
|
|
177
214
|
}
|
|
215
|
+
const { pending } = accounting;
|
|
178
216
|
let contributions;
|
|
179
217
|
try {
|
|
180
218
|
contributions = childUsage(launch, new Set(pending.baseline), pending.terminalCandidate);
|
|
@@ -183,7 +221,14 @@ export function readResult(base: string, launch: Launch): Result | undefined {
|
|
|
183
221
|
return result;
|
|
184
222
|
}
|
|
185
223
|
const { accountingPending: _pending, accountingError: _error, ...execution } = result;
|
|
186
|
-
const repaired = {
|
|
224
|
+
const repaired = {
|
|
225
|
+
...execution,
|
|
226
|
+
...accountingFields({
|
|
227
|
+
kind: "verified",
|
|
228
|
+
terminalEntryId: pending.terminalCandidate,
|
|
229
|
+
contributions,
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
187
232
|
write(path.join(runDirectory(base, launch.id), "result.json"), repaired);
|
|
188
233
|
return repaired;
|
|
189
234
|
}
|
package/src/agent/view.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import type { RunsInput } from "./tool-schemas.ts";
|
|
16
16
|
import { conversation, read, runDirectory } from "./storage.ts";
|
|
17
17
|
import { published } from "./notifications.ts";
|
|
18
|
-
import { readCanonicalResult, readResult } from "./usage.ts";
|
|
18
|
+
import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.ts";
|
|
19
19
|
import { Type } from "./typebox.ts";
|
|
20
20
|
|
|
21
21
|
const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
|
|
@@ -72,18 +72,20 @@ function toolResult(value: unknown, text: string): ToolReply {
|
|
|
72
72
|
return { content: [{ type: "text" as const, text }], details: value };
|
|
73
73
|
}
|
|
74
74
|
function receiptResult(result: Result | undefined) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
if (result === undefined) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const { contributions: _contributions, ...accounting } = accountingFields(
|
|
79
|
+
accountingState(result),
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
id: result.id,
|
|
83
|
+
owner: result.owner,
|
|
84
|
+
status: result.status,
|
|
85
|
+
endedAt: result.endedAt,
|
|
86
|
+
error: result.error,
|
|
87
|
+
...accounting,
|
|
88
|
+
};
|
|
87
89
|
}
|
|
88
90
|
/** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
|
|
89
91
|
function processView(base: string, launch: Launch, observed?: ReadonlyMap<string, boolean>) {
|
package/src/agent/wake.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
2
3
|
import { journalStamp } from "../shared/native-journal.ts";
|
|
3
4
|
import { missing } from "./storage.ts";
|
|
4
5
|
|
|
@@ -21,10 +22,12 @@ export type WatchState = "unwatched" | "armed" | "changed" | "watching";
|
|
|
21
22
|
|
|
22
23
|
/** Native change hints for one directory identity; owners still reconcile canonical files. */
|
|
23
24
|
export class DirectoryWatch {
|
|
24
|
-
private
|
|
25
|
-
|
|
25
|
+
private state:
|
|
26
|
+
| { readonly kind: "unarmed" | "polling" }
|
|
27
|
+
| { readonly kind: "armed"; readonly watcher: fs.FSWatcher; readonly identity: string } = {
|
|
28
|
+
kind: "unarmed",
|
|
29
|
+
};
|
|
26
30
|
private checked = "";
|
|
27
|
-
private failed = false;
|
|
28
31
|
private readonly directory: string;
|
|
29
32
|
private readonly relevant: (file: string | null) => boolean;
|
|
30
33
|
private readonly wake: () => void;
|
|
@@ -38,40 +41,40 @@ export class DirectoryWatch {
|
|
|
38
41
|
const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
|
|
39
42
|
const checked = this.checked;
|
|
40
43
|
this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
41
|
-
if (this.
|
|
44
|
+
if (this.state.kind === "armed" && identity === this.state.identity) {
|
|
42
45
|
return checked === this.checked ? "watching" : "changed";
|
|
43
46
|
}
|
|
44
47
|
// A replaced directory leaves an inode watch silent on Linux.
|
|
45
48
|
this.close();
|
|
46
|
-
return identity === "" || this.
|
|
49
|
+
return identity === "" || this.state.kind === "polling" ? "unwatched" : this.arm(identity);
|
|
47
50
|
}
|
|
48
51
|
private arm(identity: string): WatchState {
|
|
49
52
|
try {
|
|
50
53
|
const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
|
|
51
|
-
if (watcher === this.watcher && this.relevant(file)) {
|
|
54
|
+
if (this.state.kind === "armed" && watcher === this.state.watcher && this.relevant(file)) {
|
|
52
55
|
this.wake();
|
|
53
56
|
}
|
|
54
57
|
});
|
|
55
58
|
watcher.on("error", () => {
|
|
56
|
-
if (watcher === this.watcher) {
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
+
if (this.state.kind === "armed" && watcher === this.state.watcher) {
|
|
60
|
+
watcher.close();
|
|
61
|
+
this.state = { kind: "polling" };
|
|
59
62
|
this.wake();
|
|
60
63
|
}
|
|
61
64
|
});
|
|
62
|
-
this.
|
|
63
|
-
this.armed = identity;
|
|
65
|
+
this.state = { kind: "armed", watcher, identity };
|
|
64
66
|
return "armed";
|
|
65
67
|
} catch (error) {
|
|
66
68
|
// Unsupported platforms and watch limits keep their owner's polling fallback.
|
|
67
|
-
this.
|
|
69
|
+
this.state = { kind: missing(error) ? "unarmed" : "polling" };
|
|
68
70
|
return "unwatched";
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
close(): void {
|
|
72
|
-
this.
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
if (this.state.kind === "armed") {
|
|
75
|
+
this.state.watcher.close();
|
|
76
|
+
this.state = { kind: "unarmed" };
|
|
77
|
+
}
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
|
@@ -90,6 +93,7 @@ export class Wake {
|
|
|
90
93
|
}
|
|
91
94
|
/** Returns at once for a wake raised since the last take. */
|
|
92
95
|
async sleep(ms: number): Promise<void> {
|
|
96
|
+
assert.equal(this.resolve, undefined, "A Wake belongs to one waiting owner loop.");
|
|
93
97
|
if (this.raised) {
|
|
94
98
|
return;
|
|
95
99
|
}
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
snapshotFrames,
|
|
30
30
|
TopicSnapshot,
|
|
31
31
|
publicationMessage,
|
|
32
|
+
preparedPublication,
|
|
32
33
|
deliveryAcknowledgement,
|
|
33
34
|
shouldReplaceDelivery,
|
|
34
35
|
} from "./snapshots.ts";
|
|
@@ -245,7 +246,7 @@ class IntercomBroker {
|
|
|
245
246
|
? session.info
|
|
246
247
|
: session.topics.snapshot(session.info, prepared);
|
|
247
248
|
const from = messageSender(nextInfo);
|
|
248
|
-
const deliveries = this.planPublications(currentId, from, prepared
|
|
249
|
+
const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
|
|
249
250
|
// Validate every frame before committing state or delivering publications.
|
|
250
251
|
if (prepared.restore) {
|
|
251
252
|
session.topics.validateRestore(session.info, prepared, requestId);
|
|
@@ -202,17 +202,52 @@ class SnapshotRecords<T extends { readonly topic: string }> {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
readonly
|
|
207
|
-
readonly
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
205
|
+
type PreparedTopicChange =
|
|
206
|
+
| { readonly kind: "unchanged"; readonly restore: false }
|
|
207
|
+
| { readonly kind: "normalize"; readonly restore: true }
|
|
208
|
+
| {
|
|
209
|
+
readonly kind: "publish";
|
|
210
|
+
readonly restore: false;
|
|
211
|
+
readonly topic: TopicUpdate;
|
|
212
|
+
readonly bytes: number;
|
|
213
|
+
}
|
|
214
|
+
| {
|
|
215
|
+
readonly kind: "restore-topic";
|
|
216
|
+
readonly restore: true;
|
|
217
|
+
readonly topic: TopicUpdate;
|
|
218
|
+
readonly bytes: number;
|
|
219
|
+
}
|
|
220
|
+
| {
|
|
221
|
+
readonly kind: "subscription";
|
|
222
|
+
readonly restore: boolean;
|
|
223
|
+
readonly subscription: TopicSubscription;
|
|
224
|
+
readonly bytes: number;
|
|
225
|
+
}
|
|
226
|
+
| { readonly kind: "unsubscribe"; readonly restore: false; readonly topic: string };
|
|
227
|
+
|
|
228
|
+
const UNCHANGED: PreparedTopicChange = { kind: "unchanged", restore: false };
|
|
229
|
+
|
|
230
|
+
function updatedTopic(prepared: PreparedTopicChange) {
|
|
231
|
+
return prepared.kind === "publish" || prepared.kind === "restore-topic" ? prepared : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function subscriptionKey(prepared: PreparedTopicChange): string | undefined {
|
|
235
|
+
switch (prepared.kind) {
|
|
236
|
+
case "subscription":
|
|
237
|
+
return prepared.subscription.topic;
|
|
238
|
+
case "unsubscribe":
|
|
239
|
+
return prepared.topic;
|
|
240
|
+
case "unchanged":
|
|
241
|
+
case "normalize":
|
|
242
|
+
case "publish":
|
|
243
|
+
case "restore-topic":
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
213
246
|
}
|
|
214
247
|
|
|
215
|
-
|
|
248
|
+
export function preparedPublication(prepared: PreparedTopicChange): TopicUpdate | undefined {
|
|
249
|
+
return prepared.kind === "publish" ? prepared.topic : undefined;
|
|
250
|
+
}
|
|
216
251
|
|
|
217
252
|
/** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
|
|
218
253
|
export class TopicSnapshot {
|
|
@@ -236,62 +271,64 @@ export class TopicSnapshot {
|
|
|
236
271
|
throw new Error("Topic snapshots unavailable for this client");
|
|
237
272
|
}
|
|
238
273
|
const mutation = topicMutation(change);
|
|
239
|
-
const base = { restore: change.action === "restore", changed: true, bytes: 0 };
|
|
240
274
|
switch (mutation.kind) {
|
|
241
275
|
case "publication":
|
|
242
|
-
return this.preparePublication(mutation
|
|
276
|
+
return this.preparePublication(mutation);
|
|
243
277
|
case "subscription":
|
|
244
278
|
return {
|
|
245
|
-
|
|
279
|
+
kind: "subscription",
|
|
280
|
+
restore: change.action === "restore",
|
|
246
281
|
subscription: mutation.subscription,
|
|
247
282
|
bytes: intercomMessageSizeBytes(mutation.subscription),
|
|
248
283
|
};
|
|
249
284
|
case "unsubscribe":
|
|
250
|
-
return {
|
|
285
|
+
return { kind: "unsubscribe", restore: false, topic: mutation.topic };
|
|
251
286
|
}
|
|
252
287
|
}
|
|
253
288
|
|
|
254
289
|
private preparePublication(
|
|
255
290
|
mutation: Extract<TopicMutation, { readonly kind: "publication" }>,
|
|
256
|
-
base: PreparedTopicChange,
|
|
257
291
|
): PreparedTopicChange {
|
|
258
292
|
const previous = this.topics.get(mutation.topic.topic);
|
|
259
293
|
const update = publicationUpdate(mutation.topic, previous, mutation.publish);
|
|
260
294
|
if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
|
|
261
|
-
return
|
|
295
|
+
return { kind: "normalize", restore: true };
|
|
262
296
|
}
|
|
263
297
|
return {
|
|
264
|
-
...
|
|
298
|
+
...(mutation.publish
|
|
299
|
+
? ({ kind: "publish", restore: false } as const)
|
|
300
|
+
: ({ kind: "restore-topic", restore: true } as const)),
|
|
265
301
|
topic: update,
|
|
266
302
|
bytes: intercomMessageSizeBytes(update),
|
|
267
|
-
...(mutation.publish ? { publication: update } : {}),
|
|
268
303
|
};
|
|
269
304
|
}
|
|
270
305
|
|
|
271
306
|
snapshot(info: SessionInfo, prepared = UNCHANGED): SessionInfo {
|
|
272
|
-
const
|
|
307
|
+
const topic = updatedTopic(prepared)?.topic;
|
|
308
|
+
const subscription = prepared.kind === "subscription" ? prepared.subscription : undefined;
|
|
309
|
+
const changed = prepared.kind !== "unchanged";
|
|
310
|
+
const topics = this.topics.valuesAfter(topic?.topic, topic, changed);
|
|
273
311
|
const subscriptions = this.subscriptions.valuesAfter(
|
|
274
|
-
prepared
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
subscriptionKey(prepared),
|
|
313
|
+
subscription,
|
|
314
|
+
changed,
|
|
277
315
|
);
|
|
278
316
|
return {
|
|
279
317
|
...info,
|
|
280
318
|
...(topics !== undefined ? { topics } : {}),
|
|
281
319
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
282
|
-
...(prepared
|
|
320
|
+
...(preparedPublication(prepared) ? { lastIntercomActivity: Date.now() } : {}),
|
|
283
321
|
};
|
|
284
322
|
}
|
|
285
323
|
|
|
286
324
|
validateRestore(info: SessionInfo, prepared: PreparedTopicChange, requestId: string): void {
|
|
287
325
|
snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
);
|
|
326
|
+
const topic = updatedTopic(prepared);
|
|
327
|
+
const subscription = prepared.kind === "subscription" ? prepared : undefined;
|
|
328
|
+
const topicBytes = this.topics.maximumAfter(topic?.topic.topic, topic?.bytes ?? 0);
|
|
292
329
|
const subscriptionBytes = this.subscriptions.maximumAfter(
|
|
293
|
-
|
|
294
|
-
|
|
330
|
+
subscription?.subscription.topic,
|
|
331
|
+
subscription?.bytes ?? 0,
|
|
295
332
|
);
|
|
296
333
|
for (const [field, bytes] of [
|
|
297
334
|
["topics", topicBytes],
|
|
@@ -311,16 +348,24 @@ export class TopicSnapshot {
|
|
|
311
348
|
}
|
|
312
349
|
|
|
313
350
|
commit(prepared: PreparedTopicChange): void {
|
|
314
|
-
if (prepared.
|
|
315
|
-
|
|
316
|
-
this.subscriptions.accept();
|
|
351
|
+
if (prepared.kind === "unchanged") {
|
|
352
|
+
return;
|
|
317
353
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
354
|
+
this.topics.accept();
|
|
355
|
+
this.subscriptions.accept();
|
|
356
|
+
switch (prepared.kind) {
|
|
357
|
+
case "publish":
|
|
358
|
+
case "restore-topic":
|
|
359
|
+
this.topics.set(prepared.topic, prepared.bytes);
|
|
360
|
+
return;
|
|
361
|
+
case "subscription":
|
|
362
|
+
this.subscriptions.set(prepared.subscription, prepared.bytes);
|
|
363
|
+
return;
|
|
364
|
+
case "unsubscribe":
|
|
365
|
+
this.subscriptions.delete(prepared.topic);
|
|
366
|
+
return;
|
|
367
|
+
case "normalize":
|
|
368
|
+
return;
|
|
324
369
|
}
|
|
325
370
|
}
|
|
326
371
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
fstatSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { hasErrorCode } from "../../shared/unknown.ts";
|
|
12
|
+
import { brokerPidRecord, isLinuxProcessReplaced } from "./pid.ts";
|
|
13
|
+
|
|
14
|
+
type OwnerState = "live" | "exited" | "uncertain";
|
|
15
|
+
|
|
16
|
+
function ownerState(fd: number): OwnerState {
|
|
17
|
+
try {
|
|
18
|
+
const lines = readFileSync(fd, "utf8").split("\n");
|
|
19
|
+
const first = lines[0] ?? "";
|
|
20
|
+
const pid = Number(first);
|
|
21
|
+
if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
|
|
22
|
+
return "uncertain";
|
|
23
|
+
}
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return isLinuxProcessReplaced(pid, lines.slice(2).join("\n")) ? "exited" : "live";
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return hasErrorCode(error, "ESRCH") ? "exited" : "uncertain";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The open descriptor pins the original inode until its own cleanup finishes. */
|
|
32
|
+
function removeOwned(file: string, fd: number): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const owned = fstatSync(fd, { bigint: true });
|
|
35
|
+
const current = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
36
|
+
if (current?.dev !== owned.dev || current.ino !== owned.ino) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
unlinkSync(file);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reclaimExited(file: string): boolean {
|
|
47
|
+
let fd: number;
|
|
48
|
+
try {
|
|
49
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return hasErrorCode(error, "ENOENT");
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return ownerState(fd) === "exited" && removeOwned(file, fd);
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function publishClaim(file: string, fd: number): () => void {
|
|
61
|
+
const release = () => {
|
|
62
|
+
try {
|
|
63
|
+
removeOwned(file, fd);
|
|
64
|
+
} finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
// Keep legacy PID/timestamp lines; qualified native identity can prove later PID reuse.
|
|
70
|
+
const record = brokerPidRecord();
|
|
71
|
+
const identity = record.slice(record.indexOf("\n") + 1);
|
|
72
|
+
writeFileSync(fd, `${process.pid}\n${Date.now()}\n${identity}`);
|
|
73
|
+
return release;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
release();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An acquired claim returns its release operation; live or uncertain claims remain untouched. */
|
|
81
|
+
export function acquireSpawnClaim(file: string): (() => void) | undefined {
|
|
82
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
83
|
+
let fd: number;
|
|
84
|
+
try {
|
|
85
|
+
fd = openSync(file, "wx", 0o600);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!hasErrorCode(error, "EEXIST")) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
if (reclaimExited(file)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
return publishClaim(file, fd);
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|