pi-subagents 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -0
- package/agents/oracle.md +3 -1
- package/agents/reviewer.md +1 -0
- package/agents/scout.md +2 -2
- package/agents/worker.md +2 -1
- package/async-retention-discovery-worker.mjs +180 -0
- package/docs/agents.md +40 -2
- package/docs/configuration.md +30 -12
- package/docs/extension-api.md +42 -1
- package/docs/models.md +2 -0
- package/docs/observability.md +41 -5
- package/docs/tool-reference.md +38 -39
- package/docs/workflows.md +171 -3
- package/package.json +4 -2
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
- package/skills/pi-subagents/references/execution-controls.md +29 -15
- package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
- package/src/agents/agent-management.ts +124 -351
- package/src/agents/agents.ts +157 -31
- package/src/agents/skills.ts +1 -1
- package/src/api/external-job-provider.ts +185 -0
- package/src/api/preflight.ts +36 -8
- package/src/api/shared-types.ts +2 -0
- package/src/extension/config.ts +3 -3
- package/src/extension/doctor.ts +3 -6
- package/src/extension/fanout-child.ts +2 -2
- package/src/extension/index.ts +166 -88
- package/src/extension/public-execution.ts +31 -2
- package/src/extension/schemas.ts +12 -35
- package/src/extension/tool-description.ts +35 -24
- package/src/inspectors/herdr/actions.ts +2 -2
- package/src/inspectors/herdr/inspector-runner.ts +2 -1
- package/src/inspectors/herdr/project-panes.ts +2 -2
- package/src/intercom/native-supervisor-channel.ts +32 -10
- package/src/missions/lifecycle.ts +6 -1
- package/src/missions/store.ts +4 -9
- package/src/missions/workflow-state.ts +2 -2
- package/src/profiles/profiles.ts +3 -1
- package/src/runs/background/active-run-index.ts +31 -8
- package/src/runs/background/async-execution.ts +68 -40
- package/src/runs/background/async-job-tracker.ts +20 -4
- package/src/runs/background/async-resume.ts +47 -15
- package/src/runs/background/async-retention.ts +886 -0
- package/src/runs/background/async-status.ts +39 -53
- package/src/runs/background/chain-append.ts +3 -33
- package/src/runs/background/completion-dedupe.ts +5 -1
- package/src/runs/background/completion-replay.ts +22 -12
- package/src/runs/background/control-channel.ts +14 -68
- package/src/runs/background/fleet-view.ts +68 -19
- package/src/runs/background/index-segment.ts +59 -0
- package/src/runs/background/inspect-rpc.ts +443 -0
- package/src/runs/background/notify.ts +31 -5
- package/src/runs/background/result-files.ts +158 -90
- package/src/runs/background/result-watcher.ts +116 -20
- package/src/runs/background/resume-guidance.ts +8 -5
- package/src/runs/background/retained-children.ts +13 -3
- package/src/runs/background/run-id-query.ts +7 -0
- package/src/runs/background/run-id-resolver.ts +11 -9
- package/src/runs/background/run-status.ts +27 -18
- package/src/runs/background/scheduled-runs.ts +24 -4
- package/src/runs/background/stale-run-reconciler.ts +6 -3
- package/src/runs/background/steering.ts +11 -1
- package/src/runs/background/subagent-runner.ts +253 -140
- package/src/runs/background/subagent-wait.ts +8 -8
- package/src/runs/background/terminal-run-index.ts +129 -0
- package/src/runs/background/wait-completions.ts +21 -4
- package/src/runs/background/wait-subscriptions.ts +81 -2
- package/src/runs/foreground/async-steering-action.ts +21 -14
- package/src/runs/foreground/execution.ts +3 -1
- package/src/runs/foreground/subagent-executor.ts +666 -1579
- package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
- package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
- package/src/runs/shared/acceptance.ts +4 -4
- package/src/runs/shared/chain-outputs.ts +1 -3
- package/src/runs/shared/external-job-bridge.ts +444 -0
- package/src/runs/shared/external-job-runner.ts +286 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
- package/src/runs/shared/model-fallback.ts +98 -38
- package/src/runs/shared/orca-progress-tabs.ts +84 -22
- package/src/runs/shared/parallel-handoff.ts +46 -4
- package/src/runs/shared/parallel-utils.ts +4 -15
- package/src/runs/shared/permissions.ts +5 -1
- package/src/runs/shared/pi-args.ts +8 -1
- package/src/runs/shared/session-lease.ts +0 -6
- package/src/runs/shared/subagent-control.ts +26 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
- package/src/runs/shared/workflow-graph.ts +1 -23
- package/src/runs/shared/worktree.ts +14 -2
- package/src/shared/atomic-json.ts +22 -2
- package/src/shared/capacity-resilient-json.ts +102 -0
- package/src/shared/completion-owner.ts +14 -0
- package/src/shared/file-system-retry.ts +49 -1
- package/src/shared/fork-context.ts +42 -0
- package/src/shared/prompt-resources.ts +0 -40
- package/src/shared/settings.ts +3 -27
- package/src/shared/types.ts +71 -26
- package/src/shared/utils.ts +8 -0
- package/src/shared/watch-strategy.ts +10 -0
- package/src/slash/slash-bridge.ts +2 -1
- package/src/slash/slash-commands.ts +29 -3
- package/src/tui/fleet.ts +63 -15
- package/src/watchdog/change-signature.ts +1 -1
- package/src/watchdog/lsp-diagnostics.ts +1 -0
- package/src/workflows/chat-progress.ts +2 -2
- package/src/workflows/scripted-workflow.ts +220 -67
- package/src/runs/foreground/chain-clarify.ts +0 -1354
- package/src/runs/foreground/chain-execution.ts +0 -1581
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
8
|
+
import type { AsyncStatus } from "../../shared/types.ts";
|
|
9
|
+
import { MISSION_BINDING_FILE } from "../../missions/lifecycle.ts";
|
|
10
|
+
import { ACTIVE_RUN_INDEX_DIR } from "./active-run-index.ts";
|
|
11
|
+
import { encodeIndexSegment } from "./index-segment.ts";
|
|
12
|
+
|
|
13
|
+
export const ASYNC_RETENTION_DAYS = 30;
|
|
14
|
+
export const ASYNC_RETENTION_BATCH_SIZE = 100;
|
|
15
|
+
export const ASYNC_RETENTION_DELAY_MS = 60_000;
|
|
16
|
+
export const ASYNC_RETENTION_TOMBSTONE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
const RETENTION_MS = ASYNC_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
19
|
+
const LOCK_NAME = ".async-retention.lock";
|
|
20
|
+
const CURSOR_NAME = ".async-retention-cursor.json";
|
|
21
|
+
const LOG_NAME = "async-retention-maintenance.jsonl";
|
|
22
|
+
const RUN_TOMBSTONE_PREFIX = ".deleting-run-";
|
|
23
|
+
const RESULT_TOMBSTONE_PREFIX = ".deleting-result-";
|
|
24
|
+
const RUN_TOMBSTONE_MARKERS_DIR = "async-retention-run-tombstones";
|
|
25
|
+
const LOCK_STALE_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const RUN_MODES = new Set<AsyncStatus["mode"]>(["single", "parallel", "chain", "workflow"]);
|
|
27
|
+
const TERMINAL_STATES = new Set<AsyncStatus["state"]>(["complete", "failed", "stopped", "rejected"]);
|
|
28
|
+
const RESULT_TIMESTAMP_FIELDS = ["endedAt", "completedAt", "createdAt", "writtenAt", "expiresAt", "timestamp"] as const;
|
|
29
|
+
|
|
30
|
+
interface RetentionCursor {
|
|
31
|
+
version: 1;
|
|
32
|
+
runAfter?: string;
|
|
33
|
+
resultAfter?: string;
|
|
34
|
+
resultPublicAfter?: string;
|
|
35
|
+
resultPendingAfterBySession?: Record<string, string>;
|
|
36
|
+
resultReplayAfter?: string;
|
|
37
|
+
resultArchiveAfter?: string;
|
|
38
|
+
pendingSessionAfter?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ResultCursorKey = keyof Pick<RetentionCursor, "resultPublicAfter" | "resultReplayAfter" | "resultArchiveAfter">;
|
|
42
|
+
type ResultCursorTarget = { type: "result"; key: ResultCursorKey } | { type: "pending"; session: string };
|
|
43
|
+
|
|
44
|
+
interface ResultCandidate {
|
|
45
|
+
path: string;
|
|
46
|
+
relative: string;
|
|
47
|
+
kind: "public" | "pending" | "replay" | "archive" | "tombstone";
|
|
48
|
+
cursor: ResultCursorTarget;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface DiscoveredRunCandidate {
|
|
52
|
+
name: string;
|
|
53
|
+
relative: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type CursorOperation =
|
|
57
|
+
| { type: "set"; key: Exclude<keyof RetentionCursor, "version" | "resultPendingAfterBySession">; value: string }
|
|
58
|
+
| { type: "delete"; key: Exclude<keyof RetentionCursor, "version" | "resultPendingAfterBySession"> }
|
|
59
|
+
| { type: "set-pending"; session: string; value: string }
|
|
60
|
+
| { type: "delete-pending"; session: string };
|
|
61
|
+
|
|
62
|
+
interface RetentionDiscovery {
|
|
63
|
+
discoveryDurationMs: number;
|
|
64
|
+
rawReads: number;
|
|
65
|
+
sourceExhausted: Record<string, boolean>;
|
|
66
|
+
runCandidates: DiscoveredRunCandidate[];
|
|
67
|
+
resultCandidates: Array<Omit<ResultCandidate, "path">>;
|
|
68
|
+
cursorOps: CursorOperation[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface RetentionLockOwner {
|
|
72
|
+
version: 1;
|
|
73
|
+
token: string;
|
|
74
|
+
pid: number;
|
|
75
|
+
hostname: string;
|
|
76
|
+
startedAt: number;
|
|
77
|
+
processStartIdentity?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AsyncRetentionOptions {
|
|
81
|
+
asyncDirRoot: string;
|
|
82
|
+
resultsDir: string;
|
|
83
|
+
waitSubscriptionsDir?: string;
|
|
84
|
+
protectedRunIds?: Iterable<string>;
|
|
85
|
+
now?: () => number;
|
|
86
|
+
retentionMs?: number;
|
|
87
|
+
tombstoneGraceMs?: number;
|
|
88
|
+
batchSize?: number;
|
|
89
|
+
randomId?: () => string;
|
|
90
|
+
maintenanceRoot?: string;
|
|
91
|
+
pid?: number;
|
|
92
|
+
hostname?: string;
|
|
93
|
+
processStartIdentity?: string;
|
|
94
|
+
isProcessAlive?: (pid: number) => boolean | undefined;
|
|
95
|
+
getProcessStartIdentity?: (pid: number) => string | undefined;
|
|
96
|
+
lstatSync?: typeof fs.lstatSync;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
discoveryWorkerUrl?: URL;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AsyncRetentionResult {
|
|
102
|
+
acquired: boolean;
|
|
103
|
+
scanned: number;
|
|
104
|
+
deletedRuns: number;
|
|
105
|
+
deletedResults: number;
|
|
106
|
+
reapedTombstones: number;
|
|
107
|
+
skipped: Record<string, number>;
|
|
108
|
+
errors: string[];
|
|
109
|
+
rawReads: number;
|
|
110
|
+
sourceExhausted: Record<string, boolean>;
|
|
111
|
+
discoveryDurationMs: number;
|
|
112
|
+
commitDurationMs: number;
|
|
113
|
+
cancelled: boolean;
|
|
114
|
+
workerFailed: boolean;
|
|
115
|
+
durationMs: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isNotFound(error: unknown): boolean {
|
|
119
|
+
return typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function compactError(error: unknown): string {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
return message.replace(/\s+/g, " ").slice(0, 240);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listDir(dir: string): fs.Dirent[] {
|
|
128
|
+
try {
|
|
129
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (isNotFound(error)) return [];
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readJson(filePath: string): Record<string, unknown> | undefined {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
|
|
139
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readStatus(runDir: string): AsyncStatus | undefined {
|
|
146
|
+
const value = readJson(path.join(runDir, "status.json"));
|
|
147
|
+
if (!value
|
|
148
|
+
|| typeof value.runId !== "string"
|
|
149
|
+
|| typeof value.state !== "string"
|
|
150
|
+
|| typeof value.mode !== "string"
|
|
151
|
+
|| !RUN_MODES.has(value.mode as AsyncStatus["mode"])
|
|
152
|
+
|| typeof value.startedAt !== "number"
|
|
153
|
+
|| !Number.isFinite(value.startedAt)) return undefined;
|
|
154
|
+
return value as unknown as AsyncStatus;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function existingRegularFile(filePath: string | undefined): boolean {
|
|
158
|
+
if (!filePath) return false;
|
|
159
|
+
try {
|
|
160
|
+
const stat = fs.lstatSync(filePath);
|
|
161
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return !isNotFound(error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validRunId(runId: string): boolean {
|
|
168
|
+
return runId.length > 0 && runId !== "." && runId !== ".." && path.basename(runId) === runId;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function statusTimestamp(status: AsyncStatus, runDir: string): number | undefined {
|
|
172
|
+
if (status.endedAt !== undefined && (typeof status.endedAt !== "number" || !Number.isFinite(status.endedAt))) return undefined;
|
|
173
|
+
if (status.lastUpdate !== undefined && (typeof status.lastUpdate !== "number" || !Number.isFinite(status.lastUpdate))) return undefined;
|
|
174
|
+
const logical = status.endedAt ?? status.lastUpdate;
|
|
175
|
+
try {
|
|
176
|
+
const physical = Math.max(fs.statSync(runDir).mtimeMs, fs.statSync(path.join(runDir, "status.json")).mtimeMs);
|
|
177
|
+
const timestamp = logical === undefined ? physical : Math.max(logical, physical);
|
|
178
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hasNestedReferences(status: AsyncStatus): boolean {
|
|
185
|
+
return status.isNested === true || (status.steps ?? []).some((step) => (step.children?.length ?? 0) > 0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hasResumableContract(runDir: string, status: AsyncStatus): boolean {
|
|
189
|
+
const statusSessionFiles = [status.sessionFile, ...(status.steps ?? []).map((step) => step.sessionFile)];
|
|
190
|
+
if (statusSessionFiles.some(existingRegularFile)) return true;
|
|
191
|
+
const descriptorPath = path.join(runDir, "recovery-descriptor.json");
|
|
192
|
+
if (!fs.existsSync(descriptorPath)) return false;
|
|
193
|
+
const descriptor = readJson(descriptorPath);
|
|
194
|
+
if (!descriptor) return true;
|
|
195
|
+
const sessionFiles = [
|
|
196
|
+
typeof descriptor.sessionFile === "string" ? descriptor.sessionFile : undefined,
|
|
197
|
+
];
|
|
198
|
+
if (descriptor.sourceRunId !== status.runId) return true;
|
|
199
|
+
return sessionFiles.some(existingRegularFile);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function activeMarkerExists(asyncDirRoot: string, runId: string): boolean {
|
|
203
|
+
return fs.existsSync(path.join(asyncDirRoot, ACTIVE_RUN_INDEX_DIR, runId));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function missionObserverIndexExists(resultsDir: string, runId: string): boolean {
|
|
207
|
+
return fs.existsSync(path.join(resultsDir, "result-index", "observers", "mission", `${encodeIndexSegment(runId)}.json`));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function runTombstoneMarkerPath(maintenanceRoot: string, runId: string): string {
|
|
211
|
+
return path.join(maintenanceRoot, RUN_TOMBSTONE_MARKERS_DIR, `${encodeIndexSegment(runId)}.json`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function writeRunTombstoneMarker(maintenanceRoot: string, runId: string, tombstonePath: string, now: number): void {
|
|
215
|
+
const markerPath = runTombstoneMarkerPath(maintenanceRoot, runId);
|
|
216
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
217
|
+
writeAtomicJson(markerPath, { version: 1, runId, tombstonePath, createdAt: now });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function removeRunTombstoneMarker(maintenanceRoot: string, runId: string): void {
|
|
221
|
+
fs.rmSync(runTombstoneMarkerPath(maintenanceRoot, runId), { force: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readRunTombstoneMarker(maintenanceRoot: string, runId: string): { runId: string; tombstonePath: string } | undefined | "unreadable" {
|
|
225
|
+
const markerPath = runTombstoneMarkerPath(maintenanceRoot, runId);
|
|
226
|
+
try {
|
|
227
|
+
const stat = fs.lstatSync(markerPath);
|
|
228
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return "unreadable";
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return isNotFound(error) ? undefined : "unreadable";
|
|
231
|
+
}
|
|
232
|
+
const marker = readJson(markerPath);
|
|
233
|
+
if (marker?.version !== 1 || marker.runId !== runId || typeof marker.tombstonePath !== "string" || !marker.tombstonePath) return "unreadable";
|
|
234
|
+
return { runId, tombstonePath: marker.tombstonePath };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function runTombstoneMarkerMatches(maintenanceRoot: string, runId: string, tombstonePath: string): boolean {
|
|
238
|
+
const marker = readRunTombstoneMarker(maintenanceRoot, runId);
|
|
239
|
+
return marker !== undefined && marker !== "unreadable" && path.resolve(marker.tombstonePath) === path.resolve(tombstonePath);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function runTombstoneMarkerBlocks(maintenanceRoot: string, runId: string): boolean {
|
|
243
|
+
const marker = readRunTombstoneMarker(maintenanceRoot, runId);
|
|
244
|
+
if (marker === undefined) return false;
|
|
245
|
+
if (marker === "unreadable") return true;
|
|
246
|
+
try {
|
|
247
|
+
if (fs.existsSync(marker.tombstonePath)) return true;
|
|
248
|
+
removeRunTombstoneMarker(maintenanceRoot, runId);
|
|
249
|
+
return false;
|
|
250
|
+
} catch {
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function unresolvedHandoff(manifestPath: string): boolean {
|
|
256
|
+
const manifest = readJson(manifestPath);
|
|
257
|
+
if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.groups) || manifest.groups.length === 0) return true;
|
|
258
|
+
return manifest.groups.some((value) => {
|
|
259
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
|
|
260
|
+
const cleanup = (value as Record<string, unknown>).cleanup;
|
|
261
|
+
return !cleanup || typeof cleanup !== "object" || Array.isArray(cleanup) || (cleanup as Record<string, unknown>).state !== "complete";
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function hasUnresolvedRunHandoff(runDir: string, status: AsyncStatus): boolean {
|
|
266
|
+
const paths = new Set<string>();
|
|
267
|
+
if (status.parallelHandoff) {
|
|
268
|
+
if (typeof status.parallelHandoff.path !== "string" || !status.parallelHandoff.path) return true;
|
|
269
|
+
paths.add(status.parallelHandoff.path);
|
|
270
|
+
}
|
|
271
|
+
const localPath = path.join(runDir, "handoff.json");
|
|
272
|
+
if (fs.existsSync(localPath)) paths.add(localPath);
|
|
273
|
+
return [...paths].some(unresolvedHandoff);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function runSkipReason(input: {
|
|
277
|
+
runDir: string;
|
|
278
|
+
status: AsyncStatus | undefined;
|
|
279
|
+
asyncDirRoot: string;
|
|
280
|
+
resultsDir: string;
|
|
281
|
+
cutoff: number;
|
|
282
|
+
protectedRunIds: ReadonlySet<string>;
|
|
283
|
+
waitRunIds: ReadonlySet<string>;
|
|
284
|
+
}): string | undefined {
|
|
285
|
+
const { runDir, status } = input;
|
|
286
|
+
if (!status) return "invalid-status";
|
|
287
|
+
if (!validRunId(status.runId)) return "identity-mismatch";
|
|
288
|
+
if (path.basename(runDir) !== status.runId && !path.basename(runDir).startsWith(RUN_TOMBSTONE_PREFIX)) return "identity-mismatch";
|
|
289
|
+
if (input.protectedRunIds.has(status.runId)) return "runtime-reference";
|
|
290
|
+
if (input.waitRunIds.has(status.runId)) return "wait-reference";
|
|
291
|
+
if (activeMarkerExists(input.asyncDirRoot, status.runId)) return "active-index";
|
|
292
|
+
if (!TERMINAL_STATES.has(status.state)) return "non-terminal";
|
|
293
|
+
if (status.mode === "workflow" || status.parentWorkflowRunId || status.workflowKey) return "workflow-reference";
|
|
294
|
+
if (hasNestedReferences(status)) return "nested-reference";
|
|
295
|
+
if (fs.existsSync(path.join(runDir, MISSION_BINDING_FILE)) || missionObserverIndexExists(input.resultsDir, status.runId)) return "mission-reference";
|
|
296
|
+
if (hasUnresolvedRunHandoff(runDir, status)) return "handoff-reference";
|
|
297
|
+
if (hasResumableContract(runDir, status)) return "resumable";
|
|
298
|
+
const timestamp = statusTimestamp(status, runDir);
|
|
299
|
+
if (timestamp === undefined) return "unknown-age";
|
|
300
|
+
if (timestamp > input.cutoff) return "recent";
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseWaitRunIds(dir: string): { runIds: Set<string>; safe: boolean } {
|
|
305
|
+
const runIds = new Set<string>();
|
|
306
|
+
for (const entry of listDir(dir)) {
|
|
307
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
308
|
+
const record = readJson(path.join(dir, entry.name));
|
|
309
|
+
if (!record || typeof record.runId !== "string" || typeof record.expiresAt !== "number") return { runIds, safe: false };
|
|
310
|
+
runIds.add(record.runId);
|
|
311
|
+
}
|
|
312
|
+
return { runIds, safe: true };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function readCursor(root: string): RetentionCursor {
|
|
316
|
+
const value = readJson(path.join(root, CURSOR_NAME));
|
|
317
|
+
return value?.version === 1 ? value as unknown as RetentionCursor : { version: 1 };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function processStartIdentity(pid: number): string | undefined {
|
|
321
|
+
if (process.platform === "linux") {
|
|
322
|
+
try {
|
|
323
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
324
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
325
|
+
if (commandEnd === -1) return undefined;
|
|
326
|
+
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
327
|
+
return fields[19] ? `linux:${fields[19]}` : undefined;
|
|
328
|
+
} catch {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (process.platform === "win32") {
|
|
333
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`], { encoding: "utf-8", windowsHide: true });
|
|
334
|
+
const started = result.status === 0 ? result.stdout.trim() : "";
|
|
335
|
+
return started ? `win:${started}` : undefined;
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function processIsAlive(pid: number): boolean | undefined {
|
|
341
|
+
try {
|
|
342
|
+
process.kill(pid, 0);
|
|
343
|
+
return true;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
346
|
+
if (code === "ESRCH") return false;
|
|
347
|
+
if (code === "EPERM") return true;
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseLockOwner(lockDir: string): RetentionLockOwner | undefined {
|
|
353
|
+
const owner = readJson(path.join(lockDir, "owner.json"));
|
|
354
|
+
if (owner?.version !== 1
|
|
355
|
+
|| typeof owner.token !== "string"
|
|
356
|
+
|| typeof owner.pid !== "number"
|
|
357
|
+
|| !Number.isInteger(owner.pid)
|
|
358
|
+
|| owner.pid <= 0
|
|
359
|
+
|| typeof owner.hostname !== "string"
|
|
360
|
+
|| typeof owner.startedAt !== "number"
|
|
361
|
+
|| !Number.isFinite(owner.startedAt)) return undefined;
|
|
362
|
+
if (owner.processStartIdentity !== undefined && typeof owner.processStartIdentity !== "string") return undefined;
|
|
363
|
+
return owner as unknown as RetentionLockOwner;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function staleLock(lockDir: string, now: number, options: Required<Pick<AsyncRetentionOptions, "hostname" | "isProcessAlive" | "getProcessStartIdentity">>): { stale: boolean; token?: string } {
|
|
367
|
+
const owner = parseLockOwner(lockDir);
|
|
368
|
+
if (!owner) {
|
|
369
|
+
try {
|
|
370
|
+
return { stale: now - fs.statSync(lockDir).mtimeMs >= LOCK_STALE_MS };
|
|
371
|
+
} catch {
|
|
372
|
+
return { stale: false };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (owner.hostname !== options.hostname) return { stale: false };
|
|
376
|
+
const alive = options.isProcessAlive(owner.pid);
|
|
377
|
+
if (alive === false) return { stale: true, token: owner.token };
|
|
378
|
+
if (alive === true && owner.processStartIdentity) {
|
|
379
|
+
const currentIdentity = options.getProcessStartIdentity(owner.pid);
|
|
380
|
+
if (currentIdentity !== undefined && currentIdentity !== owner.processStartIdentity) return { stale: true, token: owner.token };
|
|
381
|
+
}
|
|
382
|
+
return { stale: now - owner.startedAt >= LOCK_STALE_MS, token: owner.token };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function createLockDirectory(lockDir: string, owner: RetentionLockOwner): boolean {
|
|
386
|
+
try {
|
|
387
|
+
fs.mkdirSync(lockDir, { mode: 0o700 });
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
fs.writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
|
|
394
|
+
return true;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function acquireRetentionLock(lockDir: string, owner: RetentionLockOwner, options: Required<Pick<AsyncRetentionOptions, "hostname" | "isProcessAlive" | "getProcessStartIdentity">>): boolean {
|
|
402
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
403
|
+
if (createLockDirectory(lockDir, owner)) return true;
|
|
404
|
+
const stale = staleLock(lockDir, owner.startedAt, options);
|
|
405
|
+
if (!stale.stale) return false;
|
|
406
|
+
const staleKey = (stale.token ?? owner.token).replace(/[^A-Za-z0-9._-]/g, "-");
|
|
407
|
+
const tombstone = `${lockDir}.stale-${staleKey}`;
|
|
408
|
+
try {
|
|
409
|
+
fs.renameSync(lockDir, tombstone);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
412
|
+
if (code === "ENOENT" || fs.existsSync(tombstone)) continue;
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function releaseRetentionLock(lockDir: string, token: string): void {
|
|
420
|
+
if (parseLockOwner(lockDir)?.token !== token) return;
|
|
421
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function resultRunId(candidate: ResultCandidate, data: Record<string, unknown>): string | undefined {
|
|
425
|
+
if (typeof data.runId === "string") return data.runId;
|
|
426
|
+
if (typeof data.id === "string") return data.id;
|
|
427
|
+
if ((candidate.kind === "public" || candidate.kind === "pending") && path.basename(candidate.path).endsWith(".json")) return path.basename(candidate.path, ".json");
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resultTimestamp(data: Record<string, unknown>, filePath: string): number | undefined {
|
|
432
|
+
for (const field of RESULT_TIMESTAMP_FIELDS) {
|
|
433
|
+
const value = data[field];
|
|
434
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) return undefined;
|
|
435
|
+
}
|
|
436
|
+
const logical = RESULT_TIMESTAMP_FIELDS.map((field) => data[field])
|
|
437
|
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
438
|
+
try {
|
|
439
|
+
return Math.max(fs.statSync(filePath).mtimeMs, ...logical);
|
|
440
|
+
} catch {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function terminalResult(data: Record<string, unknown>): boolean {
|
|
446
|
+
return data.success === true || data.success === false || (typeof data.state === "string" && TERMINAL_STATES.has(data.state as AsyncStatus["state"]));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function resultHasResumableSession(data: Record<string, unknown>): boolean {
|
|
450
|
+
if (existingRegularFile(typeof data.sessionFile === "string" ? data.sessionFile : undefined)) return true;
|
|
451
|
+
return Array.isArray(data.results) && data.results.some((value) => {
|
|
452
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
453
|
+
const sessionFile = (value as Record<string, unknown>).sessionFile;
|
|
454
|
+
return existingRegularFile(typeof sessionFile === "string" ? sessionFile : undefined);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function hasUnresolvedResultHandoff(data: Record<string, unknown>): boolean {
|
|
459
|
+
if (data.parallelHandoff === undefined) return false;
|
|
460
|
+
if (!data.parallelHandoff || typeof data.parallelHandoff !== "object" || Array.isArray(data.parallelHandoff)) return true;
|
|
461
|
+
const manifestPath = (data.parallelHandoff as Record<string, unknown>).path;
|
|
462
|
+
return typeof manifestPath !== "string" || !manifestPath || unresolvedHandoff(manifestPath);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function completionMode(data: Record<string, unknown>): unknown {
|
|
466
|
+
return data.completion && typeof data.completion === "object" && !Array.isArray(data.completion)
|
|
467
|
+
? (data.completion as Record<string, unknown>).mode
|
|
468
|
+
: undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function resultSkipReason(input: {
|
|
472
|
+
candidate: ResultCandidate;
|
|
473
|
+
data: Record<string, unknown> | undefined;
|
|
474
|
+
asyncDirRoot: string;
|
|
475
|
+
resultsDir: string;
|
|
476
|
+
maintenanceRoot: string;
|
|
477
|
+
cutoff: number;
|
|
478
|
+
protectedRunIds: ReadonlySet<string>;
|
|
479
|
+
waitRunIds: ReadonlySet<string>;
|
|
480
|
+
}): string | undefined {
|
|
481
|
+
if (!input.data) return "invalid-result";
|
|
482
|
+
const runId = resultRunId(input.candidate, input.data);
|
|
483
|
+
if (!runId || !validRunId(runId)) return "invalid-result";
|
|
484
|
+
if (runTombstoneMarkerBlocks(input.maintenanceRoot, runId)) return "run-tombstone-present";
|
|
485
|
+
if (input.protectedRunIds.has(runId)) return "runtime-reference";
|
|
486
|
+
if (input.waitRunIds.has(runId)) return "wait-reference";
|
|
487
|
+
if (activeMarkerExists(input.asyncDirRoot, runId)) return "active-index";
|
|
488
|
+
if (fs.existsSync(path.join(input.asyncDirRoot, runId))) return "run-present";
|
|
489
|
+
if (missionObserverIndexExists(input.resultsDir, runId)) return "mission-reference";
|
|
490
|
+
if (input.data.mode === "workflow" || completionMode(input.data) === "workflow" || typeof input.data.parentWorkflowRunId === "string" || typeof input.data.workflowKey === "string") return "workflow-reference";
|
|
491
|
+
if (hasUnresolvedResultHandoff(input.data)) return "handoff-reference";
|
|
492
|
+
if (resultHasResumableSession(input.data)) return "resumable";
|
|
493
|
+
if (input.candidate.kind === "archive" && fs.existsSync(path.join(input.resultsDir, "completion-replay", `${encodeURIComponent(runId)}.json`))) return "replay-reference";
|
|
494
|
+
if (!terminalResult(input.data) && input.candidate.kind !== "replay" && input.candidate.kind !== "archive" && input.candidate.kind !== "tombstone") return "non-terminal-result";
|
|
495
|
+
const timestamp = resultTimestamp(input.data, input.candidate.path);
|
|
496
|
+
if (timestamp === undefined) return "unknown-age";
|
|
497
|
+
if (timestamp > input.cutoff) return "recent";
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function appendMaintenanceLog(root: string, result: AsyncRetentionResult, now: number, deletedIds: string[]): void {
|
|
502
|
+
fs.mkdirSync(root, { recursive: true });
|
|
503
|
+
fs.appendFileSync(path.join(root, LOG_NAME), `${JSON.stringify({
|
|
504
|
+
at: new Date(now).toISOString(),
|
|
505
|
+
acquired: result.acquired,
|
|
506
|
+
scanned: result.scanned,
|
|
507
|
+
deletedRuns: result.deletedRuns,
|
|
508
|
+
deletedResults: result.deletedResults,
|
|
509
|
+
reapedTombstones: result.reapedTombstones,
|
|
510
|
+
skipped: result.skipped,
|
|
511
|
+
deleted: deletedIds.slice(0, ASYNC_RETENTION_BATCH_SIZE),
|
|
512
|
+
errors: result.errors.length,
|
|
513
|
+
rawReads: result.rawReads,
|
|
514
|
+
sourceExhausted: result.sourceExhausted,
|
|
515
|
+
discoveryDurationMs: result.discoveryDurationMs,
|
|
516
|
+
commitDurationMs: result.commitDurationMs,
|
|
517
|
+
cancelled: result.cancelled,
|
|
518
|
+
workerFailed: result.workerFailed,
|
|
519
|
+
durationMs: result.durationMs,
|
|
520
|
+
})}\n`, "utf-8");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function increment(record: Record<string, number>, key: string): void {
|
|
524
|
+
record[key] = (record[key] ?? 0) + 1;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class RetentionCancelledError extends Error {}
|
|
529
|
+
|
|
530
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
531
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function safeRelative(relative: string): boolean {
|
|
535
|
+
return relative.length > 0 && !path.isAbsolute(relative) && !relative.split(path.sep).includes("..");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function parseDiscoveryResult(message: unknown, passId: string, runBudget: number, resultBudget: number): RetentionDiscovery {
|
|
539
|
+
if (!isRecord(message) || message.type !== "result" || message.passId !== passId) throw new Error("Retention discovery worker returned a stale or malformed pass id.");
|
|
540
|
+
if (!Number.isFinite(message.discoveryDurationMs) || (message.discoveryDurationMs as number) < 0) throw new Error("Retention discovery worker returned an invalid duration.");
|
|
541
|
+
if (!Number.isInteger(message.rawReads) || (message.rawReads as number) < 0) throw new Error("Retention discovery worker returned an invalid raw-read count.");
|
|
542
|
+
if (!isRecord(message.sourceExhausted) || Object.values(message.sourceExhausted).some((value) => typeof value !== "boolean")) throw new Error("Retention discovery worker returned invalid source telemetry.");
|
|
543
|
+
if (!Array.isArray(message.runCandidates) || message.runCandidates.length > runBudget) throw new Error("Retention discovery worker exceeded the run budget.");
|
|
544
|
+
if (!Array.isArray(message.resultCandidates) || message.resultCandidates.length > resultBudget) throw new Error("Retention discovery worker exceeded the result budget.");
|
|
545
|
+
if (!Array.isArray(message.cursorOps) || message.cursorOps.length > runBudget + resultBudget + 8) throw new Error("Retention discovery worker returned invalid cursor operations.");
|
|
546
|
+
for (const candidate of message.runCandidates) {
|
|
547
|
+
if (!isRecord(candidate) || typeof candidate.name !== "string" || typeof candidate.relative !== "string" || !validRunId(candidate.name) || candidate.relative !== candidate.name) throw new Error("Retention discovery worker returned an unsafe run candidate.");
|
|
548
|
+
}
|
|
549
|
+
const resultKinds = new Set(["public", "pending", "replay", "archive", "tombstone"]);
|
|
550
|
+
const resultKeys = new Set(["resultPublicAfter", "resultReplayAfter", "resultArchiveAfter"]);
|
|
551
|
+
for (const candidate of message.resultCandidates) {
|
|
552
|
+
if (!isRecord(candidate) || typeof candidate.name !== "string" || typeof candidate.relative !== "string" || path.basename(candidate.name) !== candidate.name || !safeRelative(candidate.relative) || path.basename(candidate.relative) !== candidate.name || !resultKinds.has(candidate.kind as string) || !isRecord(candidate.cursor)) throw new Error("Retention discovery worker returned an unsafe result candidate.");
|
|
553
|
+
const cursor = candidate.cursor;
|
|
554
|
+
if (cursor.type === "pending") {
|
|
555
|
+
if (typeof cursor.session !== "string" || !validRunId(cursor.session) || path.dirname(candidate.relative) !== path.join("result-pending", cursor.session)) throw new Error("Retention discovery worker returned an invalid pending candidate.");
|
|
556
|
+
} else if (cursor.type === "result") {
|
|
557
|
+
if (typeof cursor.key !== "string" || !resultKeys.has(cursor.key)) throw new Error("Retention discovery worker returned an invalid result cursor.");
|
|
558
|
+
const expectedDir = cursor.key === "resultPublicAfter" ? "." : cursor.key === "resultReplayAfter" ? "completion-replay" : "output-archives";
|
|
559
|
+
if (path.dirname(candidate.relative) !== expectedDir) throw new Error("Retention discovery worker returned a result from the wrong source.");
|
|
560
|
+
} else throw new Error("Retention discovery worker returned an invalid result cursor target.");
|
|
561
|
+
}
|
|
562
|
+
const scalarKeys = new Set(["runAfter", "resultPublicAfter", "resultReplayAfter", "resultArchiveAfter", "pendingSessionAfter"]);
|
|
563
|
+
for (const operation of message.cursorOps) {
|
|
564
|
+
if (!isRecord(operation) || typeof operation.type !== "string") throw new Error("Retention discovery worker returned a malformed cursor operation.");
|
|
565
|
+
if (operation.type === "set" || operation.type === "delete") {
|
|
566
|
+
if (typeof operation.key !== "string" || !scalarKeys.has(operation.key) || (operation.type === "set" && (typeof operation.value !== "string" || !safeRelative(operation.value)))) throw new Error("Retention discovery worker returned an invalid scalar cursor operation.");
|
|
567
|
+
} else if (operation.type === "set-pending" || operation.type === "delete-pending") {
|
|
568
|
+
if (typeof operation.session !== "string" || !validRunId(operation.session) || (operation.type === "set-pending" && (typeof operation.value !== "string" || !safeRelative(operation.value)))) throw new Error("Retention discovery worker returned an invalid pending cursor operation.");
|
|
569
|
+
} else throw new Error("Retention discovery worker returned an unknown cursor operation.");
|
|
570
|
+
}
|
|
571
|
+
return message as unknown as RetentionDiscovery;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function runRetentionDiscovery(input: {
|
|
575
|
+
passId: string;
|
|
576
|
+
asyncDirRoot: string;
|
|
577
|
+
resultsDir: string;
|
|
578
|
+
cursor: RetentionCursor;
|
|
579
|
+
runBudget: number;
|
|
580
|
+
resultBudget: number;
|
|
581
|
+
workerUrl: URL;
|
|
582
|
+
signal?: AbortSignal;
|
|
583
|
+
}): Promise<RetentionDiscovery> {
|
|
584
|
+
return new Promise((resolve, reject) => {
|
|
585
|
+
if (input.signal?.aborted) {
|
|
586
|
+
reject(new RetentionCancelledError("Retention discovery was cancelled."));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const worker = new Worker(input.workerUrl);
|
|
590
|
+
let settled = false;
|
|
591
|
+
const cleanup = (): void => {
|
|
592
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
593
|
+
void worker.terminate();
|
|
594
|
+
};
|
|
595
|
+
const fail = (error: Error): void => {
|
|
596
|
+
if (settled) return;
|
|
597
|
+
settled = true;
|
|
598
|
+
cleanup();
|
|
599
|
+
reject(error);
|
|
600
|
+
};
|
|
601
|
+
const onAbort = (): void => fail(new RetentionCancelledError("Retention discovery was cancelled."));
|
|
602
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
603
|
+
worker.once("error", (error) => fail(error));
|
|
604
|
+
worker.once("exit", (code) => {
|
|
605
|
+
if (!settled) fail(new Error(`Retention discovery worker exited before replying (${code}).`));
|
|
606
|
+
});
|
|
607
|
+
worker.once("message", (message: unknown) => {
|
|
608
|
+
if (settled) return;
|
|
609
|
+
try {
|
|
610
|
+
if (isRecord(message) && message.type === "error" && message.passId === input.passId) throw new Error(`Retention discovery failed: ${String(message.error)}`);
|
|
611
|
+
const discovery = parseDiscoveryResult(message, input.passId, input.runBudget, input.resultBudget);
|
|
612
|
+
settled = true;
|
|
613
|
+
cleanup();
|
|
614
|
+
resolve(discovery);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
worker.postMessage({ type: "discover", passId: input.passId, asyncDirRoot: input.asyncDirRoot, resultsDir: input.resultsDir, cursor: input.cursor, runBudget: input.runBudget, resultBudget: input.resultBudget });
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function applyCursorOperations(cursor: RetentionCursor, operations: CursorOperation[]): void {
|
|
624
|
+
for (const operation of operations) {
|
|
625
|
+
if (operation.type === "set") cursor[operation.key] = operation.value;
|
|
626
|
+
else if (operation.type === "delete") delete cursor[operation.key];
|
|
627
|
+
else if (operation.type === "set-pending") {
|
|
628
|
+
cursor.resultPendingAfterBySession ??= {};
|
|
629
|
+
cursor.resultPendingAfterBySession[operation.session] = operation.value;
|
|
630
|
+
} else {
|
|
631
|
+
delete cursor.resultPendingAfterBySession?.[operation.session];
|
|
632
|
+
if (cursor.resultPendingAfterBySession && Object.keys(cursor.resultPendingAfterBySession).length === 0) delete cursor.resultPendingAfterBySession;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export async function cleanupAsyncRetention(options: AsyncRetentionOptions): Promise<AsyncRetentionResult> {
|
|
638
|
+
const startedWall = Date.now();
|
|
639
|
+
const now = options.now ?? Date.now;
|
|
640
|
+
const currentTime = now();
|
|
641
|
+
const retentionMs = options.retentionMs ?? RETENTION_MS;
|
|
642
|
+
const tombstoneGraceMs = options.tombstoneGraceMs ?? ASYNC_RETENTION_TOMBSTONE_GRACE_MS;
|
|
643
|
+
const batchSize = Math.min(ASYNC_RETENTION_BATCH_SIZE, Math.max(1, Math.trunc(options.batchSize ?? ASYNC_RETENTION_BATCH_SIZE)));
|
|
644
|
+
const randomId = options.randomId ?? randomUUID;
|
|
645
|
+
const maintenanceRoot = options.maintenanceRoot ?? path.dirname(options.asyncDirRoot);
|
|
646
|
+
const lockDir = path.join(maintenanceRoot, LOCK_NAME);
|
|
647
|
+
const lockToken = randomId();
|
|
648
|
+
const pid = options.pid ?? process.pid;
|
|
649
|
+
const hostname = options.hostname ?? os.hostname();
|
|
650
|
+
const getProcessStartIdentity = options.getProcessStartIdentity ?? processStartIdentity;
|
|
651
|
+
const currentProcessStartIdentity = options.processStartIdentity ?? getProcessStartIdentity(pid) ?? (pid === process.pid ? `runtime:${Math.round(Date.now() - process.uptime() * 1000)}` : undefined);
|
|
652
|
+
const lockOwner: RetentionLockOwner = { version: 1, token: lockToken, pid, hostname, startedAt: currentTime, ...(currentProcessStartIdentity ? { processStartIdentity: currentProcessStartIdentity } : {}) };
|
|
653
|
+
const lockOptions = { hostname, isProcessAlive: options.isProcessAlive ?? processIsAlive, getProcessStartIdentity };
|
|
654
|
+
const result: AsyncRetentionResult = {
|
|
655
|
+
acquired: false,
|
|
656
|
+
scanned: 0,
|
|
657
|
+
deletedRuns: 0,
|
|
658
|
+
deletedResults: 0,
|
|
659
|
+
reapedTombstones: 0,
|
|
660
|
+
skipped: {},
|
|
661
|
+
errors: [],
|
|
662
|
+
rawReads: 0,
|
|
663
|
+
sourceExhausted: {},
|
|
664
|
+
discoveryDurationMs: 0,
|
|
665
|
+
commitDurationMs: 0,
|
|
666
|
+
cancelled: false,
|
|
667
|
+
workerFailed: false,
|
|
668
|
+
durationMs: 0,
|
|
669
|
+
};
|
|
670
|
+
const deletedIds: string[] = [];
|
|
671
|
+
const lstatSync = options.lstatSync ?? fs.lstatSync;
|
|
672
|
+
let commitStartedAt: number | undefined;
|
|
673
|
+
let cursorCommitUnsafe = false;
|
|
674
|
+
const finish = (): AsyncRetentionResult => {
|
|
675
|
+
if (commitStartedAt !== undefined) result.commitDurationMs = Math.max(0, Date.now() - commitStartedAt);
|
|
676
|
+
result.durationMs = Math.max(0, Date.now() - startedWall);
|
|
677
|
+
appendMaintenanceLog(maintenanceRoot, result, currentTime, deletedIds);
|
|
678
|
+
return result;
|
|
679
|
+
};
|
|
680
|
+
const markCancelled = (): boolean => {
|
|
681
|
+
if (!options.signal?.aborted) return false;
|
|
682
|
+
result.cancelled = true;
|
|
683
|
+
increment(result.skipped, "cancelled");
|
|
684
|
+
return true;
|
|
685
|
+
};
|
|
686
|
+
fs.mkdirSync(maintenanceRoot, { recursive: true });
|
|
687
|
+
if (!acquireRetentionLock(lockDir, lockOwner, lockOptions)) {
|
|
688
|
+
increment(result.skipped, "lock-busy");
|
|
689
|
+
return finish();
|
|
690
|
+
}
|
|
691
|
+
result.acquired = true;
|
|
692
|
+
try {
|
|
693
|
+
if (markCancelled()) return finish();
|
|
694
|
+
const protectedRunIds = new Set(options.protectedRunIds ?? []);
|
|
695
|
+
const cutoff = currentTime - retentionMs;
|
|
696
|
+
const cursor = readCursor(maintenanceRoot);
|
|
697
|
+
const runBudget = Math.ceil(batchSize / 2);
|
|
698
|
+
const resultBudget = batchSize - runBudget;
|
|
699
|
+
const discoveryStartedAt = Date.now();
|
|
700
|
+
let discovery: RetentionDiscovery;
|
|
701
|
+
try {
|
|
702
|
+
discovery = await runRetentionDiscovery({
|
|
703
|
+
passId: randomUUID(),
|
|
704
|
+
asyncDirRoot: options.asyncDirRoot,
|
|
705
|
+
resultsDir: options.resultsDir,
|
|
706
|
+
cursor,
|
|
707
|
+
runBudget,
|
|
708
|
+
resultBudget,
|
|
709
|
+
workerUrl: options.discoveryWorkerUrl ?? new URL("../../../async-retention-discovery-worker.mjs", import.meta.url),
|
|
710
|
+
signal: options.signal,
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
result.discoveryDurationMs = Math.max(0, Date.now() - discoveryStartedAt);
|
|
714
|
+
if (error instanceof RetentionCancelledError) {
|
|
715
|
+
result.cancelled = true;
|
|
716
|
+
increment(result.skipped, "cancelled");
|
|
717
|
+
} else {
|
|
718
|
+
result.workerFailed = true;
|
|
719
|
+
increment(result.skipped, "worker-failure");
|
|
720
|
+
result.errors.push(compactError(error));
|
|
721
|
+
}
|
|
722
|
+
return finish();
|
|
723
|
+
}
|
|
724
|
+
result.discoveryDurationMs = Math.max(0, Date.now() - discoveryStartedAt);
|
|
725
|
+
result.rawReads = discovery.rawReads;
|
|
726
|
+
result.sourceExhausted = discovery.sourceExhausted;
|
|
727
|
+
commitStartedAt = Date.now();
|
|
728
|
+
if (markCancelled()) return finish();
|
|
729
|
+
if (parseLockOwner(lockDir)?.token !== lockToken) {
|
|
730
|
+
increment(result.skipped, "lock-owner-changed");
|
|
731
|
+
return finish();
|
|
732
|
+
}
|
|
733
|
+
const waitSubscriptionsDir = options.waitSubscriptionsDir ?? path.join(maintenanceRoot, "wait-subscriptions");
|
|
734
|
+
// This safety scan stays on the commit thread. Wait records are the active,
|
|
735
|
+
// swept subscription set. Each destructive action re-reads it to close the
|
|
736
|
+
// race with a newly armed wait.
|
|
737
|
+
const waitReferences = parseWaitRunIds(waitSubscriptionsDir);
|
|
738
|
+
if (!waitReferences.safe) {
|
|
739
|
+
increment(result.skipped, "wait-references-unknown");
|
|
740
|
+
return finish();
|
|
741
|
+
}
|
|
742
|
+
for (const entry of discovery.runCandidates) {
|
|
743
|
+
if (markCancelled()) return finish();
|
|
744
|
+
result.scanned += 1;
|
|
745
|
+
const runDir = path.join(options.asyncDirRoot, entry.name);
|
|
746
|
+
let mutationStateChanged = false;
|
|
747
|
+
try {
|
|
748
|
+
const stat = lstatSync(runDir);
|
|
749
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
750
|
+
increment(result.skipped, "unsafe-run-path");
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const status = readStatus(runDir);
|
|
754
|
+
const initialReason = runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
|
|
755
|
+
if (initialReason) {
|
|
756
|
+
increment(result.skipped, initialReason);
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (entry.name.startsWith(RUN_TOMBSTONE_PREFIX)) {
|
|
760
|
+
if (!runTombstoneMarkerMatches(maintenanceRoot, status!.runId, runDir)) {
|
|
761
|
+
increment(result.skipped, "run-tombstone-marker");
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (currentTime - stat.mtimeMs < tombstoneGraceMs) {
|
|
765
|
+
increment(result.skipped, "tombstone-grace");
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
|
|
769
|
+
const finalReason = freshWaitReferences.safe ? runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
|
|
770
|
+
if (finalReason) {
|
|
771
|
+
increment(result.skipped, `recheck-${finalReason}`);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
mutationStateChanged = true;
|
|
775
|
+
fs.rmSync(runDir, { recursive: true });
|
|
776
|
+
removeRunTombstoneMarker(maintenanceRoot, status!.runId);
|
|
777
|
+
result.reapedTombstones += 1;
|
|
778
|
+
deletedIds.push(`run-tombstone:${status!.runId}`);
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
const tombstone = path.join(options.asyncDirRoot, `${RUN_TOMBSTONE_PREFIX}${randomId()}`);
|
|
782
|
+
writeRunTombstoneMarker(maintenanceRoot, status!.runId, tombstone, currentTime);
|
|
783
|
+
mutationStateChanged = true;
|
|
784
|
+
try {
|
|
785
|
+
fs.renameSync(runDir, tombstone);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
removeRunTombstoneMarker(maintenanceRoot, status!.runId);
|
|
788
|
+
mutationStateChanged = false;
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
const recheckStatus = readStatus(tombstone);
|
|
792
|
+
const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
|
|
793
|
+
const recheckReason = freshWaitReferences.safe ? runSkipReason({ runDir: tombstone, status: recheckStatus, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
|
|
794
|
+
if (recheckReason) {
|
|
795
|
+
if (!fs.existsSync(runDir)) {
|
|
796
|
+
fs.renameSync(tombstone, runDir);
|
|
797
|
+
mutationStateChanged = false;
|
|
798
|
+
}
|
|
799
|
+
removeRunTombstoneMarker(maintenanceRoot, status!.runId);
|
|
800
|
+
if (mutationStateChanged) cursorCommitUnsafe = true;
|
|
801
|
+
increment(result.skipped, `recheck-${recheckReason}`);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
fs.rmSync(tombstone, { recursive: true });
|
|
805
|
+
removeRunTombstoneMarker(maintenanceRoot, status!.runId);
|
|
806
|
+
result.deletedRuns += 1;
|
|
807
|
+
deletedIds.push(`run:${status!.runId}`);
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (mutationStateChanged) cursorCommitUnsafe = true;
|
|
810
|
+
if (!isNotFound(error)) result.errors.push(compactError(error));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
for (const discoveredCandidate of discovery.resultCandidates) {
|
|
814
|
+
if (markCancelled()) return finish();
|
|
815
|
+
result.scanned += 1;
|
|
816
|
+
const candidate: ResultCandidate = { ...discoveredCandidate, path: path.join(options.resultsDir, discoveredCandidate.relative) };
|
|
817
|
+
let mutationStateChanged = false;
|
|
818
|
+
try {
|
|
819
|
+
const stat = lstatSync(candidate.path);
|
|
820
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
821
|
+
increment(result.skipped, "unsafe-result-path");
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
const data = readJson(candidate.path);
|
|
825
|
+
const reason = resultSkipReason({ candidate, data, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
|
|
826
|
+
if (reason) {
|
|
827
|
+
increment(result.skipped, reason);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (candidate.kind === "tombstone") {
|
|
831
|
+
if (currentTime - stat.mtimeMs < tombstoneGraceMs) {
|
|
832
|
+
increment(result.skipped, "tombstone-grace");
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
|
|
836
|
+
const finalReason = freshWaitReferences.safe ? resultSkipReason({ candidate, data, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
|
|
837
|
+
if (finalReason) {
|
|
838
|
+
increment(result.skipped, `recheck-${finalReason}`);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
fs.rmSync(candidate.path);
|
|
842
|
+
mutationStateChanged = true;
|
|
843
|
+
result.reapedTombstones += 1;
|
|
844
|
+
deletedIds.push(`result-tombstone:${resultRunId(candidate, data!)}`);
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const tombstone = path.join(path.dirname(candidate.path), `${RESULT_TOMBSTONE_PREFIX}${candidate.kind}-${randomId()}`);
|
|
848
|
+
fs.renameSync(candidate.path, tombstone);
|
|
849
|
+
mutationStateChanged = true;
|
|
850
|
+
const tombstoneCandidate = { ...candidate, path: tombstone, kind: "tombstone" as const };
|
|
851
|
+
const recheckData = readJson(tombstone);
|
|
852
|
+
const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
|
|
853
|
+
const recheckReason = freshWaitReferences.safe ? resultSkipReason({ candidate: tombstoneCandidate, data: recheckData, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
|
|
854
|
+
if (recheckReason) {
|
|
855
|
+
if (!fs.existsSync(candidate.path)) {
|
|
856
|
+
fs.renameSync(tombstone, candidate.path);
|
|
857
|
+
mutationStateChanged = false;
|
|
858
|
+
}
|
|
859
|
+
if (mutationStateChanged) cursorCommitUnsafe = true;
|
|
860
|
+
increment(result.skipped, `recheck-${recheckReason}`);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
fs.rmSync(tombstone);
|
|
864
|
+
result.deletedResults += 1;
|
|
865
|
+
deletedIds.push(`result:${resultRunId(candidate, data!)}`);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
if (mutationStateChanged) cursorCommitUnsafe = true;
|
|
868
|
+
if (!isNotFound(error)) result.errors.push(compactError(error));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (cursorCommitUnsafe) {
|
|
872
|
+
increment(result.skipped, "commit-failure");
|
|
873
|
+
return finish();
|
|
874
|
+
}
|
|
875
|
+
if (markCancelled()) return finish();
|
|
876
|
+
if (parseLockOwner(lockDir)?.token !== lockToken) {
|
|
877
|
+
increment(result.skipped, "lock-owner-changed");
|
|
878
|
+
return finish();
|
|
879
|
+
}
|
|
880
|
+
applyCursorOperations(cursor, discovery.cursorOps);
|
|
881
|
+
writeAtomicJson(path.join(maintenanceRoot, CURSOR_NAME), cursor);
|
|
882
|
+
return finish();
|
|
883
|
+
} finally {
|
|
884
|
+
try { releaseRetentionLock(lockDir, lockToken); } catch { /* a stale lock blocks the next pass safely */ }
|
|
885
|
+
}
|
|
886
|
+
}
|