@martinloop/mcp 0.8.7 → 0.8.9

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.
@@ -1,4 +1,4 @@
1
- import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchScore, PolicyPhase } from "../contracts/index.js";
1
+ import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchDecisionReasonCode, PatchScore, PolicyPhase } from "../contracts/index.js";
2
2
  export interface FailureAssessment {
3
3
  failureClass: FailureClass;
4
4
  rationale: string;
@@ -106,6 +106,17 @@ export interface BudgetPreflightInput {
106
106
  remainingTokens?: number;
107
107
  /** Adapter-declared estimated floor required to admit a credible first turn. */
108
108
  minimumViableTokens?: number;
109
+ /**
110
+ * Remaining iterations allowed. Used to compute a budget-aware per-attempt cap
111
+ * that reserves capacity for verifier and at least one repair attempt.
112
+ * When omitted, the preflight uses remaining budget divided by a conservative
113
+ * factor to prevent over-allocation on the first attempt.
114
+ */
115
+ remainingIterations?: number;
116
+ /**
117
+ * Explicit per-attempt spending cap. When set, overrides the computed cap.
118
+ * Only use when the caller has specific rate-limiting requirements.
119
+ */
109
120
  perAttemptCapUsd?: number;
110
121
  pricePerMTokenUsd?: number;
111
122
  }
@@ -158,5 +169,11 @@ export interface EvaluatedPatchDecision extends PatchDecisionArtifact {
158
169
  score: PatchScore;
159
170
  }
160
171
  export declare function selectRecoveryRecipe(evidence: EvidenceVector): RecoveryDecision;
172
+ /**
173
+ * Returns true ONLY when every reason code is in the explicit allowlist of
174
+ * retainable soft failures. Unknown or future reason codes default to UNSAFE
175
+ * (fail-closed), preventing silent candidate retention for unrecognized states.
176
+ */
177
+ export declare function isSafeUnverifiedDiscard(reasonCodes: readonly PatchDecisionReasonCode[]): boolean;
161
178
  export declare function evaluatePatchDecision(input: PatchDecisionInput): EvaluatedPatchDecision;
162
179
  export declare function scorePatchDecision(input: PatchDecisionInput): PatchScore;
@@ -244,17 +244,42 @@ export function inferExit(input) {
244
244
  reason: "Oscillating failure pattern detected — loop is cycling without measurable progress."
245
245
  };
246
246
  }
247
+ // C2 — progress-aware repetition check.
248
+ // Two consecutive attempts with the same failureClass are NOT necessarily
249
+ // the same state. If the verifierScore improved (e.g. fewer failing tests),
250
+ // that is measurable progress and we must not stop prematurely.
251
+ // Only stop when the failure class repeated AND there is no measurable improvement:
252
+ // - verifierScore did not increase, AND
253
+ // - candidate diff did not change (candidateRetained with same failure)
247
254
  const lastTwo = input.loop.attempts.slice(-2);
248
- const repeatedFailure = lastTwo.length === 2 &&
255
+ const repeatedFailureClass = lastTwo.length === 2 &&
249
256
  lastTwo.every((attempt) => attempt.failureClass &&
250
257
  attempt.failureClass === lastTwo[0]?.failureClass &&
251
258
  attempt.failureClass === input.lastFailure?.failureClass);
