@fitchmultz/pi-subagents 0.52.18 → 0.52.20
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 +50 -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/bridge-presence.js +30 -18
- package/dist/pi-intercom/bridge-wake.js +60 -37
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client.js +16 -50
- package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
- package/dist/pi-intercom/broker/pending-requests.js +26 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- 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/lifecycle.js +26 -26
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +13 -0
- 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/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client.ts +36 -65
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- 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/lifecycle.ts +38 -27
- package/src/shared/post-exit-stdio-guard.ts +48 -40
package/src/agent/launch.ts
CHANGED
|
@@ -8,6 +8,16 @@ import type { DelegateInput } from "./tool-schemas.ts";
|
|
|
8
8
|
import { loadSDK, packageRoot, runtimeEntry } from "./host.ts";
|
|
9
9
|
import { runDirectory, write } from "./storage.ts";
|
|
10
10
|
|
|
11
|
+
function selectedThinking(
|
|
12
|
+
requested: ThinkingLevel | undefined,
|
|
13
|
+
inherited: ThinkingLevel,
|
|
14
|
+
suffix?: ThinkingLevel,
|
|
15
|
+
): ThinkingLevel {
|
|
16
|
+
if (requested !== undefined && suffix !== undefined && requested !== suffix) {
|
|
17
|
+
throw new Error("thinking conflicts with the model's thinking suffix.");
|
|
18
|
+
}
|
|
19
|
+
return suffix ?? requested ?? inherited;
|
|
20
|
+
}
|
|
11
21
|
function selectedModel(
|
|
12
22
|
input: DelegateInput,
|
|
13
23
|
context: ExtensionContext,
|
|
@@ -18,7 +28,7 @@ function selectedModel(
|
|
|
18
28
|
if (!context.model) {
|
|
19
29
|
throw new Error("No selected model; no fallback was used.");
|
|
20
30
|
}
|
|
21
|
-
return { model: context.model, thinking };
|
|
31
|
+
return { model: context.model, thinking: selectedThinking(input.thinking, thinking) };
|
|
22
32
|
}
|
|
23
33
|
const slash = input.model.indexOf("/");
|
|
24
34
|
if (slash < 1) {
|
|
@@ -29,7 +39,7 @@ function selectedModel(
|
|
|
29
39
|
input.model.slice(slash + 1),
|
|
30
40
|
);
|
|
31
41
|
if (model) {
|
|
32
|
-
return { model, thinking };
|
|
42
|
+
return { model, thinking: selectedThinking(input.thinking, thinking) };
|
|
33
43
|
}
|
|
34
44
|
if (typeof sdk.resolveModelReference !== "function") {
|
|
35
45
|
throw new Error(
|
|
@@ -49,7 +59,10 @@ function selectedModel(
|
|
|
49
59
|
if (resolved.status !== "resolved") {
|
|
50
60
|
throw new Error(`Model ${input.model} is unavailable; no fallback was used.`);
|
|
51
61
|
}
|
|
52
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
model: resolved.model,
|
|
64
|
+
thinking: selectedThinking(input.thinking, thinking, resolved.thinkingLevel),
|
|
65
|
+
};
|
|
53
66
|
}
|
|
54
67
|
function targetTrust(
|
|
55
68
|
sdk: Awaited<ReturnType<typeof loadSDK>>,
|
|
@@ -6,7 +6,7 @@ import { readOutputPage } from "../shared/output-page.ts";
|
|
|
6
6
|
import { journalStamp } from "../shared/native-journal.ts";
|
|
7
7
|
import type { Launch, Result } from "./contracts.ts";
|
|
8
8
|
import { launchAt, list, runDirectory } from "./storage.ts";
|
|
9
|
-
import { readCanonicalResult, readResult } from "./usage.ts";
|
|
9
|
+
import { accountingState, readCanonicalResult, readResult } from "./usage.ts";
|
|
10
10
|
|
|
11
11
|
function isStringSet(value: unknown): value is Set<string> {
|
|
12
12
|
if (!(value instanceof Set)) {
|
|
@@ -160,7 +160,7 @@ function unchangedPending(pending: Loaded | undefined): boolean {
|
|
|
160
160
|
function load(base: string, id: string, owner: string): Loaded {
|
|
161
161
|
const launch = launchAt(base, id, owner);
|
|
162
162
|
const result = readCanonicalResult(base, launch);
|
|
163
|
-
if (result
|
|
163
|
+
if (accountingState(result).kind !== "pending") {
|
|
164
164
|
return { launch, result };
|
|
165
165
|
}
|
|
166
166
|
// Only pending runs need this pre-read stamp to observe concurrent publication.
|
|
@@ -169,7 +169,7 @@ function load(base: string, id: string, owner: string): Loaded {
|
|
|
169
169
|
return { launch, result: readResult(base, launch), journal };
|
|
170
170
|
}
|
|
171
171
|
function cacheableResult(loaded: Loaded): boolean {
|
|
172
|
-
return loaded.result
|
|
172
|
+
return accountingState(loaded.result).kind !== "pending" || loaded.journal !== undefined;
|
|
173
173
|
}
|
|
174
174
|
interface Scan {
|
|
175
175
|
readonly base: string;
|
|
@@ -198,7 +198,7 @@ function completedRun({ base, owner, known, settled }: Scan, id: string): Comple
|
|
|
198
198
|
const order = { endedAt: loaded.result.endedAt, createdAt: loaded.launch.createdAt };
|
|
199
199
|
// Pending accounting retries on child changes, even after the completion receipt.
|
|
200
200
|
// Ordinary settled history retains its launch/result-only idle probes.
|
|
201
|
-
const pending = loaded.result.
|
|
201
|
+
const pending = accountingState(loaded.result).kind === "pending" ? loaded : undefined;
|
|
202
202
|
if (launch !== undefined && result !== undefined && cacheableResult(loaded)) {
|
|
203
203
|
settled.set(id, { launch, result, pending, ...order });
|
|
204
204
|
}
|
|
@@ -216,7 +216,7 @@ function completed(base: string, owner: string, awaited: Set<string>): Completed
|
|
|
216
216
|
const run = completedRun(scan, id);
|
|
217
217
|
if (run === undefined) {
|
|
218
218
|
awaited.add(path.join(runDirectory(base, id), "result.json"));
|
|
219
|
-
} else if (run.loaded
|
|
219
|
+
} else if (run.loaded && accountingState(run.loaded.result).kind === "pending") {
|
|
220
220
|
awaited.add(run.loaded.launch.sessionFile);
|
|
221
221
|
}
|
|
222
222
|
return run ?? [];
|
package/src/agent/parking.ts
CHANGED
|
@@ -29,7 +29,14 @@ import type { NativeHistory } from "../pi-intercom/last-message.ts";
|
|
|
29
29
|
type SDK = Awaited<ReturnType<typeof loadSDK>>;
|
|
30
30
|
type Restore = NonNullable<SDK["readWorkingSession"]>;
|
|
31
31
|
export type Runtime = Awaited<ReturnType<SDK["createAgentSessionRuntime"]>>;
|
|
32
|
+
export type ParkOutcome = "resident" | "exit-parked" | "exit-failed";
|
|
32
33
|
type SessionManager = Parameters<SDK["createAgentSessionRuntime"]>[1]["sessionManager"];
|
|
34
|
+
type OpenSource =
|
|
35
|
+
| { readonly kind: "journal" }
|
|
36
|
+
| { readonly kind: "parked"; readonly state: string; readonly saved: WorkingState };
|
|
37
|
+
type RestorePhase =
|
|
38
|
+
| { readonly kind: "unopened" }
|
|
39
|
+
| { readonly kind: "opening" | "adopted"; readonly source: OpenSource };
|
|
33
40
|
interface WorkingHold {
|
|
34
41
|
readonly state: WorkingState;
|
|
35
42
|
readonly sleepReady: boolean;
|
|
@@ -156,10 +163,7 @@ export function parkedState(
|
|
|
156
163
|
}
|
|
157
164
|
/** One actor generation's idle parking and exact restore; commands stay with the runner. */
|
|
158
165
|
export class Parking {
|
|
159
|
-
|
|
160
|
-
/** Set before entering either native open, whose effects may outlive an exception. */
|
|
161
|
-
opened = false;
|
|
162
|
-
private restored?: string;
|
|
166
|
+
private restore: RestorePhase = { kind: "unopened" };
|
|
163
167
|
private since = 0;
|
|
164
168
|
private leaf?: string | null;
|
|
165
169
|
private shell?: string | undefined;
|
|
@@ -179,36 +183,48 @@ export class Parking {
|
|
|
179
183
|
private get file(): string {
|
|
180
184
|
return path.join(this.dir, "parked.json");
|
|
181
185
|
}
|
|
186
|
+
get saved(): WorkingState | undefined {
|
|
187
|
+
return this.restore.kind !== "unopened" && this.restore.source.kind === "parked"
|
|
188
|
+
? this.restore.source.saved
|
|
189
|
+
: undefined;
|
|
190
|
+
}
|
|
191
|
+
/** Set before entering either native open, whose effects may outlive an exception. */
|
|
192
|
+
get opened(): boolean {
|
|
193
|
+
return this.restore.kind !== "unopened";
|
|
194
|
+
}
|
|
182
195
|
/** Parked authority is exact state or nothing; never a journal-only reopen. */
|
|
183
196
|
open(): SessionManager {
|
|
184
197
|
const { sdk, launch } = this;
|
|
185
198
|
const { openWorkingSession: restore } = sdk;
|
|
186
199
|
const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
|
|
187
|
-
this.
|
|
200
|
+
this.restore = {
|
|
201
|
+
kind: "opening",
|
|
202
|
+
source: parked ? { kind: "parked", ...parked } : { kind: "journal" },
|
|
203
|
+
};
|
|
188
204
|
if (!parked || !restore) {
|
|
189
205
|
return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
|
|
190
206
|
}
|
|
191
|
-
this.saved = parked.saved;
|
|
192
|
-
this.restored = parked.state;
|
|
193
207
|
return restore(parked.saved);
|
|
194
208
|
}
|
|
195
209
|
/** The ready restored generation owns this state now; a later park writes a new cut. */
|
|
196
210
|
adopt(): void {
|
|
197
|
-
if (this.
|
|
211
|
+
if (this.restore.kind !== "opening") {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const { source } = this.restore;
|
|
215
|
+
if (source.kind === "parked") {
|
|
198
216
|
fs.rmSync(this.file);
|
|
199
|
-
fs.rmSync(
|
|
217
|
+
fs.rmSync(source.state, { force: true });
|
|
200
218
|
}
|
|
219
|
+
this.restore = { kind: "adopted", source };
|
|
201
220
|
}
|
|
202
|
-
/**
|
|
203
|
-
async park(runtime: Runtime, actor: Resident): Promise<
|
|
221
|
+
/** Both exit outcomes have disposed the runtime; only one publishes parked authority. */
|
|
222
|
+
async park(runtime: Runtime, actor: Resident): Promise<ParkOutcome> {
|
|
204
223
|
if (!actor.pi || !this.due(runtime.session, actor.busy) || this.hasShell()) {
|
|
205
|
-
return
|
|
224
|
+
return "resident";
|
|
206
225
|
}
|
|
207
226
|
const hold = await this.prepare(runtime, actor.pi, actor);
|
|
208
|
-
|
|
209
|
-
await this.commit(runtime, hold, actor.errors);
|
|
210
|
-
}
|
|
211
|
-
return hold !== undefined;
|
|
227
|
+
return hold ? this.commit(runtime, hold, actor.errors) : "resident";
|
|
212
228
|
}
|
|
213
229
|
private hasShell(): boolean {
|
|
214
230
|
const witness = this.shell === undefined ? undefined : shell(this.shell, this.token);
|
|
@@ -259,7 +275,11 @@ export class Parking {
|
|
|
259
275
|
return undefined;
|
|
260
276
|
}
|
|
261
277
|
/** Commit: native strict exit, then capture; failure leaves no parked authority. */
|
|
262
|
-
private async commit(
|
|
278
|
+
private async commit(
|
|
279
|
+
runtime: Runtime,
|
|
280
|
+
hold: WorkingHold,
|
|
281
|
+
errors: string[],
|
|
282
|
+
): Promise<Exclude<ParkOutcome, "resident">> {
|
|
263
283
|
const save = this.sdk.writeWorkingSession;
|
|
264
284
|
if (save && finalizing(runtime)) {
|
|
265
285
|
runtime.setFinalization((final) => this.capture(final, save), { strictShutdown: true });
|
|
@@ -275,7 +295,9 @@ export class Parking {
|
|
|
275
295
|
if (read(this.file, parkedSchema)?.token !== this.token) {
|
|
276
296
|
// Truthful boundary: retained input stays queued and nothing restarts implicitly.
|
|
277
297
|
write(path.join(this.dir, "park-failed.json"), { token: this.token, errors });
|
|
298
|
+
return "exit-failed";
|
|
278
299
|
}
|
|
300
|
+
return "exit-parked";
|
|
279
301
|
}
|
|
280
302
|
private async capture(
|
|
281
303
|
session: AgentSession,
|
package/src/agent/runner.ts
CHANGED
|
@@ -8,12 +8,13 @@ import {
|
|
|
8
8
|
type Command,
|
|
9
9
|
type Launch,
|
|
10
10
|
type Result,
|
|
11
|
+
type AccountingState,
|
|
11
12
|
} from "./contracts.ts";
|
|
12
13
|
import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
|
|
13
14
|
import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
|
|
14
15
|
import { errorText, loadSDK } from "./host.ts";
|
|
15
|
-
import { Parking, type Runtime } from "./parking.ts";
|
|
16
|
-
import { UsageIndex } from "./usage.ts";
|
|
16
|
+
import { Parking, type ParkOutcome, type Runtime } from "./parking.ts";
|
|
17
|
+
import { accountingFields, UsageIndex } from "./usage.ts";
|
|
17
18
|
import { initializeCwd } from "./cwd.ts";
|
|
18
19
|
import { childRuntime } from "./child-runtime.ts";
|
|
19
20
|
import { ActorObservation, type ObservationStats } from "./observation.ts";
|
|
@@ -56,7 +57,7 @@ class Actor {
|
|
|
56
57
|
private stopping = false;
|
|
57
58
|
private stopDeadline?: NodeJS.Timeout;
|
|
58
59
|
private nextParentCheck = 0;
|
|
59
|
-
private parking =
|
|
60
|
+
private parking: ParkOutcome = "resident";
|
|
60
61
|
private readonly parent = new ParentCheck();
|
|
61
62
|
// Raised when durable commands may need admission: change hints and stop.
|
|
62
63
|
private readonly wake = new Wake();
|
|
@@ -110,10 +111,7 @@ class Actor {
|
|
|
110
111
|
owner: launch.owner,
|
|
111
112
|
status: completionStatus(this.stopping, message, error),
|
|
112
113
|
endedAt: Date.now(),
|
|
113
|
-
...(accounting
|
|
114
|
-
? { terminalEntryId: terminal.id }
|
|
115
|
-
: {}),
|
|
116
|
-
...accounting,
|
|
114
|
+
...accountingFields(accounting),
|
|
117
115
|
...(failure !== undefined ? { error: failure } : {}),
|
|
118
116
|
};
|
|
119
117
|
fs.writeFileSync(path.join(runDirectory(this.base, launch.id), "output.txt"), output, {
|
|
@@ -121,20 +119,21 @@ class Actor {
|
|
|
121
119
|
});
|
|
122
120
|
write(path.join(runDirectory(this.base, launch.id), "result.json"), result);
|
|
123
121
|
}
|
|
124
|
-
private usage(
|
|
125
|
-
launch: Launch,
|
|
126
|
-
baseline: ReadonlySet<string>,
|
|
127
|
-
terminal?: string,
|
|
128
|
-
): Pick<Result, "contributions" | "accountingError" | "accountingPending"> {
|
|
122
|
+
private usage(launch: Launch, baseline: ReadonlySet<string>, terminal?: string): AccountingState {
|
|
129
123
|
try {
|
|
130
|
-
return { contributions: this.accounting.read(launch, baseline, terminal) };
|
|
131
|
-
} catch (error) {
|
|
132
124
|
return {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
: {}),
|
|
125
|
+
kind: "verified",
|
|
126
|
+
contributions: this.accounting.read(launch, baseline, terminal),
|
|
127
|
+
terminalEntryId: terminal,
|
|
137
128
|
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return terminal === undefined
|
|
131
|
+
? { kind: "unavailable", error: errorText(error), terminalEntryId: undefined }
|
|
132
|
+
: {
|
|
133
|
+
kind: "pending",
|
|
134
|
+
error: errorText(error),
|
|
135
|
+
pending: { terminalCandidate: terminal, baseline: [...baseline] },
|
|
136
|
+
};
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
private output(message: AssistantMessage | undefined): string {
|
|
@@ -384,7 +383,7 @@ class Actor {
|
|
|
384
383
|
} finally {
|
|
385
384
|
clearInterval(watch);
|
|
386
385
|
}
|
|
387
|
-
return this.parking;
|
|
386
|
+
return this.parking !== "resident";
|
|
388
387
|
}
|
|
389
388
|
private failure(error: unknown, unopened?: string): void {
|
|
390
389
|
const launch = this.current ?? this.initial;
|
|
@@ -494,7 +493,7 @@ class Actor {
|
|
|
494
493
|
process.off("SIGTERM", this.stop);
|
|
495
494
|
process.off("SIGINT", this.stop);
|
|
496
495
|
const observation: ObservationStats | undefined = this.observation?.close();
|
|
497
|
-
if (
|
|
496
|
+
if (this.parking === "resident") {
|
|
498
497
|
await runtime?.dispose();
|
|
499
498
|
}
|
|
500
499
|
if (this.stopping) {
|
|
@@ -506,7 +505,7 @@ class Actor {
|
|
|
506
505
|
errors: this.errors,
|
|
507
506
|
...(observation === undefined ? {} : { observation }),
|
|
508
507
|
});
|
|
509
|
-
if (this.parking) {
|
|
508
|
+
if (this.parking !== "resident") {
|
|
510
509
|
// Parked authority requires actual exit; no leftover extension handle may keep it alive.
|
|
511
510
|
process.exit();
|
|
512
511
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Static } from "typebox";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "./typebox.ts";
|
|
4
|
+
import { thinkingSchema } from "./contracts.ts";
|
|
4
5
|
|
|
5
6
|
// Main-session tool parameters only; the detached runner must not load pi-ai values.
|
|
6
7
|
export const delegateSchema = Type.Object(
|
|
@@ -15,6 +16,7 @@ export const delegateSchema = Type.Object(
|
|
|
15
16
|
"Exact provider/model ID (:thinking and any case need host support); no fuzzy match or fallback.",
|
|
16
17
|
}),
|
|
17
18
|
),
|
|
19
|
+
thinking: Type.Optional(thinkingSchema),
|
|
18
20
|
context: Type.Optional(StringEnum(["fresh", "fork"] as const)),
|
|
19
21
|
},
|
|
20
22
|
{ additionalProperties: false },
|
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
|
}
|
|
@@ -18,11 +18,15 @@ interface PresenceOwner {
|
|
|
18
18
|
readonly restored: (identity: Identity, expires: number) => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type PresencePhase =
|
|
22
|
+
| { readonly kind: "idle" }
|
|
23
|
+
| { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
|
|
24
|
+
| { readonly kind: "running"; readonly task: Promise<void> }
|
|
25
|
+
| { readonly kind: "stopped"; readonly task: Promise<void> };
|
|
26
|
+
|
|
21
27
|
export class PersistentPresence {
|
|
22
28
|
private readonly registrations = new Map<string, Registration>();
|
|
23
|
-
private
|
|
24
|
-
private timer?: NodeJS.Timeout;
|
|
25
|
-
private running?: Promise<void> | undefined;
|
|
29
|
+
private phase: PresencePhase = { kind: "idle" };
|
|
26
30
|
private readonly owner: PresenceOwner;
|
|
27
31
|
|
|
28
32
|
constructor(owner: PresenceOwner) {
|
|
@@ -88,22 +92,30 @@ export class PersistentPresence {
|
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
start(): void {
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}, 0);
|
|
95
|
+
if (this.phase.kind === "running" || this.phase.kind === "stopped") {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.schedule(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private schedule(delay: number): void {
|
|
102
|
+
if (this.phase.kind === "scheduled") {
|
|
103
|
+
clearTimeout(this.phase.timer);
|
|
101
104
|
}
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
const task = this.maintain().finally(() => {
|
|
107
|
+
if (this.phase.kind !== "stopped") {
|
|
108
|
+
this.schedule(15000);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
this.phase = { kind: "running", task };
|
|
112
|
+
}, delay);
|
|
113
|
+
this.phase = { kind: "scheduled", timer };
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
private async maintain(): Promise<void> {
|
|
105
117
|
for (const registration of this.registrations.values()) {
|
|
106
|
-
if (this.stopped) {
|
|
118
|
+
if (this.phase.kind === "stopped") {
|
|
107
119
|
return;
|
|
108
120
|
}
|
|
109
121
|
const identity = this.owner.clients().get(registration.fingerprint);
|
|
@@ -125,9 +137,16 @@ export class PersistentPresence {
|
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
stop(): Promise<void> {
|
|
141
|
+
const phase = this.phase;
|
|
142
|
+
if (phase.kind === "stopped") {
|
|
143
|
+
return phase.task;
|
|
144
|
+
}
|
|
145
|
+
if (phase.kind === "scheduled") {
|
|
146
|
+
clearTimeout(phase.timer);
|
|
147
|
+
}
|
|
148
|
+
const task = phase.kind === "running" ? phase.task : Promise.resolve();
|
|
149
|
+
this.phase = { kind: "stopped", task };
|
|
150
|
+
return task;
|
|
132
151
|
}
|
|
133
152
|
}
|