@alphacoder-v0/pi-loops 0.22.8 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +717 -0
- package/CONTRACT.md +7 -7
- package/README.md +114 -78
- package/README.zh-CN.md +62 -41
- package/docs/cli.md +352 -91
- package/docs/configuration.md +86 -24
- package/docs/design.md +404 -61
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +41 -28
- package/docs/web-ui-parity.md +702 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/browser-open.ts +169 -0
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +960 -143
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1370 -473
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +22 -7
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2261 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/lease.ts
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who does a conversation's automated work: one lease file per session, naming one extension
|
|
3
|
+
* instance. A second window on the same session is a viewer. There is no heartbeat and no timeout:
|
|
4
|
+
* a lease changes hands only when its holder's process is gone, so nothing here measures time and a
|
|
5
|
+
* suspended laptop cannot move one. The epoch is the fencing token every claim checks.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { pidAlive, withFileLockSync, writeFileAtomic } from "./lock.ts";
|
|
12
|
+
import { stamp } from "./schedule.ts";
|
|
13
|
+
|
|
14
|
+
export interface LeaseHolder {
|
|
15
|
+
kind: "terminal" | "host";
|
|
16
|
+
pid: number;
|
|
17
|
+
instance: string;
|
|
18
|
+
procStart?: string;
|
|
19
|
+
acquiredAt: string;
|
|
20
|
+
mode?: "tui" | "rpc" | "print" | "json" | "unbound";
|
|
21
|
+
/**
|
|
22
|
+
* Written by a window that stands aside (`LeaseBinding.standAside`): it keeps the lease, so nothing
|
|
23
|
+
* else writes into the conversation it has open, and delivers nothing, since its copy is behind the
|
|
24
|
+
* file. A reader that does not know the field ignores it; one that does (`holderDelivers`) stops
|
|
25
|
+
* waiting on this holder for a message typed into the browser page.
|
|
26
|
+
*/
|
|
27
|
+
aside?: true;
|
|
28
|
+
/** mcp.toml server names whose pushes inject into this holder's chat. */
|
|
29
|
+
pushServers: string[];
|
|
30
|
+
/** Host only: the turn it is running in this conversation right now. */
|
|
31
|
+
busy?: { runId: string; jobId?: string; since: string };
|
|
32
|
+
/**
|
|
33
|
+
* Written by a window that is a conversation open in the browser page: the background process starts
|
|
34
|
+
* it with `PI_LOOPS_PAGE_CHILD=1`. Read by `decideAcquire`, where a `tui` window takes the
|
|
35
|
+
* conversation from such a holder, and by the page server, which shows who holds a conversation.
|
|
36
|
+
*/
|
|
37
|
+
page?: true;
|
|
38
|
+
/**
|
|
39
|
+
* Written by that page holder and by a terminal (`tui`) holder (`LeaseStore.setTurn`) while pi runs
|
|
40
|
+
* or compacts (`TURN_EVENTS`): when that started. Read by `decideAcquire` for a page holder only:
|
|
41
|
+
* while it is set, a `tui` window waits as a viewer and takes the conversation on its next refresh
|
|
42
|
+
* after the turn. Read by the page server for a terminal holder: the page's copy of the
|
|
43
|
+
* conversation is read again when the turn ends (`shouldReadAgain`), not in the middle of one.
|
|
44
|
+
*/
|
|
45
|
+
turnSince?: string;
|
|
46
|
+
/** This holder writes `turnSince`. A holder without it (an older terminal) is never read again by a viewer page. */
|
|
47
|
+
turns?: true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The least time between two reads of a terminal-held conversation for the page: a turn writes in bursts. */
|
|
51
|
+
export const VIEWER_REREAD_GAP_MS = 5_000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether the page's copy of a conversation a terminal holds is read again from the file. Only once
|
|
55
|
+
* the terminal says no turn runs (it writes `turnSince` while one does, and `turns` says it does),
|
|
56
|
+
* only when the file changed since the page last read it and what was added holds a message (a run
|
|
57
|
+
* card or an audit entry is not worth a reload), only while the page's own pi is idle, and at most
|
|
58
|
+
* every `VIEWER_REREAD_GAP_MS`. `newMessage` is asked last: it reads the bytes added.
|
|
59
|
+
*/
|
|
60
|
+
export function shouldReadAgain(i: {
|
|
61
|
+
holder?: { kind: string; mine: boolean; turns?: boolean; turnSince?: string };
|
|
62
|
+
busyHere: boolean;
|
|
63
|
+
readMark: { size: number; mtimeMs: number };
|
|
64
|
+
now: { size: number; mtimeMs: number };
|
|
65
|
+
sinceLastMs: number;
|
|
66
|
+
newMessage: () => boolean;
|
|
67
|
+
}): boolean {
|
|
68
|
+
const h = i.holder;
|
|
69
|
+
if (!h || h.kind !== "terminal" || h.mine || !h.turns || h.turnSince !== undefined) return false;
|
|
70
|
+
if (i.busyHere || i.sinceLastMs < VIEWER_REREAD_GAP_MS) return false;
|
|
71
|
+
if (i.now.size === i.readMark.size && i.now.mtimeMs === i.readMark.mtimeMs) return false;
|
|
72
|
+
return i.newMessage();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `busy.jobId` pi-loops writes while it holds a closed conversation to deliver what is queued for
|
|
77
|
+
* it (a rule result, a message), not to run a job: there is no job to name.
|
|
78
|
+
*/
|
|
79
|
+
export const DELIVERY_JOB_ID = "promotion";
|
|
80
|
+
|
|
81
|
+
/** What keeps a page holder mid-turn: a run, or a compaction, which can come with no run at all (`/compact`). */
|
|
82
|
+
export type TurnReason = "run" | "compaction";
|
|
83
|
+
export type TurnMarks = Partial<Record<TurnReason, string>>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* pi's events that start or end a reason. A run lasts until `agent_settled`, not `agent_end`: pi
|
|
87
|
+
* retries, compacts, continues and takes queued messages in between, all of it in the same run.
|
|
88
|
+
*/
|
|
89
|
+
export const TURN_EVENTS = {
|
|
90
|
+
agent_start: { reason: "run", busy: true },
|
|
91
|
+
agent_settled: { reason: "run", busy: false },
|
|
92
|
+
session_before_compact: { reason: "compaction", busy: true },
|
|
93
|
+
session_compact: { reason: "compaction", busy: false },
|
|
94
|
+
session_compact_failed: { reason: "compaction", busy: false },
|
|
95
|
+
} as const satisfies Record<string, { reason: TurnReason; busy: boolean }>;
|
|
96
|
+
|
|
97
|
+
/** The marks after pi's `event` at `now`. A reason already set keeps its first stamp; any other event changes nothing. */
|
|
98
|
+
export function markTurnEvent(marks: TurnMarks, event: string, now: string): TurnMarks {
|
|
99
|
+
const e = (TURN_EVENTS as Record<string, { reason: TurnReason; busy: boolean }>)[event];
|
|
100
|
+
if (!e) return marks;
|
|
101
|
+
const { [e.reason]: was, ...rest } = marks;
|
|
102
|
+
return e.busy ? { ...rest, [e.reason]: was ?? now } : rest;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The earliest stamp of any reason: what `LeaseHolder.turnSince` says, undefined when none holds. */
|
|
106
|
+
export function turnSinceOf(marks: TurnMarks): string | undefined {
|
|
107
|
+
return Object.values(marks).sort((a, b) => Date.parse(a) - Date.parse(b))[0];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface SessionLease {
|
|
111
|
+
v: 1;
|
|
112
|
+
sessionId: string;
|
|
113
|
+
sessionFile?: string;
|
|
114
|
+
cwd?: string;
|
|
115
|
+
epoch: number;
|
|
116
|
+
holder: LeaseHolder | null;
|
|
117
|
+
updatedAt: string;
|
|
118
|
+
/**
|
|
119
|
+
* The session file's mtime when the host gave the conversation back. A file still at that mtime has
|
|
120
|
+
* been written by nobody since, so the two-minute quiet guard — there to protect someone else's
|
|
121
|
+
* writes — does not hold the host back from its own next turn or promotion.
|
|
122
|
+
*/
|
|
123
|
+
hostLeftMtime?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ProcessProbe {
|
|
127
|
+
alive(pid: number): boolean;
|
|
128
|
+
startTime(pid: number): string | undefined;
|
|
129
|
+
bootedAt(): number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `ps -o lstart=` prints a date in the caller's language and time zone, and a process's identity is
|
|
134
|
+
* compared between processes that may not share either: a LaunchAgent host has no LANG or TZ, a
|
|
135
|
+
* window has the person's. Fixed, so every process writes and reads the same string.
|
|
136
|
+
*/
|
|
137
|
+
export const PS_STABLE_ENV: NodeJS.ProcessEnv = { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" };
|
|
138
|
+
|
|
139
|
+
export function procStart(pid: number): string | undefined {
|
|
140
|
+
try {
|
|
141
|
+
if (process.platform === "linux") {
|
|
142
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
143
|
+
const close = stat.lastIndexOf(")");
|
|
144
|
+
return stat.slice(close + 2).split(" ")[19];
|
|
145
|
+
}
|
|
146
|
+
if (process.platform === "darwin") return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env: PS_STABLE_ENV }).trim() || undefined;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined; // the pid is gone, or this OS hides it: holderAlive falls back to the boot time
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const systemProbe: ProcessProbe = {
|
|
154
|
+
alive: pidAlive,
|
|
155
|
+
startTime: procStart,
|
|
156
|
+
bootedAt: () => Date.now() - os.uptime() * 1000,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The pi windows open on this machine, by the leases they hold (a terminal or the browser's pi): what
|
|
161
|
+
* `[host] auto = false` waits on. Windows write no presence file since 0.23, so the leases are the record.
|
|
162
|
+
*/
|
|
163
|
+
export function openWindowPids(leases: Pick<LeaseStore, "list">, probe: ProcessProbe = systemProbe): Set<number> {
|
|
164
|
+
const pids = new Set<number>();
|
|
165
|
+
for (const lease of leases.list()) if (lease.holder && lease.holder.kind === "terminal" && holderAlive(lease.holder, probe)) pids.add(lease.holder.pid);
|
|
166
|
+
return pids;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function holderAlive(h: LeaseHolder, probe: ProcessProbe = systemProbe): boolean {
|
|
170
|
+
if (!probe.alive(h.pid)) return false;
|
|
171
|
+
const start = probe.startTime(h.pid);
|
|
172
|
+
if (h.procStart !== undefined && start !== undefined && start !== h.procStart) return false;
|
|
173
|
+
if ((h.procStart === undefined || start === undefined) && Date.parse(h.acquiredAt) < probe.bootedAt() - 60_000) return false;
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sameHolder(a: Pick<LeaseHolder, "pid" | "instance">, b: Pick<LeaseHolder, "pid" | "instance">): boolean {
|
|
178
|
+
return a.pid === b.pid && a.instance === b.instance;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a live holder delivers the person rows addressed to its conversation. A window standing
|
|
183
|
+
* aside, and one in `print` or `json` mode (a one-shot run holds its conversation but would swallow a
|
|
184
|
+
* message into a turn about to exit), hold the lease and hand nothing over; a message typed into the
|
|
185
|
+
* browser page would wait on them for ever. The page's own pi runs such a row itself instead
|
|
186
|
+
* (src/engine.ts), and the page offers to send it over rpc, as 0.22 did.
|
|
187
|
+
*/
|
|
188
|
+
export function holderDelivers(h: LeaseHolder): boolean {
|
|
189
|
+
return h.aside !== true && h.mode !== "print" && h.mode !== "json";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type AcquireOutcome =
|
|
193
|
+
| { outcome: "held"; lease: SessionLease }
|
|
194
|
+
| { outcome: "acquired"; lease: SessionLease; tookFromPid?: number; tookFromHost?: { pid: number; runId?: string }; tookFromPage?: number }
|
|
195
|
+
| { outcome: "viewer"; lease: SessionLease; holderPid: number };
|
|
196
|
+
|
|
197
|
+
export function decideAcquire(current: SessionLease | undefined, sessionId: string, me: LeaseHolder, info: { sessionFile?: string; cwd?: string }, now: number, probe: ProcessProbe = systemProbe): AcquireOutcome & { write: boolean } {
|
|
198
|
+
const updatedAt = stamp(now);
|
|
199
|
+
const mergeInfo = (lease: SessionLease): SessionLease => ({ ...lease, ...info, updatedAt });
|
|
200
|
+
if (!current || current.holder === null) return { outcome: "acquired", lease: { v: 1, sessionId, ...info, epoch: (current?.epoch ?? 0) + 1, holder: me, updatedAt }, write: true };
|
|
201
|
+
if (sameHolder(current.holder, me)) {
|
|
202
|
+
const lease = mergeInfo(current);
|
|
203
|
+
return { outcome: "held", lease, write: lease.sessionFile !== current.sessionFile || lease.cwd !== current.cwd };
|
|
204
|
+
}
|
|
205
|
+
const alive = holderAlive(current.holder, probe);
|
|
206
|
+
// A window always takes its conversation back from the host, at once: the host holds a closed
|
|
207
|
+
// conversation only to do a piece of work in it, and a person opening it comes first.
|
|
208
|
+
if (alive && current.holder.kind === "host" && me.kind === "terminal") {
|
|
209
|
+
const lease: SessionLease = { ...current, ...info, epoch: current.epoch + 1, holder: me, updatedAt };
|
|
210
|
+
return { outcome: "acquired", lease, tookFromHost: { pid: current.holder.pid, runId: current.holder.busy?.runId }, write: true };
|
|
211
|
+
}
|
|
212
|
+
// A terminal takes its conversation from the browser page between turns: the page's pi is a
|
|
213
|
+
// convenience, and the person at the keyboard comes first. Not mid-turn, which it would cut off.
|
|
214
|
+
if (alive && current.holder.page === true && current.holder.turnSince === undefined && me.kind === "terminal" && me.mode === "tui") {
|
|
215
|
+
const lease: SessionLease = { ...current, ...info, epoch: current.epoch + 1, holder: me, updatedAt };
|
|
216
|
+
return { outcome: "acquired", lease, tookFromPage: current.holder.pid, write: true };
|
|
217
|
+
}
|
|
218
|
+
if (alive) return { outcome: "viewer", lease: current, holderPid: current.holder.pid, write: false };
|
|
219
|
+
return { outcome: "acquired", lease: { ...current, ...info, epoch: current.epoch + 1, holder: me, updatedAt }, tookFromPid: current.holder.pid, write: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** How long a closed conversation's file must have been left alone before the host writes into it. */
|
|
223
|
+
export const HOST_QUIET_MS = 120_000;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Pure. Whether the host may take `current` for one piece of work: nobody alive holds it (the host
|
|
227
|
+
* never displaces a live window), pi-loops knows its file, and the file has not been written for
|
|
228
|
+
* `quietMs` — the one guard against a pi that has the conversation open without pi-loops in it.
|
|
229
|
+
*/
|
|
230
|
+
export function decideHostAcquire(current: SessionLease | undefined, me: LeaseHolder, fileMtimeMs: number | undefined, now: number, probe: ProcessProbe = systemProbe, quietMs = HOST_QUIET_MS): { lease: SessionLease } | { refused: string } {
|
|
231
|
+
if (!current?.sessionFile) return { refused: "no known session file" };
|
|
232
|
+
// Itself included: a turn still running there is not interrupted by the next slot's.
|
|
233
|
+
if (current.holder && holderAlive(current.holder, probe)) return { refused: sameHolder(current.holder, me) ? "already working in it" : `held by pid ${current.holder.pid}` };
|
|
234
|
+
if (fileMtimeMs === undefined) return { refused: "session file is gone" };
|
|
235
|
+
// Either side of now: a clock set back left every file "written in the future", and every held
|
|
236
|
+
// turn refused until the clock caught up again.
|
|
237
|
+
if (Math.abs(now - fileMtimeMs) < quietMs && fileMtimeMs !== current.hostLeftMtime) return { refused: "session file changed in the last two minutes" };
|
|
238
|
+
return { lease: { ...current, epoch: current.epoch + 1, holder: me, updatedAt: stamp(now) } };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const validSessionId = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
242
|
+
|
|
243
|
+
/** A session id becomes a file name here and in the request store, so anything that could leave the directory is refused. */
|
|
244
|
+
export function sessionFileName(sessionId: string): string {
|
|
245
|
+
if (!validSessionId.test(sessionId) || sessionId.includes("..")) throw new Error(`invalid session id: ${sessionId}`);
|
|
246
|
+
return sessionId;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export class LeaseStore {
|
|
250
|
+
readonly dir: string;
|
|
251
|
+
private readonly probe: ProcessProbe;
|
|
252
|
+
|
|
253
|
+
constructor(dir: string, probe: ProcessProbe = systemProbe) {
|
|
254
|
+
this.dir = dir;
|
|
255
|
+
this.probe = probe;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
file(sessionId: string): string {
|
|
259
|
+
return path.join(this.dir, "leases", `${sessionFileName(sessionId)}.json`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
read(sessionId: string): SessionLease | undefined {
|
|
263
|
+
try {
|
|
264
|
+
// A regular file only: a FIFO put here would block every reader on it.
|
|
265
|
+
if (!fs.lstatSync(this.file(sessionId)).isFile()) return undefined;
|
|
266
|
+
const value: unknown = JSON.parse(fs.readFileSync(this.file(sessionId), "utf8"));
|
|
267
|
+
if (!value || typeof value !== "object") return undefined;
|
|
268
|
+
const lease = value as SessionLease;
|
|
269
|
+
return lease.v === 1 && lease.sessionId === sessionId && typeof lease.epoch === "number" ? lease : undefined;
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined; // missing, torn or an invalid id: nobody holds it
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The fencing rule: write only if the epoch on disk is still the one this critical section read (a lock broken as stale while we slept loses here). */
|
|
276
|
+
private writeIfCurrent(file: string, before: SessionLease | undefined, lease: SessionLease): boolean {
|
|
277
|
+
const current = this.read(lease.sessionId);
|
|
278
|
+
if ((current?.epoch ?? undefined) !== (before?.epoch ?? undefined)) return false;
|
|
279
|
+
writeFileAtomic(file, `${JSON.stringify(lease, null, 2)}\n`);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
acquire(sessionId: string, me: LeaseHolder, info: { sessionFile?: string; cwd?: string } = {}): AcquireOutcome {
|
|
284
|
+
const file = this.file(sessionId);
|
|
285
|
+
return withFileLockSync(path.join(this.dir, "leases.lock"), () => {
|
|
286
|
+
const before = this.read(sessionId);
|
|
287
|
+
const decision = decideAcquire(before, sessionId, me, info, Date.now(), this.probe);
|
|
288
|
+
if (!decision.write || this.writeIfCurrent(file, before, decision.lease)) return decision;
|
|
289
|
+
return decideAcquire(this.read(sessionId), sessionId, me, info, Date.now(), this.probe);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Give the conversation up. `epoch`, when given, must still be the lease's: a late release from a
|
|
295
|
+
* turn that was taken back must not end a later lease the same holder took since. `fileMtimeMs` is
|
|
296
|
+
* the host's own last write (see `hostLeftMtime`); any other holder's release clears it.
|
|
297
|
+
*/
|
|
298
|
+
release(sessionId: string, me: Pick<LeaseHolder, "pid" | "instance">, opts: { epoch?: number; fileMtimeMs?: number } = {}): boolean {
|
|
299
|
+
const file = this.file(sessionId);
|
|
300
|
+
return withFileLockSync(path.join(this.dir, "leases.lock"), () => {
|
|
301
|
+
const before = this.read(sessionId);
|
|
302
|
+
if (!before?.holder || !sameHolder(before.holder, me)) return false;
|
|
303
|
+
if (opts.epoch !== undefined && before.epoch !== opts.epoch) return false;
|
|
304
|
+
const { hostLeftMtime: _left, ...rest } = before;
|
|
305
|
+
const next: SessionLease = { ...rest, holder: null, epoch: before.epoch + 1, updatedAt: stamp(Date.now()), ...(opts.fileMtimeMs !== undefined ? { hostLeftMtime: opts.fileMtimeMs } : {}) };
|
|
306
|
+
return this.writeIfCurrent(file, before, next);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Delete the lease of a conversation no live process holds. Under the lock, so a window binding it now wins. */
|
|
311
|
+
removeIfFree(sessionId: string, probe: ProcessProbe = systemProbe): boolean {
|
|
312
|
+
return withFileLockSync(path.join(this.dir, "leases.lock"), () => {
|
|
313
|
+
const lease = this.read(sessionId);
|
|
314
|
+
if (!lease || (lease.holder && holderAlive(lease.holder, probe))) return false;
|
|
315
|
+
fs.rmSync(this.file(sessionId), { force: true });
|
|
316
|
+
return true;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
setPushServers(sessionId: string, me: Pick<LeaseHolder, "pid" | "instance">, epoch: number, servers: readonly string[]): boolean {
|
|
321
|
+
const file = this.file(sessionId);
|
|
322
|
+
return withFileLockSync(path.join(this.dir, "leases.lock"), () => {
|
|
323
|
+
const before = this.read(sessionId);
|
|
324
|
+
if (!before?.holder || !sameHolder(before.holder, me) || before.epoch !== epoch) return false;
|
|
325
|
+
const next = { ...before, holder: { ...before.holder, pushServers: [...new Set(servers)].sort() }, updatedAt: stamp(Date.now()) };
|
|
326
|
+
return this.writeIfCurrent(file, before, next);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Mark the holder as standing aside (`LeaseHolder.aside`) on its own lease, at its own epoch; the epoch does not move. */
|
|
331
|
+
setAside(sessionId: string, me: Pick<LeaseHolder, "pid" | "instance">, epoch: number): boolean {
|
|
332
|
+
const file = this.file(sessionId);
|
|
333
|
+
return withFileLockSync(path.join(this.dir, "leases.lock"), () => {
|
|
334
|
+
const before = this.read(sessionId);
|
|
335
|
+
if (!before?.holder || !sameHolder(before.holder, me) || before.epoch !== epoch) return false;
|
|
336
|
+
return this.writeIfCurrent(file, before, { ...before, holder: { ...before.holder, aside: true }, updatedAt: stamp(Date.now()) });
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Write `since` as the holder's `turnSince`, or delete it when undefined, on its own lease at its own epoch; the epoch does not move. */
|
|
341
|
+
setTurn(sessionId: string, me: Pick<LeaseHolder, "pid" | "instance">, epoch: number, since: string | undefined): boolean {
|
|
342
|
+
const file = this.file(sessionId);
|
|
343
|
+
return withFileLockSync(path.join(this.dir, "leases.lock"), () => {
|
|
344
|
+
const before = this.read(sessionId);
|
|
345
|
+
if (!before?.holder || !sameHolder(before.holder, me) || before.epoch !== epoch) return false;
|
|
346
|
+
const { turnSince: _was, ...holder } = before.holder;
|
|
347
|
+
return this.writeIfCurrent(file, before, { ...before, holder: since === undefined ? holder : { ...holder, turnSince: since }, updatedAt: stamp(Date.now()) });
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The host's acquisition for one piece of work (decideHostAcquire), compare-and-written under the
|
|
353
|
+
* lock. Returns the lease it now holds, or why not.
|
|
354
|
+
*/
|
|
355
|
+
acquireForHost(sessionId: string, me: LeaseHolder, now = Date.now(), quietMs = HOST_QUIET_MS): { lease: SessionLease } | { refused: string } {
|
|
356
|
+
const file = this.file(sessionId);
|
|
357
|
+
return withFileLockSync(path.join(this.dir, "leases.lock"), () => {
|
|
358
|
+
const before = this.read(sessionId);
|
|
359
|
+
let mtime: number | undefined;
|
|
360
|
+
try {
|
|
361
|
+
if (before?.sessionFile) mtime = fs.statSync(before.sessionFile).mtimeMs;
|
|
362
|
+
} catch {
|
|
363
|
+
mtime = undefined; // the conversation was deleted
|
|
364
|
+
}
|
|
365
|
+
const decision = decideHostAcquire(before, me, mtime, now, this.probe, quietMs);
|
|
366
|
+
if ("refused" in decision) return decision;
|
|
367
|
+
return this.writeIfCurrent(file, before, decision.lease) ? decision : { refused: "lease changed while acquiring" };
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
holds(sessionId: string, me: Pick<LeaseHolder, "pid" | "instance">, epoch?: number): boolean {
|
|
372
|
+
const lease = this.read(sessionId);
|
|
373
|
+
return !!lease?.holder && sameHolder(lease.holder, me) && (epoch === undefined || lease.epoch === epoch);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
list(): SessionLease[] {
|
|
377
|
+
let names: string[];
|
|
378
|
+
try {
|
|
379
|
+
names = fs.readdirSync(path.join(this.dir, "leases"));
|
|
380
|
+
} catch {
|
|
381
|
+
return []; // no lease was ever written
|
|
382
|
+
}
|
|
383
|
+
return names.filter((name) => name.endsWith(".json")).map((name) => this.read(name.slice(0, -5))).filter((lease): lease is SessionLease => lease !== undefined);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The pid of the live holder of `sessionId`'s lease, or undefined when nobody holds it. */
|
|
388
|
+
/**
|
|
389
|
+
* Whether a conversation is closed and the host may take it for a turn or a write: no process alive
|
|
390
|
+
* holds it, pi-loops knows its file and the file is there, and no pi-loops 0.22 window has it open
|
|
391
|
+
* (those fire its plain jobs without a lease). The one predicate the host's dispatch, a window's
|
|
392
|
+
* `/cron` markers, the host's start condition and the promotion router all ask.
|
|
393
|
+
*/
|
|
394
|
+
export function closedConversation(store: LeaseStore, sessionId: string | undefined, legacyOpen: (sessionId: string) => boolean = () => false, probe: ProcessProbe = systemProbe): boolean {
|
|
395
|
+
if (!sessionId || legacyOpen(sessionId)) return false;
|
|
396
|
+
const lease = store.read(sessionId);
|
|
397
|
+
if (!lease?.sessionFile || !fs.existsSync(lease.sessionFile)) return false;
|
|
398
|
+
return !(lease.holder && holderAlive(lease.holder, probe));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function liveHolderPid(store: LeaseStore, sessionId: string, probe: ProcessProbe = systemProbe): number | undefined {
|
|
402
|
+
let lease: SessionLease | undefined;
|
|
403
|
+
try {
|
|
404
|
+
lease = store.read(sessionId);
|
|
405
|
+
} catch {
|
|
406
|
+
return undefined; // not a session id this store can name
|
|
407
|
+
}
|
|
408
|
+
return lease?.holder && holderAlive(lease.holder, probe) ? lease.holder.pid : undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* One extension instance's side of the leases: the session it binds, whether it holds it or only
|
|
413
|
+
* views it, and the answers the scheduler and the lists ask (src/job-owner.ts). The decisions are
|
|
414
|
+
* `decideAcquire`'s; this keeps what they returned.
|
|
415
|
+
*/
|
|
416
|
+
export class LeaseBinding {
|
|
417
|
+
readonly store: LeaseStore;
|
|
418
|
+
readonly instance: string;
|
|
419
|
+
private readonly self: () => Omit<LeaseHolder, "acquiredAt">;
|
|
420
|
+
private readonly probe: ProcessProbe;
|
|
421
|
+
private sessionId: string | undefined;
|
|
422
|
+
private info: { sessionFile?: string; cwd?: string } = {};
|
|
423
|
+
private epoch: number | undefined;
|
|
424
|
+
private pushServers: string[] = [];
|
|
425
|
+
/** The current turn's start, kept so a lease taken mid-turn says so too. */
|
|
426
|
+
private turnSince: string | undefined;
|
|
427
|
+
/** The pid of the live holder while this instance is a viewer. */
|
|
428
|
+
viewerOf: number | undefined;
|
|
429
|
+
/**
|
|
430
|
+
* Set while this instance stands aside: what it has of the conversation is behind the file, so
|
|
431
|
+
* anything it wrote would branch off an old entry. It keeps the lease, so nobody else writes into a
|
|
432
|
+
* conversation that is open here, and reports holding nothing, so it fires and delivers nothing
|
|
433
|
+
* either, until it is bound again.
|
|
434
|
+
*/
|
|
435
|
+
standingAside = false;
|
|
436
|
+
|
|
437
|
+
constructor(store: LeaseStore, instance: string, self: () => Omit<LeaseHolder, "acquiredAt" | "instance">, probe: ProcessProbe = systemProbe) {
|
|
438
|
+
this.store = store;
|
|
439
|
+
this.instance = instance;
|
|
440
|
+
this.self = () => ({ ...self(), instance });
|
|
441
|
+
this.probe = probe;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private me(): LeaseHolder {
|
|
445
|
+
return { ...this.self(), acquiredAt: stamp(Date.now()) };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Take `sessionId`'s lease, or become its viewer. A different session bound before is released first. */
|
|
449
|
+
bind(sessionId: string, info: { sessionFile?: string; cwd?: string } = {}): AcquireOutcome {
|
|
450
|
+
if (this.sessionId && this.sessionId !== sessionId) this.release();
|
|
451
|
+
this.sessionId = sessionId;
|
|
452
|
+
this.info = info;
|
|
453
|
+
this.standingAside = false;
|
|
454
|
+
return this.take();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Every tick: a holder whose lease is still its own reads and returns nothing. A viewer retries,
|
|
459
|
+
* and a holder whose file was deleted re-acquires (deleting private state must not strand a session).
|
|
460
|
+
*/
|
|
461
|
+
refresh(): AcquireOutcome | undefined {
|
|
462
|
+
if (!this.sessionId || this.standingAside) return undefined;
|
|
463
|
+
if (this.epoch !== undefined && this.store.holds(this.sessionId, this.self(), this.epoch)) return undefined;
|
|
464
|
+
return this.take();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Rewrite the injecting MCP servers on the lease when the set changed. The epoch does not move. */
|
|
468
|
+
setPushServers(servers: readonly string[]): void {
|
|
469
|
+
const next = [...new Set(servers)].sort();
|
|
470
|
+
if (!this.sessionId || this.epoch === undefined || next.join("\n") === this.pushServers.join("\n")) return;
|
|
471
|
+
if (this.store.setPushServers(this.sessionId, this.self(), this.epoch, next)) this.pushServers = next;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Say on the lease that a turn runs since `since`, or that none does (`LeaseHolder.turnSince`). Only a holder writes it; the epoch does not move. */
|
|
475
|
+
setTurn(since: string | undefined): void {
|
|
476
|
+
this.turnSince = since;
|
|
477
|
+
if (this.sessionId && this.epoch !== undefined) this.store.setTurn(this.sessionId, this.self(), this.epoch, since);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
release(): void {
|
|
481
|
+
if (this.sessionId && this.epoch !== undefined) this.store.release(this.sessionId, this.self());
|
|
482
|
+
this.sessionId = undefined;
|
|
483
|
+
this.epoch = undefined;
|
|
484
|
+
this.viewerOf = undefined;
|
|
485
|
+
this.standingAside = false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** See `standingAside`. Only a holder stands aside: a viewer writes nothing already. Said on the lease too, for the page whose pi views this conversation. */
|
|
489
|
+
standAside(): void {
|
|
490
|
+
if (!this.sessionId || this.epoch === undefined) return;
|
|
491
|
+
this.standingAside = true;
|
|
492
|
+
this.store.setAside(this.sessionId, this.self(), this.epoch);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Whether the live holder of this binding's conversation, another process, delivers the person
|
|
497
|
+
* rows addressed to it (`holderDelivers`). Undefined when nobody alive holds it, or when the holder
|
|
498
|
+
* is this window: a viewer's question, about the process it would otherwise wait on.
|
|
499
|
+
*/
|
|
500
|
+
otherHolderDelivers(): boolean | undefined {
|
|
501
|
+
if (!this.sessionId) return undefined;
|
|
502
|
+
const holder = this.store.read(this.sessionId)?.holder;
|
|
503
|
+
if (!holder || !holderAlive(holder, this.probe) || sameHolder(holder, this.self())) return undefined;
|
|
504
|
+
return holderDelivers(holder);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The session bound, and whether this instance holds it (false for a viewer). */
|
|
508
|
+
here(): { sessionId?: string; holds: boolean } {
|
|
509
|
+
return { sessionId: this.sessionId, holds: this.sessionId !== undefined && this.heldEpoch(this.sessionId) !== undefined };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The epoch of this instance's lease on `sessionId`, read from disk so a lease taken away is seen at once. */
|
|
513
|
+
heldEpoch(sessionId: string): number | undefined {
|
|
514
|
+
if (sessionId !== this.sessionId || this.epoch === undefined || this.standingAside) return undefined;
|
|
515
|
+
return this.store.holds(sessionId, this.self(), this.epoch) ? this.epoch : undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The pid of the live holder of `sessionId`, this process's included. */
|
|
519
|
+
holderOf(sessionId: string): number | undefined {
|
|
520
|
+
return liveHolderPid(this.store, sessionId, this.probe);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** What kind of process holds `sessionId` right now, if anyone alive does; `page` is the page's pi. */
|
|
524
|
+
holderKindOf(sessionId: string): LeaseHolder["kind"] | "page" | undefined {
|
|
525
|
+
const holder = this.store.read(sessionId)?.holder;
|
|
526
|
+
if (!holder || !holderAlive(holder, this.probe)) return undefined;
|
|
527
|
+
return holder.kind === "terminal" && holder.page === true ? "page" : holder.kind;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private take(): AcquireOutcome {
|
|
531
|
+
const outcome = this.store.acquire(this.sessionId!, { ...this.me(), pushServers: this.pushServers, ...(this.turnSince !== undefined ? { turnSince: this.turnSince } : {}) }, this.info);
|
|
532
|
+
this.epoch = outcome.outcome === "viewer" ? undefined : outcome.lease.epoch;
|
|
533
|
+
this.viewerOf = outcome.outcome === "viewer" ? outcome.holderPid : undefined;
|
|
534
|
+
if (outcome.outcome !== "viewer") this.pushServers = outcome.lease.holder?.pushServers ?? [];
|
|
535
|
+
return outcome;
|
|
536
|
+
}
|
|
537
|
+
}
|
package/src/lock.ts
CHANGED
|
@@ -133,7 +133,12 @@ export async function withFileLock<T>(lockPath: string, fn: () => Promise<T> | T
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** Write via temp file + rename so readers never see a torn file. */
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* `mode` sets the permissions the temporary file is created with, for a file that carries a secret
|
|
138
|
+
* (`host-service.json` holds the status page's token): a chmod after the rename would leave it
|
|
139
|
+
* readable under the umask for that moment.
|
|
140
|
+
*/
|
|
141
|
+
export function writeFileAtomic(file: string, data: string, mode?: number): void {
|
|
137
142
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
138
143
|
const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
139
144
|
// fsync before the rename: without it a crash can leave the rename applied and the data not,
|
|
@@ -141,7 +146,7 @@ export function writeFileAtomic(file: string, data: string): void {
|
|
|
141
146
|
// itself survives. Both are best-effort — a filesystem that refuses them is not a reason to fail.
|
|
142
147
|
let fd: number | undefined;
|
|
143
148
|
try {
|
|
144
|
-
fd = fs.openSync(tmp, "w");
|
|
149
|
+
fd = fs.openSync(tmp, "w", mode);
|
|
145
150
|
fs.writeFileSync(fd, data, "utf8");
|
|
146
151
|
try {
|
|
147
152
|
fs.fsyncSync(fd);
|