iterate-plugin 3.5.7 → 3.7.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 +97 -22
- package/README.zh-CN.md +79 -19
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +516 -35
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +193 -0
- package/dist/index.js +61 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +54 -14
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +61 -14
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +106 -28
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1912 -512
- package/lib/parse.js +1001 -103
- package/package.json +16 -9
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +1031 -369
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +549 -36
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +211 -0
- package/src/index.ts +60 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +50 -12
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +60 -13
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +114 -23
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
- package/src/working-directory.ts +126 -0
package/dist/tools/checkpoint.js
CHANGED
|
@@ -18,10 +18,27 @@ import { readDecisionEntries } from "./decision-log.js";
|
|
|
18
18
|
import { readQualityGate } from "./quality-store.js";
|
|
19
19
|
import { readExperienceBank } from "./experience-store.js";
|
|
20
20
|
import { readDefenseEvents } from "./defense-store.js";
|
|
21
|
+
import { asNumber, asRecord, parseRenderedJson } from "./present.js";
|
|
21
22
|
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
22
23
|
/** Max findings persisted in a checkpoint (a model-authored findings payload is
|
|
23
24
|
* bounded, mirroring MAX_FIX_CONTENT_CHARS for fix content). */
|
|
24
25
|
export const MAX_CHECKPOINT_FINDINGS = 1000;
|
|
26
|
+
/**
|
|
27
|
+
* Sanity cap for round/counter fields. Status output fields are declared
|
|
28
|
+
* `type: 'integer'` in the tool schema; flooring + capping here guarantees a
|
|
29
|
+
* hand-edited value can never reach dsh's post-execute output validation as a
|
|
30
|
+
* float (INVALID_TOOL_OUTPUT on iterate_status).
|
|
31
|
+
*/
|
|
32
|
+
const MAX_COUNTER = 1_000_000_000;
|
|
33
|
+
/**
|
|
34
|
+
* Coerce a numeric field to a non-negative, in-cap INTEGER.
|
|
35
|
+
* Non-numeric (string/NaN/undefined) → `fallback`.
|
|
36
|
+
*/
|
|
37
|
+
function toInteger(v, fallback) {
|
|
38
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
39
|
+
return fallback;
|
|
40
|
+
return Math.min(Math.max(Math.floor(v), 0), MAX_COUNTER);
|
|
41
|
+
}
|
|
25
42
|
/** Read the current checkpoint from disk (missing/corrupt → null). */
|
|
26
43
|
export function readCheckpoint(projectRoot) {
|
|
27
44
|
const file = checkpointPath(projectRoot);
|
|
@@ -35,22 +52,28 @@ export function readCheckpoint(projectRoot) {
|
|
|
35
52
|
return null;
|
|
36
53
|
// Strict round gate: non-numeric (incl. NaN — `NaN <= 0` is false and
|
|
37
54
|
// `NaN < 0` also false) round values must not survive into the status.
|
|
55
|
+
// The floor also kills `round: 2.5` — validateCheckpoint only ever SAVES
|
|
56
|
+
// integers, so a non-integer on disk is hand-edited/corrupt.
|
|
38
57
|
const round = typeof parsed.round === 'number' && Number.isFinite(parsed.round)
|
|
39
58
|
? Math.floor(parsed.round)
|
|
40
59
|
: null;
|
|
41
|
-
if (round === null || round < 0)
|
|
60
|
+
if (round === null || round < 0 || round > MAX_COUNTER)
|
|
42
61
|
return null;
|
|
43
|
-
const num = (v, fallback) => typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
|
44
62
|
// Normalize every field so a hand-edited/corrupt checkpoint (string
|
|
45
|
-
// maxRounds,
|
|
46
|
-
// into the status output or crash a consumer.
|
|
63
|
+
// maxRounds, float fixedCount, missing architecturalCount, …) can never
|
|
64
|
+
// leak a non-integer value into the status output or crash a consumer.
|
|
65
|
+
const maxRounds = toInteger(parsed.maxRounds, round);
|
|
66
|
+
// Consistent with validateCheckpoint's save gate: a checkpoint claiming a
|
|
67
|
+
// round past its cap is corrupt — otherwise status renders "Round 99 / 5".
|
|
68
|
+
if (round > maxRounds)
|
|
69
|
+
return null;
|
|
47
70
|
return {
|
|
48
71
|
mode: parsed.mode,
|
|
49
72
|
round,
|
|
50
|
-
maxRounds
|
|
51
|
-
fixedCount:
|
|
52
|
-
architecturalCount:
|
|
53
|
-
resumeCount:
|
|
73
|
+
maxRounds,
|
|
74
|
+
fixedCount: toInteger(parsed.fixedCount, 0),
|
|
75
|
+
architecturalCount: toInteger(parsed.architecturalCount, 0),
|
|
76
|
+
resumeCount: toInteger(parsed.resumeCount, 0),
|
|
54
77
|
findings: Array.isArray(parsed.findings) ? parsed.findings : [],
|
|
55
78
|
startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString(),
|
|
56
79
|
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
@@ -118,24 +141,31 @@ export function computeStatus(input) {
|
|
|
118
141
|
const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
|
|
119
142
|
const lastUpdated = lastEntry?.timestamp ?? checkpoint?.updatedAt ?? null;
|
|
120
143
|
// Round = checkpoint.round (explicit) or max round seen in the decision log.
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
// Everything is funneled through toInteger: a hand-edited `round: 2.5` entry
|
|
145
|
+
// or an injected float checkpoint must never surface as a non-integer in the
|
|
146
|
+
// status output (schema `type: 'integer'`).
|
|
147
|
+
let currentRound = 0;
|
|
148
|
+
if (checkpoint) {
|
|
149
|
+
currentRound = toInteger(checkpoint.round, 0);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
123
152
|
for (const e of entries) {
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
const r = toInteger(e.round, 0);
|
|
154
|
+
if (r > currentRound)
|
|
155
|
+
currentRound = r;
|
|
126
156
|
}
|
|
127
157
|
}
|
|
128
|
-
const totalRounds = checkpoint
|
|
158
|
+
const totalRounds = checkpoint ? toInteger(checkpoint.maxRounds, currentRound) : currentRound;
|
|
129
159
|
// Coerce the registry's per-round counts defensively: a hand-edited registry
|
|
130
160
|
// round missing fixedCount/failedCount would otherwise feed NaN into the
|
|
131
|
-
// status integer fields.
|
|
132
|
-
const registryFixed = registry.rounds.reduce((sum, r) => sum + (
|
|
133
|
-
const failedCount = registry.rounds.reduce((sum, r) => sum + (
|
|
161
|
+
// status integer fields — and fractional counts (1.5) must be floored too.
|
|
162
|
+
const registryFixed = registry.rounds.reduce((sum, r) => sum + toInteger(r.fixedCount, 0), 0);
|
|
163
|
+
const failedCount = registry.rounds.reduce((sum, r) => sum + toInteger(r.failedCount, 0), 0);
|
|
134
164
|
// When a checkpoint exists, its snapshot fields are authoritative for resume
|
|
135
165
|
// (fixedCount / architecturalCount / findings); otherwise derive from the
|
|
136
166
|
// live fix registry and decision log.
|
|
137
|
-
const fixedCount = checkpoint ? checkpoint.fixedCount : registryFixed;
|
|
138
|
-
const architecturalCount = checkpoint?.architecturalCount
|
|
167
|
+
const fixedCount = checkpoint ? toInteger(checkpoint.fixedCount, 0) : registryFixed;
|
|
168
|
+
const architecturalCount = toInteger(checkpoint?.architecturalCount, 0);
|
|
139
169
|
return {
|
|
140
170
|
mode: checkpoint?.mode ?? null,
|
|
141
171
|
taskMode,
|
|
@@ -158,7 +188,7 @@ export function computeStatus(input) {
|
|
|
158
188
|
!(lastEntry != null &&
|
|
159
189
|
checkpoint.updatedAt &&
|
|
160
190
|
(Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
|
|
161
|
-
resumeCount: checkpoint?.resumeCount
|
|
191
|
+
resumeCount: toInteger(checkpoint?.resumeCount, 0),
|
|
162
192
|
checkpoint,
|
|
163
193
|
lastUpdated,
|
|
164
194
|
// v3.0: quality command-center snapshots (present only when the caller
|
|
@@ -177,6 +207,42 @@ export function computeStatus(input) {
|
|
|
177
207
|
export function registerCheckpointTool(ctx) {
|
|
178
208
|
ctx.tools.register(defineTool({
|
|
179
209
|
name: 'iterate_checkpoint',
|
|
210
|
+
// Result card (#12): the render is a raw JSON blob — turn it into a
|
|
211
|
+
// progress headline ("Checkpoint saved — round 2/5 (3 fixed)"). This
|
|
212
|
+
// tool has no `status` operation (progress lives on iterate_status), so
|
|
213
|
+
// save/load/resume/clear each get their headline. Pure: parsed from the
|
|
214
|
+
// rendered result only; an unreadable shape declines the card and the UI
|
|
215
|
+
// falls back to the raw JSON.
|
|
216
|
+
presentResult: (args, result) => {
|
|
217
|
+
const a = args;
|
|
218
|
+
const op = typeof a.operation === 'string' && a.operation ? a.operation : undefined;
|
|
219
|
+
if (!op)
|
|
220
|
+
return undefined;
|
|
221
|
+
const value = parseRenderedJson(result);
|
|
222
|
+
if (!value)
|
|
223
|
+
return undefined;
|
|
224
|
+
if (value.ok !== true) {
|
|
225
|
+
const err = typeof value.error === 'string' && value.error ? value.error : 'unknown error';
|
|
226
|
+
return { card: 'generic', title: `Checkpoint ${op} failed: ${err}` };
|
|
227
|
+
}
|
|
228
|
+
if (op === 'clear') {
|
|
229
|
+
return { card: 'generic', title: value.existed === true ? 'Checkpoint cleared' : 'No checkpoint to clear' };
|
|
230
|
+
}
|
|
231
|
+
const checkpoint = asRecord(value.checkpoint);
|
|
232
|
+
if (!checkpoint) {
|
|
233
|
+
// `load` with nothing on disk is a valid, useful outcome.
|
|
234
|
+
return op === 'load' ? { card: 'generic', title: 'No checkpoint on disk' } : undefined;
|
|
235
|
+
}
|
|
236
|
+
const round = asNumber(checkpoint.round);
|
|
237
|
+
const maxRounds = asNumber(checkpoint.maxRounds);
|
|
238
|
+
const fixed = asNumber(checkpoint.fixedCount);
|
|
239
|
+
const resumeCount = asNumber(checkpoint.resumeCount);
|
|
240
|
+
const base = op === 'save' ? 'Checkpoint saved' : op === 'resume' ? 'Checkpoint resumed' : 'Checkpoint loaded';
|
|
241
|
+
const resume = op === 'resume' && resumeCount !== undefined ? ` (resume #${resumeCount})` : '';
|
|
242
|
+
const progress = round !== undefined ? ` — round ${round}${maxRounds !== undefined ? `/${maxRounds}` : ''}` : '';
|
|
243
|
+
const fixedPart = fixed !== undefined ? ` (${fixed} fixed)` : '';
|
|
244
|
+
return { card: 'generic', title: base + resume + progress + fixedPart };
|
|
245
|
+
},
|
|
180
246
|
description: 'Save / load / resume / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
|
|
181
247
|
'each round (so a long run can resume) and clears it when the iteration completes. ' +
|
|
182
248
|
'`resume` loads an existing checkpoint, bumps its resumeCount, and persists it back — ' +
|
package/dist/tools/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
5
5
|
import { applyConfigUpdates, readRawConfig, validateConfigUpdates, writeConfigFile, } from "../config-write.js";
|
|
@@ -86,6 +86,21 @@ export function registerConfigTool(ctx) {
|
|
|
86
86
|
// ── Write operation ────────────────────────────────────────────────
|
|
87
87
|
if (args.operation === 'write') {
|
|
88
88
|
const updates = args.updates;
|
|
89
|
+
// Missing/empty `updates` used to re-dump the config as-is: the
|
|
90
|
+
// write "succeeded" (ok:true + a spurious backup) while silently
|
|
91
|
+
// re-serializing the file and losing any hand-written comments.
|
|
92
|
+
// There is nothing to merge — refuse instead.
|
|
93
|
+
const isEmptyUpdate = updates === undefined ||
|
|
94
|
+
updates === null ||
|
|
95
|
+
(typeof updates === 'object' && !Array.isArray(updates) && Object.keys(updates).length === 0);
|
|
96
|
+
if (isEmptyUpdate) {
|
|
97
|
+
return {
|
|
98
|
+
operation: 'write',
|
|
99
|
+
ok: false,
|
|
100
|
+
found: false,
|
|
101
|
+
error: 'updates is required (and must be non-empty) for operation "write"',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
89
104
|
const updateErrors = validateConfigUpdates(updates ?? {});
|
|
90
105
|
if (updateErrors.length > 0) {
|
|
91
106
|
return { operation: 'write', ok: false, found: false, errors: updateErrors };
|
|
@@ -132,6 +147,27 @@ export function registerConfigTool(ctx) {
|
|
|
132
147
|
const hasOverride = source === 'override';
|
|
133
148
|
if (args.validate) {
|
|
134
149
|
const errors = validateConfig(config);
|
|
150
|
+
// A config file that EXISTS but cannot be parsed silently degrades to
|
|
151
|
+
// the built-in defaults (loadEffectiveConfig never throws). Reporting
|
|
152
|
+
// `valid: true` for that file would wave through the breakage —
|
|
153
|
+
// re-read it raw and surface the parse failure as a validation error.
|
|
154
|
+
const configPath = join(projectRoot, 'iterate.config.yaml');
|
|
155
|
+
if (source === 'defaults' && existsSync(configPath)) {
|
|
156
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
157
|
+
if (raw.trim().length > 0) {
|
|
158
|
+
try {
|
|
159
|
+
readRawConfig(configPath);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
return {
|
|
163
|
+
found: true,
|
|
164
|
+
valid: false,
|
|
165
|
+
errors: [`iterate.config.yaml is not usable: ${String(err.message ?? err)}`],
|
|
166
|
+
section: 'validation_report',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
135
171
|
return {
|
|
136
172
|
found: hasOverride,
|
|
137
173
|
valid: errors.length === 0,
|
|
@@ -141,7 +177,13 @@ export function registerConfigTool(ctx) {
|
|
|
141
177
|
}
|
|
142
178
|
if (args.section) {
|
|
143
179
|
const configRecord = config;
|
|
144
|
-
|
|
180
|
+
// Object.hasOwn, NOT `configRecord[args.section]`: a plain
|
|
181
|
+
// `configRecord["toString"]` resolves through the PROTOTYPE CHAIN to
|
|
182
|
+
// a function (non-JSON → ToolOutputError) and `["__proto__"]`
|
|
183
|
+
// returns Object.prototype dressed up as an "existing section".
|
|
184
|
+
const section = Object.hasOwn(configRecord, args.section)
|
|
185
|
+
? configRecord[args.section]
|
|
186
|
+
: undefined;
|
|
145
187
|
if (section === undefined) {
|
|
146
188
|
return {
|
|
147
189
|
found: hasOverride,
|
package/dist/tools/context.js
CHANGED
|
@@ -25,10 +25,20 @@ export function isAllowedSkillDir(skillDir, allowedRoots) {
|
|
|
25
25
|
return allowedRoots.some((root) => {
|
|
26
26
|
if (typeof root !== 'string' || root.length === 0)
|
|
27
27
|
return false;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// Per-root try/catch: realpathSync THROWS for a root that does not exist
|
|
29
|
+
// yet. One throw around the whole `.some()` would abort the scan and
|
|
30
|
+
// reject a perfectly valid skillDir sitting inside a LATER root (the
|
|
31
|
+
// caller then silently falls back to auto-detection) — skip only the
|
|
32
|
+
// unusable root and keep checking the remaining ones.
|
|
33
|
+
try {
|
|
34
|
+
const rootReal = realpathSync(root);
|
|
35
|
+
const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep;
|
|
36
|
+
const real = realpathSync(dir);
|
|
37
|
+
return real === rootReal || real.startsWith(rootPrefix);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false; // broken/missing allowed root — not a match, keep scanning
|
|
41
|
+
}
|
|
32
42
|
});
|
|
33
43
|
}
|
|
34
44
|
catch {
|
|
@@ -279,7 +289,7 @@ export function registerContextTool(ctx) {
|
|
|
279
289
|
.split(',')
|
|
280
290
|
.map((s) => s.trim().toLowerCase())
|
|
281
291
|
.filter(Boolean);
|
|
282
|
-
const result = { found:
|
|
292
|
+
const result = { found: false, searched: [] };
|
|
283
293
|
// Relay user-attached image metadata into the review context. The
|
|
284
294
|
// orchestrator observes attached images in the session and passes their
|
|
285
295
|
// metadata here; invalid entries are dropped with a reported reason.
|
|
@@ -328,6 +338,13 @@ export function registerContextTool(ctx) {
|
|
|
328
338
|
if (requested.includes('project') || requested.includes('iterate.md')) {
|
|
329
339
|
result.project = readProjectFile(projectRoot, 'ITERATE.md');
|
|
330
340
|
}
|
|
341
|
+
// Derive `found` from what was actually returned. It mirrors the
|
|
342
|
+
// renderer's "No files found" branch exactly: requested file content,
|
|
343
|
+
// or relayed attachments (attachmentErrors alone never make a find).
|
|
344
|
+
result.found =
|
|
345
|
+
Boolean(result.skill) ||
|
|
346
|
+
Boolean(result.project) ||
|
|
347
|
+
(Array.isArray(result.attachments) && result.attachments.length > 0);
|
|
331
348
|
return result;
|
|
332
349
|
},
|
|
333
350
|
}));
|
|
@@ -2,6 +2,7 @@ import { appendFileSync, readFileSync, mkdirSync, existsSync, openSync, writeSyn
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
5
|
+
import { makeLockRelease } from "../file-lock.js";
|
|
5
6
|
const LOG_DIR = '.iterate';
|
|
6
7
|
const LOG_FILE = 'decision-log.jsonl';
|
|
7
8
|
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
@@ -13,9 +14,11 @@ const LOG_FILE = 'decision-log.jsonl';
|
|
|
13
14
|
// by synchronous I/O, but a second plugin process appending to the same
|
|
14
15
|
// project realises the race. The log is serialized with a tiny advisory lock
|
|
15
16
|
// file (exclusive create, pid stamped, stale-stealable) so `append` and the
|
|
16
|
-
// prune rewrite mutually exclude across processes.
|
|
17
|
-
// timeouts degrade to "proceed unlocked" (
|
|
18
|
-
//
|
|
17
|
+
// prune rewrite mutually exclude across processes. Appends are BEST-EFFORT:
|
|
18
|
+
// contention timeouts degrade to "proceed unlocked" (an audit line must never
|
|
19
|
+
// be lost to a wedged append), never to a crash. The prune REWRITE is the
|
|
20
|
+
// opposite — it renames the log and therefore refuses to run when the lock
|
|
21
|
+
// cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
|
|
19
22
|
const LOCK_FILE = '.decision-log.lock';
|
|
20
23
|
const LOCK_WAIT_MS = 5000;
|
|
21
24
|
const LOCK_STALE_MS = 10000;
|
|
@@ -41,15 +44,34 @@ function sleepSync(ms) {
|
|
|
41
44
|
* Acquire the decision-log lock for `projectRoot`.
|
|
42
45
|
* Returns a release function (always callable; a no-op when the lock could
|
|
43
46
|
* not be taken — never wedge an append behind a vanished holder).
|
|
47
|
+
*
|
|
48
|
+
* `options.waitMs` / `options.staleMs` override the defaults (tests use short
|
|
49
|
+
* waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
44
50
|
*/
|
|
45
|
-
export function acquireLogLock(projectRoot) {
|
|
51
|
+
export function acquireLogLock(projectRoot, options = {}) {
|
|
52
|
+
return acquireLogLockChecked(projectRoot, options).release;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* {@link acquireLogLock} plus the one thing the prune rewrite needs: whether
|
|
56
|
+
* the lock was actually TAKEN. Appends stay best-effort on contention (an
|
|
57
|
+
* audit line must not be lost to a wedged append), but the rewrite renames the
|
|
58
|
+
* log under any unlocked concurrent appender — it must refuse to run without
|
|
59
|
+
* the mutex rather than silently dropping an audit line.
|
|
60
|
+
*/
|
|
61
|
+
export function acquireLogLockChecked(projectRoot, options = {}) {
|
|
62
|
+
const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
|
|
63
|
+
? options.waitMs
|
|
64
|
+
: LOCK_WAIT_MS;
|
|
65
|
+
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
66
|
+
? options.staleMs
|
|
67
|
+
: LOCK_STALE_MS;
|
|
46
68
|
const dir = join(projectRoot, LOG_DIR);
|
|
47
69
|
if (!existsSync(dir)) {
|
|
48
70
|
try {
|
|
49
71
|
mkdirSync(dir, { recursive: true });
|
|
50
72
|
}
|
|
51
73
|
catch {
|
|
52
|
-
return () => { };
|
|
74
|
+
return { acquired: false, release: () => { } };
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
const lockPath = join(dir, LOCK_FILE);
|
|
@@ -61,12 +83,12 @@ export function acquireLogLock(projectRoot) {
|
|
|
61
83
|
}
|
|
62
84
|
catch (err) {
|
|
63
85
|
if (err.code !== 'EEXIST')
|
|
64
|
-
return () => { };
|
|
86
|
+
return { acquired: false, release: () => { } };
|
|
65
87
|
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
66
88
|
let stale = false;
|
|
67
89
|
try {
|
|
68
90
|
const st = statSync(lockPath);
|
|
69
|
-
if (Date.now() - st.mtimeMs >
|
|
91
|
+
if (Date.now() - st.mtimeMs > staleMs)
|
|
70
92
|
stale = true;
|
|
71
93
|
else {
|
|
72
94
|
const pid = Number(readFileSync(lockPath, 'utf-8'));
|
|
@@ -84,12 +106,14 @@ export function acquireLogLock(projectRoot) {
|
|
|
84
106
|
catch { /* another holder stole it */ }
|
|
85
107
|
continue;
|
|
86
108
|
}
|
|
87
|
-
if (Date.now() - start >
|
|
88
|
-
return () => { };
|
|
109
|
+
if (Date.now() - start > waitMs)
|
|
110
|
+
return { acquired: false, release: () => { } };
|
|
89
111
|
sleepSync(LOCK_POLL_MS);
|
|
90
112
|
continue;
|
|
91
113
|
}
|
|
92
114
|
// Owned the lock: stamp the holder pid, then release once on return.
|
|
115
|
+
// The release is ownership-checked (see `makeLockRelease`): a lock stolen
|
|
116
|
+
// from this process after its owner went stale must not be unlinked by us.
|
|
93
117
|
try {
|
|
94
118
|
writeSync(fd, String(process.pid));
|
|
95
119
|
}
|
|
@@ -98,28 +122,64 @@ export function acquireLogLock(projectRoot) {
|
|
|
98
122
|
closeSync(fd);
|
|
99
123
|
}
|
|
100
124
|
catch { /* best-effort */ }
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return;
|
|
105
|
-
released = true;
|
|
106
|
-
try {
|
|
107
|
-
unlinkSync(lockPath);
|
|
108
|
-
}
|
|
109
|
-
catch { /* already gone */ }
|
|
125
|
+
return {
|
|
126
|
+
acquired: true,
|
|
127
|
+
release: makeLockRelease(lockPath),
|
|
110
128
|
};
|
|
111
129
|
}
|
|
112
130
|
}
|
|
113
131
|
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
132
|
+
/**
|
|
133
|
+
* Max distinct log paths tracked by the entry-count cache. The cache is keyed
|
|
134
|
+
* by PATH (one entry per project's log) with no natural eviction — a long-lived
|
|
135
|
+
* process touching many projects would otherwise grow it without bound. A
|
|
136
|
+
* simple insertion-ordered LRU: a hit re-inserts (refreshing recency), a set
|
|
137
|
+
* past the cap evicts the least-recently-used path. Small on purpose: the
|
|
138
|
+
* hot path is one project's log.
|
|
139
|
+
*/
|
|
140
|
+
export const LOG_COUNT_CACHE_CAP = 64;
|
|
114
141
|
const entryCountCache = new Map();
|
|
115
|
-
/**
|
|
116
|
-
function
|
|
142
|
+
/** LRU-ish get: refresh recency by re-inserting the key. */
|
|
143
|
+
function cacheGet(filePath) {
|
|
144
|
+
const hit = entryCountCache.get(filePath);
|
|
145
|
+
if (hit) {
|
|
146
|
+
entryCountCache.delete(filePath);
|
|
147
|
+
entryCountCache.set(filePath, hit);
|
|
148
|
+
}
|
|
149
|
+
return hit;
|
|
150
|
+
}
|
|
151
|
+
/** Insert (or refresh) a cache entry, evicting past LOG_COUNT_CACHE_CAP. */
|
|
152
|
+
function cacheSet(filePath, value) {
|
|
153
|
+
entryCountCache.delete(filePath);
|
|
154
|
+
entryCountCache.set(filePath, value);
|
|
155
|
+
while (entryCountCache.size > LOG_COUNT_CACHE_CAP) {
|
|
156
|
+
const oldest = entryCountCache.keys().next().value;
|
|
157
|
+
if (oldest === undefined)
|
|
158
|
+
break;
|
|
159
|
+
entryCountCache.delete(oldest);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Current cache occupancy (exported for unit tests of the capacity bound). */
|
|
163
|
+
export function logCountCacheSize() {
|
|
164
|
+
return entryCountCache.size;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Line-count the log file from scratch (entries = non-empty JSON lines).
|
|
168
|
+
* Exported for unit tests.
|
|
169
|
+
*
|
|
170
|
+
* A read failure (file vanished, permission, replaced by a directory) returns
|
|
171
|
+
* `{ ok: false }` with the last KNOWN cached count for this path — or 0 when
|
|
172
|
+
* the path was never counted. The old code returned a fabricated `1` here,
|
|
173
|
+
* which the caller then cached, poisoning the base count for every later
|
|
174
|
+
* append. The `ok` flag lets the caller skip caching a failed read entirely.
|
|
175
|
+
*/
|
|
176
|
+
export function recountEntries(filePath) {
|
|
117
177
|
try {
|
|
118
178
|
const content = readFileSync(filePath, 'utf-8');
|
|
119
|
-
return content.split('\n').filter((l) => l.trim().length > 0).length;
|
|
179
|
+
return { count: content.split('\n').filter((l) => l.trim().length > 0).length, ok: true };
|
|
120
180
|
}
|
|
121
181
|
catch {
|
|
122
|
-
return
|
|
182
|
+
return { count: cacheGet(filePath)?.count ?? 0, ok: false };
|
|
123
183
|
}
|
|
124
184
|
}
|
|
125
185
|
/**
|
|
@@ -183,19 +243,37 @@ export function appendDecisionEntry(projectRoot, entry) {
|
|
|
183
243
|
// with a prune rewrite (which renames the file under us).
|
|
184
244
|
const release = acquireLogLock(projectRoot);
|
|
185
245
|
try {
|
|
186
|
-
|
|
246
|
+
// EVERYTHING that can throw — including `logPath`'s mkdirSync (EACCES when
|
|
247
|
+
// `.iterate` cannot be created, ENOTDIR when an ancestor is a regular file)
|
|
248
|
+
// and the statSync probes — sits inside the inner try: callers run this
|
|
249
|
+
// AFTER their own mutations and only handle the structured form, so an
|
|
250
|
+
// escaping exception would abort the whole iterate_fix/iterate_prune call.
|
|
187
251
|
try {
|
|
252
|
+
const filePath = logPath(projectRoot);
|
|
188
253
|
const line = JSON.stringify(entry) + '\n';
|
|
189
254
|
const prevSize = existsSync(filePath) ? statSync(filePath).size : 0;
|
|
190
255
|
appendFileSync(filePath, line, 'utf-8');
|
|
191
256
|
// Count entries via the size/count cache: when nothing else changed the
|
|
192
257
|
// file since our last append (same byte size), count is just +1 — no full
|
|
193
258
|
// re-read. Falls back to a full re-count whenever the cache is stale.
|
|
194
|
-
const cached =
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
const cached = cacheGet(filePath);
|
|
260
|
+
let count;
|
|
261
|
+
if (cached && cached.size === prevSize) {
|
|
262
|
+
count = cached.count + 1;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const recounted = recountEntries(filePath);
|
|
266
|
+
if (!recounted.ok) {
|
|
267
|
+
// The append LANDED but the read-back failed (file vanished /
|
|
268
|
+
// replaced mid-call). Report the best count we actually know —
|
|
269
|
+
// cached value or 0 — and DO NOT touch the cache: caching a failed
|
|
270
|
+
// read's fallback under the new byte size would poison every later
|
|
271
|
+
// append's count.
|
|
272
|
+
return { count: recounted.count, path: filePath };
|
|
273
|
+
}
|
|
274
|
+
count = recounted.count;
|
|
275
|
+
}
|
|
276
|
+
cacheSet(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count });
|
|
199
277
|
return { count, path: filePath };
|
|
200
278
|
}
|
|
201
279
|
catch (err) {
|