pi-orchestrator-config 4.3.12 → 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.
@@ -27,9 +27,11 @@ const log = createLogger("async_agents");
27
27
 
28
28
  export { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
29
29
  import { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
30
+ import { openAsyncStatusOverlay } from "./async-status-ui.js";
30
31
  import { setSlot } from "./status-bar.js";
31
32
  import { getSetting } from "./project-settings.js";
32
33
  import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
34
+ import { isLiveExtensionCtx } from "../shared/live-ctx.js";
33
35
 
34
36
  const SETTINGS_KEYS: Record<string, unknown> = JSON.parse(
35
37
  fs.readFileSync(
@@ -174,10 +176,20 @@ export function registerAsyncAgents(
174
176
  }
175
177
 
176
178
  let lastWidgetKey = "";
179
+ function getLiveAsyncCtx(): any | null {
180
+ const ctx = asyncState.lastCtx;
181
+ if (!isLiveExtensionCtx(ctx)) {
182
+ log.debug("async_context_stale", { hadContext: ctx !== null });
183
+ asyncState.lastCtx = null;
184
+ return null;
185
+ }
186
+ return ctx;
187
+ }
188
+
177
189
  function updateAsyncWidget() {
178
- if (!asyncState.lastCtx?.hasUI) return;
179
190
  try {
180
- const ctx = asyncState.lastCtx;
191
+ const ctx = getLiveAsyncCtx();
192
+ if (!ctx?.hasUI) return;
181
193
  const running = Array.from(asyncState.jobs.values()).filter(j => j.status === "running" || j.status === "queued");
182
194
  const names = running.map(j => j.name || j.agent).join(", ");
183
195
  const widgetKey = `${running.length}:${names}`;
@@ -207,10 +219,15 @@ export function registerAsyncAgents(
207
219
  function ensureAsyncPoller() {
208
220
  if (asyncState.poller) return;
209
221
  asyncState.poller = setInterval(() => {
222
+ const liveCtx = getLiveAsyncCtx();
223
+ if (!liveCtx) {
224
+ // ctx is stale (session ended) — stop polling
225
+ if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
226
+ return;
227
+ }
210
228
  try {
211
- if (!asyncState.lastCtx?.hasUI) return;
229
+ if (!liveCtx.hasUI) return;
212
230
  } catch {
213
- // ctx is stale (session ended) — stop polling
214
231
  if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
215
232
  return;
216
233
  }
@@ -401,7 +418,7 @@ export function registerAsyncAgents(
401
418
  deliverGroupResults(groupJobs);
402
419
  }
403
420
  // Delivery: retry for non-grouped jobs (zombie-ingest path may skip delivery)
404
- } else if (asyncState.lastCtx && !job.fireAndForget) {
421
+ } else if (getLiveAsyncCtx() && !job.fireAndForget) {
405
422
  const displayName = job.name || job.agent;
406
423
  const resultStatus = job.status === "complete" ? "✅ completed" : "❌ failed";
407
424
  const duration = job.durationMs || (job.updatedAt ? job.updatedAt - job.startedAt : 0);
@@ -437,7 +454,7 @@ export function registerAsyncAgents(
437
454
  job.delivered = true;
438
455
  log.debug(`reconcile: skipping already-delivered result for ${job.id}`);
439
456
  } else {
440
- log.debug("reconcile_deliver", job.id, "hasCtx", !!asyncState.lastCtx);
457
+ log.debug("reconcile_deliver", job.id, "hasCtx", true);
441
458
  try {
442
459
  pi.sendMessage({
443
460
  customType: "async-agent-result",
@@ -494,8 +511,6 @@ export function registerAsyncAgents(
494
511
  if (gid && groupDeliveryInProgress.has(gid)) return;
495
512
  if (gid) groupDeliveryInProgress.add(gid);
496
513
  try {
497
- if (!asyncState.lastCtx) return;
498
-
499
514
  // Ingest any unprocessed result files — zombie/kill paths may trigger delivery
500
515
  // before processResultFile() has read all group members' outputs.
501
516
  // Wait up to 2s total (not per-job) for all missing result files.
@@ -603,6 +618,12 @@ export function registerAsyncAgents(
603
618
  }
604
619
 
605
620
  if (sections.length > 0) {
621
+ // A group may have waited for result files while its session was replaced.
622
+ // Keep persisted/in-memory results for reconciliation rather than sending into it.
623
+ if (!getLiveAsyncCtx()) {
624
+ log.debug("deliver_group_results_deferred", { groupId: groupJobs[0]?.groupId, jobs: groupJobs.length, deliverableJobs: deliverableJobs.length, status: groupJobs[0]?.status });
625
+ return;
626
+ }
606
627
  try {
607
628
  pi.sendMessage({
608
629
  customType: "async-agent-result",
@@ -649,7 +670,7 @@ export function registerAsyncAgents(
649
670
  try {
650
671
  const data = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
651
672
  const job = asyncState.jobs.get(data.id);
652
- log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!asyncState.lastCtx} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
673
+ log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!getLiveAsyncCtx()} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
653
674
  if (!job) {
654
675
  // Orphan result file — job not in current session. Clean up to prevent re-delivery on reload.
655
676
  try { fs.unlinkSync(resultPath); } catch (e: any) { log.debug(`orphan cleanup failed: ${resultPath}: ${e?.message}`); }
@@ -694,11 +715,11 @@ export function registerAsyncAgents(
694
715
  // Use model context window from the agent's effective model.
695
716
  // If agent uses a custom model, we pass its context window via the config.
696
717
  // Fall back to orchestrator's model context window.
697
- const contextWindow = data.contextWindow || asyncState.lastCtx?.model?.contextWindow || 0;
718
+ const contextWindow = data.contextWindow || getLiveAsyncCtx()?.model?.contextWindow || 0;
698
719
  if (data.lastUsage?.totalTokens && data.lastUsage.totalTokens > 0 && contextWindow > 0) {
699
720
  const pct = (data.lastUsage.totalTokens / contextWindow) * 100;
700
721
  if (pct > 80) {
701
- const sessId = `async-${job.agent}-${djb2Hash(job.agent + ':' + (jobCwd(job)) + ':' + (asyncState.lastCtx?.sessionManager?.getSessionId?.() || '')).toString(36)}`;
722
+ const sessId = `async-${job.agent}-${djb2Hash(job.agent + ':' + (jobCwd(job)) + ':' + (getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || '')).toString(36)}`;
702
723
  try {
703
724
  // Find and delete the session file to force fresh on next cycle
704
725
  const sessDir = path.join(os.homedir(), '.pi', 'agent', 'sessions');
@@ -776,7 +797,7 @@ export function registerAsyncAgents(
776
797
 
777
798
  // Non-grouped job: deliver immediately (existing behavior)
778
799
  let sendSucceeded = false;
779
- if (asyncState.lastCtx && !job.fireAndForget) {
800
+ if (getLiveAsyncCtx() && !job.fireAndForget) {
780
801
  const resultStatus = data.success ? "✅ completed" : "❌ failed";
781
802
  let autoCompleteError = "";
782
803
  // Auto-complete linked task directly in the store file (no AI involvement)
@@ -852,12 +873,13 @@ export function registerAsyncAgents(
852
873
  watchedDir = ASYNC_RESULTS_DIR;
853
874
  try {
854
875
  fs.mkdirSync(ASYNC_RESULTS_DIR, { recursive: true, mode: 0o700 });
855
- asyncState.watcher = fs.watch(ASYNC_RESULTS_DIR, (ev, file) => {
876
+ const resultsDir = ASYNC_RESULTS_DIR;
877
+ asyncState.watcher = fs.watch(resultsDir, (ev, file) => {
856
878
  if (ev !== "rename" || !file) return;
857
879
  const fileName = file.toString();
858
880
  log.debug("watcher_event", ev, fileName);
859
881
  if (!fileName.endsWith(".json")) return;
860
- const resultPath = path.join(ASYNC_RESULTS_DIR, fileName);
882
+ const resultPath = path.join(resultsDir, fileName);
861
883
  setTimeout(() => processResultFile(resultPath), 100);
862
884
  });
863
885
  if (asyncState.watcher.unref) asyncState.watcher.unref();
@@ -900,8 +922,8 @@ export function registerAsyncAgents(
900
922
  parentStartTime,
901
923
  taskId: options?.taskId || null,
902
924
  cwd,
903
- projectCwd: asyncState.lastCtx?.sessionManager?.getCwd?.() || null,
904
- sessionId: asyncState.lastCtx?.sessionManager?.getSessionId?.() || null,
925
+ projectCwd: getLiveAsyncCtx()?.sessionManager?.getCwd?.() || null,
926
+ sessionId: getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || null,
905
927
  }), { mode: 0o600 });
906
928
 
907
929
  // Track reviewer spawn BEFORE building args — addReviewerPending sets status to
@@ -923,7 +945,7 @@ export function registerAsyncAgents(
923
945
  // Deterministic session ID for provider cache affinity + optional session reuse.
924
946
  // When persistSession is true, omits --no-session so the session persists to disk.
925
947
  // Same agent + cwd gets the same session ID across calls.
926
- const parentSessionId = asyncState.lastCtx?.sessionManager?.getSessionId?.() || '';
948
+ const parentSessionId = getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || '';
927
949
  const sessionIdSource = persistSession
928
950
  ? agentName + ':' + cwd + ':' + parentSessionId
929
951
  : agentName + ':' + task.slice(0, 100);
@@ -956,7 +978,7 @@ export function registerAsyncAgents(
956
978
  task,
957
979
  cwd,
958
980
  model: effectiveModel,
959
- contextWindow: asyncState.lastCtx?.model?.contextWindow || 0,
981
+ contextWindow: getLiveAsyncCtx()?.model?.contextWindow || 0,
960
982
  resultPath,
961
983
  reviewerOutputPath: agentName.startsWith("code-reviewer-")
962
984
  ? reviewerOutputArchivePath(PROJECT_TMP_DIR, id)
@@ -1094,8 +1116,8 @@ export function registerAsyncAgents(
1094
1116
  groupId: options?.groupId,
1095
1117
  taskId: options?.taskId,
1096
1118
  cwd,
1097
- projectCwd: asyncState.lastCtx?.sessionManager?.getCwd?.(),
1098
- sessionId: asyncState.lastCtx?.sessionManager?.getSessionId?.(),
1119
+ projectCwd: getLiveAsyncCtx()?.sessionManager?.getCwd?.(),
1120
+ sessionId: getLiveAsyncCtx()?.sessionManager?.getSessionId?.(),
1099
1121
  model: effectiveModel,
1100
1122
  };
1101
1123
  asyncState.jobs.set(id, job);
@@ -1185,7 +1207,7 @@ export function registerAsyncAgents(
1185
1207
 
1186
1208
  // Auto-mark linked task as in_progress AFTER successful spawn
1187
1209
  if (options?.taskId && options.taskId !== "-1") {
1188
- autoMarkInProgress(options.taskId, asyncState.lastCtx?.sessionManager?.getCwd?.() || cwd, asyncState.lastCtx?.sessionManager?.getSessionId?.() || undefined)
1210
+ autoMarkInProgress(options.taskId, getLiveAsyncCtx()?.sessionManager?.getCwd?.() || cwd, getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || undefined)
1189
1211
  .catch(() => {}); // best-effort, don't block spawn
1190
1212
  }
1191
1213
 
@@ -1194,6 +1216,10 @@ export function registerAsyncAgents(
1194
1216
 
1195
1217
  // Start result watcher on session start
1196
1218
  pi.on("session_start", (_event, ctx) => {
1219
+ if (!isLiveExtensionCtx(ctx)) {
1220
+ log.warn("session_start received a stale ctx; preserving async results for a live session");
1221
+ return;
1222
+ }
1197
1223
  asyncState.lastCtx = ctx;
1198
1224
  deliveredResultIds.clear();
1199
1225
 
@@ -1365,6 +1391,7 @@ export function registerAsyncAgents(
1365
1391
  // Clean up on shutdown
1366
1392
  pi.on("session_shutdown", () => {
1367
1393
  log.info(`shutdown: jobs=${asyncState.jobs.size}`);
1394
+ asyncState.lastCtx = null;
1368
1395
  if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
1369
1396
  if (asyncState.watcher) { asyncState.watcher.close(); asyncState.watcher = null; }
1370
1397
  });
@@ -1373,7 +1400,6 @@ export function registerAsyncAgents(
1373
1400
  async function handleAsyncStatus(ctx: any): Promise<void> {
1374
1401
  if (!ctx.hasUI) return;
1375
1402
  log.debug("opening_async_status_overlay", { jobs: asyncState.jobs.size });
1376
- const { openAsyncStatusOverlay } = await import("./async-status-ui.js");
1377
1403
  await openAsyncStatusOverlay(ctx, {
1378
1404
  listJobs: () => Array.from(asyncState.jobs.values()),
1379
1405
  killJob: (id) => {
@@ -1461,7 +1487,7 @@ export function registerAsyncAgents(
1461
1487
  if (pending.length === 0) {
1462
1488
  deliverGroupResults(groupJobs);
1463
1489
  }
1464
- } else if (asyncState.lastCtx && !job.fireAndForget) {
1490
+ } else if (getLiveAsyncCtx() && !job.fireAndForget) {
1465
1491
  // Non-grouped killed job — deliver immediately so AI knows it was killed
1466
1492
  const displayName = job.name || job.agent;
1467
1493
  const duration = job.durationMs || (Date.now() - job.startedAt);
@@ -1582,7 +1608,7 @@ export function registerAsyncAgents(
1582
1608
  // Spawn — create an async agent from pitasks' TaskExecute
1583
1609
  handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
1584
1610
  "subagents:rpc:spawn", async ({ type, prompt, options }) => {
1585
- const ctx = asyncState.lastCtx;
1611
+ const ctx = getLiveAsyncCtx();
1586
1612
  if (!ctx) throw new Error("No active session");
1587
1613
  if (!type || typeof type !== "string") throw new Error("Missing or invalid 'type' parameter");
1588
1614
  if (!prompt || typeof prompt !== "string") throw new Error("Missing or invalid 'prompt' parameter");
@@ -3,10 +3,10 @@
3
3
  */
4
4
 
5
5
  export interface CronStatusTaskView {
6
- /** Unique overlay row id (e.g. "1" or "cron-123-abc.json:1"). */
6
+ /** Unique scope-qualified overlay row id (e.g. "session:<uuid>"). */
7
7
  id: string;
8
- /** Numeric cron task id within its session file. */
9
- taskId: number;
8
+ /** Stable UUID within its session or project scope. */
9
+ taskId: string | number;
10
10
  description: string;
11
11
  task: string;
12
12
  intervalMs?: number;
@@ -15,7 +15,7 @@ export interface CronStatusTaskView {
15
15
  createdAt: number;
16
16
  lastRun?: number;
17
17
  nextRun?: number;
18
- /** Shown in list-all rows (e.g. "this session", "PID 42"). */
18
+ /** Optional scope and leader-state label shown in overlay rows. */
19
19
  sessionLabel?: string;
20
20
  isLocal?: boolean;
21
21
  cronFile?: string;
@@ -91,7 +91,7 @@ export function formatNextRunLabel(
91
91
  /** Map a CronTask-like object into an overlay row view. */
92
92
  export function toCronStatusTaskView(
93
93
  task: {
94
- id: number;
94
+ id: string | number;
95
95
  description: string;
96
96
  task: string;
97
97
  intervalMs?: number;
@@ -110,7 +110,7 @@ export function toCronStatusTaskView(
110
110
  ): CronStatusTaskView {
111
111
  return {
112
112
  id: opts.overlayId,
113
- taskId: task.id,
113
+ taskId: opts.overlayId,
114
114
  description: task.description,
115
115
  task: task.task,
116
116
  intervalMs: task.intervalMs,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * /cron list + list-all fullscreen overlay — list dashboard → task detail.
2
+ * /cron list fullscreen overlay for session and project tasks — list dashboard → task detail.
3
3
  */
4
4
 
5
5
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
@@ -25,7 +25,7 @@ export {
25
25
 
26
26
  export interface CronStatusUiDeps {
27
27
  listTasks: () => CronStatusTaskView[];
28
- /** Remove by overlay row id. Return true if removed. */
28
+ /** Remove by scope-qualified task id. Return true if removed. */
29
29
  removeTask: (id: string) => boolean;
30
30
  title?: string;
31
31
  emptyMessage?: string;
@@ -33,7 +33,7 @@ export interface CronStatusUiDeps {
33
33
  }
34
34
 
35
35
  /**
36
- * Open fullscreen cron list. Loops list → detail until user closes list.
36
+ * Open fullscreen cron list for the selected scope. Loops list → detail until closed.
37
37
  */
38
38
  export async function openCronStatusOverlay(
39
39
  ctx: ExtensionCommandContext,
@@ -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
+ }