pi-squire 0.14.0 → 0.16.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 +438 -1
- package/README.md +1062 -152
- package/package.json +4 -3
- package/src/agents.ts +395 -124
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +107 -62
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +585 -107
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-sessions.ts +39 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +176 -108
- package/src/resume-checks.ts +59 -14
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +69 -15
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- package/src/workspace.ts +38 -90
package/src/tree-lock.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Lamport bakery mutex over atomic local-filesystem files. Each contender owns
|
|
2
|
+
// a unique path: dead-owner cleanup never unlinks a successor's lock. There is
|
|
3
|
+
// no time lease; a paused/live owner cannot be evicted. Transactions are sync.
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readState, writeState } from "./local-state.ts";
|
|
8
|
+
|
|
9
|
+
type Ticket = { pid: number; choosing: boolean; number: number };
|
|
10
|
+
const pause: Int32Array = new Int32Array(new SharedArrayBuffer(4));
|
|
11
|
+
|
|
12
|
+
/** PID reuse conservatively looks live; only ESRCH proves absence. */
|
|
13
|
+
export function processAlive(pid: number): boolean {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function remove(path: string): void {
|
|
23
|
+
try {
|
|
24
|
+
unlinkSync(path);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tickets(dir: string): [string, Ticket][] {
|
|
31
|
+
const found: [string, Ticket][] = [];
|
|
32
|
+
for (const name of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
|
|
33
|
+
const path: string = join(dir, name);
|
|
34
|
+
const ticket: Ticket | undefined = readState<Ticket>(path);
|
|
35
|
+
if (ticket === undefined) continue;
|
|
36
|
+
if (
|
|
37
|
+
!Number.isSafeInteger(ticket.pid) ||
|
|
38
|
+
ticket.pid < 1 ||
|
|
39
|
+
!Number.isSafeInteger(ticket.number) ||
|
|
40
|
+
ticket.number < 0 ||
|
|
41
|
+
typeof ticket.choosing !== "boolean"
|
|
42
|
+
)
|
|
43
|
+
throw new Error(`invalid tree lock ticket ${path}; admission blocked`);
|
|
44
|
+
if (!processAlive(ticket.pid)) remove(path);
|
|
45
|
+
else found.push([name, ticket]);
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** No async work inside: other calls in this process cannot deadlock on us. */
|
|
51
|
+
export function withTreeLock<T>(dir: string, work: () => T, timeoutMs = 5000): T {
|
|
52
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
53
|
+
const name: string = `${process.pid}-${randomUUID()}.json`;
|
|
54
|
+
const path: string = join(dir, name);
|
|
55
|
+
const ticket: Ticket = { pid: process.pid, choosing: true, number: 0 };
|
|
56
|
+
writeState(path, ticket);
|
|
57
|
+
try {
|
|
58
|
+
ticket.number = 1 + Math.max(0, ...tickets(dir).map(([, item]) => item.number));
|
|
59
|
+
if (!Number.isSafeInteger(ticket.number)) throw new Error("tree lock ticket overflow");
|
|
60
|
+
ticket.choosing = false;
|
|
61
|
+
writeState(path, ticket);
|
|
62
|
+
const deadline: number = Date.now() + timeoutMs;
|
|
63
|
+
while (
|
|
64
|
+
tickets(dir).some(
|
|
65
|
+
([other, item]) =>
|
|
66
|
+
other !== name &&
|
|
67
|
+
(item.choosing || item.number < ticket.number || (item.number === ticket.number && other < name)),
|
|
68
|
+
)
|
|
69
|
+
) {
|
|
70
|
+
if (Date.now() >= deadline) throw new Error("tree registry is busy; retry the operation (no launch was queued)");
|
|
71
|
+
Atomics.wait(pause, 0, 0, 5);
|
|
72
|
+
}
|
|
73
|
+
return work();
|
|
74
|
+
} finally {
|
|
75
|
+
remove(path);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Bounded discovery is a notice only. Exact-session restore never uses this budget.
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { findSession } from "./result-sessions.ts";
|
|
6
|
+
import { ownerDetails, sessionIsOwned } from "./session-owner.ts";
|
|
7
|
+
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
8
|
+
import { listTreeRoots, readTree, type TreeEntry, treePath } from "./tree-registry.ts";
|
|
9
|
+
import { listWindows } from "./windows.ts";
|
|
10
|
+
|
|
11
|
+
export const NOTICE_TREES = 64;
|
|
12
|
+
export const NOTICE_OWNERS = 10;
|
|
13
|
+
export const NOTICE_MS = 200;
|
|
14
|
+
export async function treeNotice(
|
|
15
|
+
ctx: ExtensionContext,
|
|
16
|
+
tmux: TmuxClient,
|
|
17
|
+
active: () => boolean = () => true,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const deadline: number = performance.now() + NOTICE_MS;
|
|
20
|
+
const session: string = ctx.sessionManager.getSessionId();
|
|
21
|
+
const dir: string = ctx.sessionManager.getSessionDir();
|
|
22
|
+
const roots: string[] = listTreeRoots().filter((root) => root !== session);
|
|
23
|
+
const newest: string[] = roots.sort((a, b) => modified(b) - modified(a)).slice(0, NOTICE_TREES);
|
|
24
|
+
if (!newest.length) return;
|
|
25
|
+
const windows: TmuxWindow[] | undefined = await noticeWindows(tmux, deadline);
|
|
26
|
+
if (!active()) return;
|
|
27
|
+
if (!windows) {
|
|
28
|
+
ctx.ui.notify(
|
|
29
|
+
"delegate: owner discovery unavailable: tmux could not be asked within the total notice budget; no absence or ownership was inferred.",
|
|
30
|
+
"warning",
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const owners: Map<string, TreeEntry> = new Map();
|
|
35
|
+
let unavailable = 0,
|
|
36
|
+
scanned = 0,
|
|
37
|
+
ownerErrors = 0;
|
|
38
|
+
for (const root of newest) {
|
|
39
|
+
if (!active() || remaining(deadline) <= 0 || owners.size >= NOTICE_OWNERS) break;
|
|
40
|
+
try {
|
|
41
|
+
collectOwners(root, session, dir, windows, owners, deadline);
|
|
42
|
+
} catch {
|
|
43
|
+
unavailable++;
|
|
44
|
+
}
|
|
45
|
+
scanned++;
|
|
46
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
47
|
+
}
|
|
48
|
+
if (!active()) return;
|
|
49
|
+
ownerErrors = reportOwners(ctx, dir, owners, deadline);
|
|
50
|
+
if (roots.length > NOTICE_TREES || scanned < newest.length || unavailable || ownerErrors)
|
|
51
|
+
ctx.ui.notify(
|
|
52
|
+
`delegate: discovery is limited to newest ${NOTICE_TREES} trees and ${NOTICE_OWNERS} owners in this agent/session directory, with a ${NOTICE_MS} ms total budget and 1 MiB per tree; ${unavailable} registries unavailable and ${ownerErrors} owner records unreadable. Reopen an exact owner to restore its full tree.`,
|
|
53
|
+
"warning",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
function reportOwners(ctx: ExtensionContext, dir: string, owners: Map<string, TreeEntry>, deadline: number): number {
|
|
57
|
+
let ownerErrors = 0;
|
|
58
|
+
for (const [parent, entry] of [...owners].slice(0, NOTICE_OWNERS)) {
|
|
59
|
+
const path: string | undefined = remaining(deadline) > 0 ? findSession(dir, parent) : undefined;
|
|
60
|
+
let action: string;
|
|
61
|
+
try {
|
|
62
|
+
action = sessionIsOwned(parent)
|
|
63
|
+
? "Its Squire owner is already open; return to that process."
|
|
64
|
+
: `Reopen the owning Pi session with pi --session ${shellArg(path ?? parent)} --session-dir ${shellArg(dir)}.`;
|
|
65
|
+
} catch {
|
|
66
|
+
ownerErrors++;
|
|
67
|
+
action = `Owner status is unavailable: ${ownerDetails(parent)}. Inspect it before reopening.`;
|
|
68
|
+
}
|
|
69
|
+
ctx.ui.notify(
|
|
70
|
+
`delegate: earlier session ${parent} has running delegates (tree ${entry.identity.root}). ${action} This session does not adopt them.`,
|
|
71
|
+
"info",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return ownerErrors;
|
|
75
|
+
}
|
|
76
|
+
async function noticeWindows(tmux: TmuxClient, deadline: number): Promise<TmuxWindow[] | undefined> {
|
|
77
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([
|
|
80
|
+
listWindows(tmux),
|
|
81
|
+
new Promise<undefined>((resolve) => {
|
|
82
|
+
timer = setTimeout(() => resolve(undefined), remaining(deadline));
|
|
83
|
+
}),
|
|
84
|
+
]);
|
|
85
|
+
} finally {
|
|
86
|
+
if (timer) clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function remaining(deadline: number): number {
|
|
90
|
+
return Math.max(0, deadline - performance.now());
|
|
91
|
+
}
|
|
92
|
+
function collectOwners(
|
|
93
|
+
root: string,
|
|
94
|
+
session: string,
|
|
95
|
+
dir: string,
|
|
96
|
+
windows: TmuxWindow[],
|
|
97
|
+
owners: Map<string, TreeEntry>,
|
|
98
|
+
deadline: number,
|
|
99
|
+
): void {
|
|
100
|
+
if (statSync(treePath(root)).size > 1024 * 1024) throw new Error("tree exceeds notice byte budget");
|
|
101
|
+
for (const entry of readTree(root, Math.min(20, remaining(deadline))).entries) {
|
|
102
|
+
if (owners.size >= NOTICE_OWNERS || remaining(deadline) <= 0) return;
|
|
103
|
+
if (entry.identity.parent !== session && entry.sessionDir === dir && live(entry, windows))
|
|
104
|
+
owners.set(entry.identity.parent, entry);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function shellArg(value: string): string {
|
|
108
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
109
|
+
}
|
|
110
|
+
function modified(root: string): number {
|
|
111
|
+
try {
|
|
112
|
+
return statSync(treePath(root)).mtimeMs;
|
|
113
|
+
} catch {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function live(entry: TreeEntry, windows: TmuxWindow[]): boolean {
|
|
118
|
+
return (
|
|
119
|
+
entry.phase === "started" &&
|
|
120
|
+
!entry.finished &&
|
|
121
|
+
!entry.closed &&
|
|
122
|
+
windows.some((window) => window.id === entry.windowId && window.name === entry.windowName && !window.exited)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// One registry per root owns structural/admission accounting. Parent-local
|
|
2
|
+
// child facts and frozen worker replies remain semantic authorities. No bodies,
|
|
3
|
+
// mailboxes, processing inboxes, or ancestor-liveness policy live here.
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { readChildRecords } from "./child-tasks.ts";
|
|
8
|
+
import type { Identity } from "./identity.ts";
|
|
9
|
+
import { readState, writeState } from "./local-state.ts";
|
|
10
|
+
import { sessionIdOf } from "./result-sessions.ts";
|
|
11
|
+
import { isStarted, type Recorded } from "./result-types.ts";
|
|
12
|
+
import { sessionIsOwned } from "./session-owner.ts";
|
|
13
|
+
import { readTaskState, taskStatePath } from "./task-store.ts";
|
|
14
|
+
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
15
|
+
import { checkResumeFence, fenced } from "./tree-fence.ts";
|
|
16
|
+
import { processAlive, withTreeLock } from "./tree-lock.ts";
|
|
17
|
+
import { treeCaps } from "./tree-settings.ts";
|
|
18
|
+
import type { Reservation, TreeCounts, TreeEntry, TreeIntent, TreeState } from "./tree-types.ts";
|
|
19
|
+
import { assertRootPlacement } from "./window-placement.ts";
|
|
20
|
+
import { listWindows } from "./windows.ts";
|
|
21
|
+
|
|
22
|
+
export type { Reservation, TreeCounts, TreeEntry, TreeIntent, TreeState } from "./tree-types.ts";
|
|
23
|
+
|
|
24
|
+
export function treePath(root: string): string {
|
|
25
|
+
return join(getAgentDir(), "squire", "trees", `${encodeURIComponent(root)}.json`);
|
|
26
|
+
}
|
|
27
|
+
/** Discovery only; callers choose notice/view scope, never adopt another root. */
|
|
28
|
+
export function listTreeRoots(): string[] {
|
|
29
|
+
try {
|
|
30
|
+
return readdirSync(join(getAgentDir(), "squire", "trees"))
|
|
31
|
+
.filter((name) => name.endsWith(".json"))
|
|
32
|
+
.map((name) => decodeURIComponent(name.slice(0, -5)));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function load(root: string): TreeState {
|
|
39
|
+
const saved: TreeState | undefined = readState<TreeState>(treePath(root));
|
|
40
|
+
const state: TreeState = saved === undefined ? { version: 1, root, entries: [] } : saved;
|
|
41
|
+
if (state?.version !== 1 || state.root !== root || !Array.isArray(state.entries))
|
|
42
|
+
throw new Error(`invalid tree registry for ${root}; admission blocked`);
|
|
43
|
+
const ids: Set<string> = new Set();
|
|
44
|
+
for (const entry of state.entries) {
|
|
45
|
+
if (
|
|
46
|
+
!entry ||
|
|
47
|
+
typeof entry.id !== "string" ||
|
|
48
|
+
ids.has(entry.id) ||
|
|
49
|
+
typeof entry.sessionId !== "string" ||
|
|
50
|
+
entry.identity?.root !== root ||
|
|
51
|
+
typeof entry.identity.parent !== "string" ||
|
|
52
|
+
!Number.isSafeInteger(entry.ownerPid) ||
|
|
53
|
+
entry.ownerPid < 1 ||
|
|
54
|
+
!["reserved", "dispatched", "started", "not-started"].includes(entry.phase)
|
|
55
|
+
)
|
|
56
|
+
throw new Error(`invalid entry in tree registry for ${root}; admission blocked`);
|
|
57
|
+
validateFields(entry);
|
|
58
|
+
ids.add(entry.id);
|
|
59
|
+
}
|
|
60
|
+
validateCloses(state, ids);
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateCloses(state: TreeState, ids: Set<string>): void {
|
|
65
|
+
if (state.closes === undefined) return;
|
|
66
|
+
if (!Array.isArray(state.closes)) throw new Error("invalid close fences; admission blocked");
|
|
67
|
+
const tokens: Set<string> = new Set();
|
|
68
|
+
for (const close of state.closes) {
|
|
69
|
+
if (
|
|
70
|
+
!close ||
|
|
71
|
+
typeof close.id !== "string" ||
|
|
72
|
+
!close.id ||
|
|
73
|
+
tokens.has(close.id) ||
|
|
74
|
+
typeof close.target !== "string" ||
|
|
75
|
+
(close.target !== state.root && !state.entries.some((entry) => entry.sessionId === close.target)) ||
|
|
76
|
+
!Array.isArray(close.entries) ||
|
|
77
|
+
!close.entries.every((id) => typeof id === "string" && ids.has(id)) ||
|
|
78
|
+
new Set(close.entries).size !== close.entries.length
|
|
79
|
+
)
|
|
80
|
+
throw new Error(`invalid close fence for ${state.root}; admission blocked`);
|
|
81
|
+
tokens.add(close.id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateFields(entry: TreeEntry): void {
|
|
86
|
+
const own = entry.identity;
|
|
87
|
+
assertRootPlacement(own.rootPlacement);
|
|
88
|
+
if (
|
|
89
|
+
!Number.isSafeInteger(own.depth) ||
|
|
90
|
+
own.depth < 1 ||
|
|
91
|
+
typeof own.agent !== "string" ||
|
|
92
|
+
!Array.isArray(own.chain) ||
|
|
93
|
+
!own.chain.every((name) => typeof name === "string") ||
|
|
94
|
+
typeof own.autoExit !== "boolean"
|
|
95
|
+
)
|
|
96
|
+
throw new Error(`invalid identity in tree entry ${entry.id}`);
|
|
97
|
+
for (const key of ["finished", "gone", "closed", "closing", "createPending"] as const)
|
|
98
|
+
if (entry[key] !== undefined && typeof entry[key] !== "boolean")
|
|
99
|
+
throw new Error(`invalid ${key} in tree entry ${entry.id}`);
|
|
100
|
+
for (const key of ["requestId", "windowId", "windowName", "cwd", "sessionDir", "resumedFrom"] as const)
|
|
101
|
+
if (entry[key] !== undefined && typeof entry[key] !== "string")
|
|
102
|
+
throw new Error(`invalid ${key} in tree entry ${entry.id}`);
|
|
103
|
+
}
|
|
104
|
+
/** Shared close/launch boundary. Callback is synchronous; never await tmux/UI. */
|
|
105
|
+
export function treeTransaction<T>(root: string, work: (state: TreeState) => T): T {
|
|
106
|
+
return withTreeLock(`${treePath(root)}.lock`, () => {
|
|
107
|
+
const state: TreeState = load(root);
|
|
108
|
+
const result: T = work(state);
|
|
109
|
+
writeState(treePath(root), state);
|
|
110
|
+
return result;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export function readTree(root: string, timeoutMs = 5000): TreeState {
|
|
114
|
+
return withTreeLock(`${treePath(root)}.lock`, () => load(root), timeoutMs);
|
|
115
|
+
}
|
|
116
|
+
export function treeCounts(state: TreeState): TreeCounts {
|
|
117
|
+
const charged: TreeEntry[] = state.entries.filter((entry) => entry.phase !== "not-started");
|
|
118
|
+
return {
|
|
119
|
+
lifetime: charged.length,
|
|
120
|
+
concurrent: charged.filter(
|
|
121
|
+
(entry) =>
|
|
122
|
+
!entry.finished &&
|
|
123
|
+
!entry.closed &&
|
|
124
|
+
(!entry.gone ||
|
|
125
|
+
(!state.entries.some((next) => next.resumedFrom === entry.id && next.phase !== "not-started") &&
|
|
126
|
+
sessionIsOwned(entry.sessionId))),
|
|
127
|
+
).length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Session-based ancestry survives a coordinator's absent process and resumes. */
|
|
132
|
+
export function descendants(state: TreeState, ancestor: string): TreeEntry[] {
|
|
133
|
+
const sessions: Set<string> = new Set([ancestor]);
|
|
134
|
+
let changed = true;
|
|
135
|
+
while (changed) {
|
|
136
|
+
changed = false;
|
|
137
|
+
for (const entry of state.entries)
|
|
138
|
+
if (sessions.has(entry.identity.parent) && !sessions.has(entry.sessionId)) {
|
|
139
|
+
sessions.add(entry.sessionId);
|
|
140
|
+
changed = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return state.entries.filter((entry) => sessions.has(entry.identity.parent));
|
|
144
|
+
}
|
|
145
|
+
function entryFor(state: TreeState, token: Reservation): TreeEntry {
|
|
146
|
+
const entry: TreeEntry | undefined = state.entries.find((entry) => entry.id === token.id);
|
|
147
|
+
if (!entry) throw new Error(`missing tree reservation ${token.id}`);
|
|
148
|
+
return entry;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Read-only preflight also reused under the admission lock against races. */
|
|
152
|
+
export function assertParentPlacement(state: TreeState, identity: Identity): void {
|
|
153
|
+
if (
|
|
154
|
+
state.entries.some(
|
|
155
|
+
(entry) => entry.sessionId === identity.parent && entry.identity.rootPlacement !== identity.rootPlacement,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
throw new Error("conflicting rootPlacement with recorded direct parent; launch refused");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function reserveTree(intent: TreeIntent): Reservation {
|
|
162
|
+
assertRootPlacement(intent.identity.rootPlacement);
|
|
163
|
+
const root: string = intent.identity.root;
|
|
164
|
+
return treeTransaction(root, (state) => {
|
|
165
|
+
reconcileFacts(state);
|
|
166
|
+
assertParentPlacement(state, intent.identity);
|
|
167
|
+
const caps = treeCaps();
|
|
168
|
+
const counts: TreeCounts = treeCounts(state);
|
|
169
|
+
if (fenced(state, intent.identity.parent)) throw new Error("delegation subtree is closing; launch refused");
|
|
170
|
+
checkResumeFence(state, intent);
|
|
171
|
+
if (state.entries.some((entry) => entry.id === intent.id)) throw new Error(`duplicate reservation ${intent.id}`);
|
|
172
|
+
if (state.entries.some((entry) => entry.sessionId === intent.sessionId && entry.phase === "dispatched"))
|
|
173
|
+
throw new Error(
|
|
174
|
+
`session ${intent.sessionId} has an earlier launch whose outcome is unknown; another resume is refused while its reservation remains charged. Its start in a window or a terminal result can resolve the uncertainty.`,
|
|
175
|
+
);
|
|
176
|
+
for (const [key, count] of [
|
|
177
|
+
["maxConcurrentDelegates", counts.concurrent],
|
|
178
|
+
["maxDelegatesPerRoot", counts.lifetime],
|
|
179
|
+
] as const)
|
|
180
|
+
if (caps[key] > 0 && count >= caps[key])
|
|
181
|
+
throw new Error(`${key}: current count ${count}, limit ${caps[key]}; launch refused (no queue)`);
|
|
182
|
+
state.entries.push({ ...intent, ownerPid: process.pid, phase: "reserved" });
|
|
183
|
+
return { root, id: intent.id };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function bindTree(
|
|
188
|
+
token: Reservation,
|
|
189
|
+
facts: Partial<Pick<TreeEntry, "requestId" | "windowName" | "cwd" | "workspace">>,
|
|
190
|
+
): void {
|
|
191
|
+
treeTransaction(token.root, (state) => Object.assign(entryFor(state, token), facts));
|
|
192
|
+
}
|
|
193
|
+
export function dispatchTree(token: Reservation): void {
|
|
194
|
+
treeTransaction(token.root, (state) => {
|
|
195
|
+
const entry: TreeEntry = entryFor(state, token);
|
|
196
|
+
if (entry.phase !== "reserved" || entry.closing || fenced(state, entry.identity.parent))
|
|
197
|
+
throw new Error("tree reservation is no longer dispatchable");
|
|
198
|
+
entry.phase = "dispatched";
|
|
199
|
+
entry.createPending = true;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
export function openedTree(token: Reservation, windowId: string): void {
|
|
203
|
+
treeTransaction(token.root, (state) => {
|
|
204
|
+
const entry: TreeEntry = entryFor(state, token);
|
|
205
|
+
entry.phase = "started";
|
|
206
|
+
entry.windowId = windowId;
|
|
207
|
+
entry.createPending = false;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** A child can locate its own window even if its opener lost tmux's response. */
|
|
211
|
+
export function claimTree(root: string, session: string, window: { id: string; name: string } | undefined): void {
|
|
212
|
+
treeTransaction(root, (state) => {
|
|
213
|
+
const candidates: TreeEntry[] = state.entries.filter(
|
|
214
|
+
(entry) =>
|
|
215
|
+
entry.sessionId === session &&
|
|
216
|
+
(entry.phase === "dispatched" || entry.phase === "started") &&
|
|
217
|
+
!entry.finished &&
|
|
218
|
+
!entry.closed &&
|
|
219
|
+
!state.entries.some((next) => next.resumedFrom === entry.id && next.phase !== "not-started"),
|
|
220
|
+
);
|
|
221
|
+
if (candidates.length !== 1) return;
|
|
222
|
+
const entry: TreeEntry | undefined = candidates[0];
|
|
223
|
+
if (!entry) return;
|
|
224
|
+
entry.phase = "started";
|
|
225
|
+
if (window) {
|
|
226
|
+
entry.windowId = window.id;
|
|
227
|
+
entry.windowName = window.name;
|
|
228
|
+
entry.createPending = false;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** Only a confirmed pre-start failure may refund lifetime. Unknown stays charged. */
|
|
233
|
+
export function failedTree(token: Reservation, notOpened = false): void {
|
|
234
|
+
treeTransaction(token.root, (state) => {
|
|
235
|
+
const entry: TreeEntry = entryFor(state, token);
|
|
236
|
+
if (entry.phase === "reserved" || (entry.phase === "dispatched" && notOpened && !entry.finished)) {
|
|
237
|
+
entry.phase = "not-started";
|
|
238
|
+
entry.createPending = false;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function applyRecord(entry: TreeEntry, record: Recorded): void {
|
|
244
|
+
if (!isStarted(record)) return;
|
|
245
|
+
entry.launch = { ...record };
|
|
246
|
+
// Do not store semantic results or child processing facts in this index.
|
|
247
|
+
delete (entry.launch as Partial<typeof record>).result;
|
|
248
|
+
delete (entry.launch as Partial<typeof record>).gone;
|
|
249
|
+
delete (entry.launch as Partial<typeof record>).closed;
|
|
250
|
+
entry.phase = "started";
|
|
251
|
+
entry.createPending = false;
|
|
252
|
+
// A same-id manual reopen can positively locate a newer window. Historical
|
|
253
|
+
// parent facts must not overwrite that current locator at the next admission.
|
|
254
|
+
if (!entry.windowId || (!entry.closed && (!entry.gone || !sessionIsOwned(entry.sessionId)))) {
|
|
255
|
+
entry.windowId = record.windowId;
|
|
256
|
+
entry.windowName = record.windowName;
|
|
257
|
+
}
|
|
258
|
+
entry.requestId = record.requestId;
|
|
259
|
+
entry.cwd = record.cwd;
|
|
260
|
+
entry.workspace = record.workspace;
|
|
261
|
+
entry.sessionDir = record.sessionDir;
|
|
262
|
+
if (record.result) entry.finished = true;
|
|
263
|
+
if (record.gone) entry.gone = true;
|
|
264
|
+
if (record.closed) entry.closed = true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Publish only already-durable local facts. It never acknowledges outcomes. */
|
|
268
|
+
export function publishTreeRecords(root: string, parent: string, records: Recorded[]): void {
|
|
269
|
+
if (!records.some(isStarted)) return;
|
|
270
|
+
treeTransaction(root, (state) => {
|
|
271
|
+
for (const record of records) {
|
|
272
|
+
if (!isStarted(record)) continue;
|
|
273
|
+
let entry: TreeEntry | undefined = state.entries.find((item) => item.id === record.id);
|
|
274
|
+
if (!entry && record.identity?.root === root) {
|
|
275
|
+
entry = {
|
|
276
|
+
id: record.id,
|
|
277
|
+
sessionId: sessionIdOf(record),
|
|
278
|
+
identity: record.identity,
|
|
279
|
+
ownerPid: process.pid,
|
|
280
|
+
phase: "started",
|
|
281
|
+
resumedFrom: record.resumedFrom,
|
|
282
|
+
};
|
|
283
|
+
state.entries.push(entry);
|
|
284
|
+
}
|
|
285
|
+
if (entry?.identity.parent === parent) applyRecord(entry, record);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The worker invokes this after freezing its reply, before transport/dequeue. */
|
|
291
|
+
export function finishTree(root: string, session: string, requests: readonly string[]): void {
|
|
292
|
+
treeTransaction(root, (state) => {
|
|
293
|
+
bindOrphanRequest(state, session, requests);
|
|
294
|
+
for (const entry of state.entries)
|
|
295
|
+
if (entry.sessionId === session && entry.requestId && requests.includes(entry.requestId)) {
|
|
296
|
+
entry.finished = true;
|
|
297
|
+
entry.phase = "started";
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Resume mails after open. Recover the send/bind crash gap only when both
|
|
303
|
+
// the unresolved launch and the as-yet-unbound request are unambiguous.
|
|
304
|
+
function bindOrphanRequest(state: TreeState, session: string, requests: readonly string[]): void {
|
|
305
|
+
const candidates: TreeEntry[] = state.entries.filter(
|
|
306
|
+
(entry) => entry.sessionId === session && !entry.requestId && entry.phase === "started",
|
|
307
|
+
);
|
|
308
|
+
const unknown: string[] = requests.filter((id) => !state.entries.some((entry) => entry.requestId === id));
|
|
309
|
+
if (candidates.length === 1 && unknown.length === 1) {
|
|
310
|
+
const candidate: TreeEntry | undefined = candidates[0];
|
|
311
|
+
if (candidate) candidate.requestId = unknown[0];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function markFinished(entry: TreeEntry): void {
|
|
315
|
+
entry.finished = true;
|
|
316
|
+
entry.phase = "started";
|
|
317
|
+
}
|
|
318
|
+
function reconcileTask(state: TreeState, entry: TreeEntry): void {
|
|
319
|
+
const task = readTaskState(taskStatePath(entry.sessionId));
|
|
320
|
+
if (task && (!Array.isArray(task.outbox) || !Array.isArray(task.requests)))
|
|
321
|
+
throw new Error(`invalid task outbox for ${entry.sessionId}`);
|
|
322
|
+
if (task?.parent === entry.identity.parent)
|
|
323
|
+
bindOrphanRequest(
|
|
324
|
+
state,
|
|
325
|
+
entry.sessionId,
|
|
326
|
+
task.requests.map((request) => request.id),
|
|
327
|
+
);
|
|
328
|
+
if (entry.requestId && task?.outbox.some((reply) => reply.ids.includes(entry.requestId as string)))
|
|
329
|
+
markFinished(entry);
|
|
330
|
+
}
|
|
331
|
+
function reconcileFacts(state: TreeState): void {
|
|
332
|
+
const parents: Map<string, Recorded[]> = new Map();
|
|
333
|
+
for (const entry of state.entries) {
|
|
334
|
+
const parent: string = entry.identity.parent;
|
|
335
|
+
if (!parents.has(parent)) parents.set(parent, readChildRecords(parent));
|
|
336
|
+
const record: Recorded | undefined = parents.get(parent)?.find((record) => record.id === entry.id);
|
|
337
|
+
if (record) applyRecord(entry, record);
|
|
338
|
+
reconcileTask(state, entry);
|
|
339
|
+
if (entry.phase === "reserved" && !processAlive(entry.ownerPid)) entry.phase = "not-started";
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function reconcileWindows(state: TreeState, snapshot: TreeState, windows: TmuxWindow[]): void {
|
|
343
|
+
for (const previous of snapshot.entries) {
|
|
344
|
+
if (previous.phase !== "started" || !previous.windowId) continue;
|
|
345
|
+
const entry: TreeEntry | undefined = state.entries.find((item) => item.id === previous.id);
|
|
346
|
+
if (!entry || entry.windowId !== previous.windowId || entry.windowName !== previous.windowName) continue;
|
|
347
|
+
const window: TmuxWindow | undefined = windows.find(
|
|
348
|
+
(window) => window.id === entry.windowId && window.name === entry.windowName,
|
|
349
|
+
);
|
|
350
|
+
if (!window || window.exited) entry.gone = true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Snapshot before tmux: a newly started child cannot be judged by an old list. */
|
|
354
|
+
export async function reconcileTree(root: string, tmux: TmuxClient): Promise<void> {
|
|
355
|
+
const snapshot: TreeState = readTree(root);
|
|
356
|
+
const windows: TmuxWindow[] | undefined = snapshot.entries.some(
|
|
357
|
+
(entry) => entry.phase === "started" && !entry.finished && !entry.gone && !entry.closed,
|
|
358
|
+
)
|
|
359
|
+
? await listWindows(tmux)
|
|
360
|
+
: undefined;
|
|
361
|
+
treeTransaction(root, (state) => {
|
|
362
|
+
reconcileFacts(state);
|
|
363
|
+
if (windows) reconcileWindows(state, snapshot, windows);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { maxDepth } from "./identity.ts";
|
|
5
|
+
|
|
6
|
+
export type TreeCaps = { maxConcurrentDelegates: number; maxDelegatesPerRoot: number };
|
|
7
|
+
|
|
8
|
+
/** Call-time settings; malformed cap state must never silently become unlimited. */
|
|
9
|
+
export function treeCaps(): TreeCaps {
|
|
10
|
+
const file: string = join(getAgentDir(), "squire", "settings.json");
|
|
11
|
+
let settings: Record<string, unknown>;
|
|
12
|
+
try {
|
|
13
|
+
settings = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
16
|
+
throw new Error(`could not read tree caps in ${file}: ${String(error)}`);
|
|
17
|
+
settings = {};
|
|
18
|
+
}
|
|
19
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
20
|
+
throw new Error(`invalid tree caps in ${file}: expected an object`);
|
|
21
|
+
const nested: boolean = maxDepth() > 1;
|
|
22
|
+
const caps: TreeCaps = { maxConcurrentDelegates: nested ? 16 : 0, maxDelegatesPerRoot: nested ? 64 : 0 };
|
|
23
|
+
for (const key of ["maxConcurrentDelegates", "maxDelegatesPerRoot"] as const) {
|
|
24
|
+
const value: unknown = settings[key];
|
|
25
|
+
if (value === undefined) continue;
|
|
26
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
27
|
+
throw new Error(`invalid ${key} in ${file}: expected a nonnegative integer (0 means unlimited)`);
|
|
28
|
+
caps[key] = value;
|
|
29
|
+
}
|
|
30
|
+
return caps;
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Identity } from "./identity.ts";
|
|
2
|
+
import type { Delegation } from "./results.ts";
|
|
3
|
+
import type { Workspace } from "./workspace.ts";
|
|
4
|
+
|
|
5
|
+
/** Shared structural/admission facts, never parent outcome-processing state. */
|
|
6
|
+
export type TreeEntry = {
|
|
7
|
+
id: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
identity: Identity;
|
|
10
|
+
ownerPid: number;
|
|
11
|
+
phase: "reserved" | "dispatched" | "started" | "not-started";
|
|
12
|
+
requestId?: string;
|
|
13
|
+
windowId?: string;
|
|
14
|
+
windowName?: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
sessionDir?: string;
|
|
17
|
+
workspace?: Workspace;
|
|
18
|
+
resumedFrom?: string;
|
|
19
|
+
launch?: Delegation;
|
|
20
|
+
finished?: boolean;
|
|
21
|
+
gone?: boolean;
|
|
22
|
+
closed?: boolean;
|
|
23
|
+
/** Explicit close fences this incarnation until an authorized successor supersedes it. */
|
|
24
|
+
closing?: boolean;
|
|
25
|
+
/** Dispatch may still create a window; completion accounting alone cannot clear this. */
|
|
26
|
+
createPending?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type TreeClose = { id: string; target: string; entries: string[] };
|
|
29
|
+
export type TreeState = { version: 1; root: string; entries: TreeEntry[]; closes?: TreeClose[] };
|
|
30
|
+
export type Reservation = { root: string; id: string };
|
|
31
|
+
export type TreeIntent = Pick<TreeEntry, "id" | "sessionId" | "identity" | "resumedFrom" | "sessionDir">;
|
|
32
|
+
export type TreeCounts = { concurrent: number; lifetime: number };
|