252
- if (repeatedFailure && input.lastFailure) {
259
+ // Measure progress using REAL evidence from actual verifier step results.
260
+ // Multiple signals are checked; any one constituting real progress allows continuation.
261
+ const measurableProgressOccurred = (() => {
262
+ const [prev, curr] = lastTwo;
263
+ if (!prev || !curr)
264
+ return false;
265
+ // Signal 1: verifierScore improved (computed from real passing/total step ratio)
266
+ const prevScore = prev.verifierScore ?? 0;
267
+ const currScore = curr.verifierScore ?? 0;
268
+ if (currScore > prevScore)
269
+ return true;
270
+ // Signal 2: fewer failing verifier steps than previous attempt (monotonic improvement)
271
+ if (prev.failingVerifierStepCount !== undefined &&
272
+ curr.failingVerifierStepCount !== undefined &&
273
+ curr.failingVerifierStepCount < prev.failingVerifierStepCount)
274
+ return true;
275
+ return false;
276
+ })();
277
+ if (repeatedFailureClass && !measurableProgressOccurred && input.lastFailure) {
253
278
  return {
254
279
  shouldExit: true,
255
280
  lifecycleState: "diminishing_returns",
256
281
  status: "exited",
257
- reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts.`
282
+ reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts with no measurable progress (score, step count, or failure surface unchanged).`
258
283
  };
259
284
  }
260
285
  if (input.lastFailure?.failureClass === "environment_mismatch" &&
@@ -398,11 +423,34 @@ export function evaluateBudgetPreflight(input) {
398
423
  estimate
399
424
  };
400
425
  }
401
- const perAttemptCap = input.perAttemptCapUsd ?? Math.max(input.remainingBudgetUsd * 0.2, 0.05);
426
+ // D2 — Budget-aware per-attempt cap.
427
+ //
428
+ // Old behavior: rigid maxUsd * 0.25 — blocked viable attempts when total budget
429
+ // was large (e.g. $0.30 cost blocked on $1.00 budget because 0.30 > 0.25).
430
+ //
431
+ // New behavior: if an explicit cap is provided, honor it. Otherwise compute a
432
+ // cap that reserves budget for the verifier and at least one repair attempt beyond
433
+ // the current one, giving the provider meaningful runway.
434
+ //
435
+ // Formula: cap = remainingBudget / (remainingIterations + 1)
436
+ // +1 accounts for the current attempt being the "next" attempt.
437
+ // Minimum cap of $0.05 prevents degenerate near-zero rejections.
438
+ const perAttemptCap = (() => {
439
+ if (input.perAttemptCapUsd !== undefined)
440
+ return input.perAttemptCapUsd;
441
+ const iters = input.remainingIterations;
442
+ if (iters !== undefined && iters > 0) {
443
+ // Divide remaining budget evenly across remaining iterations.
444
+ return Math.max(roundUsd(input.remainingBudgetUsd / iters), 0.05);
445
+ }
446
+ // No iteration information: allow up to 80% of remaining budget per attempt
447
+ // (down from the old 25%-of-total which was frequently too low).
448
+ return Math.max(roundUsd(input.remainingBudgetUsd * 0.8), 0.05);
449
+ })();
402
450
  if (estimatedAttemptCostUsd > perAttemptCap) {
403
451
  return {
404
452
  allowed: false,
405
- reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)}.`,
453
+ reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)} (remaining budget $${input.remainingBudgetUsd}, ${input.remainingIterations ?? "?"} iterations left).`,
406
454
  estimate
407
455
  };
408
456
  }
@@ -517,6 +565,41 @@ export function selectRecoveryRecipe(evidence) {
517
565
  intervention: "escalate_human"
518
566
  };
519
567
  }
