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