@mgiles/perk 2.2.0 → 3.0.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/README.md +1 -1
- package/extension/adapters/planAdapterPlannotator.ts +132 -91
- package/extension/adapters/planAdapterTombell.ts +5 -3
- package/extension/doors/address.ts +235 -37
- package/extension/doors/annotationPush.ts +924 -0
- package/extension/doors/auditWaveTools.ts +352 -0
- package/extension/doors/ciExecutor.ts +220 -55
- package/extension/doors/draftReviewWaveTools.ts +384 -0
- package/extension/doors/harvestWaveTools.ts +279 -0
- package/extension/doors/land.ts +1 -0
- package/extension/doors/learn.ts +238 -24
- package/extension/doors/learnFactory.ts +33 -7
- package/extension/doors/lifecycleGates.ts +1 -1
- package/extension/doors/objectiveReviewBrowser.ts +514 -0
- package/extension/doors/objectiveStack.ts +1143 -0
- package/extension/doors/planReviewBrowser.ts +453 -0
- package/extension/doors/plannotatorHandoff.ts +120 -49
- package/extension/doors/prReview.ts +203 -22
- package/extension/doors/prReviewBrowser.ts +31 -23
- package/extension/doors/prReviewDynamic.ts +256 -0
- package/extension/doors/prReviewTerminal.ts +6 -11
- package/extension/doors/reviewWaveTools.ts +374 -0
- package/extension/doors/selfcheck.ts +1 -1
- package/extension/doors/submit.ts +86 -10
- package/extension/factories/gistAuthor.ts +4 -4
- package/extension/factories/gistDraft.ts +1 -1
- package/extension/factories/gistSave.ts +2 -2
- package/extension/factories/objective.ts +7 -7
- package/extension/factories/objectiveAuthor.ts +5 -4
- package/extension/factories/objectiveDraft.ts +61 -6
- package/extension/factories/objectivePlan.ts +163 -8
- package/extension/factories/objectiveSave.ts +16 -1
- package/extension/factories/planMode.ts +5 -3
- package/extension/factories/planReview.ts +131 -36
- package/extension/factories/planTitle.ts +27 -9
- package/extension/hunkFeedback/inbox.ts +389 -0
- package/extension/hunkFeedback/perkFeedback.ts +364 -0
- package/extension/hunkFeedback/receiver.ts +296 -0
- package/extension/hunkFeedback/store.ts +533 -0
- package/extension/index.ts +116 -44
- package/extension/substrate/cache.ts +75 -22
- package/extension/substrate/coldDoor.ts +2 -3
- package/extension/substrate/config.ts +42 -14
- package/extension/substrate/git.ts +10 -0
- package/extension/substrate/providers.ts +4 -33
- package/extension/substrate/resources.ts +11 -0
- package/extension/substrate/result.ts +13 -5
- package/extension/substrate/sessionData.ts +3 -3
- package/extension/substrate/sessionPointers.ts +3 -3
- package/extension/substrate/structuredOutput.ts +78 -19
- package/extension/substrate/terminalLaunch.ts +1 -1
- package/extension/substrate/toolGating.ts +206 -26
- package/extension/substrate/workflowState.ts +6 -0
- package/extension/surfaces/footerProvider.ts +6 -6
- package/extension/surfaces/surfaces.ts +98 -282
- package/extension/vendor/btw/btw.ts +57 -12
- package/extension/waves/adversarialReviewWave.ts +164 -0
- package/extension/waves/auditWave.ts +312 -0
- package/extension/waves/draftReviewWave.ts +189 -0
- package/extension/waves/harvestWave.ts +399 -0
- package/extension/waves/learnWave.ts +155 -0
- package/extension/waves/memoryAdapter.ts +139 -0
- package/extension/waves/objectiveExplorerWave.ts +120 -0
- package/extension/waves/prReviewDynamicWave.ts +696 -0
- package/extension/waves/prReviewWave.ts +274 -0
- package/extension/waves/reportWave.ts +749 -0
- package/extension/waves/reviewClassifierWave.ts +109 -0
- package/extension/waves/rpcAdapter.ts +264 -0
- package/extension/worker/readOnlySession.ts +3 -3
- package/extension/worker/worker.ts +71 -93
- package/extension/workerMain.ts +4 -6
- package/package.json +20 -5
- package/prompts/README.md +19 -0
- package/prompts/_fixtures/live.yaml +71 -72
- package/prompts/common/resume-advisory.md +1 -0
- package/prompts/contexts/adapters/plannotator-gist.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +1 -2
- package/prompts/contexts/adapters/plannotator-plan.md +1 -2
- package/prompts/contexts/gist-authoring.md +6 -20
- package/prompts/contexts/objective-authoring.md +6 -18
- package/prompts/stages/address/action.md +4 -4
- package/prompts/stages/address/preview.md +3 -3
- package/prompts/stages/audit.md +18 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/gist-author/seed.md +4 -4
- package/prompts/stages/gist-save.md +1 -1
- package/prompts/stages/implement.md +3 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-harvest.md +15 -0
- package/prompts/stages/learn-orchestrate.md +7 -5
- package/prompts/stages/objective-author/adopt.md +5 -3
- package/prompts/stages/objective-author/file.md +5 -4
- package/prompts/stages/objective-author/seed.md +5 -4
- package/prompts/stages/objective-land.md +7 -0
- package/prompts/stages/objective-plan/guidance.md +2 -2
- package/prompts/stages/objective-plan/seed.md +5 -1
- package/prompts/stages/objective-recover.md +8 -0
- package/prompts/stages/objective-replan.md +4 -2
- package/prompts/stages/objective-review-browser.md +9 -0
- package/prompts/stages/objective-sync.md +6 -0
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/plan-review-browser.md +9 -0
- package/prompts/stages/pr-review-browser/active.md +6 -6
- package/prompts/stages/pr-review-browser/foreign.md +6 -6
- package/prompts/stages/pr-review-dynamic.md +7 -0
- package/prompts/stages/pr-review-terminal/active.md +7 -7
- package/prompts/stages/pr-review-terminal/foreign.md +7 -7
- package/prompts/stages/pr-review.md +7 -6
- package/prompts/stages/replan.md +3 -3
- package/shared/README.md +2 -2
- package/shared/bindings.yaml +15 -0
- package/shared/contracts-history.md +11 -0
- package/shared/contracts.md +3713 -227
- package/shared/providers.yaml +27 -60
- package/shared/registry.yaml +32 -8
- package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
- package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
- package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
- package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
- package/shared/schemas/outputs/plan-save.schema.json +12 -0
- package/shared/schemas/outputs/pr-submit.schema.json +177 -1
- package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
- package/extension/checkpoints/checkpoints.ts +0 -550
- package/extension/checkpoints/planSteps.ts +0 -108
- package/extension/doors/askUser.ts +0 -198
- package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// The receiver-plane file contract behind the hunk watch feedback bridge (contracts.md §8.58):
|
|
2
|
+
// lenient NDJSON reads over the outbox/delivered streams, the append-only ack writer, and the
|
|
3
|
+
// single-consumer lease operations. Pure file mechanics — no timers, no session effects; the
|
|
4
|
+
// delivery machine lives in inbox.ts. Paths reach here from the cache-seam helpers
|
|
5
|
+
// (substrate/cache.ts) — this module never constructs `.perk/workflow` segments itself.
|
|
6
|
+
//
|
|
7
|
+
// Read posture (§8.58): reads are TOTAL and lenient — a missing file is no feedback; a trailing
|
|
8
|
+
// partial line is HELD (a concurrent append in flight); a malformed complete line warns and is
|
|
9
|
+
// skipped; an unknown `schema` is held with a loud version warning (never acked); duplicate
|
|
10
|
+
// `feedback_id`s collapse to the first valid record and conflicting later bytes for the same id
|
|
11
|
+
// are reported as corruption. Full-file reads with ID indexing — no cursors/compaction in v1.
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
appendFileSync,
|
|
16
|
+
lstatSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
realpathSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
statSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
26
|
+
import { atomicWriteFileSync, hunkDeliveredPath, hunkWatchDir } from "../substrate/cache.ts";
|
|
27
|
+
|
|
28
|
+
/** Heartbeat renewal cadence — an implementation constant (§8.58), not config. */
|
|
29
|
+
export const HEARTBEAT_MS = 5_000;
|
|
30
|
+
/** A lease whose heartbeat is older than this is reclaimable — implementation constant. */
|
|
31
|
+
export const STALE_LEASE_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
// --- record shapes (feedback record v1 / acknowledgement v1 / lease, §8.58) ----------------
|
|
34
|
+
|
|
35
|
+
export interface FeedbackAnchor {
|
|
36
|
+
file_path: string;
|
|
37
|
+
/** Zero-based hunk position within the file (Hunk's own index). */
|
|
38
|
+
hunk_index: number;
|
|
39
|
+
side: "old" | "new";
|
|
40
|
+
/** Positive one-based line number on `side`. */
|
|
41
|
+
line: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FeedbackRecord {
|
|
45
|
+
schema: 1;
|
|
46
|
+
/** `<watch_instance_id>:<hunk-note-id>` — the stable at-least-once identity. */
|
|
47
|
+
feedback_id: string;
|
|
48
|
+
watch_instance_id: string;
|
|
49
|
+
plan_id: string;
|
|
50
|
+
/** Publisher-assigned ISO-8601. */
|
|
51
|
+
created_at: string;
|
|
52
|
+
changeset_id: string | null;
|
|
53
|
+
anchor: FeedbackAnchor;
|
|
54
|
+
body: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DeliveryAck {
|
|
58
|
+
schema: 1;
|
|
59
|
+
feedback_id: string;
|
|
60
|
+
delivered_at: string;
|
|
61
|
+
run_id: string;
|
|
62
|
+
pi_session_id: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OutboxRead {
|
|
66
|
+
records: FeedbackRecord[];
|
|
67
|
+
/** Lines held for a later read: the trailing partial line + unknown-schema lines. */
|
|
68
|
+
held: number;
|
|
69
|
+
warnings: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAnchor(value: unknown): value is FeedbackAnchor {
|
|
77
|
+
if (!isRecord(value)) return false;
|
|
78
|
+
return (
|
|
79
|
+
typeof value.file_path === "string" &&
|
|
80
|
+
typeof value.hunk_index === "number" &&
|
|
81
|
+
Number.isInteger(value.hunk_index) &&
|
|
82
|
+
value.hunk_index >= 0 &&
|
|
83
|
+
(value.side === "old" || value.side === "new") &&
|
|
84
|
+
typeof value.line === "number" &&
|
|
85
|
+
Number.isInteger(value.line) &&
|
|
86
|
+
value.line >= 1
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isFeedbackRecord(
|
|
91
|
+
value: Record<string, unknown>,
|
|
92
|
+
): value is FeedbackRecord & Record<string, unknown> {
|
|
93
|
+
return (
|
|
94
|
+
typeof value.feedback_id === "string" &&
|
|
95
|
+
value.feedback_id !== "" &&
|
|
96
|
+
typeof value.watch_instance_id === "string" &&
|
|
97
|
+
typeof value.plan_id === "string" &&
|
|
98
|
+
typeof value.created_at === "string" &&
|
|
99
|
+
(value.changeset_id === null || typeof value.changeset_id === "string") &&
|
|
100
|
+
isAnchor(value.anchor) &&
|
|
101
|
+
typeof value.body === "string" &&
|
|
102
|
+
value.body !== ""
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Split NDJSON content into complete lines + the held trailing partial (no trailing LF means a
|
|
108
|
+
* concurrent appender may still be mid-write — hold it for the next read, never parse it).
|
|
109
|
+
*/
|
|
110
|
+
function completeLines(content: string): { lines: string[]; heldPartial: boolean } {
|
|
111
|
+
const heldPartial = content !== "" && !content.endsWith("\n");
|
|
112
|
+
const lines = content.split("\n");
|
|
113
|
+
// The final split element is either "" (trailing LF) or the held partial — drop it either way.
|
|
114
|
+
lines.pop();
|
|
115
|
+
return { lines: lines.filter((line) => line.trim() !== ""), heldPartial };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The lenient §8.58 outbox read. A MISSING file is the normal, silent no-feedback state;
|
|
120
|
+
* every other read failure (EACCES, EISDIR, EIO, …) is surfaced as a warning — queued feedback
|
|
121
|
+
* must never stall invisibly (the caller's once-per-distinct-message dedupe bounds the noise).
|
|
122
|
+
*/
|
|
123
|
+
export function readOutbox(path: string): OutboxRead {
|
|
124
|
+
let content: string;
|
|
125
|
+
try {
|
|
126
|
+
content = readFileSync(path, "utf8");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
129
|
+
return { records: [], held: 0, warnings: [] };
|
|
130
|
+
}
|
|
131
|
+
return { records: [], held: 0, warnings: [`could not read the feedback outbox: ${error}`] };
|
|
132
|
+
}
|
|
133
|
+
const { lines, heldPartial } = completeLines(content);
|
|
134
|
+
const records: FeedbackRecord[] = [];
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
let held = heldPartial ? 1 : 0;
|
|
137
|
+
const firstLineById = new Map<string, string>();
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
let parsed: unknown;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(line);
|
|
142
|
+
} catch {
|
|
143
|
+
warnings.push(`skipping a malformed outbox line (not JSON): ${line.slice(0, 80)}`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!isRecord(parsed)) {
|
|
147
|
+
warnings.push(`skipping a malformed outbox line (not an object): ${line.slice(0, 80)}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (parsed.schema !== 1) {
|
|
151
|
+
// An unknown version is HELD, never skipped/acked: a newer writer may be talking to an
|
|
152
|
+
// older receiver — pausing keeps at-least-once intact for a receiver that understands it.
|
|
153
|
+
warnings.push(
|
|
154
|
+
`holding an outbox record with unknown schema ${JSON.stringify(parsed.schema)} — ` +
|
|
155
|
+
"a newer perk may be required to deliver it",
|
|
156
|
+
);
|
|
157
|
+
held += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!isFeedbackRecord(parsed)) {
|
|
161
|
+
warnings.push(`skipping a structurally invalid outbox record: ${line.slice(0, 80)}`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const prior = firstLineById.get(parsed.feedback_id);
|
|
165
|
+
if (prior !== undefined) {
|
|
166
|
+
if (prior !== line) {
|
|
167
|
+
warnings.push(
|
|
168
|
+
`conflicting bytes for feedback_id ${parsed.feedback_id} — keeping the first record ` +
|
|
169
|
+
"(outbox corruption)",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
continue; // duplicates collapse to the first valid record
|
|
173
|
+
}
|
|
174
|
+
firstLineById.set(parsed.feedback_id, line);
|
|
175
|
+
records.push(parsed);
|
|
176
|
+
}
|
|
177
|
+
return { records, held, warnings };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isDeliveryAck(
|
|
181
|
+
value: Record<string, unknown>,
|
|
182
|
+
): value is DeliveryAck & Record<string, unknown> {
|
|
183
|
+
return (
|
|
184
|
+
typeof value.feedback_id === "string" &&
|
|
185
|
+
value.feedback_id !== "" &&
|
|
186
|
+
typeof value.delivered_at === "string" &&
|
|
187
|
+
typeof value.run_id === "string" &&
|
|
188
|
+
typeof value.pi_session_id === "string"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface DeliveredRead {
|
|
193
|
+
ids: Set<string>;
|
|
194
|
+
warnings: string[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The delivered-id set — acknowledgement v1 ONLY (§8.58): an id is suppressed solely by a
|
|
199
|
+
* structurally valid, schema-1 acknowledgement (transcript-observation evidence). A malformed
|
|
200
|
+
* line or an unknown `schema` never counts as delivered (the safe direction is a duplicate
|
|
201
|
+
* redelivery, never a silent suppression) — both warn. A missing file is silent; other read
|
|
202
|
+
* failures warn (redelivery-safe).
|
|
203
|
+
*/
|
|
204
|
+
export function readDeliveredIds(path: string): DeliveredRead {
|
|
205
|
+
let content: string;
|
|
206
|
+
try {
|
|
207
|
+
content = readFileSync(path, "utf8");
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ids: new Set(), warnings: [] };
|
|
210
|
+
return {
|
|
211
|
+
ids: new Set(),
|
|
212
|
+
warnings: [`could not read the feedback acknowledgements: ${error}`],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const ids = new Set<string>();
|
|
216
|
+
const warnings: string[] = [];
|
|
217
|
+
for (const line of completeLines(content).lines) {
|
|
218
|
+
let parsed: unknown;
|
|
219
|
+
try {
|
|
220
|
+
parsed = JSON.parse(line);
|
|
221
|
+
} catch {
|
|
222
|
+
warnings.push(`skipping a malformed acknowledgement line (not JSON): ${line.slice(0, 80)}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!isRecord(parsed)) {
|
|
226
|
+
warnings.push(`skipping a malformed acknowledgement line: ${line.slice(0, 80)}`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (parsed.schema !== 1) {
|
|
230
|
+
warnings.push(
|
|
231
|
+
`ignoring an acknowledgement with unknown schema ${JSON.stringify(parsed.schema)} — ` +
|
|
232
|
+
"its record may redeliver (a newer perk wrote it)",
|
|
233
|
+
);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!isDeliveryAck(parsed)) {
|
|
237
|
+
warnings.push(`skipping a structurally invalid acknowledgement: ${line.slice(0, 80)}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
ids.add(parsed.feedback_id);
|
|
241
|
+
}
|
|
242
|
+
return { ids, warnings };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Refuse a symlinked append target (§8.58): every path component from the hunk-watch dir down
|
|
247
|
+
* must be a real directory/file under the CANONICAL family dir — a force-tracked symlink at
|
|
248
|
+
* `.perk`, `workflow`, `hunk-watch`, or the file itself would otherwise redirect the O_APPEND
|
|
249
|
+
* write outside the worktree. Symlinks ABOVE the worktree root stay legal (macOS /tmp).
|
|
250
|
+
* Check-then-append TOCTOU is accepted: the threat is force-tracked static checkout content,
|
|
251
|
+
* not a live same-uid attacker (who already owns the files).
|
|
252
|
+
*/
|
|
253
|
+
function assertUnredirectedAppendTarget(cwd: string, path: string): void {
|
|
254
|
+
const watchDir = hunkWatchDir(cwd);
|
|
255
|
+
const expected = join(realpathSync(cwd), relative(cwd, watchDir));
|
|
256
|
+
if (realpathSync(watchDir) !== expected) {
|
|
257
|
+
throw new Error(`refusing a symlinked hunk-watch dir (${watchDir} resolves elsewhere)`);
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
261
|
+
throw new Error(`refusing a symlinked append target: ${path}`);
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Append acknowledgements — one complete line + LF per ack (the O_APPEND discipline), after
|
|
270
|
+
* refusing symlinked path components (the target must live in THIS worktree's family dir).
|
|
271
|
+
*/
|
|
272
|
+
export function appendAcks(cwd: string, acks: readonly DeliveryAck[]): void {
|
|
273
|
+
const path = hunkDeliveredPath(cwd);
|
|
274
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
275
|
+
assertUnredirectedAppendTarget(cwd, path);
|
|
276
|
+
for (const ack of acks) {
|
|
277
|
+
appendFileSync(path, `${JSON.stringify(ack)}\n`, "utf8");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- the consumer lease (§8.58) ------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
export interface LeaseIdentity {
|
|
284
|
+
runId: string;
|
|
285
|
+
piSessionId: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type LeaseAcquisition = { owned: true; token: string } | { owned: false; reason: string };
|
|
289
|
+
|
|
290
|
+
interface LeaseFile {
|
|
291
|
+
schema: 1;
|
|
292
|
+
token: string;
|
|
293
|
+
run_id: string;
|
|
294
|
+
pi_session_id: string;
|
|
295
|
+
claimed_at: string;
|
|
296
|
+
heartbeat_at: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function leasePath(lockDir: string): string {
|
|
300
|
+
return join(lockDir, "lease.json");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function readLease(lockDir: string): LeaseFile | null {
|
|
304
|
+
try {
|
|
305
|
+
const parsed: unknown = JSON.parse(readFileSync(leasePath(lockDir), "utf8"));
|
|
306
|
+
if (
|
|
307
|
+
isRecord(parsed) &&
|
|
308
|
+
parsed.schema === 1 &&
|
|
309
|
+
typeof parsed.token === "string" &&
|
|
310
|
+
typeof parsed.run_id === "string" &&
|
|
311
|
+
typeof parsed.pi_session_id === "string" &&
|
|
312
|
+
typeof parsed.claimed_at === "string" &&
|
|
313
|
+
typeof parsed.heartbeat_at === "string"
|
|
314
|
+
) {
|
|
315
|
+
return parsed as unknown as LeaseFile;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Temp-file + rename within the lock dir (atomicWriteFileSync's own discipline). */
|
|
324
|
+
function writeLease(lockDir: string, lease: LeaseFile): void {
|
|
325
|
+
atomicWriteFileSync(leasePath(lockDir), `${JSON.stringify(lease)}\n`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function freshLease(identity: LeaseIdentity, nowMs: number, claimedAt?: string): LeaseFile {
|
|
329
|
+
const at = new Date(nowMs).toISOString();
|
|
330
|
+
return {
|
|
331
|
+
schema: 1,
|
|
332
|
+
token: randomBytes(8).toString("hex"),
|
|
333
|
+
run_id: identity.runId,
|
|
334
|
+
pi_session_id: identity.piSessionId,
|
|
335
|
+
claimed_at: claimedAt ?? at,
|
|
336
|
+
heartbeat_at: at,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Atomic `mkdir` (non-recursive, so EEXIST is the contention signal) + first lease write. */
|
|
341
|
+
function tryFreshAcquire(lockDir: string, identity: LeaseIdentity, nowMs: number): string | null {
|
|
342
|
+
try {
|
|
343
|
+
mkdirSync(lockDir);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
const lease = freshLease(identity, nowMs);
|
|
349
|
+
writeLease(lockDir, lease);
|
|
350
|
+
return lease.token;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Deterministic-interleave seams for the reclaim-race tests — never set in production. */
|
|
354
|
+
export interface AcquireRaceHooks {
|
|
355
|
+
/** Runs after the stale lease is observed, before the quarantine rename. */
|
|
356
|
+
beforeQuarantine?(): void;
|
|
357
|
+
/** Runs after the quarantine rename attempt, before the fresh-acquire retry. */
|
|
358
|
+
afterQuarantine?(): void;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Acquire the single-consumer lease (§8.58). Atomic directory creation is the primitive; on
|
|
363
|
+
* contention: same-identity → idempotent reacquire with a FRESH token (the fencing that retires
|
|
364
|
+
* a `/reload` predecessor instance); stale (heartbeat older than `STALE_LEASE_MS`, lock-dir
|
|
365
|
+
* mtime when `lease.json` is corrupt) → quarantine-rename then ONE fresh-acquire retry
|
|
366
|
+
* (competing reclaimers converge on one winner — the rename loser can legitimately win the
|
|
367
|
+
* retry; the winner best-effort-removes its quarantine dir); fresh foreign → passive. `now()`
|
|
368
|
+
* is injected for deterministic tests.
|
|
369
|
+
*/
|
|
370
|
+
export function acquireLease(
|
|
371
|
+
lockDir: string,
|
|
372
|
+
identity: LeaseIdentity,
|
|
373
|
+
now: () => number,
|
|
374
|
+
hooks: AcquireRaceHooks = {},
|
|
375
|
+
): LeaseAcquisition {
|
|
376
|
+
mkdirSync(dirname(lockDir), { recursive: true });
|
|
377
|
+
const nowMs = now();
|
|
378
|
+
const fresh = tryFreshAcquire(lockDir, identity, nowMs);
|
|
379
|
+
if (fresh !== null) return { owned: true, token: fresh };
|
|
380
|
+
|
|
381
|
+
const lease = readLease(lockDir);
|
|
382
|
+
if (
|
|
383
|
+
lease !== null &&
|
|
384
|
+
lease.run_id === identity.runId &&
|
|
385
|
+
lease.pi_session_id === identity.piSessionId
|
|
386
|
+
) {
|
|
387
|
+
const reacquired = freshLease(identity, nowMs, lease.claimed_at);
|
|
388
|
+
writeLease(lockDir, reacquired);
|
|
389
|
+
return { owned: true, token: reacquired.token };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Staleness basis: the recorded heartbeat, else (corrupt lease.json) the lock-dir mtime.
|
|
393
|
+
let basisMs = lease !== null ? Date.parse(lease.heartbeat_at) : Number.NaN;
|
|
394
|
+
if (Number.isNaN(basisMs)) {
|
|
395
|
+
try {
|
|
396
|
+
basisMs = statSync(lockDir).mtimeMs;
|
|
397
|
+
} catch {
|
|
398
|
+
// The dir vanished between mkdir-EEXIST and stat (a racing release) — retry fresh below.
|
|
399
|
+
basisMs = Number.NEGATIVE_INFINITY;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (nowMs - basisMs < STALE_LEASE_MS) {
|
|
403
|
+
const holder =
|
|
404
|
+
lease !== null
|
|
405
|
+
? `run ${lease.run_id} (session ${lease.pi_session_id})`
|
|
406
|
+
: "an unidentified session";
|
|
407
|
+
return {
|
|
408
|
+
owned: false,
|
|
409
|
+
reason: `another live implement session holds the feedback lease: ${holder}`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Stale: quarantine the dead lock dir under a unique name, then ONE fresh-acquire retry.
|
|
414
|
+
// A failed rename means a competing reclaimer already moved it — still take the retry.
|
|
415
|
+
hooks.beforeQuarantine?.();
|
|
416
|
+
const quarantine = `${lockDir}.stale-${process.pid.toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
417
|
+
let renamed = false;
|
|
418
|
+
try {
|
|
419
|
+
renameSync(lockDir, quarantine);
|
|
420
|
+
renamed = true;
|
|
421
|
+
} catch {
|
|
422
|
+
renamed = false;
|
|
423
|
+
}
|
|
424
|
+
if (renamed) {
|
|
425
|
+
// Post-rename freshness re-check: between our staleness judgment and the rename, a
|
|
426
|
+
// competing reclaimer may have COMPLETED a full reclaim — the dir we just moved would
|
|
427
|
+
// then hold a FRESH successor lease, not the stale one we judged. Restore it and stay
|
|
428
|
+
// passive (a fresh foreign lease is never stolen). If the restore loses a further race,
|
|
429
|
+
// the quarantined holder fails closed on its own verify fence — never two live consumers.
|
|
430
|
+
const moved = readLease(quarantine);
|
|
431
|
+
const movedFresh =
|
|
432
|
+
moved !== null &&
|
|
433
|
+
!Number.isNaN(Date.parse(moved.heartbeat_at)) &&
|
|
434
|
+
nowMs - Date.parse(moved.heartbeat_at) < STALE_LEASE_MS;
|
|
435
|
+
if (movedFresh) {
|
|
436
|
+
try {
|
|
437
|
+
renameSync(quarantine, lockDir);
|
|
438
|
+
} catch {
|
|
439
|
+
// the name was retaken meanwhile — leave the quarantine for the sweep
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
owned: false,
|
|
443
|
+
reason: `another live implement session holds the feedback lease: run ${moved.run_id} (session ${moved.pi_session_id})`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
hooks.afterQuarantine?.();
|
|
448
|
+
const retried = tryFreshAcquire(lockDir, identity, nowMs);
|
|
449
|
+
if (renamed) {
|
|
450
|
+
try {
|
|
451
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
452
|
+
} catch {
|
|
453
|
+
// best-effort — a leftover quarantine dir is harmless and swept on the next open
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (retried !== null) return { owned: true, token: retried };
|
|
457
|
+
return { owned: false, reason: "another session reclaimed the stale feedback lease first" };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Best-effort removal of leftover `consumer.lock.stale-*` quarantine dirs beside `lockDir`.
|
|
462
|
+
* Returns warnings for anything it could not remove (warn-and-leave — the tier is disposable).
|
|
463
|
+
*/
|
|
464
|
+
export function sweepQuarantine(lockDir: string): string[] {
|
|
465
|
+
const parent = dirname(lockDir);
|
|
466
|
+
const prefix = `${basename(lockDir)}.stale-`;
|
|
467
|
+
const warnings: string[] = [];
|
|
468
|
+
let entries: string[];
|
|
469
|
+
try {
|
|
470
|
+
entries = readdirSync(parent);
|
|
471
|
+
} catch {
|
|
472
|
+
return warnings;
|
|
473
|
+
}
|
|
474
|
+
for (const entry of entries) {
|
|
475
|
+
if (!entry.startsWith(prefix)) continue;
|
|
476
|
+
try {
|
|
477
|
+
rmSync(join(parent, entry), { recursive: true, force: true });
|
|
478
|
+
} catch (error) {
|
|
479
|
+
warnings.push(`could not sweep the stale lease quarantine ${entry}: ${error}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return warnings;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The lock dir's inode — the directory-identity fence for check-then-act operations. */
|
|
486
|
+
function lockDirIno(lockDir: string): bigint {
|
|
487
|
+
return statSync(lockDir, { bigint: true }).ino;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Renew `heartbeat_at` — throws on a lost/foreign lease (the caller reports, never renews).
|
|
492
|
+
*
|
|
493
|
+
* Inode-fenced against the reclaim race (§8.58): a stale reclaimer replaces the lock DIRECTORY
|
|
494
|
+
* (rename + fresh mkdir), so the inode captured before the read must still be the inode after
|
|
495
|
+
* the write — a mismatch means the write may have clobbered a successor's lease, and the throw
|
|
496
|
+
* makes THIS holder stop too. The residual sub-window degrades to BOTH consumers failing
|
|
497
|
+
* closed (the successor's own verifyLease fence rejects the clobbered token) — never to two
|
|
498
|
+
* live consumers.
|
|
499
|
+
*/
|
|
500
|
+
export function renewHeartbeat(lockDir: string, token: string, now: () => number): void {
|
|
501
|
+
const inoBefore = lockDirIno(lockDir);
|
|
502
|
+
const lease = readLease(lockDir);
|
|
503
|
+
if (lease === null || lease.token !== token) {
|
|
504
|
+
throw new Error("feedback lease lost — heartbeat not renewed");
|
|
505
|
+
}
|
|
506
|
+
writeLease(lockDir, { ...lease, heartbeat_at: new Date(now()).toISOString() });
|
|
507
|
+
if (lockDirIno(lockDir) !== inoBefore) {
|
|
508
|
+
throw new Error("feedback lease lock dir was replaced during renewal — fencing lost");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** True iff the on-disk lease still carries `token`. Any read failure is false (fail-closed). */
|
|
513
|
+
export function verifyLease(lockDir: string, token: string): boolean {
|
|
514
|
+
const lease = readLease(lockDir);
|
|
515
|
+
return lease !== null && lease.token === token;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Release the lease — removes the lock dir only on token match; best-effort, never throws.
|
|
520
|
+
* Inode-fenced like `renewHeartbeat`: the verify-then-remove window is re-checked against the
|
|
521
|
+
* directory identity so a mid-release reclaim is (almost) never deleted; the residual window
|
|
522
|
+
* degrades to the successor failing closed, never to misdelivery.
|
|
523
|
+
*/
|
|
524
|
+
export function releaseLease(lockDir: string, token: string): void {
|
|
525
|
+
try {
|
|
526
|
+
const inoBefore = lockDirIno(lockDir);
|
|
527
|
+
if (!verifyLease(lockDir, token)) return;
|
|
528
|
+
if (lockDirIno(lockDir) !== inoBefore) return; // replaced mid-verify — not ours anymore
|
|
529
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
530
|
+
} catch {
|
|
531
|
+
// best-effort — a leftover lock dir goes stale and is reclaimed by the next open
|
|
532
|
+
}
|
|
533
|
+
}
|