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.
- package/README.md +66 -66
- package/extensions/coms/coms-p2p.ts +53 -16
- package/extensions/orchestrator/async-agents.ts +50 -24
- 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/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/package.json +2 -1
|
@@ -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 =
|
|
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 (!
|
|
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 (
|
|
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",
|
|
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=${!!
|
|
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 ||
|
|
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)) + ':' + (
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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:
|
|
904
|
-
sessionId:
|
|
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 =
|
|
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:
|
|
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:
|
|
1098
|
-
sessionId:
|
|
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,
|
|
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 (
|
|
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 =
|
|
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. "
|
|
6
|
+
/** Unique scope-qualified overlay row id (e.g. "session:<uuid>"). */
|
|
7
7
|
id: string;
|
|
8
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|