568
+ /**
569
+ * Explicit allowlist of reason codes that are SAFE to retain as an unverified
570
+ * candidate for the next repair attempt.
571
+ *
572
+ * FAIL-CLOSED: any reason code NOT in this set causes an immediate rollback.
573
+ * This prevents future or unknown reason codes from silently becoming "safe."
574
+ *
575
+ * Retainable only when ALL of these are also true:
576
+ * - scope_violation is absent (agent stayed within allowed paths)
577
+ * - safety_violation is absent (no secret/tenancy/test-integrity violation)
578
+ * - workspace observation was authoritative (changed files were tracked)
579
+ * - provider did not crash/fail (status !== "failed")
580
+ *
581
+ * no_code_change is explicitly NOT retainable — there is nothing to repair.
582
+ * Use a different recovery strategy instead.
583
+ */
584
+ const RETAINABLE_SOFT_REASON_CODES = new Set([
585
+ "verifier_not_improved",
586
+ "low_novelty_no_progress",
587
+ "large_diff_no_improvement",
588
+ "grounding_failure",
589
+ "verifier_regressed", // actual test regression — retry with different approach
590
+ ]);
591
+ /**
592
+ * Returns true ONLY when every reason code is in the explicit allowlist of
593
+ * retainable soft failures. Unknown or future reason codes default to UNSAFE
594
+ * (fail-closed), preventing silent candidate retention for unrecognized states.
595
+ */
596
+ export function isSafeUnverifiedDiscard(reasonCodes) {
597
+ if (reasonCodes.length === 0) {
598
+ // No specific signal — we don't know why it's being discarded; fail closed.
599
+ return false;
600
+ }
601
+ return reasonCodes.every((code) => RETAINABLE_SOFT_REASON_CODES.has(code));
602
+ }
520
603
  export function evaluatePatchDecision(input) {
521
604
  const score = scorePatchDecision(input);
522
605
  const reasonCodes = [...score.reasonCodes];
@@ -624,21 +707,41 @@ function tokenizeDiff(diff) {
624
707
  return tokens;
625
708
  }
626
709
  function decidePatchOutcome(input, reasonCodes) {
710
+ // 1. Explicit human approval required — always escalate regardless of verifier.
711
+ // 2. Safety violation — always escalate.
627
712
  if (input.humanApprovalRequired || reasonCodes.includes("safety_violation")) {
628
713
  return "ESCALATE";
629
714
  }
715
+ // 3. Scope violation — hard boundary: out-of-scope writes are never kept,
716
+ // even if the verifier passes, because the change violated the task contract.
717
+ if (reasonCodes.includes("scope_violation")) {
718
+ return "DISCARD";
719
+ }
720
+ // 4. Required mutation is absent — a green generic test suite is not proof
721
+ // that the requested work happened. Discard if:
722
+ // - a code change was required (mutation_required = edit mode)
723
+ // - no files were actually changed
724
+ // - the task was not pre-satisfied by the existing codebase
725
+ // Note: the scorer already suppresses no_code_change when
726
+ // definitionOfDonePreSatisfied is true, so that legitimate case passes through.
727
+ if (reasonCodes.includes("no_code_change")) {
728
+ return "DISCARD";
729
+ }
730
+ // 5. Passing verifier is the authoritative signal and overrides lower-confidence
731
+ // heuristics such as grounding failure. A grounding heuristic (symbol
732
+ // resolution, repo anatomy check) is weaker evidence than an actual passing
733
+ // host-run test exit code.
734
+ if (input.verificationPassed) {
735
+ return "KEEP";
736
+ }
737
+ // 6. Non-passing verifier: discard on any remaining negative heuristic signal.
630
738
  if (reasonCodes.includes("grounding_failure") ||
631
- reasonCodes.includes("scope_violation") ||
632
- reasonCodes.includes("no_code_change") ||
633
739
  reasonCodes.includes("verifier_regressed") ||
634
740
  reasonCodes.includes("large_diff_no_improvement") ||
635
741
  reasonCodes.includes("low_novelty_no_progress") ||
636
742
  reasonCodes.includes("verifier_not_improved")) {
637
743
  return "DISCARD";
638
744
  }
639
- if (input.verificationPassed) {
640
- return "KEEP";
641
- }
642
745
  return "DISCARD";
643
746
  }
644
747
  function buildPatchDecisionSummary(decision, reasonCodes, summary) {
@@ -0,0 +1,39 @@
1
+ import type { LoopRecord, RollbackBoundaryArtifact } from "../contracts/index.js";
2
+ export interface RunExecutionLease {
3
+ schemaVersion: "martin.execution-lease.v1";
4
+ runId: string;
5
+ token: string;
6
+ owner: string;
7
+ pid: number;
8
+ hostname: string;
9
+ acquiredAt: string;
10
+ }
11
+ export interface AcquiredRunExecutionLease {
12
+ lease: RunExecutionLease;
13
+ path: string;
14
+ release(): Promise<void>;
15
+ }
16
+ export interface ResumeVerifierFailure {
17
+ command: string;
18
+ exitCode?: number;
19
+ crashed?: boolean;
20
+ timedOut?: boolean;
21
+ failureExcerpt: string;
22
+ attemptId: string;
23
+ attemptIndex: number;
24
+ }
25
+ export interface ResumabilityDecision {
26
+ allowed: boolean;
27
+ reason: string;
28
+ }
29
+ export declare function evaluateRunResumability(loop: LoopRecord): ResumabilityDecision;
30
+ export declare function loadResumeCandidateBoundary(runsRoot: string, loop: LoopRecord): Promise<RollbackBoundaryArtifact | undefined>;
31
+ export declare function loadResumeVerifierFailure(runsRoot: string, loop: LoopRecord): Promise<ResumeVerifierFailure | undefined>;
32
+ export declare function acquireRunExecutionLease(input: {
33
+ runsRoot: string;
34
+ runId: string;
35
+ owner: string;
36
+ now?: () => string;
37
+ host?: string;
38
+ pid?: number;
39
+ }): Promise<AcquiredRunExecutionLease>;
@@ -0,0 +1,196 @@
1
+ /// <reference types="node" />
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { mkdir, open, readFile, unlink } from "node:fs/promises";
4
+ import { hostname } from "node:os";
5
+ import { join } from "node:path";
6
+ import { artifactDir } from "./persistence/index.js";
7
+ export function evaluateRunResumability(loop) {
8
+ if (loop.terminationEnvelope) {
9
+ return { allowed: false, reason: "The run already has authoritative terminal evidence." };
10
+ }
11
+ if (!["queued", "running", "verifying"].includes(loop.status)) {
12
+ return { allowed: false, reason: `Run status ${loop.status} is terminal and cannot be continued.` };
13
+ }
14
+ if (!["created", "running", "verifying"].includes(loop.lifecycleState)) {
15
+ return {
16
+ allowed: false,
17
+ reason: `Run lifecycle ${loop.lifecycleState} is terminal and cannot be continued.`
18
+ };
19
+ }
20
+ if (loop.task.taskClass === "irreversible" || loop.task.approvalPolicy?.externalWrites === true) {
21
+ return {
22
+ allowed: false,
23
+ reason: "Automatic execution continuation is disabled for irreversible/external-write tasks."
24
+ };
25
+ }
26
+ if (loop.attempts.length >= loop.budget.maxIterations) {
27
+ return { allowed: false, reason: "The run has no remaining iteration authority." };
28
+ }
29
+ if (loop.cost.actualUsd >= loop.budget.maxUsd) {
30
+ return { allowed: false, reason: "The run has no remaining USD budget authority." };
31
+ }
32
+ if (loop.budget.maxTokens !== undefined &&
33
+ loop.cost.tokensIn + loop.cost.tokensOut >= loop.budget.maxTokens) {
34
+ return { allowed: false, reason: "The run has no remaining token budget authority." };
35
+ }
36
+ const started = new Set(loop.events
37
+ .filter((event) => event.type === "attempt.started")
38
+ .map((event) => String(event.payload["attemptId"] ?? ""))
39
+ .filter(Boolean));
40
+ for (const event of loop.events) {
41
+ if (event.type === "attempt.completed") {
42
+ const attemptId = String(event.payload["attemptId"] ?? "");
43
+ if (attemptId)
44
+ started.delete(attemptId);
45
+ }
46
+ }
47
+ if (started.size > 0) {
48
+ return {
49
+ allowed: false,
50
+ reason: "A persisted attempt is still marked active; provider-process ownership is uncertain."
51
+ };
52
+ }
53
+ return { allowed: true, reason: "Persisted run has remaining safe execution authority." };
54
+ }
55
+ export async function loadResumeCandidateBoundary(runsRoot, loop) {
56
+ const latestAttempt = loop.attempts.at(-1);
57
+ if (latestAttempt?.candidateRetained !== true)
58
+ return undefined;
59
+ // Recover the first rollback boundary in the current contiguous retained-candidate
60
+ // suffix. Earlier retained candidates that were later discarded must not become
61
+ // authority for a new resume.
62
+ let firstRetainedAttempt = latestAttempt;
63
+ for (let index = loop.attempts.length - 2; index >= 0; index -= 1) {
64
+ const candidate = loop.attempts[index];
65
+ if (candidate?.candidateRetained !== true)
66
+ break;
67
+ firstRetainedAttempt = candidate;
68
+ }
69
+ const path = join(artifactDir(runsRoot, loop.loopId, firstRetainedAttempt.index), "rollback-boundary.json");
70
+ const raw = await readFile(path, "utf8").catch((error) => {
71
+ if (error.code === "ENOENT") {
72
+ throw new Error(`Cannot continue ${loop.loopId}: retained candidate boundary evidence is missing for attempt ${firstRetainedAttempt.index}.`);
73
+ }
74
+ throw error;
75
+ });
76
+ return JSON.parse(raw);
77
+ }
78
+ export async function loadResumeVerifierFailure(runsRoot, loop) {
79
+ const attempt = loop.attempts.at(-1);
80
+ if (!attempt || attempt.verifierScore === 1)
81
+ return undefined;
82
+ const path = join(artifactDir(runsRoot, loop.loopId, attempt.index), "verification.json");
83
+ const raw = await readFile(path, "utf8").catch((error) => {
84
+ if (error.code === "ENOENT")
85
+ return undefined;
86
+ throw error;
87
+ });
88
+ if (!raw)
89
+ return undefined;
90
+ const verification = JSON.parse(raw);
91
+ const step = verification.steps?.find((candidate) => candidate.launched === true &&
92
+ (candidate.crashed === true ||
93
+ candidate.timedOut === true ||
94
+ (candidate.exitCode !== undefined && candidate.exitCode !== 0)));
95
+ const excerpt = (step?.detail ??
96
+ [verification.summary, ...(verification.warnings ?? [])].filter(Boolean).join("\n")).trim();
97
+ return {
98
+ command: step?.command ?? loop.task.verificationPlan[0] ?? "unknown",
99
+ ...(!step?.crashed && !step?.timedOut && step?.exitCode !== undefined
100
+ ? { exitCode: step.exitCode }
101
+ : {}),
102
+ ...(step?.crashed ? { crashed: true } : {}),
103
+ ...(step?.timedOut ? { timedOut: true } : {}),
104
+ failureExcerpt: excerpt.slice(-1500),
105
+ attemptId: attempt.attemptId,
106
+ attemptIndex: attempt.index
107
+ };
108
+ }
109
+ export async function acquireRunExecutionLease(input) {
110
+ const now = input.now ?? (() => new Date().toISOString());
111
+ const host = input.host ?? hostname();
112
+ const pid = input.pid ?? process.pid;
113
+ const directory = join(input.runsRoot, ".execution-leases");
114
+ const leaseFile = `${createHash("sha256").update(input.runId).digest("hex")}.json`;
115
+ const path = join(directory, leaseFile);
116
+ await mkdir(directory, { recursive: true });
117
+ const token = `${pid}-${randomBytes(16).toString("hex")}`;
118
+ const lease = {
119
+ schemaVersion: "martin.execution-lease.v1",
120
+ runId: input.runId,
121
+ token,
122
+ owner: input.owner,
123
+ pid,
124
+ hostname: host,
125
+ acquiredAt: now()
126
+ };
127
+ for (let attempt = 0; attempt < 2; attempt += 1) {
128
+ try {
129
+ const handle = await open(path, "wx", 0o600);
130
+ try {
131
+ await handle.writeFile(`${JSON.stringify(lease, null, 2)}\n`, "utf8");
132
+ await handle.sync();
133
+ }
134
+ finally {
135
+ await handle.close();
136
+ }
137
+ return {
138
+ lease,
139
+ path,
140
+ async release() {
141
+ try {
142
+ const existing = JSON.parse(await readFile(path, "utf8"));
143
+ if (existing.token === token)
144
+ await unlink(path);
145
+ }
146
+ catch (error) {
147
+ if (error.code !== "ENOENT")
148
+ throw error;
149
+ }
150
+ }
151
+ };
152
+ }
153
+ catch (error) {
154
+ if (error.code !== "EEXIST")
155
+ throw error;
156
+ const existing = await readLease(path);
157
+ if (!existing)
158
+ continue;
159
+ if (existing.hostname !== host) {
160
+ throw new Error(`Run ${input.runId} is leased by ${existing.owner} on ${existing.hostname}; refusing cross-host takeover.`);
161
+ }
162
+ if (isProcessAlive(existing.pid)) {
163
+ throw new Error(`Run ${input.runId} is already executing under ${existing.owner} (pid ${existing.pid}).`);
164
+ }
165
+ await unlink(path).catch((unlinkError) => {
166
+ if (unlinkError.code !== "ENOENT")
167
+ throw unlinkError;
168
+ });
169
+ }
170
+ }
171
+ throw new Error(`Unable to acquire execution lease for run ${input.runId}.`);
172
+ }
173
+ async function readLease(path) {
174
+ try {
175
+ return JSON.parse(await readFile(path, "utf8"));
176
+ }
177
+ catch (error) {
178
+ if (error.code === "ENOENT")
179
+ return undefined;
180
+ throw new Error(`Existing execution lease is unreadable: ${error instanceof Error ? error.message : String(error)}`);
181
+ }
182
+ }
183
+ function isProcessAlive(pid) {
184
+ if (!Number.isInteger(pid) || pid <= 0)
185
+ return true;
186
+ try {
187
+ process.kill(pid, 0);
188
+ return true;
189
+ }
190
+ catch (error) {
191
+ const code = error.code;
192
+ if (code === "ESRCH")
193
+ return false;
194
+ return true;
195
+ }
196
+ }
@@ -6,8 +6,11 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
6
6
  import { DEPENDENCY_AND_BUILD_DIRECTORIES, isInsideDependencyOrBuildDirectory } from "./output-directories.js";
7
7
  /** Past this many ignored files, further ignored directories stay collapsed. */
8
8
  const MAX_EXPANDED_IGNORED_FILES = 20_000;
9
+ const MAX_HASHED_IGNORED_FILE_BYTES = 8 * 1024 * 1024;
10
+ const MAX_HASHED_IGNORED_TOTAL_BYTES = 16 * 1024 * 1024;
9
11
  const MAX_IGNORED_SNAPSHOT_BYTES = 64 * 1024 * 1024;
10
12
  const ignoredContentSnapshots = new WeakMap();
13
+ const ignoredObservations = new WeakMap();
11
14
  /** Git listings of large untracked or ignored trees exceed spawnSync's 1 MiB default. */
12
15
  const GIT_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
13
16
  export class RollbackStateUnavailableError extends Error {
@@ -50,7 +53,12 @@ export async function captureRollbackBoundary(input) {
50
53
  for (const filePath of snapshotPaths) {
51
54
  snapshots.push(await readRollbackSnapshot(input.repoRoot, filePath));
52
55
  }
53
- const ignoredFiles = readIgnoredInventory(input.repoRoot);
56
+ const observedIgnoredFiles = readIgnoredInventory(input.repoRoot);
57
+ const ignoredFiles = observedIgnoredFiles.map(({ path, size, mtimeMs }) => ({
58
+ path,
59
+ size,
60
+ mtimeMs
61
+ }));
54
62
  const headRef = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
55
63
  const headBranch = readHeadBranch(input.repoRoot);
56
64
  const boundary = {
@@ -63,6 +71,7 @@ export async function captureRollbackBoundary(input) {
63
71
  snapshots,
64
72
  ignoredFiles
65
73
  };
74
+ ignoredObservations.set(boundary, new Map(observedIgnoredFiles.map((entry) => [entry.path, entry])));
66
75
  ignoredContentSnapshots.set(boundary, captureIgnoredContent(input.repoRoot, ignoredFiles));
67
76
  return boundary;
68
77
  }
@@ -75,6 +84,7 @@ export function listIgnoredFileChangesSinceBoundary(input) {
75
84
  return makeIgnoredFileChanges([], [], []);
76
85
  }
77
86
  const baseline = new Map(input.boundary.ignoredFiles.map((entry) => [entry.path, entry]));
87
+ const baselineObservations = ignoredObservations.get(input.boundary);
78
88
  const created = [];
79
89
  const modified = [];
80
90
  const currentEntries = readIgnoredInventory(input.repoRoot);
@@ -87,15 +97,23 @@ export function listIgnoredFileChangesSinceBoundary(input) {
87
97
  if (!previous) {
88
98
  created.push(entry.path);
89
99
  }
90
- else if (!entry.path.endsWith("/") &&
91
- (previous.sha256 !== undefined && entry.sha256 !== undefined
92
- ? previous.sha256 !== entry.sha256
93
- : previous.size !== entry.size || previous.mtimeMs !== entry.mtimeMs)) {
100
+ else if (!entry.path.endsWith("/") && ignoredObservationChanged(previous, baselineObservations?.get(entry.path), entry)) {
94
101
  modified.push(entry.path);
95
102
  }
96
103
  }
97
104
  return makeIgnoredFileChanges(created.sort(), modified.sort(), deleted.sort());
98
105
  }
106
+ function ignoredObservationChanged(previous, previousObservation, current) {
107
+ if (previousObservation?.sha256 !== undefined && current.sha256 !== undefined) {
108
+ return previousObservation.sha256 !== current.sha256;
109
+ }
110
+ if (previousObservation !== undefined) {
111
+ return previous.size !== current.size ||
112
+ previous.mtimeMs !== current.mtimeMs ||
113
+ previousObservation.ctimeMs !== current.ctimeMs;
114
+ }
115
+ return previous.size !== current.size || previous.mtimeMs !== current.mtimeMs;
116
+ }
99
117
  function makeIgnoredFileChanges(created, modified, deleted) {
100
118
  const changes = { created, modified };
101
119
  // Preserve the historical enumerable shape when there are no deletions while
@@ -345,25 +363,31 @@ function readIgnoredInventory(repoRoot) {
345
363
  ]));
346
364
  const inventory = [];
347
365
  let expanded = 0;
366
+ const hashBudget = { remainingBytes: MAX_HASHED_IGNORED_TOTAL_BYTES };
348
367
  for (const entry of collapsed) {
349
368
  const isDirectory = entry.endsWith("/");
350
369
  const leaf = entry.replace(/\/$/u, "").split("/").at(-1) ?? entry;
351
- if (isDirectory &&
352
- (DEPENDENCY_AND_BUILD_DIRECTORIES.has(leaf) || expanded >= MAX_EXPANDED_IGNORED_FILES)) {
353
- inventory.push({ path: entry, size: 0, mtimeMs: 0 });
370
+ if (isDirectory && DEPENDENCY_AND_BUILD_DIRECTORIES.has(leaf)) {
371
+ inventory.push({ path: entry, size: 0, mtimeMs: 0, ctimeMs: 0 });
354
372
  continue;
355
373
  }
374
+ if (isDirectory && expanded >= MAX_EXPANDED_IGNORED_FILES) {
375
+ throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
376
+ }
356
377
  const files = isDirectory
357
378
  ? collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--", entry]))
358
379
  : [entry];
359
380
  for (const filePath of files) {
360
- inventory.push(stampIgnoredFile(repoRoot, filePath));
381
+ if (expanded >= MAX_EXPANDED_IGNORED_FILES) {
382
+ throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
383
+ }
384
+ inventory.push(stampIgnoredFile(repoRoot, filePath, hashBudget));
361
385
  expanded += 1;
362
386
  }
363
387
  }
364
388
  return inventory.sort((left, right) => left.path.localeCompare(right.path));
365
389
  }
366
- function stampIgnoredFile(repoRoot, filePath) {
390
+ function stampIgnoredFile(repoRoot, filePath, hashBudget) {
367
391
  try {
368
392
  const absolutePath = resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
369
393
  const link = isLinkPath(repoRoot, filePath);
@@ -371,10 +395,16 @@ function stampIgnoredFile(repoRoot, filePath) {
371
395
  const stamp = {
372
396
  path: normalizeRepoPath(filePath),
373
397
  size: stats.size,
374
- mtimeMs: Math.trunc(stats.mtimeMs)
398
+ mtimeMs: Math.trunc(stats.mtimeMs),
399
+ ctimeMs: Math.trunc(stats.ctimeMs)
375
400
  };
376
- if (!link && stats.isFile() && !isInsideDependencyOrBuildDirectory(filePath)) {
401
+ if (!link &&
402
+ stats.isFile() &&
403
+ stats.size <= MAX_HASHED_IGNORED_FILE_BYTES &&
404
+ stats.size <= hashBudget.remainingBytes &&
405
+ !isInsideDependencyOrBuildDirectory(filePath)) {
377
406
  stamp.sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
407
+ hashBudget.remainingBytes -= stats.size;
378
408
  }
379
409
  return stamp;
380
410
  }
@@ -383,7 +413,7 @@ function stampIgnoredFile(repoRoot, filePath) {
383
413
  throw new RollbackStateUnavailableError(`Unable to authenticate ignored rollback path ${normalizeRepoPath(filePath)}: ${toErrorMessage(error)}`);
384
414
  }
385
415
  // Vanished between listing and stat; record presence only so later comparison fails closed.
386
- return { path: normalizeRepoPath(filePath), size: -1, mtimeMs: -1 };
416
+ return { path: normalizeRepoPath(filePath), size: -1, mtimeMs: -1, ctimeMs: -1 };
387
417
  }
388
418
  }
389
419
  function captureIgnoredContent(repoRoot, ignoredFiles) {
@@ -226,43 +226,66 @@ export function findWeakenedTestFiles(input) {
226
226
  if (candidates.length === 0) {
227
227
  return [];
228
228
  }
229
- const removedLines = listFilesWithRemovedLines(input.repoRoot, input.baseRef, candidates);
230
- return [...removedLines].filter((filePath) => isWeakened(input, filePath)).sort();
229
+ const changedLines = listChangedTestFiles(input.repoRoot, input.baseRef, candidates);
230
+ for (const filePath of candidates) {
231
+ if (!changedLines.has(filePath) &&
232
+ readBaseContent(input.repoRoot, input.baseRef, filePath) === undefined &&
233
+ readWorkingContent(input.repoRoot, filePath) !== undefined) {
234
+ // Untracked files are absent from `git diff --numstat` but still belong
235
+ // to the caller's observed changed-file set.
236
+ changedLines.set(filePath, false);
237
+ }
238
+ }
239
+ return [...changedLines.entries()]
240
+ .filter(([filePath, removedLines]) => isWeakened(input, filePath, removedLines))
241
+ .map(([filePath]) => filePath)
242
+ .sort();
231
243
  }
232
- /** Files among `candidates` that lost at least one line (or are binary) since `baseRef`. */
233
- function listFilesWithRemovedLines(repoRoot, baseRef, candidates) {
244
+ /** Modified files among `candidates`, with whether they lost a line (or are binary). */
245
+ function listChangedTestFiles(repoRoot, baseRef, candidates) {
234
246
  const result = spawnSync("git", ["diff", "--numstat", "--no-renames", "-z", baseRef, "--", ...candidates], { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
235
247
  if (result.status !== 0 || typeof result.stdout !== "string") {
236
248
  const detail = typeof result.stderr === "string" ? result.stderr.trim() : "";
237
249
  throw new Error(`Test-change inspection failed: ${detail || `git exited with ${String(result.status)}`}`);
238
250
  }
239
- const removed = new Set();
251
+ const changed = new Map();
240
252
  for (const record of result.stdout.split("\0")) {
241
- const [, deletions, filePath] = record.split("\t");
242
- if (filePath && deletions !== undefined && (deletions === "-" || Number(deletions) > 0)) {
243
- removed.add(filePath.trim());
253
+ const [additions, deletions, filePath] = record.split("\t");
254
+ if (filePath && additions !== undefined && deletions !== undefined) {
255
+ changed.set(filePath.trim(), deletions === "-" || Number(deletions) > 0);
244
256
  }
245
257
  }
246
- return removed;
258
+ return changed;
247
259
  }
248
260
  /** Cap on file size the surface analysis reads; larger files use the strict rule. */
249
261
  const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
250
- function isWeakened(input, filePath) {
262
+ const BASE_CONTENT_UNINSPECTABLE = Symbol("base-content-uninspectable");
263
+ function isWeakened(input, filePath, removedLines) {
251
264
  const before = readBaseContent(input.repoRoot, input.baseRef, filePath);
252
- if (before === undefined) {
265
+ if (before === BASE_CONTENT_UNINSPECTABLE) {
253
266
  return true;
254
267
  }
268
+ if (before === undefined) {
269
+ const after = readWorkingContent(input.repoRoot, filePath);
270
+ // New tests are welcome, but focus/skip/expected-failure markers can still
271
+ // suppress the authoritative suite and therefore require approval.
272
+ return after === undefined || testSurfaceWeakened("", after, filePath);
273
+ }
255
274
  const after = readWorkingContent(input.repoRoot, filePath);
256
275
  if (after === undefined) {
257
276
  // Deleted, replaced by a link, unreadable or oversized: cannot prove it still asserts.
258
277
  return true;
259
278
  }
260
279
  if (!hasTestSurface(extractTestSurface(before, filePath))) {
261
- return true;
280
+ return removedLines || testSurfaceWeakened(before, after, filePath);
262
281
  }
263
282
  return testSurfaceWeakened(before, after, filePath);
264
283
  }
265
284
  function readBaseContent(repoRoot, baseRef, filePath) {
285
+ const exists = spawnSync("git", ["cat-file", "-e", `${baseRef}:${filePath}`], { cwd: repoRoot });
286
+ if (exists.status !== 0) {
287
+ return undefined;
288
+ }
266
289
  const result = spawnSync("git", ["show", `${baseRef}:${filePath}`], {
267
290
  cwd: repoRoot,
268
291
  encoding: "utf8",
@@ -270,7 +293,7 @@ function readBaseContent(repoRoot, baseRef, filePath) {
270
293
  });
271
294
  return result.status === 0 && typeof result.stdout === "string" && result.stdout.length <= MAX_SURFACE_BYTES
272
295
  ? result.stdout
273
- : undefined;
296
+ : BASE_CONTENT_UNINSPECTABLE;
274
297
  }
275
298
  function readWorkingContent(repoRoot, filePath) {
276
299
  const absolutePath = join(repoRoot, filePath);