pi-daddy 0.47.1 → 0.48.1
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 +12 -0
- package/README.md +40 -5
- package/dist/executors/child-final.d.ts +3 -0
- package/dist/executors/child-final.d.ts.map +1 -1
- package/dist/executors/child-final.js +13 -1
- package/dist/executors/child-final.js.map +1 -1
- package/dist/executors/herdr-cli.d.ts.map +1 -1
- package/dist/executors/herdr-cli.js +5 -1
- package/dist/executors/herdr-cli.js.map +1 -1
- package/dist/executors/herdr-qualification.d.ts +1 -3
- package/dist/executors/herdr-qualification.d.ts.map +1 -1
- package/dist/executors/herdr-qualification.js +14 -19
- package/dist/executors/herdr-qualification.js.map +1 -1
- package/dist/governance/auto-mode-policy.d.ts.map +1 -1
- package/dist/governance/auto-mode-policy.js +32 -1
- package/dist/governance/auto-mode-policy.js.map +1 -1
- package/dist/governance/dispatch-operation-client.d.ts +4 -0
- package/dist/governance/dispatch-operation-client.d.ts.map +1 -0
- package/dist/governance/dispatch-operation-client.js +93 -0
- package/dist/governance/dispatch-operation-client.js.map +1 -0
- package/dist/governance/dispatch-operations.d.ts +17 -0
- package/dist/governance/dispatch-operations.d.ts.map +1 -0
- package/dist/governance/dispatch-operations.js +81 -0
- package/dist/governance/dispatch-operations.js.map +1 -0
- package/dist/governance/execution-retention.d.ts +5 -4
- package/dist/governance/execution-retention.d.ts.map +1 -1
- package/dist/governance/execution-retention.js +3 -1
- package/dist/governance/execution-retention.js.map +1 -1
- package/dist/governance/native-session.d.ts +10 -1
- package/dist/governance/native-session.d.ts.map +1 -1
- package/dist/governance/native-session.js +15 -0
- package/dist/governance/native-session.js.map +1 -1
- package/dist/governance/retention-contract.d.ts +30 -0
- package/dist/governance/retention-contract.d.ts.map +1 -1
- package/dist/governance/retention-contract.js +9 -4
- package/dist/governance/retention-contract.js.map +1 -1
- package/dist/kernel/auto-mode.d.ts +1 -0
- package/dist/kernel/auto-mode.d.ts.map +1 -1
- package/dist/kernel/auto-mode.js.map +1 -1
- package/dist/kernel/chain.d.ts +1 -0
- package/dist/kernel/chain.d.ts.map +1 -1
- package/dist/kernel/chain.js.map +1 -1
- package/dist/kernel/delegate.d.ts.map +1 -1
- package/dist/kernel/delegate.js +8 -0
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/dispatch-operation.d.ts +47 -0
- package/dist/kernel/dispatch-operation.d.ts.map +1 -0
- package/dist/kernel/dispatch-operation.js +4 -0
- package/dist/kernel/dispatch-operation.js.map +1 -0
- package/dist/kernel/propagation.d.ts +4 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +11 -1
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/products/activity-timeline.d.ts +8 -0
- package/dist/products/activity-timeline.d.ts.map +1 -1
- package/dist/products/activity-timeline.js +8 -1
- package/dist/products/activity-timeline.js.map +1 -1
- package/dist/products/dashboard-screen.d.ts +1 -0
- package/dist/products/dashboard-screen.d.ts.map +1 -1
- package/dist/products/dashboard-screen.js +20 -2
- package/dist/products/dashboard-screen.js.map +1 -1
- package/extensions/activity-timeline.ts +12 -2
- package/extensions/delegate-chain.ts +46 -4
- package/extensions/delegation-operations.ts +171 -0
- package/extensions/delegation.ts +34 -10
- package/extensions/execute-child.ts +21 -3
- package/extensions/execution-evidence.ts +3 -0
- package/extensions/fanout-outcome.ts +1 -0
- package/extensions/grants.ts +2 -3
- package/extensions/run-delegation.ts +58 -3
- package/extensions/runtime-snapshot.ts +59 -1
- package/package.json +1 -1
- package/src/executors/child-final.ts +17 -1
- package/src/executors/herdr-cli.ts +4 -1
- package/src/executors/herdr-qualification.ts +14 -21
- package/src/governance/auto-mode-policy.ts +27 -1
- package/src/governance/dispatch-operation-client.ts +96 -0
- package/src/governance/dispatch-operations.ts +81 -0
- package/src/governance/execution-retention.ts +7 -3
- package/src/governance/native-session.ts +28 -1
- package/src/governance/retention-contract.ts +9 -4
- package/src/kernel/auto-mode.ts +1 -0
- package/src/kernel/chain.ts +1 -0
- package/src/kernel/delegate.ts +14 -0
- package/src/kernel/dispatch-operation.ts +38 -0
- package/src/kernel/propagation.ts +18 -1
- package/src/products/activity-timeline.ts +10 -0
- package/src/products/dashboard-screen.ts +21 -2
|
@@ -5,6 +5,7 @@ import { openRuntimeSettlement, settlementHash } from "../src/governance/runtime
|
|
|
5
5
|
import { reconcileDelegationCapacity } from "./session-capacity.ts";
|
|
6
6
|
import { agentDir } from "../src/kernel/project-paths.ts";
|
|
7
7
|
import type { GrantsSession } from "./session.ts";
|
|
8
|
+
export const OPERATION_STATUS_EVENT = "pi-daddy:operation-status:v1";
|
|
8
9
|
export const RUNTIME_SNAPSHOT_EVENT = "pi-daddy:runtime-snapshot:v1";
|
|
9
10
|
export function registerRuntimeSnapshot(
|
|
10
11
|
pi: ExtensionAPI,
|
|
@@ -15,7 +16,7 @@ export function registerRuntimeSnapshot(
|
|
|
15
16
|
let bound: { sessionId: string; cwd: string; lifecycle: object } | undefined, problem: string | undefined;
|
|
16
17
|
const generation = {};
|
|
17
18
|
let bindSequence = 0;
|
|
18
|
-
const
|
|
19
|
+
const unsubscribeRuntime = pi.events?.on?.(RUNTIME_SNAPSHOT_EVENT, (input: unknown) => {
|
|
19
20
|
const request = input as {
|
|
20
21
|
version?: number;
|
|
21
22
|
requestId?: string;
|
|
@@ -91,6 +92,63 @@ export function registerRuntimeSnapshot(
|
|
|
91
92
|
}
|
|
92
93
|
})().catch(() => undefined);
|
|
93
94
|
});
|
|
95
|
+
const unsubscribeOperations = pi.events?.on?.(OPERATION_STATUS_EVENT, (input: unknown) => {
|
|
96
|
+
const request = input as {
|
|
97
|
+
version?: number;
|
|
98
|
+
requestId?: string;
|
|
99
|
+
sessionId?: string;
|
|
100
|
+
cwd?: string;
|
|
101
|
+
operationId?: string;
|
|
102
|
+
reply?: (value: unknown) => void;
|
|
103
|
+
};
|
|
104
|
+
if (
|
|
105
|
+
request?.version !== 1 ||
|
|
106
|
+
typeof request.requestId !== "string" ||
|
|
107
|
+
request.requestId.length > 512 ||
|
|
108
|
+
typeof request.operationId !== "string" ||
|
|
109
|
+
typeof request.reply !== "function"
|
|
110
|
+
)
|
|
111
|
+
return;
|
|
112
|
+
if (bound && session.reloadLifecycle.runtimeSnapshotGeneration !== generation) return;
|
|
113
|
+
void (async () => {
|
|
114
|
+
const current = bound,
|
|
115
|
+
lifecycle = session.reloadLifecycle;
|
|
116
|
+
const envelope = {
|
|
117
|
+
version: 1,
|
|
118
|
+
requestId: request.requestId,
|
|
119
|
+
sessionId: current?.sessionId ?? "",
|
|
120
|
+
cwd: current?.cwd ?? "",
|
|
121
|
+
operationId: request.operationId,
|
|
122
|
+
};
|
|
123
|
+
if (
|
|
124
|
+
!current ||
|
|
125
|
+
current.lifecycle !== lifecycle ||
|
|
126
|
+
request.sessionId !== current.sessionId ||
|
|
127
|
+
request.cwd !== current.cwd ||
|
|
128
|
+
!session.ownerBound ||
|
|
129
|
+
!session.autoMode?.operations
|
|
130
|
+
) {
|
|
131
|
+
request.reply!({ ...envelope, qualified: false, operation: null, reason: "operation owner is not ready" });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const operation = await session.autoMode.operations.read(request.operationId!);
|
|
136
|
+
if (
|
|
137
|
+
bound !== current ||
|
|
138
|
+
session.reloadLifecycle !== lifecycle ||
|
|
139
|
+
lifecycle.runtimeSnapshotGeneration !== generation
|
|
140
|
+
)
|
|
141
|
+
throw Error("operation owner changed during read");
|
|
142
|
+
request.reply!({ ...envelope, qualified: true, operation });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
request.reply!({ ...envelope, qualified: false, operation: null, reason: String(error) });
|
|
145
|
+
}
|
|
146
|
+
})().catch(() => undefined);
|
|
147
|
+
});
|
|
148
|
+
const unsubscribe = () => {
|
|
149
|
+
unsubscribeRuntime?.();
|
|
150
|
+
unsubscribeOperations?.();
|
|
151
|
+
};
|
|
94
152
|
return {
|
|
95
153
|
async bind(ctx) {
|
|
96
154
|
const sequence = ++bindSequence,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.1",
|
|
4
4
|
"description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { readdir } from "node:fs/promises";
|
|
6
6
|
import { SessionManager, type FileEntry } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { CapturedSessionBranch } from "../governance/native-session.ts";
|
|
7
8
|
import { BoundedReadCleanupError, readBoundedBytes } from "../kernel/bounded-read.ts";
|
|
8
9
|
const MAX_FINAL_BYTES = 4 * 1024 * 1024,
|
|
9
10
|
MAX_LINE_BYTES = 32 * 1024 * 1024,
|
|
@@ -13,6 +14,11 @@ const hash = (s: string) => createHash("sha256").update(s, "utf8").digest("hex")
|
|
|
13
14
|
export type ChildFinal =
|
|
14
15
|
| { state: "complete"; text: string; sessionId: string; messageId: string; leafId: string; sha256: string }
|
|
15
16
|
| { state: "unavailable"; reason: string; diagnosticText?: string };
|
|
17
|
+
const capturedSessions = new WeakMap<ChildFinal, Readonly<CapturedSessionBranch>>();
|
|
18
|
+
/** Only the original in-process final carries this observation; JSON/copies do not recreate it. */
|
|
19
|
+
export function capturedFinalSession(final: ChildFinal): Readonly<CapturedSessionBranch> | undefined {
|
|
20
|
+
return capturedSessions.get(final);
|
|
21
|
+
}
|
|
16
22
|
export class ChildFinalCapture {
|
|
17
23
|
private decoder = new TextDecoder("utf-8", { fatal: true });
|
|
18
24
|
private buffer = "";
|
|
@@ -171,7 +177,7 @@ export class ChildFinalCapture {
|
|
|
171
177
|
!isDeepStrictEqual(currentUser.message, this.user)
|
|
172
178
|
)
|
|
173
179
|
throw Error("stream final does not match the current persisted branch and turn");
|
|
174
|
-
|
|
180
|
+
const complete: ChildFinal = {
|
|
175
181
|
state: "complete",
|
|
176
182
|
text,
|
|
177
183
|
sessionId: this.sessionId,
|
|
@@ -179,6 +185,16 @@ export class ChildFinalCapture {
|
|
|
179
185
|
leafId: manager.getLeafId()!,
|
|
180
186
|
sha256: hash(text),
|
|
181
187
|
};
|
|
188
|
+
capturedSessions.set(
|
|
189
|
+
complete,
|
|
190
|
+
Object.freeze({
|
|
191
|
+
path,
|
|
192
|
+
sessionId: this.sessionId,
|
|
193
|
+
leafId: complete.leafId,
|
|
194
|
+
sha256: createHash("sha256").update(read.bytes).digest("hex"),
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
return complete;
|
|
182
198
|
} catch (error) {
|
|
183
199
|
if (error instanceof BoundedReadCleanupError) {
|
|
184
200
|
if (!this.onReadCleanup) throw error;
|
|
@@ -97,7 +97,10 @@ export async function probeHerdr(options: { exec?: HerdrExec; timeoutMs?: number
|
|
|
97
97
|
return await Promise.race<HerdrProbe>([
|
|
98
98
|
exec(["tab", "list"]).then((reply) => {
|
|
99
99
|
const parsed = parseReply(reply);
|
|
100
|
-
|
|
100
|
+
if (parsed.error) return { ok: false, error: parsed.error };
|
|
101
|
+
if (reply.code !== 0 || !Array.isArray(parsed.result?.tabs))
|
|
102
|
+
return { ok: false, error: "Herdr tab list failed or returned an unsupported response" };
|
|
103
|
+
return { ok: true };
|
|
101
104
|
}),
|
|
102
105
|
new Promise<HerdrProbe>((settle) => {
|
|
103
106
|
timer = setTimeout(() => settle({ ok: false, error: `probe timed out after ${timeoutMs}ms` }), timeoutMs);
|
|
@@ -1,31 +1,24 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Check the live CLI contract; product release numbers are not compatibility boundaries. */
|
|
2
2
|
import { defaultExec, type HerdrExec, type HerdrProbe } from "./herdr-cli.ts";
|
|
3
|
-
|
|
4
|
-
export const QUALIFIED_HERDR_PROTOCOL = 20;
|
|
3
|
+
|
|
5
4
|
export async function qualifyHerdr(probe: HerdrProbe, exec: HerdrExec = defaultExec): Promise<HerdrProbe> {
|
|
6
5
|
if (!probe.ok) return probe;
|
|
7
6
|
try {
|
|
8
7
|
if (process.platform !== "linux" || process.arch !== "x64") throw Error("Herdr owned execution requires Linux x64");
|
|
9
|
-
const
|
|
10
|
-
if (
|
|
11
|
-
throw Error("Herdr
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return [line.slice(0, at).trim(), line.slice(at + 1).trim()];
|
|
19
|
-
}),
|
|
20
|
-
);
|
|
8
|
+
const reply = await exec(["status", "server", "--json"]);
|
|
9
|
+
if (reply.code !== 0)
|
|
10
|
+
throw Error("Herdr live server status failed: " + (reply.stderr.trim() || reply.stdout.trim()));
|
|
11
|
+
const status = JSON.parse(reply.stdout);
|
|
12
|
+
if (!status || status.status !== "running" || status.running !== true) throw Error("Herdr server is not running");
|
|
13
|
+
// Our tab/pane CLI commands use the private protocol. Endpoint compatibility alone is insufficient.
|
|
14
|
+
if (!Number.isSafeInteger(status.protocol) || status.protocol < 1 || status.compatible !== true)
|
|
15
|
+
throw Error("Herdr client/server CLI protocol is incompatible or unavailable");
|
|
16
|
+
// Legacy servers do not advertise an endpoint generation. Respect an explicit modern incompatibility.
|
|
21
17
|
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
fields.version !== QUALIFIED_HERDR_VERSION ||
|
|
25
|
-
fields.protocol !== String(QUALIFIED_HERDR_PROTOCOL) ||
|
|
26
|
-
fields.compatible !== "yes"
|
|
18
|
+
(status.endpoint_compatible !== undefined || status.capabilities?.endpoint_protocol_generation !== undefined) &&
|
|
19
|
+
status.endpoint_compatible !== true
|
|
27
20
|
)
|
|
28
|
-
throw Error("Herdr server
|
|
21
|
+
throw Error("Herdr client/server endpoint is incompatible or unavailable");
|
|
29
22
|
return { ...probe, qualified: true };
|
|
30
23
|
} catch (error) {
|
|
31
24
|
return { ...probe, qualified: false, qualificationReason: String(error) };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createDispatchOperations } from "./dispatch-operations.ts";
|
|
2
|
+
import { connectDispatchOperations } from "./dispatch-operation-client.ts";
|
|
1
3
|
/** Root-owned live Auto authority. Descendants receive read/admit access, never mutation access. */
|
|
2
4
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
5
|
import { chmod, unlink } from "node:fs/promises";
|
|
@@ -38,6 +40,7 @@ export async function createAutoModeAuthority(initial: {
|
|
|
38
40
|
source = initial.source,
|
|
39
41
|
revision = 0,
|
|
40
42
|
closed = false;
|
|
43
|
+
const operations = createDispatchOperations();
|
|
41
44
|
const pending = new Map<string, PendingApproval>();
|
|
42
45
|
const waiters = new Set<() => void>();
|
|
43
46
|
const sockets = new Set<Socket>();
|
|
@@ -92,12 +95,14 @@ export async function createAutoModeAuthority(initial: {
|
|
|
92
95
|
const controller = new AbortController();
|
|
93
96
|
let input = "",
|
|
94
97
|
handled = false,
|
|
95
|
-
untrack: (() => void) | undefined
|
|
98
|
+
untrack: (() => void) | undefined,
|
|
99
|
+
abandonOperation: (() => void) | undefined;
|
|
96
100
|
socket.on("error", () => {});
|
|
97
101
|
socket.once("close", () => {
|
|
98
102
|
sockets.delete(socket);
|
|
99
103
|
controller.abort();
|
|
100
104
|
untrack?.();
|
|
105
|
+
abandonOperation?.();
|
|
101
106
|
});
|
|
102
107
|
const reply = (value: unknown) => {
|
|
103
108
|
if (!socket.destroyed) socket.end(JSON.stringify(value) + "\n");
|
|
@@ -129,6 +134,21 @@ export async function createAutoModeAuthority(initial: {
|
|
|
129
134
|
} else if (request.action === "pending" && validPending(request.approval)) {
|
|
130
135
|
socket.setTimeout(0);
|
|
131
136
|
untrack = trackPending(request.approval);
|
|
137
|
+
} else if (request.action === "operation-claim") {
|
|
138
|
+
const value = operations.claim(request.request);
|
|
139
|
+
if (!value.reused) {
|
|
140
|
+
socket.setTimeout(0);
|
|
141
|
+
abandonOperation = () => operations.abandoned(value.operation.operationId, value.ticket!);
|
|
142
|
+
socket.write(JSON.stringify({ ok: true, value }) + "\n");
|
|
143
|
+
} else reply({ ok: true, value });
|
|
144
|
+
} else if (request.action === "operation-read") {
|
|
145
|
+
reply({ ok: true, value: operations.read(request.operationId) });
|
|
146
|
+
} else if (request.action === "operation-started") {
|
|
147
|
+
operations.started(request.operationId, request.ticket, request.cwd, request.workspaceId);
|
|
148
|
+
reply({ ok: true });
|
|
149
|
+
} else if (request.action === "operation-finish") {
|
|
150
|
+
operations.finish(request.operationId, request.ticket, request.state, request.runtime);
|
|
151
|
+
reply({ ok: true });
|
|
132
152
|
} else throw new Error("Unknown Auto policy action");
|
|
133
153
|
} catch (error) {
|
|
134
154
|
reply({ ok: false, error: String(error) });
|
|
@@ -153,7 +173,9 @@ export async function createAutoModeAuthority(initial: {
|
|
|
153
173
|
} catch {}
|
|
154
174
|
};
|
|
155
175
|
process.once("exit", exit);
|
|
176
|
+
const operationAccess = connectDispatchOperations(reference);
|
|
156
177
|
return {
|
|
178
|
+
operations: operationAccess,
|
|
157
179
|
reference,
|
|
158
180
|
snapshot,
|
|
159
181
|
read: async () => snapshot(),
|
|
@@ -175,6 +197,7 @@ export async function createAutoModeAuthority(initial: {
|
|
|
175
197
|
close: async () => {
|
|
176
198
|
if (closed) return;
|
|
177
199
|
closed = true;
|
|
200
|
+
operationAccess.close();
|
|
178
201
|
enabled = false;
|
|
179
202
|
for (const wake of [...waiters]) wake();
|
|
180
203
|
for (const socket of sockets) socket.destroy();
|
|
@@ -233,6 +256,7 @@ function request(ref: AutoModeRef, action: "read" | "admit" | "wait", signal?: A
|
|
|
233
256
|
}
|
|
234
257
|
|
|
235
258
|
export function connectAutoMode(reference: AutoModeRef): AutoModeReader {
|
|
259
|
+
const operations = connectDispatchOperations(reference);
|
|
236
260
|
let closed = false;
|
|
237
261
|
const active = new Set<AbortController>();
|
|
238
262
|
const pending = new Set<Socket>();
|
|
@@ -252,6 +276,7 @@ export function connectAutoMode(reference: AutoModeRef): AutoModeReader {
|
|
|
252
276
|
};
|
|
253
277
|
return {
|
|
254
278
|
reference,
|
|
279
|
+
operations,
|
|
255
280
|
read: async () => {
|
|
256
281
|
const value = (await call("read")) as AutoModeSnapshot;
|
|
257
282
|
if (
|
|
@@ -290,6 +315,7 @@ export function connectAutoMode(reference: AutoModeRef): AutoModeReader {
|
|
|
290
315
|
},
|
|
291
316
|
close: async () => {
|
|
292
317
|
closed = true;
|
|
318
|
+
operations.close();
|
|
293
319
|
for (const controller of active) controller.abort();
|
|
294
320
|
for (const socket of pending) socket.destroy();
|
|
295
321
|
},
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** A held claim connection makes caller loss uncertain; another caller never inherits execution. */
|
|
2
|
+
import { createConnection, type Socket } from "node:net";
|
|
3
|
+
import type { AutoModeRef } from "../kernel/auto-mode.ts";
|
|
4
|
+
import type {
|
|
5
|
+
OperationAccess,
|
|
6
|
+
OperationClaim,
|
|
7
|
+
OperationReference,
|
|
8
|
+
OperationRequest,
|
|
9
|
+
} from "../kernel/dispatch-operation.ts";
|
|
10
|
+
|
|
11
|
+
export function connectDispatchOperations(reference: AutoModeRef): OperationAccess {
|
|
12
|
+
const sockets = new Set<Socket>();
|
|
13
|
+
let closed = false;
|
|
14
|
+
const request = (
|
|
15
|
+
action: string,
|
|
16
|
+
input: object,
|
|
17
|
+
signal?: AbortSignal,
|
|
18
|
+
hold = false,
|
|
19
|
+
): Promise<{ value: any; socket: Socket }> =>
|
|
20
|
+
new Promise((resolve, reject) => {
|
|
21
|
+
if (closed || signal?.aborted) return reject(Error("Operation owner unavailable or request aborted"));
|
|
22
|
+
const socket = createConnection(reference.socketPath);
|
|
23
|
+
sockets.add(socket);
|
|
24
|
+
let output = "",
|
|
25
|
+
answered = false;
|
|
26
|
+
const cleanup = () => signal?.removeEventListener("abort", abort);
|
|
27
|
+
const fail = (error: Error) => {
|
|
28
|
+
cleanup();
|
|
29
|
+
socket.destroy();
|
|
30
|
+
if (!answered) {
|
|
31
|
+
answered = true;
|
|
32
|
+
reject(error);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const abort = () => fail(Error("Operation request aborted"));
|
|
36
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
37
|
+
socket.setEncoding("utf8");
|
|
38
|
+
socket.setTimeout(2000, () => fail(Error("Operation owner unavailable")));
|
|
39
|
+
socket.once("error", () => fail(Error("Operation owner unavailable")));
|
|
40
|
+
socket.once("connect", () => socket.write(JSON.stringify({ ...reference, action, ...input }) + "\n"));
|
|
41
|
+
socket.once("close", () => {
|
|
42
|
+
sockets.delete(socket);
|
|
43
|
+
cleanup();
|
|
44
|
+
if (!answered) fail(Error("Operation owner disconnected"));
|
|
45
|
+
});
|
|
46
|
+
socket.on("data", (chunk) => {
|
|
47
|
+
if (answered) return;
|
|
48
|
+
output += chunk;
|
|
49
|
+
if (output.length > 65536) return fail(Error("Operation response too large"));
|
|
50
|
+
const end = output.indexOf("\n");
|
|
51
|
+
if (end < 0) return;
|
|
52
|
+
try {
|
|
53
|
+
const reply = JSON.parse(output.slice(0, end));
|
|
54
|
+
if (reply.ok !== true) throw Error(reply.error ?? "Operation request failed");
|
|
55
|
+
answered = true;
|
|
56
|
+
cleanup();
|
|
57
|
+
socket.setTimeout(0);
|
|
58
|
+
if (!hold || reply.value?.reused) socket.destroy();
|
|
59
|
+
resolve({ value: reply.value, socket });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
fail(error instanceof Error ? error : Error(String(error)));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
claim: async (input: OperationRequest, signal?: AbortSignal): Promise<OperationClaim> => {
|
|
67
|
+
const { value, socket } = await request("operation-claim", { request: input }, signal, true);
|
|
68
|
+
if (value.reused) return { operation: value.operation, reused: true };
|
|
69
|
+
const update = async (action: string, data: object) => {
|
|
70
|
+
if (socket.destroyed) throw Error("Operation claim owner disconnected; settlement is uncertain");
|
|
71
|
+
return request(action, { operationId: input.operationId, ticket: value.ticket, ...data });
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
operation: value.operation,
|
|
75
|
+
reused: false,
|
|
76
|
+
started: async (cwd, workspaceId) => {
|
|
77
|
+
await update("operation-started", { cwd, workspaceId });
|
|
78
|
+
},
|
|
79
|
+
finish: async (state, runtime) => {
|
|
80
|
+
try {
|
|
81
|
+
await update("operation-finish", { state, runtime });
|
|
82
|
+
Object.assign(value.operation, (await request("operation-read", { operationId: input.operationId })).value);
|
|
83
|
+
} finally {
|
|
84
|
+
socket.destroy();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
read: async (operationId: string) =>
|
|
90
|
+
(await request("operation-read", { operationId })).value as OperationReference | null,
|
|
91
|
+
close: () => {
|
|
92
|
+
closed = true;
|
|
93
|
+
for (const socket of sockets) socket.destroy();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Atomic leader election at the tree owner. No task text, execution permission or cached output. */
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import type { OperationReference, OperationRequest } from "../kernel/dispatch-operation.ts";
|
|
4
|
+
import { validOperationId } from "../kernel/dispatch-operation.ts";
|
|
5
|
+
|
|
6
|
+
export function createDispatchOperations() {
|
|
7
|
+
const records = new Map<string, { digest: string; ticket: string; value: OperationReference }>();
|
|
8
|
+
const copy = (value: OperationReference) => structuredClone(value);
|
|
9
|
+
const requireTicket = (id: string, ticket: string) => {
|
|
10
|
+
const row = records.get(id);
|
|
11
|
+
if (!row || typeof ticket !== "string" || row.ticket !== ticket) throw Error("Operation owner mismatch");
|
|
12
|
+
return row;
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
claim(request: OperationRequest) {
|
|
16
|
+
if (
|
|
17
|
+
!request ||
|
|
18
|
+
!validOperationId(request.operationId) ||
|
|
19
|
+
typeof request.requestDigest !== "string" ||
|
|
20
|
+
!/^[a-f0-9]{64}$/.test(request.requestDigest) ||
|
|
21
|
+
typeof request.executionId !== "string" ||
|
|
22
|
+
!request.executionId.startsWith("exec:") ||
|
|
23
|
+
request.executionId.length > 128 ||
|
|
24
|
+
typeof request.cwd !== "string" ||
|
|
25
|
+
!request.cwd.startsWith("/") ||
|
|
26
|
+
request.cwd.length > 4096 ||
|
|
27
|
+
!(request.workspaceId === null || typeof request.workspaceId === "string")
|
|
28
|
+
)
|
|
29
|
+
throw Error("Invalid dispatch operation request");
|
|
30
|
+
const old = records.get(request.operationId);
|
|
31
|
+
if (old) {
|
|
32
|
+
if (old.digest !== request.requestDigest)
|
|
33
|
+
throw Error("Operation identity conflict; inspect the original request before choosing a new operation_id");
|
|
34
|
+
return { reused: true, operation: copy(old.value) };
|
|
35
|
+
}
|
|
36
|
+
// Never evict a terminal ID and accidentally permit a mutation to run twice.
|
|
37
|
+
if (records.size >= 4096) throw Error("Session operation registry is full; no dispatch was admitted");
|
|
38
|
+
const now = new Date().toISOString(),
|
|
39
|
+
ticket = randomBytes(24).toString("hex");
|
|
40
|
+
const value: OperationReference = {
|
|
41
|
+
operationId: request.operationId,
|
|
42
|
+
executionId: request.executionId,
|
|
43
|
+
cwd: request.cwd,
|
|
44
|
+
workspaceId: request.workspaceId,
|
|
45
|
+
state: "admitting",
|
|
46
|
+
startedAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
};
|
|
49
|
+
records.set(request.operationId, { digest: request.requestDigest, ticket, value });
|
|
50
|
+
return { reused: false, operation: copy(value), ticket };
|
|
51
|
+
},
|
|
52
|
+
read(id: string) {
|
|
53
|
+
if (!validOperationId(id)) throw Error("Invalid operation_id");
|
|
54
|
+
const value = records.get(id)?.value;
|
|
55
|
+
return value ? copy(value) : null;
|
|
56
|
+
},
|
|
57
|
+
started(id: string, ticket: string, cwd: string, workspaceId: string | null) {
|
|
58
|
+
const row = requireTicket(id, ticket);
|
|
59
|
+
if (row.value.state !== "admitting") throw Error("Operation is no longer admitting");
|
|
60
|
+
if (
|
|
61
|
+
typeof cwd !== "string" ||
|
|
62
|
+
!cwd.startsWith("/") ||
|
|
63
|
+
cwd.length > 4096 ||
|
|
64
|
+
!(workspaceId === null || typeof workspaceId === "string")
|
|
65
|
+
)
|
|
66
|
+
throw Error("Invalid operation workspace");
|
|
67
|
+
row.value = { ...row.value, state: "running", cwd, workspaceId, updatedAt: new Date().toISOString() };
|
|
68
|
+
},
|
|
69
|
+
finish(id: string, ticket: string, state: OperationReference["state"], runtime?: OperationReference["runtime"]) {
|
|
70
|
+
const row = requireTicket(id, ticket);
|
|
71
|
+
if (!["settled", "not-started", "uncertain"].includes(state)) throw Error("Invalid operation terminal state");
|
|
72
|
+
if (!["admitting", "running"].includes(row.value.state)) throw Error("Operation already ended");
|
|
73
|
+
row.value = { ...row.value, state, ...(runtime ? { runtime } : {}), updatedAt: new Date().toISOString() };
|
|
74
|
+
},
|
|
75
|
+
abandoned(id: string, ticket: string) {
|
|
76
|
+
const row = requireTicket(id, ticket);
|
|
77
|
+
if (["admitting", "running"].includes(row.value.state))
|
|
78
|
+
row.value = { ...row.value, state: "uncertain", updatedAt: new Date().toISOString() };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type NativeSessionObservation,
|
|
9
9
|
type NativeSessionManager,
|
|
10
10
|
type NativeSessionCapture,
|
|
11
|
+
type CapturedSessionBranch,
|
|
11
12
|
} from "./native-session.ts";
|
|
12
13
|
export {
|
|
13
14
|
buildExecutionRetentionManifest,
|
|
@@ -26,7 +27,7 @@ export { ENV_EXECUTION_ARCHIVE } from "../kernel/env-names.ts";
|
|
|
26
27
|
import { isUnderPiProjectDir } from "../kernel/project-paths.ts";
|
|
27
28
|
|
|
28
29
|
/** Operator-owned archive boundary; never a model-facing parameter or an authority source. */
|
|
29
|
-
export const RETENTION_VERSION = "2.
|
|
30
|
+
export const RETENTION_VERSION = "2.1";
|
|
30
31
|
const LIMIT = 1024 * 1024;
|
|
31
32
|
const MAX_ACTIVE = 32;
|
|
32
33
|
let active = 0;
|
|
@@ -49,7 +50,7 @@ export interface RetainedContent {
|
|
|
49
50
|
bytes: number | null;
|
|
50
51
|
}
|
|
51
52
|
export interface ExecutionRetentionManifest {
|
|
52
|
-
version: "2.0";
|
|
53
|
+
version: "2.0" | "2.1";
|
|
53
54
|
archiveId: string;
|
|
54
55
|
identity: RetentionIdentity;
|
|
55
56
|
native: {
|
|
@@ -155,9 +156,10 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
155
156
|
let nativeTask: Promise<void> | undefined;
|
|
156
157
|
let nextNative:
|
|
157
158
|
| {
|
|
158
|
-
source: "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager";
|
|
159
|
+
source: "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager" | "pi-captured-final";
|
|
159
160
|
value: string;
|
|
160
161
|
manager?: NativeSessionManager;
|
|
162
|
+
capturedBranch?: CapturedSessionBranch;
|
|
161
163
|
}
|
|
162
164
|
| undefined;
|
|
163
165
|
let pinnedSessionId: string | undefined;
|
|
@@ -236,6 +238,7 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
236
238
|
source: reference.source,
|
|
237
239
|
value: reference.value,
|
|
238
240
|
...(reference.manager ? { manager: reference.manager } : {}),
|
|
241
|
+
...(reference.capturedBranch ? { capturedBranch: { ...reference.capturedBranch } } : {}),
|
|
239
242
|
};
|
|
240
243
|
if (nativeTask) return;
|
|
241
244
|
nativeTask = Promise.resolve()
|
|
@@ -258,6 +261,7 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
|
|
|
258
261
|
path: current.value,
|
|
259
262
|
source: current.source,
|
|
260
263
|
manager: current.manager,
|
|
264
|
+
capturedBranch: current.capturedBranch,
|
|
261
265
|
expectedSessionId: pinnedSessionId,
|
|
262
266
|
expectedFile: pinnedFile,
|
|
263
267
|
});
|
|
@@ -7,7 +7,8 @@ import { ENV_NATIVE_SESSION_ROOT } from "../kernel/env-names.ts";
|
|
|
7
7
|
export { ENV_NATIVE_SESSION_ROOT } from "../kernel/env-names.ts";
|
|
8
8
|
|
|
9
9
|
export const MAX_NATIVE_SESSION_BYTES = 1024 * 1024;
|
|
10
|
-
export type NativeSessionSource =
|
|
10
|
+
export type NativeSessionSource =
|
|
11
|
+
"herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager" | "pi-captured-final";
|
|
11
12
|
export interface NativeSessionObservation {
|
|
12
13
|
source: NativeSessionSource | null;
|
|
13
14
|
status: "missing" | "verified" | "invalid" | "changed" | "truncated" | "unsupported";
|
|
@@ -25,6 +26,13 @@ export interface NativeSessionCapture {
|
|
|
25
26
|
bytes?: Buffer;
|
|
26
27
|
fileIdentity?: { device: string; inode: string };
|
|
27
28
|
}
|
|
29
|
+
/** Byte-bound metadata from the executor's qualified native final, not a locator-derived branch. */
|
|
30
|
+
export interface CapturedSessionBranch {
|
|
31
|
+
path: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
leafId: string;
|
|
34
|
+
sha256: string;
|
|
35
|
+
}
|
|
28
36
|
/** Read-only methods of pi 0.84.2's public SessionManager; no runtime/session creation or model work. */
|
|
29
37
|
export interface NativeSessionManager {
|
|
30
38
|
getSessionFile(): string | undefined;
|
|
@@ -59,6 +67,7 @@ export function parseNativeSessionBytes(
|
|
|
59
67
|
expectedSessionId?: string;
|
|
60
68
|
truncated?: boolean;
|
|
61
69
|
liveLeaf?: { sessionId: string; leafId: string | null };
|
|
70
|
+
capturedBranch?: CapturedSessionBranch;
|
|
62
71
|
},
|
|
63
72
|
): NativeSessionCapture {
|
|
64
73
|
const observation = { ...missingNativeSession(), source: input.source, sessionPath: input.path };
|
|
@@ -138,6 +147,23 @@ export function parseNativeSessionBytes(
|
|
|
138
147
|
observation.branchLeafId = input.liveLeaf.leafId;
|
|
139
148
|
observation.branchState = "observed";
|
|
140
149
|
}
|
|
150
|
+
if (input.source === "pi-captured-final" && !input.capturedBranch)
|
|
151
|
+
return fail("invalid", "native-session-final-branch-missing");
|
|
152
|
+
if (input.capturedBranch) {
|
|
153
|
+
const branch = input.capturedBranch;
|
|
154
|
+
if (
|
|
155
|
+
input.source !== "pi-captured-final" ||
|
|
156
|
+
branch.path !== input.path ||
|
|
157
|
+
branch.sessionId !== header.id ||
|
|
158
|
+
branch.sha256 !== observation.sha256 ||
|
|
159
|
+
!ids.has(branch.leafId) ||
|
|
160
|
+
(input.liveLeaf && input.liveLeaf.leafId !== branch.leafId)
|
|
161
|
+
) {
|
|
162
|
+
return fail("changed", "native-session-final-branch-mismatch");
|
|
163
|
+
}
|
|
164
|
+
observation.branchLeafId = branch.leafId;
|
|
165
|
+
observation.branchState = "observed";
|
|
166
|
+
}
|
|
141
167
|
observation.status = "verified";
|
|
142
168
|
observation.reason = observation.branchState === "unknown" ? "active-branch-unknown" : null;
|
|
143
169
|
return result;
|
|
@@ -150,6 +176,7 @@ export async function readNativeSession(input: {
|
|
|
150
176
|
expectedSessionId?: string;
|
|
151
177
|
allowedRoot?: string;
|
|
152
178
|
manager?: NativeSessionManager;
|
|
179
|
+
capturedBranch?: CapturedSessionBranch;
|
|
153
180
|
expectedFile?: { device: string; inode: string };
|
|
154
181
|
}): Promise<NativeSessionCapture> {
|
|
155
182
|
const gap = (status: NativeSessionObservation["status"], reason: string): NativeSessionCapture => ({
|
|
@@ -31,7 +31,7 @@ export const RETENTION_SCHEMA = freeze({
|
|
|
31
31
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
32
32
|
$id: "https://pi-daddy.local/contracts/execution-retention/v2/manifest.schema.json",
|
|
33
33
|
...closed({
|
|
34
|
-
version:
|
|
34
|
+
version: { enum: ["2.0", "2.1"] },
|
|
35
35
|
archiveId: uuid,
|
|
36
36
|
identity: closed({
|
|
37
37
|
executionId: string(),
|
|
@@ -54,7 +54,7 @@ export const RETENTION_SCHEMA = freeze({
|
|
|
54
54
|
branchLeafId: nullable(string(128)),
|
|
55
55
|
}),
|
|
56
56
|
nativeSession: closed({
|
|
57
|
-
source: { enum: [null, "herdr-id", "herdr-path", "pi-session-file", "pi-session-manager"] },
|
|
57
|
+
source: { enum: [null, "herdr-id", "herdr-path", "pi-session-file", "pi-session-manager", "pi-captured-final"] },
|
|
58
58
|
status: { enum: ["missing", "verified", "invalid", "changed", "truncated", "unsupported"] },
|
|
59
59
|
sessionId: nullable(uuid),
|
|
60
60
|
sessionPath: nullable(string(4096)),
|
|
@@ -101,6 +101,10 @@ export const RETENTION_SCHEMA = freeze({
|
|
|
101
101
|
acceptance: literal("not-assessed"),
|
|
102
102
|
}),
|
|
103
103
|
allOf: [
|
|
104
|
+
{
|
|
105
|
+
if: { properties: { version: literal("2.0") } },
|
|
106
|
+
then: { properties: { nativeSession: { properties: { source: { not: literal("pi-captured-final") } } } } },
|
|
107
|
+
},
|
|
104
108
|
{
|
|
105
109
|
if: { properties: { state: literal("running") } },
|
|
106
110
|
then: { properties: { outcome: literal(null) } },
|
|
@@ -162,7 +166,7 @@ export function buildExecutionRetentionManifest(value: unknown): ExecutionRetent
|
|
|
162
166
|
}
|
|
163
167
|
};
|
|
164
168
|
inspect(value, 0);
|
|
165
|
-
if (!validator.Check(value)) throw new TypeError("invalid execution-retention 2.
|
|
169
|
+
if (!validator.Check(value)) throw new TypeError("invalid execution-retention 2.x manifest");
|
|
166
170
|
const m = JSON.parse(JSON.stringify(value)) as ExecutionRetentionManifest;
|
|
167
171
|
if (
|
|
168
172
|
m.native.sessionId !== m.nativeSession.sessionId ||
|
|
@@ -178,7 +182,8 @@ export function buildExecutionRetentionManifest(value: unknown): ExecutionRetent
|
|
|
178
182
|
}
|
|
179
183
|
if (
|
|
180
184
|
m.nativeSession.branchState === "observed" &&
|
|
181
|
-
(
|
|
185
|
+
(!["pi-session-manager", "pi-captured-final"].includes(m.nativeSession.source ?? "") ||
|
|
186
|
+
m.nativeSession.status !== "verified")
|
|
182
187
|
)
|
|
183
188
|
throw new TypeError("unverified active native branch");
|
|
184
189
|
if (
|
package/src/kernel/auto-mode.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface AutoModeRef {
|
|
|
19
19
|
}
|
|
20
20
|
export interface AutoModeReader {
|
|
21
21
|
readonly reference: AutoModeRef;
|
|
22
|
+
readonly operations?: import("./dispatch-operation.ts").OperationAccess;
|
|
22
23
|
read(): Promise<AutoModeSnapshot>;
|
|
23
24
|
/** Linearization point: only a current ON answer admits new work. No approval is banked. */
|
|
24
25
|
admit(signal?: AbortSignal): Promise<boolean>;
|
package/src/kernel/chain.ts
CHANGED
|
@@ -152,6 +152,7 @@ export function composeStepTask(template: string, previous: string | undefined):
|
|
|
152
152
|
|
|
153
153
|
/** One chain step as `runOneDelegation` takes it: the operator's spec with its task composed. */
|
|
154
154
|
export interface ChainStep {
|
|
155
|
+
operation_id?: string;
|
|
155
156
|
definitionId?: string;
|
|
156
157
|
task: string;
|
|
157
158
|
agent?: string;
|