pi-daddy 0.43.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -33
- package/README.md +27 -5
- package/THIRD_PARTY_LICENSES/musl-COPYRIGHT.txt +193 -0
- package/THIRD_PARTY_LICENSES/zig-MIT.txt +21 -0
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/contracts/ledger-record/v1/README.md +40 -25
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -5
- package/dist/cli.js.map +1 -1
- package/dist/executors/activity-session.js +116 -65
- package/dist/executors/activity-session.js.map +1 -1
- package/dist/executors/captured-execution.d.ts +13 -0
- package/dist/executors/captured-execution.d.ts.map +1 -0
- package/dist/executors/captured-execution.js +53 -0
- package/dist/executors/captured-execution.js.map +1 -0
- package/dist/executors/child-final.d.ts +31 -0
- package/dist/executors/child-final.d.ts.map +1 -0
- package/dist/executors/child-final.js +197 -0
- package/dist/executors/child-final.js.map +1 -0
- package/dist/executors/executor.d.ts +2 -1
- package/dist/executors/executor.d.ts.map +1 -1
- package/dist/executors/executor.js +12 -0
- package/dist/executors/executor.js.map +1 -1
- package/dist/executors/owned-worker.d.ts +18 -0
- package/dist/executors/owned-worker.d.ts.map +1 -0
- package/dist/executors/owned-worker.js +295 -0
- package/dist/executors/owned-worker.js.map +1 -0
- package/dist/executors/worker-artifact.d.ts +3 -0
- package/dist/executors/worker-artifact.d.ts.map +1 -0
- package/dist/executors/worker-artifact.js +3 -0
- package/dist/executors/worker-artifact.js.map +1 -0
- package/dist/governance/approval-prompt.d.ts +4 -3
- package/dist/governance/approval-prompt.d.ts.map +1 -1
- package/dist/governance/approval-prompt.js +13 -6
- package/dist/governance/approval-prompt.js.map +1 -1
- package/dist/governance/captured-worker-record.d.ts +9 -0
- package/dist/governance/captured-worker-record.d.ts.map +1 -0
- package/dist/governance/captured-worker-record.js +123 -0
- package/dist/governance/captured-worker-record.js.map +1 -0
- package/dist/governance/lease-record.d.ts +4 -0
- package/dist/governance/lease-record.d.ts.map +1 -1
- package/dist/governance/lease-record.js +19 -2
- package/dist/governance/lease-record.js.map +1 -1
- package/dist/governance/ledger-events.d.ts +5 -5
- package/dist/governance/ledger-events.d.ts.map +1 -1
- package/dist/governance/ledger-v3-validation.js +1 -1
- package/dist/governance/ledger-v3-validation.js.map +1 -1
- package/dist/governance/workspace-acceptance.d.ts +3 -1
- package/dist/governance/workspace-acceptance.d.ts.map +1 -1
- package/dist/governance/workspace-acceptance.js +21 -5
- package/dist/governance/workspace-acceptance.js.map +1 -1
- package/dist/governance/workspace-lease.d.ts.map +1 -1
- package/dist/governance/workspace-lease.js +44 -1
- package/dist/governance/workspace-lease.js.map +1 -1
- package/dist/kernel/authority-text.d.ts +3 -0
- package/dist/kernel/authority-text.d.ts.map +1 -0
- package/dist/kernel/authority-text.js +22 -0
- package/dist/kernel/authority-text.js.map +1 -0
- package/dist/kernel/bounded-read-failures.d.ts +9 -0
- package/dist/kernel/bounded-read-failures.d.ts.map +1 -0
- package/dist/kernel/bounded-read-failures.js +33 -0
- package/dist/kernel/bounded-read-failures.js.map +1 -0
- package/dist/kernel/bounded-read-sync.d.ts +24 -0
- package/dist/kernel/bounded-read-sync.d.ts.map +1 -0
- package/dist/kernel/bounded-read-sync.js +75 -0
- package/dist/kernel/bounded-read-sync.js.map +1 -0
- package/dist/kernel/bounded-read.d.ts +29 -2
- package/dist/kernel/bounded-read.d.ts.map +1 -1
- package/dist/kernel/bounded-read.js +98 -9
- package/dist/kernel/bounded-read.js.map +1 -1
- package/dist/kernel/capacity.d.ts +36 -0
- package/dist/kernel/capacity.d.ts.map +1 -0
- package/dist/kernel/capacity.js +94 -0
- package/dist/kernel/capacity.js.map +1 -0
- package/dist/kernel/captured-worker-contract.d.ts +38 -0
- package/dist/kernel/captured-worker-contract.d.ts.map +1 -0
- package/dist/kernel/captured-worker-contract.js +2 -0
- package/dist/kernel/captured-worker-contract.js.map +1 -0
- package/dist/kernel/catalog.d.ts +12 -3
- package/dist/kernel/catalog.d.ts.map +1 -1
- package/dist/kernel/catalog.js +28 -8
- package/dist/kernel/catalog.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/definitions.d.ts +11 -1
- package/dist/kernel/definitions.d.ts.map +1 -1
- package/dist/kernel/definitions.js +6 -3
- package/dist/kernel/definitions.js.map +1 -1
- package/dist/kernel/fanout.d.ts +5 -25
- package/dist/kernel/fanout.d.ts.map +1 -1
- package/dist/kernel/fanout.js +15 -30
- package/dist/kernel/fanout.js.map +1 -1
- package/dist/kernel/grant-env.d.ts +0 -2
- package/dist/kernel/grant-env.d.ts.map +1 -1
- package/dist/kernel/grant-env.js +0 -1
- package/dist/kernel/grant-env.js.map +1 -1
- package/dist/kernel/model-preflight.d.ts +8 -0
- package/dist/kernel/model-preflight.d.ts.map +1 -1
- package/dist/kernel/model-preflight.js +23 -0
- package/dist/kernel/model-preflight.js.map +1 -1
- package/dist/kernel/selected-definitions.d.ts +15 -0
- package/dist/kernel/selected-definitions.d.ts.map +1 -0
- package/dist/kernel/selected-definitions.js +146 -0
- package/dist/kernel/selected-definitions.js.map +1 -0
- package/dist/kernel/skill-packages.d.ts +4 -2
- package/dist/kernel/skill-packages.d.ts.map +1 -1
- package/dist/kernel/skill-packages.js +34 -41
- package/dist/kernel/skill-packages.js.map +1 -1
- package/dist/kernel/skill-resources.d.ts.map +1 -1
- package/dist/kernel/skill-resources.js +8 -11
- package/dist/kernel/skill-resources.js.map +1 -1
- package/dist/kernel/workspace.d.ts +6 -3
- package/dist/kernel/workspace.d.ts.map +1 -1
- package/dist/kernel/workspace.js +10 -6
- package/dist/kernel/workspace.js.map +1 -1
- package/dist/products/dashboard-cli.js +1 -1
- package/dist/products/dashboard-cli.js.map +1 -1
- package/dist/products/dashboard-render.d.ts +1 -2
- package/dist/products/dashboard-render.d.ts.map +1 -1
- package/dist/products/dashboard-render.js +3 -1
- package/dist/products/dashboard-render.js.map +1 -1
- package/dist/products/dashboard-session-client.d.ts +1 -2
- package/dist/products/dashboard-session-client.d.ts.map +1 -1
- package/dist/products/dashboard-session-client.js.map +1 -1
- package/dist/products/episode-report.d.ts +12 -8
- package/dist/products/episode-report.d.ts.map +1 -1
- package/dist/products/episode-report.js +61 -12
- package/dist/products/episode-report.js.map +1 -1
- package/extensions/approvals.ts +1 -1
- package/extensions/chain-plan.ts +9 -3
- package/extensions/context-staging.ts +30 -35
- package/extensions/dashboard-session-server.ts +1 -2
- package/extensions/definition-describe.ts +44 -0
- package/extensions/definition-runtime.ts +89 -26
- package/extensions/delegate-chain.ts +273 -256
- package/extensions/delegation-activation.ts +1 -1
- package/extensions/delegation.ts +41 -53
- package/extensions/execute-child.ts +167 -134
- package/extensions/fanout-outcome.ts +1 -9
- package/extensions/grants-command.ts +2 -9
- package/extensions/grants.ts +106 -38
- package/extensions/init-command.ts +52 -12
- package/extensions/reload-environment.ts +21 -2
- package/extensions/retired-inputs.ts +7 -0
- package/extensions/run-delegation.ts +212 -265
- package/extensions/session-capacity.ts +53 -0
- package/extensions/session-environment.ts +5 -2
- package/extensions/session-model-prompt.ts +33 -32
- package/extensions/session-report.ts +28 -11
- package/extensions/session.ts +200 -157
- package/extensions/workspace-runtime.ts +2 -1
- package/native/README.md +25 -0
- package/native/linux-x64/worker +0 -0
- package/native/linux-x64/worker.sha256 +1 -0
- package/native/provenance.json +42 -0
- package/native/toolchain.json +18 -0
- package/native/worker.c +209 -0
- package/package.json +15 -7
- package/src/cli.ts +16 -6
- package/src/executors/activity-session.ts +120 -66
- package/src/executors/captured-execution.ts +61 -0
- package/src/executors/child-final.ts +190 -0
- package/src/executors/executor.ts +14 -1
- package/src/executors/owned-worker.ts +322 -0
- package/src/executors/worker-artifact.ts +2 -0
- package/src/governance/approval-prompt.ts +18 -8
- package/src/governance/captured-worker-record.ts +138 -0
- package/src/governance/lease-record.ts +21 -2
- package/src/governance/ledger-events.ts +5 -5
- package/src/governance/ledger-v3-validation.ts +1 -1
- package/src/governance/workspace-acceptance.ts +20 -4
- package/src/governance/workspace-lease.ts +59 -1
- package/src/kernel/authority-text.ts +17 -0
- package/src/kernel/bounded-read-failures.ts +42 -0
- package/src/kernel/bounded-read-sync.ts +87 -0
- package/src/kernel/bounded-read.ts +128 -11
- package/src/kernel/capacity.ts +123 -0
- package/src/kernel/captured-worker-contract.ts +29 -0
- package/src/kernel/catalog.ts +45 -16
- package/src/kernel/chain.ts +1 -0
- package/src/kernel/definitions.ts +11 -2
- package/src/kernel/fanout.ts +17 -32
- package/src/kernel/grant-env.ts +0 -3
- package/src/kernel/model-preflight.ts +29 -0
- package/src/kernel/selected-definitions.ts +156 -0
- package/src/kernel/skill-packages.ts +40 -38
- package/src/kernel/skill-resources.ts +8 -9
- package/src/kernel/workspace.ts +12 -5
- package/src/products/dashboard-cli.ts +1 -1
- package/src/products/dashboard-render.ts +6 -2
- package/src/products/dashboard-session-client.ts +1 -2
- package/src/products/episode-report.ts +80 -17
- package/dist/advisors/advisor.d.ts +0 -90
- package/dist/advisors/advisor.d.ts.map +0 -1
- package/dist/advisors/advisor.js +0 -154
- package/dist/advisors/advisor.js.map +0 -1
- package/dist/advisors/decider.d.ts +0 -88
- package/dist/advisors/decider.d.ts.map +0 -1
- package/dist/advisors/decider.js +0 -29
- package/dist/advisors/decider.js.map +0 -1
- package/dist/advisors/jev.d.ts +0 -41
- package/dist/advisors/jev.d.ts.map +0 -1
- package/dist/advisors/jev.js +0 -139
- package/dist/advisors/jev.js.map +0 -1
- package/dist/advisors/settings.d.ts +0 -49
- package/dist/advisors/settings.d.ts.map +0 -1
- package/dist/advisors/settings.js +0 -99
- package/dist/advisors/settings.js.map +0 -1
- package/dist/governance/episode-cost-gate.d.ts +0 -31
- package/dist/governance/episode-cost-gate.d.ts.map +0 -1
- package/dist/governance/episode-cost-gate.js +0 -112
- package/dist/governance/episode-cost-gate.js.map +0 -1
- package/extensions/advisor-session.ts +0 -67
- package/extensions/effort-advice.ts +0 -112
- package/extensions/pruning-advice.ts +0 -133
- package/src/advisors/advisor.ts +0 -246
- package/src/advisors/decider.ts +0 -74
- package/src/advisors/jev.ts +0 -163
- package/src/advisors/settings.ts +0 -115
- package/src/governance/episode-cost-gate.ts +0 -128
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Independent ownership evidence; unreadable/mismatched receipts never establish settlement. */
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, realpath, stat, readFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import type { CapturedWorkerIdentity, CapturedWorkerReceipt } from "../kernel/captured-worker-contract.ts";
|
|
6
|
+
|
|
7
|
+
const identityKeys = [
|
|
8
|
+
"revision",
|
|
9
|
+
"executionId",
|
|
10
|
+
"nonce",
|
|
11
|
+
"root",
|
|
12
|
+
"rootDevice",
|
|
13
|
+
"rootInode",
|
|
14
|
+
"bootId",
|
|
15
|
+
"pidNamespace",
|
|
16
|
+
"helperPid",
|
|
17
|
+
"helperStartTicks",
|
|
18
|
+
"helperSha256",
|
|
19
|
+
"workerPid",
|
|
20
|
+
"ownershipPath",
|
|
21
|
+
"receiptPath",
|
|
22
|
+
] as const;
|
|
23
|
+
export function isCapturedWorkerIdentity(value: unknown): value is CapturedWorkerIdentity {
|
|
24
|
+
if (!value || typeof value !== "object") return false;
|
|
25
|
+
const v = value as Record<string, unknown>;
|
|
26
|
+
if (
|
|
27
|
+
v.revision !== 1 ||
|
|
28
|
+
!Number.isSafeInteger(v.helperPid) ||
|
|
29
|
+
Number(v.helperPid) < 1 ||
|
|
30
|
+
!Number.isSafeInteger(v.workerPid) ||
|
|
31
|
+
Number(v.workerPid) < 1
|
|
32
|
+
)
|
|
33
|
+
return false;
|
|
34
|
+
if (
|
|
35
|
+
identityKeys.some(
|
|
36
|
+
(key) => !["revision", "helperPid", "workerPid"].includes(key) && (typeof v[key] !== "string" || !v[key]),
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return false;
|
|
40
|
+
if (!/^[a-f0-9]{64}$/.test(String(v.helperSha256)) || !/^\d+$/.test(String(v.helperStartTicks))) return false;
|
|
41
|
+
return (
|
|
42
|
+
v.ownershipPath === join(dirname(String(v.receiptPath)), "ownership.json") &&
|
|
43
|
+
v.receiptPath === join(dirname(String(v.ownershipPath)), "receipt.json")
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
export async function readWorkerRecord(path: string): Promise<unknown> {
|
|
47
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
48
|
+
try {
|
|
49
|
+
const info = await file.stat();
|
|
50
|
+
if (!info.isFile() || info.size > 16384) throw new Error("worker record must be a bounded ordinary file");
|
|
51
|
+
const buffer = Buffer.alloc(16385);
|
|
52
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
53
|
+
if (bytesRead > 16384) throw new Error("worker record grew beyond its bound");
|
|
54
|
+
return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
55
|
+
} finally {
|
|
56
|
+
await file.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function sameWorkerIdentity(a: CapturedWorkerIdentity, b: CapturedWorkerIdentity): boolean {
|
|
60
|
+
return identityKeys.every((key) => a[key] === b[key]);
|
|
61
|
+
}
|
|
62
|
+
export async function readCapturedWorkerReceipt(
|
|
63
|
+
identity: CapturedWorkerIdentity,
|
|
64
|
+
): Promise<CapturedWorkerReceipt | null> {
|
|
65
|
+
try {
|
|
66
|
+
if (!isCapturedWorkerIdentity(identity)) return null;
|
|
67
|
+
const ownership = (await readWorkerRecord(identity.ownershipPath)) as { state?: unknown; identity?: unknown };
|
|
68
|
+
const value = (await readWorkerRecord(identity.receiptPath)) as CapturedWorkerReceipt;
|
|
69
|
+
if (
|
|
70
|
+
ownership.state !== "ready" ||
|
|
71
|
+
!isCapturedWorkerIdentity(ownership.identity) ||
|
|
72
|
+
!sameWorkerIdentity(identity, ownership.identity)
|
|
73
|
+
)
|
|
74
|
+
return null;
|
|
75
|
+
if (
|
|
76
|
+
value.state !== "settled" ||
|
|
77
|
+
value.reapedAll !== true ||
|
|
78
|
+
!isCapturedWorkerIdentity(value.identity) ||
|
|
79
|
+
!sameWorkerIdentity(identity, value.identity)
|
|
80
|
+
)
|
|
81
|
+
return null;
|
|
82
|
+
if (
|
|
83
|
+
value.workerCode !== null &&
|
|
84
|
+
(!Number.isInteger(value.workerCode) || value.workerCode < 0 || value.workerCode > 255)
|
|
85
|
+
)
|
|
86
|
+
return null;
|
|
87
|
+
if (!Number.isInteger(value.workerSignal) || value.workerSignal < 0 || value.workerSignal > 64) return null;
|
|
88
|
+
if (
|
|
89
|
+
!["worker-exit", "owner-loss", "cancelled", "helper-signal", "ownership-write-failed", "start-failed"].includes(
|
|
90
|
+
value.reason,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return null;
|
|
94
|
+
if (value.workerSignal !== 0 && value.workerCode !== null) return null;
|
|
95
|
+
return value;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Before release of the execution gate, validate namespace, workspace and the still-live actual helper. */
|
|
101
|
+
export async function validateLiveWorker(identity: CapturedWorkerIdentity, helperPath: string): Promise<void> {
|
|
102
|
+
if (!isCapturedWorkerIdentity(identity)) throw new Error("malformed captured worker identity");
|
|
103
|
+
const [root, rootInfo, boot, namespace, processStat, executable, packaged] = await Promise.all([
|
|
104
|
+
realpath(identity.root),
|
|
105
|
+
stat(identity.root, { bigint: true }),
|
|
106
|
+
readFile("/proc/sys/kernel/random/boot_id", "utf8"),
|
|
107
|
+
import("node:fs/promises").then((fs) => fs.readlink("/proc/self/ns/pid")),
|
|
108
|
+
readFile(`/proc/${identity.helperPid}/stat`, "utf8"),
|
|
109
|
+
stat(`/proc/${identity.helperPid}/exe`),
|
|
110
|
+
stat(helperPath),
|
|
111
|
+
]);
|
|
112
|
+
const fields = processStat.slice(processStat.lastIndexOf(")") + 2).split(" ");
|
|
113
|
+
if (
|
|
114
|
+
root !== identity.root ||
|
|
115
|
+
String(rootInfo.dev) !== identity.rootDevice ||
|
|
116
|
+
String(rootInfo.ino) !== identity.rootInode ||
|
|
117
|
+
boot.trim() !== identity.bootId ||
|
|
118
|
+
namespace !== identity.pidNamespace ||
|
|
119
|
+
fields[19] !== identity.helperStartTicks ||
|
|
120
|
+
executable.dev !== packaged.dev ||
|
|
121
|
+
executable.ino !== packaged.ino
|
|
122
|
+
)
|
|
123
|
+
throw new Error("captured worker identity differs from actual live ownership");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function matchesWorkerWorkspace(identity: CapturedWorkerIdentity, root: string): Promise<boolean> {
|
|
127
|
+
try {
|
|
128
|
+
const info = await stat(root, { bigint: true });
|
|
129
|
+
return (
|
|
130
|
+
identity.root === root &&
|
|
131
|
+
(await realpath(root)) === root &&
|
|
132
|
+
String(info.dev) === identity.rootDevice &&
|
|
133
|
+
String(info.ino) === identity.rootInode
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* successor's token — all of which are about what the record SAYS rather than about the kernel lock.
|
|
7
7
|
*/
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
-
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
-
import { join } from "node:path";
|
|
9
|
+
import { open, readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
11
|
import type { ValidatedWorkspace, WorkspaceAccess } from "../kernel/workspace.ts";
|
|
12
12
|
|
|
13
|
+
import type { CapturedWorkerIdentity } from "../kernel/captured-worker-contract.ts";
|
|
14
|
+
import { isCapturedWorkerIdentity } from "./captured-worker-record.ts";
|
|
15
|
+
|
|
13
16
|
export interface LeaseMetadata {
|
|
14
17
|
version: 1;
|
|
15
18
|
state: "active" | "released";
|
|
@@ -19,6 +22,7 @@ export interface LeaseMetadata {
|
|
|
19
22
|
root: string;
|
|
20
23
|
pid: number;
|
|
21
24
|
acquired_at: string;
|
|
25
|
+
captured_worker?: CapturedWorkerIdentity;
|
|
22
26
|
released_at?: string;
|
|
23
27
|
release_reason?: string;
|
|
24
28
|
}
|
|
@@ -35,6 +39,8 @@ export interface WorkspaceLease {
|
|
|
35
39
|
recovered: boolean | "unknown";
|
|
36
40
|
/** Attach the governed resource so parent death cleans it before the kernel lock is released. */
|
|
37
41
|
attachProcess(pid: number): void;
|
|
42
|
+
/** Persist exact gated-worker ownership before its command may execute. */
|
|
43
|
+
attachCapturedWorker(identity: CapturedWorkerIdentity): Promise<void>;
|
|
38
44
|
attachHerdrTab(tabId: string): void;
|
|
39
45
|
/** Resolves only when the kernel-lock helper exits before explicit release. */
|
|
40
46
|
lost: Promise<Error>;
|
|
@@ -104,7 +110,19 @@ export function leasePaths(leaseDir: string, root: string) {
|
|
|
104
110
|
export async function atomicMetadata(path: string, value: LeaseMetadata): Promise<void> {
|
|
105
111
|
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
106
112
|
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
113
|
+
const file = await open(temp, "r+");
|
|
114
|
+
try {
|
|
115
|
+
await file.sync();
|
|
116
|
+
} finally {
|
|
117
|
+
await file.close();
|
|
118
|
+
}
|
|
107
119
|
await rename(temp, path);
|
|
120
|
+
const directory = await open(dirname(path), "r");
|
|
121
|
+
try {
|
|
122
|
+
await directory.sync();
|
|
123
|
+
} finally {
|
|
124
|
+
await directory.close();
|
|
125
|
+
}
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
/**
|
|
@@ -124,6 +142,7 @@ export async function readMetadata(path: string): Promise<LeaseMetadata | null |
|
|
|
124
142
|
if (value?.version !== 1) return "malformed";
|
|
125
143
|
if (value.state !== "active" && value.state !== "released") return "malformed";
|
|
126
144
|
if (typeof value.token !== "string" || !value.token) return "malformed";
|
|
145
|
+
if (value.captured_worker !== undefined && !isCapturedWorkerIdentity(value.captured_worker)) return "malformed";
|
|
127
146
|
return value as LeaseMetadata;
|
|
128
147
|
} catch {
|
|
129
148
|
return "malformed";
|
|
@@ -150,13 +150,13 @@ export interface ChildLifecycleEvent extends LedgerEventBase {
|
|
|
150
150
|
idleTimeoutMs?: number;
|
|
151
151
|
/** The provider/model selected for the child, confirmed from its persisted assistant message when available. */
|
|
152
152
|
resolvedModel?: { provider: string; modelId: string } | null;
|
|
153
|
-
modelSource?: "explicit" | "session" | "definition" | "global" | "pi";
|
|
153
|
+
modelSource?: "explicit" | "session" | "definition" | "authored" | "global" | "pi";
|
|
154
154
|
/** The persisted effective level and its selection source, when pi reports one. */
|
|
155
155
|
thinkingLevel?: {
|
|
156
156
|
level: string;
|
|
157
|
-
source: "explicit" | "session" | "advisor" | "definition" | "global" | "pi";
|
|
157
|
+
source: "explicit" | "session" | "advisor" | "definition" | "authored" | "global" | "pi";
|
|
158
158
|
} | null;
|
|
159
|
-
thinkingSource?: "explicit" | "session" | "advisor" | "definition" | "global" | "pi";
|
|
159
|
+
thinkingSource?: "explicit" | "session" | "advisor" | "definition" | "authored" | "global" | "pi";
|
|
160
160
|
/** Provider-reported token dimensions; zero is normalised to null rather than invented as reported usage. */
|
|
161
161
|
tokenDetail?: ChildTokenDetail;
|
|
162
162
|
/** Aggregate model usage read from the child's pi session file after it stopped. */
|
|
@@ -353,9 +353,9 @@ export function buildChildLifecycleEvent(args: {
|
|
|
353
353
|
truncated?: boolean;
|
|
354
354
|
reason?: string;
|
|
355
355
|
resolvedModel?: { provider: string; modelId: string };
|
|
356
|
-
modelSource?: "explicit" | "session" | "definition" | "global" | "pi";
|
|
356
|
+
modelSource?: "explicit" | "session" | "definition" | "authored" | "global" | "pi";
|
|
357
357
|
effectiveThinkingLevel?: string;
|
|
358
|
-
thinkingSource?: "explicit" | "session" | "advisor" | "definition" | "global" | "pi";
|
|
358
|
+
thinkingSource?: "explicit" | "session" | "advisor" | "definition" | "authored" | "global" | "pi";
|
|
359
359
|
tokenDetail?: ChildTokenDetail;
|
|
360
360
|
usage?: ChildUsageTotals;
|
|
361
361
|
compactionCount?: number;
|
|
@@ -406,7 +406,7 @@ function validResolvedModel(value: unknown): boolean {
|
|
|
406
406
|
);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
-
const MODEL_SOURCES = ["explicit", "session", "definition", "global", "pi"];
|
|
409
|
+
const MODEL_SOURCES = ["explicit", "session", "definition", "authored", "global", "pi"];
|
|
410
410
|
const THINKING_SOURCES = [...MODEL_SOURCES, "advisor"];
|
|
411
411
|
function validThinkingLevel(value: unknown): boolean {
|
|
412
412
|
return (
|
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
*
|
|
41
41
|
* Keyed by registry path, not by project: accepting an id is a statement about the operator's registry.
|
|
42
42
|
*/
|
|
43
|
-
import { mkdir,
|
|
43
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
44
44
|
import { dirname, join } from "node:path";
|
|
45
|
+
import { readAuthorityText } from "../kernel/authority-text.ts";
|
|
45
46
|
import { acceptedWorkspacesPath } from "../kernel/project-paths.ts";
|
|
46
47
|
|
|
47
48
|
export interface AcceptedWorkspaces {
|
|
@@ -84,12 +85,23 @@ export async function reconcileAcceptedWorkspaces(
|
|
|
84
85
|
registryPath: string,
|
|
85
86
|
registryIds: readonly string[],
|
|
86
87
|
env: NodeJS.ProcessEnv = process.env,
|
|
88
|
+
/** Trusted physical-read dependency; never supplied by a registry or requester. */
|
|
89
|
+
read: (path: string) => Promise<string> = readAuthorityText,
|
|
90
|
+
assertHealthy?: () => void,
|
|
87
91
|
): Promise<AcceptedWorkspaces> {
|
|
92
|
+
assertHealthy?.();
|
|
88
93
|
const path = acceptedWorkspacesPath(registryPath, env);
|
|
89
|
-
const text = await
|
|
94
|
+
const text = await read(path).catch((error: unknown) => {
|
|
95
|
+
// Unknown decisions are not first use; preserve physical failures and their recovery capabilities.
|
|
96
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
97
|
+
assertHealthy?.();
|
|
98
|
+
return undefined;
|
|
99
|
+
});
|
|
100
|
+
assertHealthy?.();
|
|
90
101
|
if (text === undefined) {
|
|
91
102
|
const accepted = [...registryIds].sort();
|
|
92
|
-
await write(path, { version: 1, registry: registryPath, accepted });
|
|
103
|
+
await write(path, { version: 1, registry: registryPath, accepted }, assertHealthy);
|
|
104
|
+
assertHealthy?.();
|
|
93
105
|
return { accepted, firstUse: true, unaccepted: [] };
|
|
94
106
|
}
|
|
95
107
|
const stored = parse(text);
|
|
@@ -116,9 +128,13 @@ export async function acceptWorkspaces(
|
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
/** Atomic, for the reason every other store here is: a half-written record reads as a narrower decision. */
|
|
119
|
-
async function write(path: string, body: StoredAcceptance): Promise<void> {
|
|
131
|
+
async function write(path: string, body: StoredAcceptance, assertHealthy?: () => void): Promise<void> {
|
|
132
|
+
assertHealthy?.();
|
|
120
133
|
await mkdir(dirname(path), { recursive: true });
|
|
134
|
+
assertHealthy?.();
|
|
121
135
|
const temp = join(dirname(path), `.${Math.random().toString(36).slice(2)}.tmp`);
|
|
122
136
|
await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
137
|
+
assertHealthy?.();
|
|
123
138
|
await rename(temp, path);
|
|
139
|
+
assertHealthy?.();
|
|
124
140
|
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readCapturedWorkerReceipt,
|
|
3
|
+
matchesWorkerWorkspace,
|
|
4
|
+
readWorkerRecord,
|
|
5
|
+
sameWorkerIdentity,
|
|
6
|
+
isCapturedWorkerIdentity,
|
|
7
|
+
} from "./captured-worker-record.ts";
|
|
1
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
9
|
import {
|
|
3
10
|
atomicMetadata,
|
|
@@ -84,6 +91,7 @@ export async function acquireWorkspaceLease(input: {
|
|
|
84
91
|
ownerId: input.ownerId,
|
|
85
92
|
recovered: false,
|
|
86
93
|
attachProcess: () => {},
|
|
94
|
+
attachCapturedWorker: async () => {},
|
|
87
95
|
attachHerdrTab: () => {},
|
|
88
96
|
// A read lease took no kernel lock, so there is nothing to keep: `not-held` is the same answer
|
|
89
97
|
// `release()` gives, and it is the truth rather than a retention that never happened (R-152).
|
|
@@ -238,8 +246,25 @@ export async function acquireWorkspaceLease(input: {
|
|
|
238
246
|
}
|
|
239
247
|
|
|
240
248
|
const previous = await readMetadata(paths.metadata);
|
|
249
|
+
// A free kernel lock cannot establish settlement of an earlier captured subtree.
|
|
250
|
+
if (
|
|
251
|
+
previous === "malformed" ||
|
|
252
|
+
(previous?.captured_worker &&
|
|
253
|
+
(!(await matchesWorkerWorkspace(previous.captured_worker, input.workspace.root)) ||
|
|
254
|
+
!(await readCapturedWorkerReceipt(previous.captured_worker)))) ||
|
|
255
|
+
(previous?.state === "active" && !previous.captured_worker)
|
|
256
|
+
) {
|
|
257
|
+
holder.stdin?.end();
|
|
258
|
+
throw new GovernanceRefusal(
|
|
259
|
+
refusal(
|
|
260
|
+
"WORKSPACE_LEASE_STALE",
|
|
261
|
+
"predecessor workspace ownership is unresolved; original matching worker settlement evidence is required",
|
|
262
|
+
{ workspace_id: input.workspace.workspaceId, root: input.workspace.root },
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
241
266
|
// "malformed" is not "no prior owner": an unreadable record cannot prove a clean handover (R-100).
|
|
242
|
-
const recovered
|
|
267
|
+
const recovered = previous?.state === "active";
|
|
243
268
|
const token = randomUUID();
|
|
244
269
|
const metadata: LeaseMetadata = {
|
|
245
270
|
version: 1,
|
|
@@ -295,6 +320,28 @@ export async function acquireWorkspaceLease(input: {
|
|
|
295
320
|
access: "write",
|
|
296
321
|
ownerId: input.ownerId,
|
|
297
322
|
recovered,
|
|
323
|
+
async attachCapturedWorker(identity) {
|
|
324
|
+
if (releasing || holder.exitCode !== null || holder.signalCode !== null)
|
|
325
|
+
throw new Error("writer lease lost before captured ownership persistence");
|
|
326
|
+
if (!isCapturedWorkerIdentity(identity) || !(await matchesWorkerWorkspace(identity, input.workspace.root)))
|
|
327
|
+
throw new Error("captured identity does not match leased workspace");
|
|
328
|
+
const current = await readMetadata(paths.metadata);
|
|
329
|
+
if (current === "malformed" || current?.token !== token)
|
|
330
|
+
throw new Error("writer ownership record changed before captured attachment");
|
|
331
|
+
if (metadata.captured_worker && !sameWorkerIdentity(metadata.captured_worker, identity))
|
|
332
|
+
throw new Error("writer lease already owns another captured execution");
|
|
333
|
+
const record = (await readWorkerRecord(identity.ownershipPath)) as { state?: unknown; identity?: unknown };
|
|
334
|
+
if (
|
|
335
|
+
record.state !== "ready" ||
|
|
336
|
+
!isCapturedWorkerIdentity(record.identity) ||
|
|
337
|
+
!sameWorkerIdentity(identity, record.identity)
|
|
338
|
+
)
|
|
339
|
+
throw new Error("captured readiness record mismatch");
|
|
340
|
+
metadata.captured_worker = identity;
|
|
341
|
+
await atomicMetadata(paths.metadata, metadata);
|
|
342
|
+
if (holder.exitCode !== null || holder.signalCode !== null)
|
|
343
|
+
throw new Error("writer lease lost during captured ownership persistence");
|
|
344
|
+
},
|
|
298
345
|
attachProcess(pid) {
|
|
299
346
|
attach({ process_pid: pid });
|
|
300
347
|
},
|
|
@@ -383,6 +430,17 @@ export async function acquireWorkspaceLease(input: {
|
|
|
383
430
|
async release(reason = "completed"): Promise<LeaseReleaseOutcome> {
|
|
384
431
|
// Memoized, not a boolean. A second call used to answer "released" whatever the first returned, so a
|
|
385
432
|
// retry — or a `finally` after an explicit release — read a clean handover that never happened.
|
|
433
|
+
if (metadata.captured_worker) {
|
|
434
|
+
const receipt = await readCapturedWorkerReceipt(metadata.captured_worker);
|
|
435
|
+
if (!receipt) {
|
|
436
|
+
holder.unref();
|
|
437
|
+
unrefStream(holder.stdout);
|
|
438
|
+
unrefStream(holder.stderr);
|
|
439
|
+
return (settled = "retained");
|
|
440
|
+
}
|
|
441
|
+
// Only original matching receipt can reconcile a previously uncertain captured worker.
|
|
442
|
+
if (settled === "retained") settled = undefined;
|
|
443
|
+
}
|
|
386
444
|
if (settled) return settled;
|
|
387
445
|
releasing = true;
|
|
388
446
|
// Already gone: this owner never released anything, and saying so is the caller's business to
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Authority metadata: only ENOENT is absence; damage and unresolved cleanup preserve failure. */
|
|
2
|
+
import { readBoundedFile } from "./bounded-read.ts";
|
|
3
|
+
export async function readAuthorityText(path: string): Promise<string> {
|
|
4
|
+
const read = await readBoundedFile(path, { maxBytes: 1024 * 1024, timeoutMs: 2_000 });
|
|
5
|
+
if (read.ok) return read.text;
|
|
6
|
+
const error = new Error(`Cannot read ${path}: ${read.why} (${read.detail})`);
|
|
7
|
+
if ("code" in read && read.code) Object.assign(error, { code: read.code });
|
|
8
|
+
throw error;
|
|
9
|
+
}
|
|
10
|
+
export async function readOptionalAuthorityText(path: string): Promise<string | undefined> {
|
|
11
|
+
try {
|
|
12
|
+
return await readAuthorityText(path);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BoundedReadCleanupError } from "./bounded-read.ts";
|
|
2
|
+
|
|
3
|
+
/** Independent failed readers remain typed, with every original recovery capability intact. */
|
|
4
|
+
export class CollectedBoundedReadCleanupError extends BoundedReadCleanupError {
|
|
5
|
+
readonly errors: readonly BoundedReadCleanupError[];
|
|
6
|
+
constructor(errors: readonly BoundedReadCleanupError[]) {
|
|
7
|
+
const members = Object.freeze([...errors]);
|
|
8
|
+
super(members, async () => {
|
|
9
|
+
const outcomes = await Promise.allSettled(members.map((error) => error.cleanup()));
|
|
10
|
+
const failed = members.filter((_error, index) => outcomes[index].status === "rejected");
|
|
11
|
+
if (failed.length) throw collectBoundedReadFailures(failed);
|
|
12
|
+
});
|
|
13
|
+
this.name = "CollectedBoundedReadCleanupError";
|
|
14
|
+
this.message = `${members.length} bounded reader descriptor cleanups unresolved; retain and explicitly retry`;
|
|
15
|
+
this.errors = members;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Preserve singleton identity; flatten only our typed collection, never arbitrary caller errors. */
|
|
20
|
+
export function collectBoundedReadFailures(errors: readonly unknown[]): BoundedReadCleanupError | undefined {
|
|
21
|
+
const members = [
|
|
22
|
+
...new Set(
|
|
23
|
+
errors.flatMap((error) =>
|
|
24
|
+
error instanceof CollectedBoundedReadCleanupError
|
|
25
|
+
? error.errors
|
|
26
|
+
: error instanceof BoundedReadCleanupError
|
|
27
|
+
? [error]
|
|
28
|
+
: [],
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
];
|
|
32
|
+
const existing = errors.find(
|
|
33
|
+
(error) =>
|
|
34
|
+
error instanceof CollectedBoundedReadCleanupError &&
|
|
35
|
+
error.errors.length === members.length &&
|
|
36
|
+
error.errors.every((member, index) => member === members[index]),
|
|
37
|
+
);
|
|
38
|
+
return (
|
|
39
|
+
(existing as CollectedBoundedReadCleanupError | undefined) ??
|
|
40
|
+
(members.length > 1 ? new CollectedBoundedReadCleanupError(members) : members[0])
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Bounded synchronous reads for the synchronous planner/settings seams. */
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, readSync, realpathSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative } from "node:path";
|
|
4
|
+
import { BoundedReadCleanupError } from "./bounded-read.ts";
|
|
5
|
+
|
|
6
|
+
export interface SyncReadPorts {
|
|
7
|
+
open: typeof openSync;
|
|
8
|
+
close: typeof closeSync;
|
|
9
|
+
stat: typeof fstatSync;
|
|
10
|
+
read: typeof readSync;
|
|
11
|
+
realpath: (path: string) => string;
|
|
12
|
+
}
|
|
13
|
+
const ports: SyncReadPorts = {
|
|
14
|
+
open: openSync,
|
|
15
|
+
close: closeSync,
|
|
16
|
+
stat: fstatSync,
|
|
17
|
+
read: readSync,
|
|
18
|
+
realpath: realpathSync,
|
|
19
|
+
};
|
|
20
|
+
const unresolved = new Set<BoundedReadCleanupError>();
|
|
21
|
+
/** Diagnostic owners only: a numeric fd cannot safely be retried after close failed and may have been reused. */
|
|
22
|
+
export function unresolvedSyncReadCleanups(): readonly BoundedReadCleanupError[] {
|
|
23
|
+
return [...unresolved];
|
|
24
|
+
}
|
|
25
|
+
export function readBoundedTextSync(
|
|
26
|
+
path: string,
|
|
27
|
+
options: {
|
|
28
|
+
maxBytes: number;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
prefix?: boolean;
|
|
31
|
+
confinedRoot?: string;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
},
|
|
34
|
+
io: SyncReadPorts = ports,
|
|
35
|
+
): { text: string; truncated: boolean; size: number } {
|
|
36
|
+
const now = options.now ?? Date.now;
|
|
37
|
+
const deadline = now() + (options.timeoutMs ?? 2_000);
|
|
38
|
+
const fd = io.open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
39
|
+
try {
|
|
40
|
+
const stat = io.stat(fd);
|
|
41
|
+
if (!stat.isFile()) throw new Error("not a regular file");
|
|
42
|
+
// Linux /proc resolves the held object, including ancestor substitutions between realpath and open.
|
|
43
|
+
// If this proof is unavailable, named context files are refused rather than trusting the earlier path.
|
|
44
|
+
if (options.confinedRoot) {
|
|
45
|
+
const held = io.realpath(`/proc/self/fd/${fd}`);
|
|
46
|
+
const within = relative(options.confinedRoot, held);
|
|
47
|
+
if (!within || within === ".." || within.startsWith("../") || isAbsolute(within))
|
|
48
|
+
throw new Error("held file is outside this session's working directory");
|
|
49
|
+
}
|
|
50
|
+
if (!options.prefix && stat.size > options.maxBytes) throw new Error("file exceeds byte limit");
|
|
51
|
+
const bytes = Buffer.alloc(options.maxBytes + 1);
|
|
52
|
+
let offset = 0;
|
|
53
|
+
while (offset < bytes.length) {
|
|
54
|
+
if (now() > deadline) throw new Error("bounded read timed out");
|
|
55
|
+
const count = io.read(fd, bytes, offset, Math.min(64 * 1024, bytes.length - offset), offset);
|
|
56
|
+
if (!count) break;
|
|
57
|
+
offset += count;
|
|
58
|
+
}
|
|
59
|
+
const truncated = offset > options.maxBytes;
|
|
60
|
+
if (truncated && !options.prefix) throw new Error("file grew beyond byte limit");
|
|
61
|
+
// stream:true accepts only an incomplete final codepoint at a deliberately truncated boundary.
|
|
62
|
+
// Malformed bytes in the retained prefix still throw; valid replacement characters remain intact.
|
|
63
|
+
let text: string;
|
|
64
|
+
try {
|
|
65
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
|
66
|
+
bytes.subarray(0, Math.min(offset, options.maxBytes)),
|
|
67
|
+
{ stream: truncated },
|
|
68
|
+
);
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
throw new Error("invalid UTF-8", { cause });
|
|
71
|
+
}
|
|
72
|
+
return { text, truncated, size: stat.size };
|
|
73
|
+
} finally {
|
|
74
|
+
try {
|
|
75
|
+
io.close(fd);
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
let failure: BoundedReadCleanupError;
|
|
78
|
+
failure = new BoundedReadCleanupError(cause, async () => {
|
|
79
|
+
throw failure;
|
|
80
|
+
});
|
|
81
|
+
failure.message =
|
|
82
|
+
"synchronous reader close failed; descriptor ownership is unresolved; automatic retry is unsafe";
|
|
83
|
+
unresolved.add(failure);
|
|
84
|
+
throw failure;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|