pi-orchestrator-config 4.3.11 → 4.3.13
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/README.md +66 -66
- package/extensions/coms/coms-p2p.ts +53 -16
- package/extensions/orchestrator/async-agents.ts +127 -42
- package/extensions/orchestrator/async-result-format.ts +58 -0
- package/extensions/orchestrator/async-runner.ts +20 -4
- package/extensions/orchestrator/cron-status-format.ts +6 -6
- package/extensions/orchestrator/cron-status-ui.ts +3 -3
- package/extensions/orchestrator/cron-store.ts +208 -0
- package/extensions/orchestrator/cron.ts +168 -699
- package/extensions/orchestrator/extended-autocomplete.ts +27 -27
- package/extensions/orchestrator/reviewer-output-archive.ts +69 -0
- package/extensions/orchestrator/status.ts +7 -2
- package/extensions/pidash/pidash-ui/dist/assets/{index-BoNVhD5K.js → index-Ehfwjr9d.js} +1 -1
- package/extensions/pidash/pidash-ui/dist/index.html +1 -1
- package/extensions/pidash/pidash-ui/src/components/InfoBar.tsx +3 -7
- package/extensions/pidash/pidash-ui/src/components/MessageList.tsx +0 -4
- package/extensions/pidash/pidash-ui/tsconfig.json +1 -0
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/pitasks/index.ts +2 -5
- package/extensions/pitasks/task-tools.ts +2 -2
- package/package.json +2 -1
- package/scripts/build-extension-uis.sh +6 -6
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/** Durable cron storage and local-filesystem leader locks. Delivery is at-least-once. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { parseProcStartTime } from "./utils.js";
|
|
7
|
+
import { createLogger } from "../shared/logger.js";
|
|
8
|
+
|
|
9
|
+
export type CronScope = "session" | "project";
|
|
10
|
+
export interface DurableCronTask {
|
|
11
|
+
id: string;
|
|
12
|
+
scope: "project";
|
|
13
|
+
cwd: string;
|
|
14
|
+
description: string;
|
|
15
|
+
task: string;
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
atHour?: number;
|
|
18
|
+
atMinute?: number;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
lastRun?: number;
|
|
21
|
+
nextRun?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface DurableCronEnvelope { version: 1; tasks: DurableCronTask[]; }
|
|
24
|
+
export interface CronLockOwner { pid: number; process_start_token: string; instance_id: string; heartbeat_at: string; fd?: number; }
|
|
25
|
+
|
|
26
|
+
type StartTokenDeps = { readFileSync: (path: string, encoding: BufferEncoding) => string; execFileSync: (file: string, args: readonly string[], options: { encoding: BufferEncoding; windowsHide: boolean }) => string; platform: NodeJS.Platform };
|
|
27
|
+
const startTokenDeps: StartTokenDeps = { readFileSync: fs.readFileSync, execFileSync, platform: process.platform };
|
|
28
|
+
const log = createLogger("cron_store");
|
|
29
|
+
const STALE_LOCK_MS = 30_000;
|
|
30
|
+
const leaderOwners = new Map<string, CronLockOwner>();
|
|
31
|
+
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
32
|
+
function sleep(ms: number) { Atomics.wait(sleepCell, 0, 0, ms); }
|
|
33
|
+
|
|
34
|
+
/** Return an OS-issued token which changes when a PID is reused, or null when unavailable. */
|
|
35
|
+
export function resolveProcessStartToken(pid: number, deps: StartTokenDeps = startTokenDeps): string | null {
|
|
36
|
+
try {
|
|
37
|
+
const procToken = parseProcStartTime(deps.readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
38
|
+
if (procToken) { log.debug("cron_process_token", { pid, platform: deps.platform, source: "proc" }); return `proc:${procToken}`; }
|
|
39
|
+
} catch (error: any) { log.debug("cron_process_token_proc_failed", { pid, platform: deps.platform, code: error?.code }); }
|
|
40
|
+
if (deps.platform !== "win32") { log.warn("cron_process_token_unavailable", { pid, platform: deps.platform }); return null; }
|
|
41
|
+
try {
|
|
42
|
+
const output = deps.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).StartTime.ToFileTimeUtc()`], { encoding: "utf8", windowsHide: true }).trim();
|
|
43
|
+
const token = /^\d{17,}$/.test(output) ? `win:${output}` : null;
|
|
44
|
+
log.debug("cron_process_token_windows", { pid, valid: !!token });
|
|
45
|
+
return token;
|
|
46
|
+
} catch (error: any) { log.warn("cron_process_token_windows_failed", { pid, code: error?.code }); return null; }
|
|
47
|
+
}
|
|
48
|
+
export function processStartToken(pid = process.pid): string { return resolveProcessStartToken(pid) || "unknown"; }
|
|
49
|
+
export function durableCronSupported(): boolean { const supported = resolveProcessStartToken(process.pid) !== null; log.debug("durable_cron_supported", { platform: process.platform, supported }); return supported; }
|
|
50
|
+
function lockPath(store: string, kind: "mutation" | "leader") { return `${store}.${kind}.lock`; }
|
|
51
|
+
function ownerPath(dir: string) { return path.join(dir, "owner.json"); }
|
|
52
|
+
/** True only when the OS can prove this record cannot still own the lock. */
|
|
53
|
+
function ownerIsProvenDead(owner: CronLockOwner): boolean {
|
|
54
|
+
if (!owner?.pid || !Number.isInteger(owner.pid) || owner.pid < 1) return false;
|
|
55
|
+
try {
|
|
56
|
+
process.kill(owner.pid, 0);
|
|
57
|
+
} catch (error: any) {
|
|
58
|
+
// EPERM means the process exists but is not signalable; all other failures
|
|
59
|
+
// are treated conservatively except the OS's definitive "no such process".
|
|
60
|
+
return error?.code === "ESRCH";
|
|
61
|
+
}
|
|
62
|
+
if (!owner.process_start_token || owner.process_start_token === "unknown") return false;
|
|
63
|
+
const token = resolveProcessStartToken(owner.pid);
|
|
64
|
+
// An unavailable identity source fails closed. A different issued token
|
|
65
|
+
// proves PID reuse, so this record belongs to a dead process.
|
|
66
|
+
return token !== null && token !== owner.process_start_token;
|
|
67
|
+
}
|
|
68
|
+
function readOwner(dir: string): CronLockOwner | null {
|
|
69
|
+
try {
|
|
70
|
+
const owner = JSON.parse(fs.readFileSync(ownerPath(dir), "utf8"));
|
|
71
|
+
const valid = owner && typeof owner.pid === "number" && typeof owner.process_start_token === "string" && typeof owner.instance_id === "string" && typeof owner.heartbeat_at === "string";
|
|
72
|
+
if (!valid) log.warn("cron_lock_owner_invalid", { dir, reason: "invalid_owner" });
|
|
73
|
+
return valid ? owner : null;
|
|
74
|
+
} catch (error: any) { log.debug("cron_lock_owner_unreadable", { dir, reason: error?.code || "parse_error" }); return null; }
|
|
75
|
+
}
|
|
76
|
+
function sameOwner(a: CronLockOwner | null, b: CronLockOwner) {
|
|
77
|
+
return !!a && a.instance_id === b.instance_id && a.pid === b.pid && a.process_start_token === b.process_start_token;
|
|
78
|
+
}
|
|
79
|
+
function serializedOwner(owner: CronLockOwner) {
|
|
80
|
+
const { fd: _fd, ...record } = owner;
|
|
81
|
+
return JSON.stringify(record);
|
|
82
|
+
}
|
|
83
|
+
function claimIsOld(claim: string) {
|
|
84
|
+
try {
|
|
85
|
+
const ageMs = Date.now() - fs.statSync(claim).mtimeMs;
|
|
86
|
+
const old = ageMs >= STALE_LOCK_MS;
|
|
87
|
+
log.debug("cron_lock_claim_age", { claim, age_ms: ageMs, old });
|
|
88
|
+
return old;
|
|
89
|
+
} catch (error: any) { log.warn("cron_lock_claim_stat_failed", { claim, reason: error?.code || "stat_error" }); return false; }
|
|
90
|
+
}
|
|
91
|
+
function restoreClaim(claim: string, dir: string) {
|
|
92
|
+
try { fs.renameSync(claim, dir); } catch { log.warn("cron_lock_claim_restore_failed", { claim, dir }); }
|
|
93
|
+
}
|
|
94
|
+
/** Read the canonical record first; detach and delete only that proven-stale record. */
|
|
95
|
+
function reclaimStaleLock(dir: string, instanceId: string, kind: "leader" | "mutation"): boolean {
|
|
96
|
+
const expected = readOwner(dir);
|
|
97
|
+
if (!(expected ? ownerIsProvenDead(expected) : claimIsOld(dir))) return false;
|
|
98
|
+
const claim = `${dir}.claim-${randomUUID()}`;
|
|
99
|
+
try { fs.renameSync(dir, claim); } catch { return false; }
|
|
100
|
+
const current = readOwner(claim);
|
|
101
|
+
if (expected ? !sameOwner(current, expected) : current || !claimIsOld(claim)) { restoreClaim(claim, dir); return false; }
|
|
102
|
+
try { fs.rmSync(claim, { recursive: true, force: false }); log.info(`cron_${kind}_lock_reclaim`, { dir, instance_id: instanceId, outcome: "reclaimed" }); return true; }
|
|
103
|
+
catch (error: any) { log.warn(`cron_${kind}_lock_reclaim`, { dir, instance_id: instanceId, outcome: "failed", reason: error?.code || "remove_error" }); restoreClaim(claim, dir); return false; }
|
|
104
|
+
}
|
|
105
|
+
function safelyRemoveLock(dir: string, expected?: CronLockOwner): boolean {
|
|
106
|
+
if (expected && !sameOwner(readOwner(dir), expected)) { log.debug("cron_lock_remove", { dir, outcome: "not_owner" }); return false; }
|
|
107
|
+
const claim = `${dir}.claim-${randomUUID()}`;
|
|
108
|
+
try { fs.renameSync(dir, claim); } catch (error: any) { log.warn("cron_lock_remove", { dir, outcome: "claim_failed", reason: error?.code || "rename_error" }); return false; }
|
|
109
|
+
if (expected && !sameOwner(readOwner(claim), expected)) { restoreClaim(claim, dir); log.warn("cron_lock_remove", { dir, outcome: "claim_not_owned" }); return false; }
|
|
110
|
+
try { fs.rmSync(claim, { recursive: true, force: false }); log.info("cron_lock_remove", { dir, outcome: "removed" }); return true; }
|
|
111
|
+
catch (error: any) { restoreClaim(claim, dir); log.warn("cron_lock_remove", { dir, outcome: "remove_failed", reason: error?.code || "remove_error" }); return false; }
|
|
112
|
+
}
|
|
113
|
+
function acquireDirectoryLock(dir: string, instanceId: string, reclaimStale: boolean, deps = startTokenDeps): CronLockOwner | null {
|
|
114
|
+
const token = resolveProcessStartToken(process.pid, deps);
|
|
115
|
+
// Without a PID-reuse-safe token, durable leadership is disabled rather than unsafe.
|
|
116
|
+
if (!token) { log.warn("cron_leader_lock_unavailable", { dir, instance_id: instanceId, reason: "no_process_token" }); return null; }
|
|
117
|
+
const owner: CronLockOwner = { pid: process.pid, process_start_token: token, instance_id: instanceId, heartbeat_at: new Date().toISOString() };
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
120
|
+
owner.fd = fs.openSync(ownerPath(dir), "wx", 0o600);
|
|
121
|
+
fs.writeFileSync(owner.fd, serializedOwner(owner));
|
|
122
|
+
const acquired = sameOwner(readOwner(dir), owner);
|
|
123
|
+
log.info("cron_leader_lock_acquire", { dir, instance_id: instanceId, outcome: acquired ? "acquired" : "verification_failed" });
|
|
124
|
+
if (acquired) { leaderOwners.set(dir, owner); return owner; }
|
|
125
|
+
safelyRemoveLock(dir, owner);
|
|
126
|
+
if (owner.fd !== undefined) try { fs.closeSync(owner.fd); } catch {}
|
|
127
|
+
return null;
|
|
128
|
+
} catch (error: any) {
|
|
129
|
+
if (owner.fd !== undefined) try { fs.closeSync(owner.fd); } catch {}
|
|
130
|
+
if (error?.code !== "EEXIST" || !reclaimStale) { (error?.code === "EEXIST" ? log.debug : log.warn)("cron_leader_lock_acquire", { dir, instance_id: instanceId, outcome: "failed", reason: error?.code || "lock_error" }); return null; }
|
|
131
|
+
if (!reclaimStaleLock(dir, instanceId, "leader")) return null;
|
|
132
|
+
return acquireDirectoryLock(dir, instanceId, false);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function readDurableCronStore(store: string): DurableCronEnvelope {
|
|
136
|
+
try {
|
|
137
|
+
const value = JSON.parse(fs.readFileSync(store, "utf8"));
|
|
138
|
+
if (value?.version === 1 && Array.isArray(value.tasks)) return value;
|
|
139
|
+
if (Array.isArray(value)) return { version: 1, tasks: value };
|
|
140
|
+
} catch {}
|
|
141
|
+
return { version: 1, tasks: [] };
|
|
142
|
+
}
|
|
143
|
+
export function validateDurableCronTask(task: unknown): asserts task is DurableCronTask {
|
|
144
|
+
const value = task as DurableCronTask;
|
|
145
|
+
if (!value || typeof value !== "object" || typeof value.id !== "string" || !value.id || typeof value.cwd !== "string" || !value.cwd || typeof value.description !== "string" || typeof value.task !== "string" || !value.task.trim() || value.scope !== "project" || !Number.isFinite(value.createdAt)) throw new Error("Invalid durable cron task");
|
|
146
|
+
if (value.intervalMs !== undefined && (!Number.isFinite(value.intervalMs) || value.intervalMs < 10_000)) throw new Error("Invalid durable cron interval");
|
|
147
|
+
if (value.atHour !== undefined && (!Number.isInteger(value.atHour) || value.atHour < 0 || value.atHour > 23)) throw new Error("Invalid durable cron hour");
|
|
148
|
+
if (value.atMinute !== undefined && (!Number.isInteger(value.atMinute) || value.atMinute < 0 || value.atMinute > 59 || value.atHour === undefined)) throw new Error("Invalid durable cron minute");
|
|
149
|
+
if (value.intervalMs === undefined && value.atHour === undefined) throw new Error("Invalid durable cron schedule");
|
|
150
|
+
if ((value.lastRun !== undefined && !Number.isFinite(value.lastRun)) || (value.nextRun !== undefined && !Number.isFinite(value.nextRun))) throw new Error("Invalid durable cron timestamp");
|
|
151
|
+
}
|
|
152
|
+
function validate(tasks: DurableCronTask[]) { for (const task of tasks) validateDurableCronTask(task); }
|
|
153
|
+
function atomicWrite(file: string, content: string) {
|
|
154
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
155
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
156
|
+
fs.writeFileSync(temp, content, { mode: 0o600 });
|
|
157
|
+
fs.renameSync(temp, file);
|
|
158
|
+
}
|
|
159
|
+
function acquireMutationLock(dir: string, instanceId = randomUUID()): CronLockOwner | null {
|
|
160
|
+
const token = resolveProcessStartToken(process.pid);
|
|
161
|
+
if (!token) { log.warn("cron_mutation_lock_unavailable", { dir, instance_id: instanceId, reason: "no_process_token" }); return null; }
|
|
162
|
+
const owner: CronLockOwner = { pid: process.pid, process_start_token: token, instance_id: instanceId, heartbeat_at: new Date().toISOString() };
|
|
163
|
+
try {
|
|
164
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
165
|
+
fs.writeFileSync(ownerPath(dir), serializedOwner(owner), { flag: "wx", mode: 0o600 });
|
|
166
|
+
const acquired = sameOwner(readOwner(dir), owner);
|
|
167
|
+
log.debug("cron_mutation_lock_acquire", { dir, instance_id: instanceId, outcome: acquired ? "acquired" : "verification_failed" });
|
|
168
|
+
return acquired ? owner : null;
|
|
169
|
+
} catch (error: any) {
|
|
170
|
+
if (error?.code !== "EEXIST") { log.warn("cron_mutation_lock_acquire", { dir, instance_id: instanceId, outcome: "failed", reason: error?.code || "lock_error" }); return null; }
|
|
171
|
+
if (!reclaimStaleLock(dir, instanceId, "mutation")) return null;
|
|
172
|
+
return acquireMutationLock(dir, instanceId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function releaseMutationLock(dir: string, owner: CronLockOwner) { safelyRemoveLock(dir, owner); }
|
|
176
|
+
export function mutateDurableCronStore(store: string, change: (tasks: DurableCronTask[]) => DurableCronTask[]): DurableCronEnvelope {
|
|
177
|
+
const lock = lockPath(store, "mutation"); let owner: CronLockOwner | null = null;
|
|
178
|
+
for (let attempt = 0; attempt < 100; attempt++) { owner = acquireMutationLock(lock); if (owner) break; sleep(20); }
|
|
179
|
+
if (!owner) throw new Error("Timed out waiting for cron storage lock");
|
|
180
|
+
try {
|
|
181
|
+
// Corrupt records must not prevent valid tasks from being updated. Schedulers
|
|
182
|
+
// log each skipped record before calling here.
|
|
183
|
+
const current = readDurableCronStore(store).tasks.filter((task) => { try { validateDurableCronTask(task); return true; } catch { return false; } });
|
|
184
|
+
const tasks = change(current); validate(tasks); const envelope: DurableCronEnvelope = { version: 1, tasks }; atomicWrite(store, JSON.stringify(envelope)); return envelope;
|
|
185
|
+
}
|
|
186
|
+
finally { releaseMutationLock(lock, owner); }
|
|
187
|
+
}
|
|
188
|
+
export function acquireLeaderLock(store: string, instanceId: string, deps: StartTokenDeps = startTokenDeps): CronLockOwner | null { return acquireDirectoryLock(lockPath(store, "leader"), instanceId, true, deps); }
|
|
189
|
+
export function refreshLeaderLock(store: string, owner: CronLockOwner): boolean {
|
|
190
|
+
const dir = lockPath(store, "leader");
|
|
191
|
+
if (owner.fd === undefined || !sameOwner(readOwner(dir), owner)) return false;
|
|
192
|
+
owner.heartbeat_at = new Date().toISOString();
|
|
193
|
+
try {
|
|
194
|
+
// Write through the exclusively-created inode, never a lock pathname that
|
|
195
|
+
// could have been atomically reclaimed and recreated by another process.
|
|
196
|
+
fs.ftruncateSync(owner.fd, 0); fs.writeSync(owner.fd, serializedOwner(owner), 0, "utf8"); fs.fsyncSync(owner.fd);
|
|
197
|
+
return sameOwner(readOwner(dir), owner);
|
|
198
|
+
} catch { return false; }
|
|
199
|
+
}
|
|
200
|
+
export function releaseLeaderLock(store: string, owner: CronLockOwner) {
|
|
201
|
+
const dir = lockPath(store, "leader");
|
|
202
|
+
const locallyOwned = leaderOwners.get(dir) === owner;
|
|
203
|
+
const released = safelyRemoveLock(dir, owner);
|
|
204
|
+
if (locallyOwned && owner.fd !== undefined) try { fs.closeSync(owner.fd); } catch {}
|
|
205
|
+
if (locallyOwned) leaderOwners.delete(dir);
|
|
206
|
+
(released ? log.info : log.warn)("cron_leader_lock_release", { dir, instance_id: owner.instance_id, outcome: released ? "released" : "path_retained" });
|
|
207
|
+
return released;
|
|
208
|
+
}
|