@fitchmultz/pi-subagents 0.52.20 → 0.52.21
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 +30 -2
- package/dist/agent/contracts.js +12 -3
- package/dist/agent/index.js +9 -9
- package/dist/agent/legacy.js +5 -5
- package/dist/agent/notifications.js +21 -14
- package/dist/agent/parent-check.js +38 -0
- package/dist/agent/process.js +45 -52
- package/dist/agent/runner.js +59 -64
- package/dist/agent/storage.js +16 -4
- package/dist/agent/view.js +1 -3
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +4 -26
- package/dist/pi-intercom/broker/client-park.d.ts +1 -1
- package/dist/pi-intercom/broker/client-park.js +22 -6
- package/dist/pi-intercom/broker/client.d.ts +3 -2
- package/dist/pi-intercom/broker/client.js +88 -62
- package/dist/pi-intercom/broker/connection.d.ts +3 -5
- package/dist/pi-intercom/broker/connection.js +48 -33
- package/dist/pi-intercom/broker/framing.d.ts +1 -1
- package/dist/pi-intercom/broker/framing.js +19 -12
- package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
- package/dist/pi-intercom/connection.js +5 -8
- package/dist/pi-intercom/inbound-journal.js +5 -2
- package/dist/shared/journal-frames.js +19 -24
- package/docs/development.md +6 -0
- package/package.json +1 -1
- package/src/agent/contracts.ts +12 -3
- package/src/agent/index.ts +10 -10
- package/src/agent/legacy.ts +5 -5
- package/src/agent/notifications.ts +35 -15
- package/src/agent/parent-check.ts +40 -0
- package/src/agent/process.ts +53 -55
- package/src/agent/runner.ts +65 -65
- package/src/agent/storage.ts +18 -5
- package/src/agent/view.ts +1 -4
- package/src/pi-intercom/broker/broker.ts +5 -37
- package/src/pi-intercom/broker/client-park.ts +27 -6
- package/src/pi-intercom/broker/client.ts +91 -61
- package/src/pi-intercom/broker/connection.ts +58 -39
- package/src/pi-intercom/broker/framing.ts +24 -12
- package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
- package/src/pi-intercom/connection.ts +9 -9
- package/src/pi-intercom/inbound-journal.ts +5 -2
- package/src/shared/journal-frames.ts +22 -23
|
@@ -12,7 +12,6 @@ export class IntercomConnection {
|
|
|
12
12
|
reconnectTimer = null;
|
|
13
13
|
startupTimer = null;
|
|
14
14
|
connecting = null;
|
|
15
|
-
connectingGeneration = null;
|
|
16
15
|
attempt = 0;
|
|
17
16
|
lifecycle;
|
|
18
17
|
config;
|
|
@@ -294,8 +293,8 @@ export class IntercomConnection {
|
|
|
294
293
|
if (!ctx || generation !== this.lifecycle.generation) {
|
|
295
294
|
throw new Error("Intercom shutting down");
|
|
296
295
|
}
|
|
297
|
-
if (this.connecting
|
|
298
|
-
return this.connecting;
|
|
296
|
+
if (this.connecting?.generation === generation) {
|
|
297
|
+
return this.connecting.task;
|
|
299
298
|
}
|
|
300
299
|
if (this.client?.isConnected() === true) {
|
|
301
300
|
return this.client;
|
|
@@ -307,8 +306,7 @@ export class IntercomConnection {
|
|
|
307
306
|
this.clearReconnect();
|
|
308
307
|
// Publish ownership before registration, which can synchronously invoke subscribers.
|
|
309
308
|
const attempt = Promise.resolve().then(() => this.connect(ctx, generation, sessionId));
|
|
310
|
-
this.connecting = attempt;
|
|
311
|
-
this.connectingGeneration = generation;
|
|
309
|
+
this.connecting = { task: attempt, generation };
|
|
312
310
|
return await this.finishAttempt(attempt, ctx, generation);
|
|
313
311
|
}
|
|
314
312
|
async finishAttempt(attempt, ctx, generation) {
|
|
@@ -321,9 +319,8 @@ export class IntercomConnection {
|
|
|
321
319
|
throw error;
|
|
322
320
|
}
|
|
323
321
|
finally {
|
|
324
|
-
if (this.connecting === attempt) {
|
|
322
|
+
if (this.connecting?.task === attempt) {
|
|
325
323
|
this.connecting = null;
|
|
326
|
-
this.connectingGeneration = null;
|
|
327
324
|
}
|
|
328
325
|
if (retry) {
|
|
329
326
|
this.scheduleReconnect();
|
|
@@ -334,7 +331,7 @@ export class IntercomConnection {
|
|
|
334
331
|
this.clearTimers();
|
|
335
332
|
this.cancelWakes();
|
|
336
333
|
// The bounded launcher attempt is joined before socket shutdown.
|
|
337
|
-
await Promise.allSettled([this.connecting]);
|
|
334
|
+
await Promise.allSettled([this.connecting?.task]);
|
|
338
335
|
if (this.client) {
|
|
339
336
|
await this.client.disconnect();
|
|
340
337
|
this.client = null;
|
|
@@ -150,13 +150,16 @@ export class InboundJournal {
|
|
|
150
150
|
if (pending.stage === "queued" &&
|
|
151
151
|
pending.from.id === entry.from.id &&
|
|
152
152
|
pending.message.threadId === entry.message.threadId) {
|
|
153
|
-
this.
|
|
154
|
-
this.replies.markReplied(pending.message.id);
|
|
153
|
+
this.supersede(pending.message.id);
|
|
155
154
|
}
|
|
156
155
|
}
|
|
157
156
|
}
|
|
158
157
|
this.pending.set(entry.message.id, entry);
|
|
159
158
|
}
|
|
159
|
+
supersede(id) {
|
|
160
|
+
this.consume(id);
|
|
161
|
+
this.replies.markReplied(id);
|
|
162
|
+
}
|
|
160
163
|
setStage(entry, stage) {
|
|
161
164
|
if (entry.stage === stage) {
|
|
162
165
|
return;
|
|
@@ -5,9 +5,7 @@ import { errorDescription, isRecord } from "./unknown.js";
|
|
|
5
5
|
export class JournalFrames {
|
|
6
6
|
projection;
|
|
7
7
|
decoder = new TextDecoder("utf8", { fatal: true });
|
|
8
|
-
|
|
9
|
-
failed = false;
|
|
10
|
-
nonblank = false;
|
|
8
|
+
state = { kind: "blank" };
|
|
11
9
|
start;
|
|
12
10
|
offset;
|
|
13
11
|
policy;
|
|
@@ -29,21 +27,21 @@ export class JournalFrames {
|
|
|
29
27
|
return new JsonProjection(this.select, this.options.stringChunk);
|
|
30
28
|
}
|
|
31
29
|
part(bytes) {
|
|
32
|
-
if (
|
|
33
|
-
|
|
30
|
+
if (this.state.kind === "invalid") {
|
|
31
|
+
return;
|
|
34
32
|
}
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
33
|
+
if (this.state.kind === "blank" && bytes.some((byte) => ![9, 10, 13, 32].includes(byte))) {
|
|
34
|
+
this.state = { kind: "reading" };
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
this.projection.write(this.decoder.decode(bytes, { stream: true }));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
this.state = { kind: "invalid", error };
|
|
43
41
|
}
|
|
44
42
|
}
|
|
45
43
|
finishRecord(end) {
|
|
46
|
-
if (
|
|
44
|
+
if (this.state.kind !== "reading") {
|
|
47
45
|
return undefined;
|
|
48
46
|
}
|
|
49
47
|
try {
|
|
@@ -55,17 +53,16 @@ export class JournalFrames {
|
|
|
55
53
|
return { value, start: this.start, end };
|
|
56
54
|
}
|
|
57
55
|
catch (error) {
|
|
58
|
-
this.
|
|
59
|
-
this.error = error;
|
|
56
|
+
this.state = { kind: "invalid", error };
|
|
60
57
|
return undefined;
|
|
61
58
|
}
|
|
62
59
|
}
|
|
63
60
|
commit(end) {
|
|
64
61
|
const start = this.start;
|
|
65
62
|
const record = this.finishRecord(end);
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
const state = this.state;
|
|
64
|
+
if (state.kind === "invalid" && this.policy !== "inspect") {
|
|
65
|
+
const { error } = state;
|
|
69
66
|
throw new SyntaxError(`Invalid JSONL record at byte ${start}: ${errorDescription(error)}`, {
|
|
70
67
|
cause: error,
|
|
71
68
|
});
|
|
@@ -73,10 +70,8 @@ export class JournalFrames {
|
|
|
73
70
|
this.start = end;
|
|
74
71
|
this.projection = this.newProjection();
|
|
75
72
|
this.decoder = new TextDecoder("utf8", { fatal: true });
|
|
76
|
-
this.
|
|
77
|
-
|
|
78
|
-
this.nonblank = false;
|
|
79
|
-
if (!failed && record) {
|
|
73
|
+
this.state = { kind: "blank" };
|
|
74
|
+
if (record) {
|
|
80
75
|
this.record(record);
|
|
81
76
|
}
|
|
82
77
|
}
|
|
@@ -93,7 +88,7 @@ export class JournalFrames {
|
|
|
93
88
|
}
|
|
94
89
|
finish() {
|
|
95
90
|
if (this.requireNewline) {
|
|
96
|
-
if (this.policy === "strict" && this.
|
|
91
|
+
if (this.policy === "strict" && this.state.kind !== "blank") {
|
|
97
92
|
throw new SyntaxError(`Unpublished JSONL record at byte ${this.start}: newline required`);
|
|
98
93
|
}
|
|
99
94
|
}
|
package/docs/development.md
CHANGED
|
@@ -86,6 +86,12 @@ npm and attached to the matching GitHub release.
|
|
|
86
86
|
|
|
87
87
|
## Isolation and safety
|
|
88
88
|
|
|
89
|
+
Actor command admission keeps execution separate from native session activity.
|
|
90
|
+
Execution is either idle or running with one launch and task. A stopping actor
|
|
91
|
+
admits no new Run work. Run, Steer and Stop payloads are action-specific;
|
|
92
|
+
guidance always carries a prompt and new Stops always name the actor token.
|
|
93
|
+
Historical unbound Stops remain readable and receive stale admission receipts.
|
|
94
|
+
|
|
89
95
|
Parent attachment keeps one binding through detached, waiting, attaching and
|
|
90
96
|
attached phases. The existing shutdown signal closes the owner. Managed
|
|
91
97
|
readiness can attach only its own still-current waiting binding; shutdown joins
|
package/package.json
CHANGED
package/src/agent/contracts.ts
CHANGED
|
@@ -110,14 +110,23 @@ export type AccountingState =
|
|
|
110
110
|
readonly error: string;
|
|
111
111
|
readonly terminalEntryId: string | undefined;
|
|
112
112
|
};
|
|
113
|
-
export const
|
|
113
|
+
export const commandMetadataSchema = Type.Object({
|
|
114
114
|
id: Type.String(),
|
|
115
115
|
runId: Type.String(),
|
|
116
116
|
action: Type.Union([Type.Literal("run"), Type.Literal("steer"), Type.Literal("stop")]),
|
|
117
|
-
prompt: Type.Optional(Type.String()),
|
|
118
|
-
actorToken: Type.Optional(Type.String()),
|
|
119
117
|
createdAt: Type.Number(),
|
|
120
118
|
});
|
|
119
|
+
const commandFields = Type.Omit(commandMetadataSchema, ["action"]).properties;
|
|
120
|
+
export const commandSchema = Type.Union([
|
|
121
|
+
Type.Object({ ...commandFields, action: Type.Literal("run") }),
|
|
122
|
+
Type.Object({ ...commandFields, action: Type.Literal("steer"), prompt: Type.String() }),
|
|
123
|
+
Type.Object({
|
|
124
|
+
...commandFields,
|
|
125
|
+
action: Type.Literal("stop"),
|
|
126
|
+
// Historical unbound Stops parse but cannot target any current actor.
|
|
127
|
+
actorToken: Type.Optional(Type.String()),
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
121
130
|
export type Command = Static<typeof commandSchema>;
|
|
122
131
|
export const successorSchema = Type.Object({ id: Type.String() });
|
|
123
132
|
/** Authority to restore exact working state, valid only after the named actor has exited. */
|
package/src/agent/index.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type {
|
|
|
8
8
|
ToolDefinition,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
inspectActorClaim,
|
|
12
|
+
possiblyLive,
|
|
13
13
|
ensureActor,
|
|
14
14
|
ownIdentity,
|
|
15
15
|
parkFailure,
|
|
@@ -324,7 +324,7 @@ class AgentRuntime {
|
|
|
324
324
|
executionCwd: executionCwd(this.pi, context),
|
|
325
325
|
});
|
|
326
326
|
this.checkMain();
|
|
327
|
-
enqueue(this.base, launch, "run");
|
|
327
|
+
enqueue(this.base, launch, { action: "run" });
|
|
328
328
|
await ensureActor(this.base, launch, this.shutdown.signal);
|
|
329
329
|
return renderReceipt(this.base, launch, this.ctx, {
|
|
330
330
|
receipt: "launch",
|
|
@@ -352,14 +352,14 @@ class AgentRuntime {
|
|
|
352
352
|
return;
|
|
353
353
|
}
|
|
354
354
|
private async retryContinuation(launch: Launch, input: RunsInput) {
|
|
355
|
-
const
|
|
355
|
+
const claim = inspectActorClaim(this.base, launch);
|
|
356
356
|
const command = read(
|
|
357
357
|
path.join(conversation(this.base, launch), "commands", `${launch.id}.json`),
|
|
358
358
|
commandSchema,
|
|
359
359
|
);
|
|
360
360
|
if (
|
|
361
361
|
input.prompt !== launch.prompt ||
|
|
362
|
-
(
|
|
362
|
+
possiblyLive(claim) ||
|
|
363
363
|
command?.id !== launch.id ||
|
|
364
364
|
command.runId !== launch.id ||
|
|
365
365
|
command.action !== "run"
|
|
@@ -383,9 +383,9 @@ class AgentRuntime {
|
|
|
383
383
|
if (completed === "pending") {
|
|
384
384
|
return this.retryContinuation(launch, input);
|
|
385
385
|
}
|
|
386
|
-
const
|
|
387
|
-
if (
|
|
388
|
-
const command = enqueue(this.base, launch, "steer",
|
|
386
|
+
const claim = completed ? undefined : inspectActorClaim(this.base, launch);
|
|
387
|
+
if (possiblyLive(claim)) {
|
|
388
|
+
const command = enqueue(this.base, launch, { action: "steer", prompt: input.prompt });
|
|
389
389
|
return renderReceipt(this.base, launch, this.ctx, {
|
|
390
390
|
receipt: "guidance",
|
|
391
391
|
command,
|
|
@@ -400,7 +400,7 @@ class AgentRuntime {
|
|
|
400
400
|
});
|
|
401
401
|
}
|
|
402
402
|
// Retain successor claims on ambiguous after-launch failures: no replay.
|
|
403
|
-
enqueue(this.base, next, "run");
|
|
403
|
+
enqueue(this.base, next, { action: "run" });
|
|
404
404
|
await ensureActor(this.base, next, this.shutdown.signal);
|
|
405
405
|
return renderReceipt(this.base, next, this.ctx, { receipt: "continuation" });
|
|
406
406
|
}
|
|
@@ -417,7 +417,7 @@ class AgentRuntime {
|
|
|
417
417
|
});
|
|
418
418
|
if (created) {
|
|
419
419
|
// The durable adoption claim survives ambiguous after-launch failures.
|
|
420
|
-
enqueue(this.base, launch, "run");
|
|
420
|
+
enqueue(this.base, launch, { action: "run" });
|
|
421
421
|
await ensureActor(this.base, launch, this.shutdown.signal);
|
|
422
422
|
}
|
|
423
423
|
return renderReceipt(this.base, launch, this.ctx, {
|
package/src/agent/legacy.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { journalStamp } from "../shared/native-journal.ts";
|
|
|
7
7
|
import { receiptId } from "./receipts.ts";
|
|
8
8
|
import { resolveTempRootDir } from "../shared/types.ts";
|
|
9
9
|
import { isRecord } from "../shared/unknown.ts";
|
|
10
|
-
import { release,
|
|
10
|
+
import { release, completionDelivery, type Completion } from "./notifications.ts";
|
|
11
11
|
import { missing } from "./storage.ts";
|
|
12
12
|
|
|
13
13
|
interface LegacyInput {
|
|
@@ -135,13 +135,13 @@ function published(ctx: ExtensionContext, input: LegacyInput): "file" | "memory"
|
|
|
135
135
|
}
|
|
136
136
|
function notification(ctx: ExtensionContext, input: LegacyInput): Completion | undefined {
|
|
137
137
|
const identity = `legacy:${input.id}:${input.key}`;
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
138
|
+
const state = completionDelivery(input.owner, identity, published(ctx, input));
|
|
139
|
+
if (state.kind === "published") {
|
|
140
140
|
release(input.owner, identity);
|
|
141
141
|
// Retire only an unchanged recovery input whose receipt is LF-published in the session file.
|
|
142
142
|
// An in-memory receipt stops redelivery but never removes the saved input.
|
|
143
143
|
if (
|
|
144
|
-
where === "file" &&
|
|
144
|
+
state.where === "file" &&
|
|
145
145
|
journalStamp(fs.lstatSync(input.file, { bigint: true })) === input.stamp &&
|
|
146
146
|
fs.readFileSync(input.file, "utf8") === input.raw
|
|
147
147
|
) {
|
|
@@ -149,7 +149,7 @@ function notification(ctx: ExtensionContext, input: LegacyInput): Completion | u
|
|
|
149
149
|
}
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
152
|
-
if (
|
|
152
|
+
if (state.kind === "held") {
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
155
|
return {
|
|
@@ -28,19 +28,29 @@ function queuedAdmissions(): Set<string> {
|
|
|
28
28
|
return queued;
|
|
29
29
|
}
|
|
30
30
|
const admissions = queuedAdmissions();
|
|
31
|
-
function hold(owner: string, id: string):
|
|
32
|
-
|
|
33
|
-
if (admissions.has(key)) {
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
admissions.add(key);
|
|
37
|
-
return true;
|
|
31
|
+
function hold(owner: string, id: string): void {
|
|
32
|
+
admissions.add(`${owner}/${id}`);
|
|
38
33
|
}
|
|
39
34
|
export function release(owner: string, id: string): void {
|
|
40
35
|
admissions.delete(`${owner}/${id}`);
|
|
41
36
|
}
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
type DeliveryDecision =
|
|
38
|
+
| { readonly kind: "published"; readonly where: "file" | "memory" }
|
|
39
|
+
| { readonly kind: "held" | "deliverable" };
|
|
40
|
+
|
|
41
|
+
/** Canonical result readers establish availability before deciding delivery. */
|
|
42
|
+
function deliveryDecision(receipt: "file" | "memory" | undefined, held: boolean): DeliveryDecision {
|
|
43
|
+
return receipt === undefined
|
|
44
|
+
? { kind: held ? "held" : "deliverable" }
|
|
45
|
+
: { kind: "published", where: receipt };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function completionDelivery(
|
|
49
|
+
owner: string,
|
|
50
|
+
id: string,
|
|
51
|
+
receipt: "file" | "memory" | undefined,
|
|
52
|
+
): DeliveryDecision {
|
|
53
|
+
return deliveryDecision(receipt, admissions.has(`${owner}/${id}`));
|
|
44
54
|
}
|
|
45
55
|
/** Returns whether any admission was released for reconciliation. */
|
|
46
56
|
export function settle(owner: string, pending: boolean): boolean {
|
|
@@ -243,14 +253,24 @@ export function notifications(ctx: ExtensionContext, base: string, owner: string
|
|
|
243
253
|
const awaited = new Set<string>();
|
|
244
254
|
const runs = completed(base, owner, awaited);
|
|
245
255
|
const receipts = runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager, owner);
|
|
256
|
+
const where = ctx.sessionManager.getSessionFile() === undefined ? "memory" : "file";
|
|
246
257
|
for (const run of runs) {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
258
|
+
const state = completionDelivery(
|
|
259
|
+
owner,
|
|
260
|
+
run.id,
|
|
261
|
+
receipts?.get(run.id) === undefined ? undefined : where,
|
|
262
|
+
);
|
|
263
|
+
switch (state.kind) {
|
|
264
|
+
case "published":
|
|
265
|
+
release(owner, run.id);
|
|
266
|
+
continue;
|
|
267
|
+
case "held":
|
|
268
|
+
continue;
|
|
269
|
+
case "deliverable":
|
|
270
|
+
break;
|
|
251
271
|
}
|
|
252
|
-
const loaded =
|
|
253
|
-
if (loaded
|
|
272
|
+
const loaded = run.loaded ?? load(base, run.id, owner);
|
|
273
|
+
if (loaded.result !== undefined) {
|
|
254
274
|
notices.push(notification(base, loaded.launch, loaded.result));
|
|
255
275
|
}
|
|
256
276
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { attachmentSchema } from "./contracts.ts";
|
|
2
|
+
import { observeAlive, published } from "./process.ts";
|
|
3
|
+
import { read } from "./storage.ts";
|
|
4
|
+
|
|
5
|
+
/** A parent liveness verdict acts only while its check and attachment generation are current. */
|
|
6
|
+
export class ParentCheck {
|
|
7
|
+
private current?: object | undefined;
|
|
8
|
+
check(file: string, detached: () => void): Promise<void> {
|
|
9
|
+
if (this.current !== undefined) {
|
|
10
|
+
return Promise.resolve();
|
|
11
|
+
}
|
|
12
|
+
const attachment = published(file, read(file, attachmentSchema));
|
|
13
|
+
if (attachment === undefined || !attachment.attached) {
|
|
14
|
+
detached();
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
const check = {};
|
|
18
|
+
this.current = check;
|
|
19
|
+
const settle = async () => {
|
|
20
|
+
try {
|
|
21
|
+
const [live] = await observeAlive([attachment]);
|
|
22
|
+
if (
|
|
23
|
+
this.current === check &&
|
|
24
|
+
live !== true &&
|
|
25
|
+
read(file, attachmentSchema)?.generation === attachment.generation
|
|
26
|
+
) {
|
|
27
|
+
detached();
|
|
28
|
+
}
|
|
29
|
+
} finally {
|
|
30
|
+
if (this.current === check) {
|
|
31
|
+
this.current = undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return settle();
|
|
36
|
+
}
|
|
37
|
+
cancel(): void {
|
|
38
|
+
this.current = undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/agent/process.ts
CHANGED
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
import { agentObservationSocketPath } from "../pi-intercom/broker/paths.ts";
|
|
15
15
|
import { hasErrorCode } from "../shared/unknown.ts";
|
|
16
16
|
import {
|
|
17
|
-
attachmentSchema,
|
|
18
17
|
parkedSchema,
|
|
19
18
|
processSchema,
|
|
20
19
|
wakeFailureSchema,
|
|
@@ -49,12 +48,19 @@ function probe(identity: ClaimedProcess): boolean | undefined {
|
|
|
49
48
|
}
|
|
50
49
|
return identity.identity === "unverified" ? true : undefined;
|
|
51
50
|
}
|
|
51
|
+
function presence(identity: ClaimedProcess): "live" | "exited" | "uncertain" {
|
|
52
|
+
const probed = probe(identity);
|
|
53
|
+
if (probed !== undefined) {
|
|
54
|
+
return probed ? "uncertain" : "exited";
|
|
55
|
+
}
|
|
56
|
+
if (readChildProcessIdentity(identity.pid) === identity.identity) {
|
|
57
|
+
return "live";
|
|
58
|
+
}
|
|
59
|
+
return replaced(identity) ? "exited" : "uncertain";
|
|
60
|
+
}
|
|
52
61
|
export function alive(identity: ClaimedProcess): boolean {
|
|
53
|
-
//
|
|
54
|
-
return (
|
|
55
|
-
probe(identity) ??
|
|
56
|
-
(readChildProcessIdentity(identity.pid) === identity.identity || !replaced(identity))
|
|
57
|
-
);
|
|
62
|
+
// Uncertain identity remains possibly owned; only death/replacement authorizes reuse.
|
|
63
|
+
return presence(identity) !== "exited";
|
|
58
64
|
}
|
|
59
65
|
/** Routine `alive` without waiting on `ps`; signals still use the synchronous proof. */
|
|
60
66
|
export async function observeAlive(identities: readonly ClaimedProcess[]): Promise<boolean[]> {
|
|
@@ -68,51 +74,6 @@ export async function observeAlive(identities: readonly ClaimedProcess[]): Promi
|
|
|
68
74
|
}
|
|
69
75
|
return verdicts;
|
|
70
76
|
}
|
|
71
|
-
/**
|
|
72
|
-
* A child's routine parent check, one observation at a time. A dead parent detaches the child
|
|
73
|
-
* only while its check is current and the attachment generation is unchanged: a newer parent
|
|
74
|
-
* may adopt the child meanwhile.
|
|
75
|
-
*/
|
|
76
|
-
export class ParentCheck {
|
|
77
|
-
private current?: object | undefined;
|
|
78
|
-
/**
|
|
79
|
-
* Reads the attachment synchronously, so an unreadable one still fails the caller at once.
|
|
80
|
-
* Missing or released attachments detach immediately; a check in flight makes this a no-op.
|
|
81
|
-
*/
|
|
82
|
-
check(file: string, detached: () => void): Promise<void> {
|
|
83
|
-
if (this.current !== undefined) {
|
|
84
|
-
return Promise.resolve();
|
|
85
|
-
}
|
|
86
|
-
const attachment = published(file, read(file, attachmentSchema));
|
|
87
|
-
if (attachment === undefined || !attachment.attached) {
|
|
88
|
-
detached();
|
|
89
|
-
return Promise.resolve();
|
|
90
|
-
}
|
|
91
|
-
const check = {};
|
|
92
|
-
this.current = check;
|
|
93
|
-
const settle = async () => {
|
|
94
|
-
try {
|
|
95
|
-
const [live] = await observeAlive([attachment]);
|
|
96
|
-
if (
|
|
97
|
-
this.current === check &&
|
|
98
|
-
live !== true &&
|
|
99
|
-
read(file, attachmentSchema)?.generation === attachment.generation
|
|
100
|
-
) {
|
|
101
|
-
detached();
|
|
102
|
-
}
|
|
103
|
-
} finally {
|
|
104
|
-
if (this.current === check) {
|
|
105
|
-
this.current = undefined;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
return settle();
|
|
110
|
-
}
|
|
111
|
-
/** A check settling later no longer acts. */
|
|
112
|
-
cancel(): void {
|
|
113
|
-
this.current = undefined;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
77
|
function replaced(identity: ClaimedProcess): boolean {
|
|
117
78
|
return (
|
|
118
79
|
identity.identity !== "unverified" &&
|
|
@@ -132,6 +93,42 @@ export function actor(base: string, launch: Launch): OwnedProcess | undefined {
|
|
|
132
93
|
const file = path.join(conversation(base, launch), "actor.lock", "process.json");
|
|
133
94
|
return published(file, read(file, processSchema));
|
|
134
95
|
}
|
|
96
|
+
type ActorClaim =
|
|
97
|
+
| { readonly kind: "absent" | "launcher-only"; readonly process: undefined }
|
|
98
|
+
| { readonly kind: "actor-live" | "actor-exited"; readonly process: OwnedProcess }
|
|
99
|
+
| { readonly kind: "uncertain"; readonly process: OwnedProcess | undefined };
|
|
100
|
+
|
|
101
|
+
export function possiblyLive(claim: ActorClaim | undefined): boolean {
|
|
102
|
+
return (
|
|
103
|
+
claim?.kind === "actor-live" || (claim?.kind === "uncertain" && claim.process !== undefined)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Launcher claims do not prove actor identity; shell ownership is checked separately. */
|
|
108
|
+
export function inspectActorClaim(base: string, launch: Launch): ActorClaim {
|
|
109
|
+
const process = actor(base, launch);
|
|
110
|
+
if (process !== undefined) {
|
|
111
|
+
switch (presence(process)) {
|
|
112
|
+
case "live":
|
|
113
|
+
return { kind: "actor-live", process };
|
|
114
|
+
case "exited":
|
|
115
|
+
return { kind: "actor-exited", process };
|
|
116
|
+
case "uncertain":
|
|
117
|
+
return { kind: "uncertain", process };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const lock = path.join(conversation(base, launch), "actor.lock");
|
|
121
|
+
if (!fs.existsSync(lock)) {
|
|
122
|
+
return { kind: "absent", process: undefined };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
kind:
|
|
126
|
+
read(path.join(lock, "claim.json"), processSchema) === undefined
|
|
127
|
+
? "uncertain"
|
|
128
|
+
: "launcher-only",
|
|
129
|
+
process: undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
135
132
|
function childEnvironment(launch: Launch): NodeJS.ProcessEnv {
|
|
136
133
|
const env = { ...process.env };
|
|
137
134
|
for (const key of Object.keys(env)) {
|
|
@@ -299,8 +296,9 @@ export async function ensureActor(
|
|
|
299
296
|
await awaitStopping(dir, actor(base, launch), signal);
|
|
300
297
|
signal?.throwIfAborted();
|
|
301
298
|
// Another parallel tool may replace the claim while this invocation yields.
|
|
302
|
-
const
|
|
303
|
-
|
|
299
|
+
const claim = inspectActorClaim(base, launch);
|
|
300
|
+
const existing = claim.process;
|
|
301
|
+
if (possiblyLive(claim)) {
|
|
304
302
|
return;
|
|
305
303
|
}
|
|
306
304
|
parkFailure(base, launch, existing);
|
|
@@ -438,13 +436,13 @@ export function requestStop(
|
|
|
438
436
|
return false;
|
|
439
437
|
}
|
|
440
438
|
};
|
|
441
|
-
const identity =
|
|
439
|
+
const identity = inspectActorClaim(base, launch).process;
|
|
442
440
|
const targets = identity ? [identity, ...shells(base, launch, identity.token)] : [];
|
|
443
441
|
// Interactive Stop validates the whole set; shutdown reports and skips only ambiguous targets.
|
|
444
442
|
const live = targets.filter((target) => attempt(() => verifyProcess(target)));
|
|
445
443
|
const command =
|
|
446
444
|
identity && live.includes(identity)
|
|
447
|
-
? enqueue(base, launch, "stop",
|
|
445
|
+
? enqueue(base, launch, { action: "stop", actorToken: identity.token })
|
|
448
446
|
: undefined;
|
|
449
447
|
if (identity && live.length > 0) {
|
|
450
448
|
write(path.join(conversation(base, launch), `stopping-${identity.token}.json`), {
|