pi-session-mcp 0.12.1 → 0.13.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/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
- package/.pi-session-mcp-cli/config.js +36 -3
- package/.pi-session-mcp-cli/doctor.js +52 -4
- package/.pi-session-mcp-cli/execution-modes.js +10 -2
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
- package/.pi-session-mcp-cli/session-tools.js +6 -2
- package/.pi-session-mcp-cli/writer-boundary.js +27 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
- package/.pi-session-mcp-cli/writer-limits.js +19 -0
- package/.pi-session-mcp-cli/writer-state.js +5 -2
- package/CHANGELOG.md +147 -0
- package/CONTRIBUTING.md +8 -1
- package/README.md +5 -2
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +3 -1
- package/dist/client-adapters/mcp-verifier.js +2 -1
- package/dist/config.d.ts +12 -1
- package/dist/config.js +36 -3
- package/dist/confined-tools.d.ts +30 -0
- package/dist/confined-tools.js +122 -0
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +52 -4
- package/dist/execution-modes.d.ts +13 -1
- package/dist/execution-modes.js +10 -2
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/main.js +10 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +1 -0
- package/dist/sandbox-shell.d.ts +71 -0
- package/dist/sandbox-shell.js +254 -0
- package/dist/sdk-pi-adapter.d.ts +17 -1
- package/dist/sdk-pi-adapter.js +65 -10
- package/dist/session-tools.d.ts +4 -2
- package/dist/session-tools.js +6 -2
- package/dist/setup-cli.d.ts +1 -2
- package/dist/setup-cli.js +40 -9
- package/dist/setup-modes.d.ts +3 -0
- package/dist/setup-modes.js +18 -1
- package/dist/setup-writers-common.d.ts +78 -0
- package/dist/setup-writers-common.js +177 -0
- package/dist/setup-writers-plan.d.ts +34 -0
- package/dist/setup-writers-plan.js +105 -0
- package/dist/setup-writers-reconcile.d.ts +8 -0
- package/dist/setup-writers-reconcile.js +169 -0
- package/dist/setup-writers.d.ts +6 -30
- package/dist/setup-writers.js +19 -353
- package/dist/tool-schemas.d.ts +4 -0
- package/dist/tool-schemas.js +1 -0
- package/dist/wake-hook.d.ts +11 -0
- package/dist/wake-hook.js +51 -8
- package/dist/writer-boundary.d.ts +2 -0
- package/dist/writer-boundary.js +27 -0
- package/dist/writer-lifecycle.d.ts +1 -0
- package/dist/writer-lifecycle.js +6 -4
- package/dist/writer-limits.d.ts +13 -0
- package/dist/writer-limits.js +19 -0
- package/dist/writer-relay.js +1 -1
- package/dist/writer-state.d.ts +11 -0
- package/dist/writer-state.js +5 -2
- package/docs/acceptance.md +271 -0
- package/docs/adr/0009-execution-modes.md +3 -2
- package/docs/adr/0012-leased-writer-pool.md +3 -3
- package/docs/adr/0013-hardened-read-only-profiles.md +184 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup.md +3 -3
- package/docs/doctor-design.md +3 -1
- package/docs/execution-modes.md +60 -4
- package/docs/installation.md +25 -1
- package/docs/isolation-setup.md +31 -2
- package/docs/roadmap.md +14 -0
- package/docs/session-mcp.md +15 -0
- package/docs/threat-model.md +51 -0
- package/docs/tool-contracts.md +33 -4
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +2 -2
- package/skills/pi-session-orchestration/references/start-errors.md +1 -1
package/dist/setup-writers.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { loadConfig } from "./config.js";
|
|
@@ -8,300 +7,20 @@ import { ALIAS_PATTERN } from "./execution-profile.js";
|
|
|
8
7
|
import { runDoctor } from "./doctor.js";
|
|
9
8
|
import { MAX_WRITERS } from "./execution-modes.js";
|
|
10
9
|
import { changeExecutionMode } from "./setup-modes.js";
|
|
11
|
-
import {
|
|
12
|
-
import { createCommandMcpStdioLauncher, createProcessRunner } from "./setup-process.js";
|
|
13
|
-
import { DurableOwnershipStore } from "./setup-ownership.js";
|
|
14
|
-
import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hooks.js";
|
|
10
|
+
import { createCommandMcpStdioLauncher } from "./setup-process.js";
|
|
15
11
|
import { result } from "./setup-result.js";
|
|
16
|
-
import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
|
|
17
12
|
import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
|
|
18
|
-
import {
|
|
19
|
-
import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
|
|
20
|
-
import { ensureWriterAbsent } from "./writer-fence.js";
|
|
21
|
-
import * as lifecycle from "./writer-lifecycle.js";
|
|
13
|
+
import { podmanCgroupControllers } from "./writer-boundary.js";
|
|
22
14
|
import * as acceptance from "./writer-acceptance.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavailable"];
|
|
28
|
-
const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
|
|
29
|
-
const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
|
|
30
|
-
const modesTarget = (alias) => ({ client: "modes", scope: "workspace", alias: safeAlias(alias) });
|
|
31
|
-
const codeFor = (error) => error instanceof OverrideLockError || error instanceof WriterLeaseError ? error.code : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message) ? error.message : "writer_operation_failed";
|
|
32
|
-
function services(deps) {
|
|
33
|
-
const home = deps.home ?? homedir();
|
|
34
|
-
const runner = deps.runner ?? createProcessRunner({ timeoutMs: IMAGE_PULL_TIMEOUT_MS, maxOutputBytes: 1_048_576, ...(deps.signal ? { signal: deps.signal } : {}) });
|
|
35
|
-
return { home, runner, life: deps.lifecycle ?? lifecycle, accept: deps.acceptance ?? acceptance,
|
|
36
|
-
ownership: deps.ownership ?? new DurableOwnershipStore(join(home, ".local", "state", "pi-session-mcp", "ownership.json"), { ...(deps.signal ? { signal: deps.signal } : {}) }),
|
|
37
|
-
adapter: deps.adapter ?? createWriterRegistrationAdapter(runner) };
|
|
38
|
-
}
|
|
39
|
-
async function context(alias, deps) {
|
|
40
|
-
const config = await loadConfig(deps.configPath);
|
|
41
|
-
const checkout = config.workspaces.get(alias);
|
|
42
|
-
if (!checkout)
|
|
43
|
-
throw new Error("alias_unknown");
|
|
44
|
-
const { home, runner } = services(deps);
|
|
45
|
-
return { home, runner, checkout, alias, releaseRoot: deps.releaseRoot, nodePath: process.execPath };
|
|
46
|
-
}
|
|
47
|
-
async function locked(deps, action) {
|
|
48
|
-
const directory = writersRoot(deps.home ?? homedir());
|
|
49
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
50
|
-
await chmod(directory, 0o700);
|
|
51
|
-
const lock = await (deps.lock ?? acquireOverrideLock)(directory, { ...(deps.signal ? { signal: deps.signal } : {}) });
|
|
52
|
-
try {
|
|
53
|
-
return await action();
|
|
54
|
-
}
|
|
55
|
-
finally {
|
|
56
|
-
await lock.release();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
async function fenced(ctx, name, deps, action) {
|
|
60
|
-
let lease;
|
|
61
|
-
try {
|
|
62
|
-
lease = await (deps.lease ?? acquireWriterLease)(ctx.home, name, { waitMs: SETUP_LEASE_WAIT_MS, ...(deps.signal ? { signal: deps.signal } : {}) });
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
throw new Error(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_lease_unavailable");
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
try {
|
|
69
|
-
await (deps.ensureAbsent ?? ensureWriterAbsent)(ctx.runner, writerPaths(ctx.home, name), name, { leaseFd: lease.fd });
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
throw new Error("writer_fenced");
|
|
73
|
-
}
|
|
74
|
-
return await action({ ...ctx, leaseFd: lease.fd });
|
|
75
|
-
}
|
|
76
|
-
finally {
|
|
77
|
-
await lease.release();
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
function intent(ctx, name, client) {
|
|
81
|
-
return { client, name: writerRegistrationName(name), wrapperPath: writerPaths(ctx.home, name).wrapper, projectDir: ctx.checkout };
|
|
82
|
-
}
|
|
15
|
+
import { writerName, writerPaths, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
|
|
16
|
+
import { codeFor, context, DoctorGateError, doctorGate, FENCE_CODES, fenced, GLOBAL_ERRORS, locked, modesTarget, plan, preflight, remove, services, targetFor } from "./setup-writers-common.js";
|
|
17
|
+
import { gateBeforeWrite, inferBase, plannedWriters } from "./setup-writers-plan.js";
|
|
18
|
+
import { reconcileUnlocked } from "./setup-writers-reconcile.js";
|
|
83
19
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
|
|
87
|
-
* Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
|
|
20
|
+
* Writer setup entry points: a repository's mode change with its writers (planning and base inference in
|
|
21
|
+
* `setup-writers-plan.ts`, reconciliation in `setup-writers-reconcile.ts`), single-writer operations and the isolation control.
|
|
88
22
|
*/
|
|
89
|
-
|
|
90
|
-
const { ownership } = services(deps);
|
|
91
|
-
const roots = { packageRoot: deps.releaseRoot, home: ctx.home, cwd: ctx.checkout, env: process.env, nodePath: ctx.nodePath };
|
|
92
|
-
// Nothing wanted and nothing owned: not even the project's settings are looked at.
|
|
93
|
-
if (!enabled && !await ownsWakeHook("local", roots, ownership))
|
|
94
|
-
return undefined;
|
|
95
|
-
const request = (access, op) => runWakeHook({
|
|
96
|
-
operation: op, scope: "local", enabled, ownership: access,
|
|
97
|
-
roots,
|
|
98
|
-
keep: () => localHookNeededByOthers({ ownership: access, home: ctx.home, cwd: ctx.checkout, configPath: deps.configPath, except: { writer: name } }),
|
|
99
|
-
});
|
|
100
|
-
const outcome = operation === "apply" || operation === "remove" ? await ownership.transaction((access) => request(access, operation)) : await request(ownership, operation);
|
|
101
|
-
return outcome.code === "hook_not_enabled" || outcome.code === "hook_not_owned" ? undefined : outcome;
|
|
102
|
-
}
|
|
103
|
-
class DoctorGateError extends Error {
|
|
104
|
-
findings;
|
|
105
|
-
constructor(findings) {
|
|
106
|
-
super("doctor_failed");
|
|
107
|
-
this.findings = findings;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
async function doctorGate(ctx, deps) {
|
|
111
|
-
const doctor = await (deps.doctor ?? runDoctor)({ home: ctx.home, packageRoot: deps.releaseRoot, configPath: deps.configPath, loadConfig: () => loadConfig(deps.configPath) });
|
|
112
|
-
const ownSubjects = new Set([ctx.alias, ...Array.from({ length: 16 }, (_, index) => writerName(ctx.alias, index + 1))]);
|
|
113
|
-
// A refused profile of this repository is its own finding: collect, finish and the profile change that fixes it must stay possible (#141).
|
|
114
|
-
// Another repository's writer that is not ready is untouched by this step and expected after an upgrade; counting it would deadlock
|
|
115
|
-
// the repository steps of two container repositories (#148).
|
|
116
|
-
const own = (check) => ownSubjects.has(check.subject)
|
|
117
|
-
|| (check.id === "container_profile_refused" && check.subject.startsWith(`${ctx.alias}:`))
|
|
118
|
-
|| (check.id === "writer_not_ready" && WRITER_NAME_PATTERN.test(check.subject));
|
|
119
|
-
const blocking = doctor.checks.filter((check) => check.severity === "error" && !own(check));
|
|
120
|
-
if (blocking.length)
|
|
121
|
-
throw new DoctorGateError(blocking.map((check) => ({ status: "failed", code: check.id, target: { client: "doctor", scope: "check", alias: gateAlias(check.id, check.subject) } })));
|
|
122
|
-
}
|
|
123
|
-
/** The alias that leads a Doctor subject: `<profile>:<package>` and writer names report their alias part; global checks have none. */
|
|
124
|
-
function gateAlias(id, subject) {
|
|
125
|
-
if (GLOBAL_ERRORS.has(id))
|
|
126
|
-
return "invalid";
|
|
127
|
-
const head = subject.split(":")[0] ?? "";
|
|
128
|
-
return safeAlias(WRITER_NAME_PATTERN.test(head) ? head.slice(0, head.lastIndexOf("-w")) : head);
|
|
129
|
-
}
|
|
130
|
-
async function preflight(ctx, names, discard, deps) {
|
|
131
|
-
if (discard)
|
|
132
|
-
return;
|
|
133
|
-
for (const name of names) {
|
|
134
|
-
const state = await readWriterState(ctx.home, name);
|
|
135
|
-
const work = await (deps.lifecycle ?? lifecycle).inspectWork(ctx, name);
|
|
136
|
-
if (!work.baseline && state?.collected?.digest !== work.digest)
|
|
137
|
-
throw new Error("writer_uncollected");
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
async function remove(ctx, name, discard, deps, extras = []) {
|
|
141
|
-
await preflight(ctx, [name], discard, deps);
|
|
142
|
-
const { adapter, ownership, life } = services(deps);
|
|
143
|
-
// Remove registrations before deleting the wrapper used to prove ownership.
|
|
144
|
-
for (const client of ["claude-code", "codex"]) {
|
|
145
|
-
const expected = intent(ctx, name, client);
|
|
146
|
-
const owned = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
|
|
147
|
-
if (owned)
|
|
148
|
-
await unregisterWriter(ownership, adapter, expected);
|
|
149
|
-
else if (["equivalent", "divergent"].includes(await adapter.inspect(expected)))
|
|
150
|
-
throw new Error("ownership_unavailable");
|
|
151
|
-
}
|
|
152
|
-
// After the registration is gone. A hook that cannot be edited is reported, not a reason to keep the
|
|
153
|
-
// writer: the operator's settings are not the writer's to hold hostage, and the record keeps the entry owned.
|
|
154
|
-
const hook = await writerHook(ctx, name, deps, "remove", false).catch(() => ({ status: "failed", code: "hook_install_failed" }));
|
|
155
|
-
if (hook)
|
|
156
|
-
extras.push(hook);
|
|
157
|
-
return life.removeWriter(ctx, name, { discard });
|
|
158
|
-
}
|
|
159
|
-
const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
|
|
160
|
-
async function plan(ctx, entry) {
|
|
161
|
-
const keep = [], missing = [], surplus = [];
|
|
162
|
-
for (let index = 1; index <= MAX_WRITERS; index++) {
|
|
163
|
-
const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
|
|
164
|
-
if (entry.mode === "container" && index <= entry.container.writers) {
|
|
165
|
-
keep.push(name);
|
|
166
|
-
if (!state || state.phase === "provisioning")
|
|
167
|
-
missing.push(name);
|
|
168
|
-
}
|
|
169
|
-
else if (state)
|
|
170
|
-
surplus.push(name);
|
|
171
|
-
}
|
|
172
|
-
return { keep, missing, surplus };
|
|
173
|
-
}
|
|
174
|
-
async function reconcileUnlocked(configPath, alias, request, deps) {
|
|
175
|
-
const findings = [];
|
|
176
|
-
const ctx = await context(alias, { ...deps, configPath });
|
|
177
|
-
const config = await loadConfig(configPath), entry = config.workspaceModes.get(alias);
|
|
178
|
-
const planned = await plan(ctx, entry), { life, accept, ownership, adapter } = services(deps);
|
|
179
|
-
const add = (name, outcome) => { findings.push({ target: targetFor(alias, name), ...outcome }); };
|
|
180
|
-
await doctorGate(ctx, deps);
|
|
181
|
-
// A surplus writer that cannot be fenced is reported on its own target and left in place; the others proceed.
|
|
182
|
-
const unfenced = new Set();
|
|
183
|
-
for (const name of planned.surplus) {
|
|
184
|
-
try {
|
|
185
|
-
await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard, deps));
|
|
186
|
-
}
|
|
187
|
-
catch (error) {
|
|
188
|
-
if (!FENCE_CODES.includes(codeFor(error)))
|
|
189
|
-
throw error;
|
|
190
|
-
unfenced.add(name);
|
|
191
|
-
add(name, { status: "failed", code: codeFor(error) });
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
|
|
195
|
-
// Each writer's reconciliation, including first provisioning and acceptance, owns one fence.
|
|
196
|
-
for (const name of planned.keep) {
|
|
197
|
-
try {
|
|
198
|
-
await fenced(ctx, name, deps, async (ctx) => {
|
|
199
|
-
if (planned.missing.includes(name)) {
|
|
200
|
-
const outcome = await life.provisionWriter(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory, ...(request.baseRef ? { baseRef: request.baseRef } : {}) });
|
|
201
|
-
add(name, outcome);
|
|
202
|
-
if (outcome.status === "failed")
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
// Settings first: a drifted writer is left exactly as it is, neither migrated nor accepted again (#134).
|
|
206
|
-
if (!planned.missing.includes(name)) {
|
|
207
|
-
const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory });
|
|
208
|
-
if (drift) {
|
|
209
|
-
add(name, drift);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
// Decide for both clients before removing anything, so a divergent registration of one client never
|
|
214
|
-
// costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
|
|
215
|
-
let divergent = false;
|
|
216
|
-
const owned = [];
|
|
217
|
-
for (const client of ["claude-code", "codex"]) {
|
|
218
|
-
const expected = intent(ctx, name, client);
|
|
219
|
-
const record = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
|
|
220
|
-
const current = await adapter.inspect(expected);
|
|
221
|
-
if (record ? current === "divergent" : current === "equivalent" || current === "divergent")
|
|
222
|
-
divergent = true;
|
|
223
|
-
else if (record)
|
|
224
|
-
owned.push(expected);
|
|
225
|
-
}
|
|
226
|
-
if (!divergent) {
|
|
227
|
-
for (const expected of owned) {
|
|
228
|
-
try {
|
|
229
|
-
await unregisterWriter(ownership, adapter, expected);
|
|
230
|
-
}
|
|
231
|
-
catch (error) {
|
|
232
|
-
if (codeFor(error) !== "ownership_diverged")
|
|
233
|
-
throw error;
|
|
234
|
-
divergent = true;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
const hook = await writerHook(ctx, name, deps, "remove", false);
|
|
239
|
-
if (hook)
|
|
240
|
-
findings.push({ target: targetFor(alias, name), ...hook });
|
|
241
|
-
const state = await readWriterState(ctx.home, name);
|
|
242
|
-
if (!state)
|
|
243
|
-
throw new Error("writer_state_invalid");
|
|
244
|
-
if (divergent) {
|
|
245
|
-
if (state.launch !== "legacy")
|
|
246
|
-
await writeWriterState(ctx.home, name, { ...state, launch: "legacy" });
|
|
247
|
-
add(name, { status: "failed", code: "registration_divergent" });
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
const paths = writerPaths(ctx.home, name), wrapper = renderWrapper(ctx.nodePath, ctx.releaseRoot, name);
|
|
251
|
-
if (!(await lstat(paths.wrapper)).isFile())
|
|
252
|
-
throw new Error("writer_state_invalid");
|
|
253
|
-
const wrapperChanged = wrapperDigest(await readFile(paths.wrapper, "utf8")) !== wrapperDigest(wrapper);
|
|
254
|
-
if (wrapperChanged) {
|
|
255
|
-
const temporary = join(paths.root, `.wrapper-${randomUUID()}.tmp`);
|
|
256
|
-
try {
|
|
257
|
-
await writeFile(temporary, wrapper, { flag: "wx", mode: 0o700 });
|
|
258
|
-
await rename(temporary, paths.wrapper);
|
|
259
|
-
}
|
|
260
|
-
finally {
|
|
261
|
-
await rm(temporary, { force: true });
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
// A launch change also needs fresh provider-free acceptance, even when wrapper bytes match.
|
|
265
|
-
const migrated = state.launch !== "relay";
|
|
266
|
-
if (migrated || wrapperChanged) {
|
|
267
|
-
const { acceptance: _oldAcceptance, ...unaccepted } = state;
|
|
268
|
-
await writeWriterState(ctx.home, name, { ...unaccepted, launch: "relay", phase: "not_ready" });
|
|
269
|
-
}
|
|
270
|
-
// A ready phase with a stale binding (package upgrade, new image, Podman version) is accepted again (#130).
|
|
271
|
-
const current = await accept.acceptanceCurrent(ctx, name);
|
|
272
|
-
if (current === "stale") {
|
|
273
|
-
const outcome = await accept.acceptWriter(ctx, name);
|
|
274
|
-
add(name, outcome);
|
|
275
|
-
if (outcome.status === "failed")
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
else if (current === "unknown") {
|
|
279
|
-
add(name, { status: "failed", code: "podman_unavailable" });
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
catch (error) {
|
|
285
|
-
if (!FENCE_CODES.includes(codeFor(error)))
|
|
286
|
-
throw error;
|
|
287
|
-
add(name, { status: "failed", code: codeFor(error) });
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
for (const name of planned.surplus) {
|
|
291
|
-
if (unfenced.has(name))
|
|
292
|
-
continue;
|
|
293
|
-
const extras = [];
|
|
294
|
-
try {
|
|
295
|
-
add(name, await fenced(ctx, name, deps, (leased) => remove(leased, name, request.discard, deps, extras)));
|
|
296
|
-
}
|
|
297
|
-
catch (error) {
|
|
298
|
-
add(name, { status: "failed", code: codeFor(error) });
|
|
299
|
-
}
|
|
300
|
-
for (const extra of extras)
|
|
301
|
-
findings.push({ target: targetFor(alias, name), ...extra });
|
|
302
|
-
}
|
|
303
|
-
return result("apply", findings);
|
|
304
|
-
}
|
|
23
|
+
export { SETUP_LEASE_WAIT_MS } from "./setup-writers-common.js";
|
|
305
24
|
/** Serializes planning, destructive-work refusal, mode commit and writer reconciliation. */
|
|
306
25
|
export async function changeWriterMode(configPath, request, deps) {
|
|
307
26
|
const operation = request.apply ? "apply" : "dry-run";
|
|
@@ -311,7 +30,7 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
311
30
|
return failed("alias_unknown");
|
|
312
31
|
let runtimeProbe;
|
|
313
32
|
const containerRuntime = () => runtimeProbe ??= (deps.containerRuntime ?? containerRuntimeUsable)().catch(() => false);
|
|
314
|
-
const modeDependencies = { containerRuntime };
|
|
33
|
+
const modeDependencies = { containerRuntime, cgroupControllers: () => podmanCgroupControllers(services(deps).runner) };
|
|
315
34
|
try {
|
|
316
35
|
let writerWork = request.mode === "container";
|
|
317
36
|
if (!writerWork) {
|
|
@@ -346,62 +65,18 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
346
65
|
}
|
|
347
66
|
const action = async () => {
|
|
348
67
|
let proposed;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
try {
|
|
353
|
-
recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps), request.baseRef) };
|
|
354
|
-
}
|
|
355
|
-
catch (error) {
|
|
356
|
-
return failed(codeFor(error));
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
else if (request.mode === "container" || request.mode === undefined && await containerRuntime()) {
|
|
360
|
-
const config = await loadConfig(configPath);
|
|
361
|
-
if (!config.workspaceModes.get(request.alias)?.container?.base) {
|
|
362
|
-
const bases = new Set();
|
|
363
|
-
for (let index = 1; index <= MAX_WRITERS; index++) {
|
|
364
|
-
const state = await readWriterState(deps.home ?? homedir(), writerName(request.alias, index));
|
|
365
|
-
if (state)
|
|
366
|
-
bases.add(state.base.ref);
|
|
367
|
-
}
|
|
368
|
-
if (bases.size > 1)
|
|
369
|
-
return result(operation, [{ target: modesTarget(request.alias), status: "failed", code: "workspace_base_ambiguous" }]);
|
|
370
|
-
if (bases.size === 1)
|
|
371
|
-
recorded = { ...request, baseRef: [...bases][0].replace(/^refs\/heads\//, "") };
|
|
372
|
-
// First provisioning: record the base it will resolve (origin/HEAD, else main), so default starts find writers.
|
|
373
|
-
else if (request.mode === "container") {
|
|
374
|
-
try {
|
|
375
|
-
recorded = { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps)) };
|
|
376
|
-
}
|
|
377
|
-
catch (error) {
|
|
378
|
-
return failed(codeFor(error));
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
68
|
+
const recorded = await inferBase(configPath, request, deps, containerRuntime, failed);
|
|
69
|
+
if ("schemaVersion" in recorded)
|
|
70
|
+
return recorded;
|
|
383
71
|
return changeExecutionMode(configPath, recorded, {
|
|
384
72
|
...modeDependencies,
|
|
385
73
|
beforeWrite: async (entry) => {
|
|
386
74
|
proposed = entry;
|
|
387
75
|
const ctx = await context(request.alias, deps), planned = await plan(ctx, entry);
|
|
388
76
|
try {
|
|
389
|
-
if (request.apply)
|
|
390
|
-
await
|
|
391
|
-
|
|
392
|
-
try {
|
|
393
|
-
await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard ?? false, deps));
|
|
394
|
-
}
|
|
395
|
-
catch (error) {
|
|
396
|
-
// Refuse the mode change, but name the writer that could not be fenced.
|
|
397
|
-
if (FENCE_CODES.includes(codeFor(error)))
|
|
398
|
-
return result(operation, [{ target: targetFor(request.alias, name), status: "failed", code: codeFor(error) }]);
|
|
399
|
-
throw error;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
else
|
|
404
|
-
await preflight(ctx, planned.surplus, request.discard ?? false, deps);
|
|
77
|
+
if (request.apply)
|
|
78
|
+
return await gateBeforeWrite(configPath, ctx, entry, planned, request.discard ?? false, operation, deps);
|
|
79
|
+
await preflight(ctx, planned.surplus, request.discard ?? false, deps);
|
|
405
80
|
}
|
|
406
81
|
catch (error) {
|
|
407
82
|
return error instanceof DoctorGateError ? result(operation, [...failed(error.message).findings, ...error.findings]) : failed(codeFor(error));
|
|
@@ -414,16 +89,8 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
414
89
|
if (request.apply)
|
|
415
90
|
writers = await reconcileUnlocked(configPath, request.alias, { discard: request.discard ?? false, ...(request.baseRef ? { baseRef: request.baseRef } : {}) }, deps);
|
|
416
91
|
else {
|
|
417
|
-
const ctx = await context(request.alias, deps)
|
|
418
|
-
|
|
419
|
-
const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
|
|
420
|
-
for (const name of planned.keep) {
|
|
421
|
-
// The same settings check as apply, so a drifted writer shows before anything is applied (#134).
|
|
422
|
-
const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
|
|
423
|
-
kept.push({ target: targetFor(request.alias, name), ...(drift ?? { status: "planned", code: "writer_planned" }) });
|
|
424
|
-
}
|
|
425
|
-
const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
|
|
426
|
-
writers = result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(request.alias, name), status: "planned", code: "writer_remove_planned" }))], refused ? "failed" : "planned");
|
|
92
|
+
const ctx = await context(request.alias, deps);
|
|
93
|
+
writers = await plannedWriters(configPath, ctx, proposed, await plan(ctx, proposed), deps);
|
|
427
94
|
}
|
|
428
95
|
}
|
|
429
96
|
catch (error) {
|
|
@@ -443,7 +110,6 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
443
110
|
return failed(codeFor(error));
|
|
444
111
|
}
|
|
445
112
|
}
|
|
446
|
-
const GLOBAL_ERRORS = new Set(["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid"]);
|
|
447
113
|
export async function runWriterOperation(name, operation, opts, deps) {
|
|
448
114
|
const reportOperation = operation === "verify" ? "verify" : opts.apply ? operation === "remove" ? "remove" : "apply" : "dry-run";
|
|
449
115
|
if (!WRITER_NAME_PATTERN.test(name))
|
package/dist/tool-schemas.d.ts
CHANGED
|
@@ -334,6 +334,10 @@ export declare const capabilitiesSchema: z.ZodObject<{
|
|
|
334
334
|
}>;
|
|
335
335
|
extensions: z.ZodArray<z.ZodString>;
|
|
336
336
|
grantedExtensionTools: z.ZodArray<z.ZodString>;
|
|
337
|
+
shell: z.ZodEnum<{
|
|
338
|
+
none: "none";
|
|
339
|
+
sandboxed: "sandboxed";
|
|
340
|
+
}>;
|
|
337
341
|
skills: z.ZodArray<z.ZodString>;
|
|
338
342
|
}, z.core.$strict>>;
|
|
339
343
|
}, z.core.$strict>;
|
package/dist/tool-schemas.js
CHANGED
|
@@ -44,6 +44,7 @@ export const capabilitiesSchema = z.object({
|
|
|
44
44
|
thinkingLevel: thinkingLevelSchema,
|
|
45
45
|
extensions: z.array(z.string()),
|
|
46
46
|
grantedExtensionTools: z.array(z.string()),
|
|
47
|
+
shell: z.enum(["none", "sandboxed"]),
|
|
47
48
|
skills: z.array(aliasSchema),
|
|
48
49
|
}).strict()),
|
|
49
50
|
}).strict();
|
package/dist/wake-hook.d.ts
CHANGED
|
@@ -139,11 +139,22 @@ export interface WaiterLockOptions {
|
|
|
139
139
|
readonly pid?: number;
|
|
140
140
|
readonly isAlive?: (pid: number) => boolean;
|
|
141
141
|
readonly maxAgeMs?: number;
|
|
142
|
+
/** Test seam: runs after a lock was judged stale and before its reclamation starts. */
|
|
143
|
+
readonly beforeReclaim?: () => void;
|
|
142
144
|
}
|
|
143
145
|
/**
|
|
144
146
|
* At most one waiter per turn: `<claims>/wait-…` is created exclusively and holds the owner's pid. A lock whose owner
|
|
145
147
|
* is alive (and younger than `maxAgeMs`, which guards against a reused pid) means another waiter already covers this
|
|
146
148
|
* turn: returns `undefined`. A stale lock (dead pid, too old, unreadable content) is replaced. Returns the lock path.
|
|
149
|
+
*
|
|
150
|
+
* Reclaiming a stale lock is serialized by the exclusive guard `<lock>.reclaim`, and the lock is judged again while
|
|
151
|
+
* holding it: an earlier observation never deletes a lock that another waiter created after it. A guard that is already
|
|
152
|
+
* there means another waiter is reclaiming and will cover the turn. A guard left by a crash between two calls blocks
|
|
153
|
+
* reclamation for that one turn only, until `cleanupOldClaims` removes it. A guard that cannot be created (for example
|
|
154
|
+
* in a full runtime directory) fails like the lock itself, and the caller waits without a lock rather than dropping the
|
|
155
|
+
* wake. Remaining limit: the guard does not serialize a fresh create or a release, so a lock judged stale by age alone
|
|
156
|
+
* (its pid alive) that its owner releases between the inode check and the unlink, and that a new waiter recreates with
|
|
157
|
+
* the same inode number, can still be removed.
|
|
147
158
|
*/
|
|
148
159
|
export declare function acquireWaiterLock(claims: string, namespace: WakeNamespace, sessionId: string, turnId: string, options?: WaiterLockOptions): string | undefined;
|
|
149
160
|
/** Removes the lock only if it still names `pid`, so a replacement lock of another waiter is never deleted. */
|
package/dist/wake-hook.js
CHANGED
|
@@ -448,12 +448,12 @@ function readLockPid(path) {
|
|
|
448
448
|
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
449
449
|
const stat = fstatSync(fd);
|
|
450
450
|
if (!stat.isFile())
|
|
451
|
-
return { pid: undefined, ageMs: Number.POSITIVE_INFINITY };
|
|
451
|
+
return { pid: undefined, ageMs: Number.POSITIVE_INFINITY, ino: stat.ino };
|
|
452
452
|
const buffer = Buffer.alloc(32);
|
|
453
453
|
const length = readSync(fd, buffer, 0, buffer.length, null);
|
|
454
454
|
const text = buffer.toString("utf8", 0, length).trim();
|
|
455
455
|
const pid = /^[0-9]{1,10}$/.test(text) ? Number(text) : undefined;
|
|
456
|
-
return { pid: pid !== undefined && pid > 0 ? pid : undefined, ageMs: Date.now() - stat.mtimeMs };
|
|
456
|
+
return { pid: pid !== undefined && pid > 0 ? pid : undefined, ageMs: Date.now() - stat.mtimeMs, ino: stat.ino };
|
|
457
457
|
}
|
|
458
458
|
catch {
|
|
459
459
|
return undefined;
|
|
@@ -470,11 +470,23 @@ function readLockPid(path) {
|
|
|
470
470
|
* At most one waiter per turn: `<claims>/wait-…` is created exclusively and holds the owner's pid. A lock whose owner
|
|
471
471
|
* is alive (and younger than `maxAgeMs`, which guards against a reused pid) means another waiter already covers this
|
|
472
472
|
* turn: returns `undefined`. A stale lock (dead pid, too old, unreadable content) is replaced. Returns the lock path.
|
|
473
|
+
*
|
|
474
|
+
* Reclaiming a stale lock is serialized by the exclusive guard `<lock>.reclaim`, and the lock is judged again while
|
|
475
|
+
* holding it: an earlier observation never deletes a lock that another waiter created after it. A guard that is already
|
|
476
|
+
* there means another waiter is reclaiming and will cover the turn. A guard left by a crash between two calls blocks
|
|
477
|
+
* reclamation for that one turn only, until `cleanupOldClaims` removes it. A guard that cannot be created (for example
|
|
478
|
+
* in a full runtime directory) fails like the lock itself, and the caller waits without a lock rather than dropping the
|
|
479
|
+
* wake. Remaining limit: the guard does not serialize a fresh create or a release, so a lock judged stale by age alone
|
|
480
|
+
* (its pid alive) that its owner releases between the inode check and the unlink, and that a new waiter recreates with
|
|
481
|
+
* the same inode number, can still be removed.
|
|
473
482
|
*/
|
|
474
483
|
export function acquireWaiterLock(claims, namespace, sessionId, turnId, options = {}) {
|
|
475
484
|
const path = join(claims, waiterLockName(namespace, sessionId, turnId));
|
|
476
485
|
const pid = options.pid ?? process.pid, isAlive = options.isAlive ?? processAlive, maxAgeMs = options.maxAgeMs ?? DEFAULT_BUDGET_MS + WAITER_LOCK_GRACE_MS;
|
|
477
|
-
|
|
486
|
+
const stale = (held) =>
|
|
487
|
+
// A lock that is still being written (no pid yet) is young; it is stale only once it has been unreadable for a while.
|
|
488
|
+
!(held.pid !== undefined ? isAlive(held.pid) && held.ageMs <= maxAgeMs : held.ageMs < 2000);
|
|
489
|
+
const create = () => {
|
|
478
490
|
try {
|
|
479
491
|
const fd = openSync(path, "wx", 0o600);
|
|
480
492
|
try {
|
|
@@ -488,18 +500,49 @@ export function acquireWaiterLock(claims, namespace, sessionId, turnId, options
|
|
|
488
500
|
catch (error) {
|
|
489
501
|
if (error.code !== "EEXIST")
|
|
490
502
|
throw error;
|
|
503
|
+
return undefined;
|
|
491
504
|
}
|
|
505
|
+
};
|
|
506
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
507
|
+
if (create() !== undefined)
|
|
508
|
+
return path;
|
|
492
509
|
const held = readLockPid(path);
|
|
493
510
|
if (held === undefined)
|
|
494
511
|
continue; // vanished meanwhile: try to create it again
|
|
495
|
-
|
|
496
|
-
const live = held.pid !== undefined ? isAlive(held.pid) && held.ageMs <= maxAgeMs : held.ageMs < 2000;
|
|
497
|
-
if (live)
|
|
512
|
+
if (!stale(held))
|
|
498
513
|
return undefined;
|
|
514
|
+
options.beforeReclaim?.();
|
|
515
|
+
const guard = `${path}.reclaim`;
|
|
499
516
|
try {
|
|
500
|
-
|
|
517
|
+
closeSync(openSync(guard, "wx", 0o600));
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error.code === "EEXIST")
|
|
521
|
+
return undefined;
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
const current = readLockPid(path);
|
|
526
|
+
if (current !== undefined) {
|
|
527
|
+
if (!stale(current))
|
|
528
|
+
return undefined;
|
|
529
|
+
// Only the file that was judged stale is removed; a different one at the path is decided by the create below.
|
|
530
|
+
try {
|
|
531
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.ino === current.ino)
|
|
532
|
+
unlinkSync(path);
|
|
533
|
+
}
|
|
534
|
+
catch { /* gone or replaced */ }
|
|
535
|
+
}
|
|
536
|
+
// Created while holding the guard, so a reclamation on the last attempt still covers the turn.
|
|
537
|
+
if (create() !== undefined)
|
|
538
|
+
return path;
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
try {
|
|
542
|
+
unlinkSync(guard);
|
|
543
|
+
}
|
|
544
|
+
catch { /* already gone */ }
|
|
501
545
|
}
|
|
502
|
-
catch { /* replaced by someone else: the next attempt decides */ }
|
|
503
546
|
}
|
|
504
547
|
return undefined;
|
|
505
548
|
}
|
|
@@ -46,6 +46,8 @@ export declare function podmanInfo(runner: CommandRunner): Promise<{
|
|
|
46
46
|
usable: boolean;
|
|
47
47
|
version?: string;
|
|
48
48
|
}>;
|
|
49
|
+
/** A failed or malformed host probe provides no usable controllers. */
|
|
50
|
+
export declare function podmanCgroupControllers(runner: CommandRunner): Promise<readonly string[]>;
|
|
49
51
|
export declare function resolveImage(runner: CommandRunner, image: string, pull: boolean): Promise<string>;
|
|
50
52
|
export declare function verifyCloneLayout(clone: string): Promise<void>;
|
|
51
53
|
export declare function isSafeRefName(name: string): boolean;
|
package/dist/writer-boundary.js
CHANGED
|
@@ -35,6 +35,12 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
|
|
|
35
35
|
"-e", "PI_CODING_AGENT_DIR=/pi/agent",
|
|
36
36
|
"-e", "PI_SESSION_MCP_WRITER_GUARD=/pi/config/writer-guard.json",
|
|
37
37
|
];
|
|
38
|
+
if (state.limits?.cpus !== undefined)
|
|
39
|
+
args.push(`--cpus=${state.limits.cpus}`);
|
|
40
|
+
if (state.limits?.cpuShares !== undefined)
|
|
41
|
+
args.push(`--cpu-shares=${state.limits.cpuShares}`);
|
|
42
|
+
if (state.limits?.memory !== undefined)
|
|
43
|
+
args.push(`--memory=${state.limits.memory}`);
|
|
38
44
|
if (options?.name !== undefined)
|
|
39
45
|
args.push("--name", writerContainerName(options.name));
|
|
40
46
|
if (eventsDir !== undefined)
|
|
@@ -43,6 +49,8 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
|
|
|
43
49
|
args.push("-v", `${paths.go}:/pi/go:rw`, "-e", "GOPATH=/pi/go", "-e", "GOCACHE=/pi/go/cache", "-e", "GOTOOLCHAIN=local");
|
|
44
50
|
if (state.cache === "uv")
|
|
45
51
|
args.push("-v", `${paths.uv}:/pi/uv:rw`, "-e", "UV_CACHE_DIR=/pi/uv/cache", "-e", "npm_config_cache=/pi/uv/npm");
|
|
52
|
+
if (state.cache === "npm")
|
|
53
|
+
args.push("-v", `${paths.npm}:/pi/npm:rw`, "-e", "npm_config_cache=/pi/npm/cache");
|
|
46
54
|
return [...args, state.imageId, ...command];
|
|
47
55
|
}
|
|
48
56
|
export class EventsDirectoryError extends Error {
|
|
@@ -261,6 +269,25 @@ export async function podmanInfo(runner) {
|
|
|
261
269
|
return { usable: false };
|
|
262
270
|
}
|
|
263
271
|
}
|
|
272
|
+
/** A failed or malformed host probe provides no usable controllers. */
|
|
273
|
+
export async function podmanCgroupControllers(runner) {
|
|
274
|
+
try {
|
|
275
|
+
const output = await podman(runner, ["info", "--format", "json"]);
|
|
276
|
+
if (output.exitCode !== 0)
|
|
277
|
+
return [];
|
|
278
|
+
const info = JSON.parse(output.stdout);
|
|
279
|
+
if (!info || typeof info !== "object" || !("host" in info))
|
|
280
|
+
return [];
|
|
281
|
+
const host = info.host;
|
|
282
|
+
if (!host || typeof host !== "object" || !("cgroupControllers" in host))
|
|
283
|
+
return [];
|
|
284
|
+
const controllers = host.cgroupControllers;
|
|
285
|
+
return Array.isArray(controllers) && controllers.every((value) => typeof value === "string") ? controllers : [];
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
264
291
|
export async function resolveImage(runner, image, pull) {
|
|
265
292
|
if (image !== "node" && !/^[0-9a-f]{64}$/.test(image))
|
|
266
293
|
throw new BoundaryError("image_unavailable");
|
|
@@ -35,6 +35,7 @@ export declare function renderContainedConfig(alias: string, clonePath: string,
|
|
|
35
35
|
mcpServers?: Readonly<Record<string, import("./mcp-config.js").McpServerConfig>> | undefined;
|
|
36
36
|
extensions?: string[] | undefined;
|
|
37
37
|
grantedExtensionTools?: string[] | undefined;
|
|
38
|
+
shell?: "sandboxed" | undefined;
|
|
38
39
|
skills?: string[] | undefined;
|
|
39
40
|
}>;
|
|
40
41
|
};
|