Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/config/config.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export const ENV_FLAG = {
expertRescue: "TSFORGE_EXPERT_RESCUE",
noNearGreenCheckpoint: "TSFORGE_NO_NEAR_GREEN_CHECKPOINT",
noE2eAcceptance: "TSFORGE_NO_E2E_ACCEPTANCE",
noNearGreenRotation: "TSFORGE_NO_NEAR_GREEN_ROTATION",
} as const;
9 changes: 9 additions & 0 deletions packages/core/src/config/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,13 @@ export const flags = {
* without knowing a flag exists. Kill-switch TSFORGE_NO_NEAR_GREEN_CHECKPOINT=1 disables
* it (A/B control / escape hatch). Thresholds N=2, M=3 from Phase 0a. */
nearGreenCheckpoint: (): boolean => !isOn(ENV_FLAG.noNearGreenCheckpoint),
/** WS-B near-green ROTATION steer (#77): when the build sits at a near-green count but the
* SPECIFIC error set rotates for several cycles (the model fixes one error and the fix spawns
* another — e.g. extracts a component → its siblings/tests are now missing), inject a
* completion-only steer ("finish the files that already have errors; don't create new
* files/modules unless the same edit adds their siblings + tests"). This is the last-mile gap
* that made green non-deterministic (build17 parked on it; build16 crossed by luck). Count-only
* WS-B can't see it (the count never sprays). DEFAULT ON, deterministic (no network); kill with
* TSFORGE_NO_NEAR_GREEN_ROTATION=1. Generic — keyed only on rule/file, no stack knowledge. */
nearGreenRotation: (): boolean => !isOn(ENV_FLAG.noNearGreenRotation),
};
121 changes: 121 additions & 0 deletions packages/core/src/loop/near-green-checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,124 @@ export function shouldRollback(

return checkpoint.errorCount <= n && curr > checkpoint.errorCount + m;
}

/** #77: near-green ROTATING-error oscillation. At near green the model can clear its single
* remaining error, but the FIX spawns a new one — e.g. it extracts a component, so that
* component's required sibling set + colocated tests are now missing; fixing those creates
* more, etc. The error COUNT stays low while the error SET rotates, so the build never reaches
* 0 (build17 parked on this; build16 crossed the same tail by luck). Count-only WS-B can't see
* it (count never sprays past the checkpoint). The window of consecutive near-green cycles over
* which a CHANGING error-set signature counts as rotation rather than a one-off wobble. */
export const ROTATION_WINDOW = 3;

/** Max CONSECUTIVE spikes (cycles above the near-green ceiling) tolerated between two near-green
* samples before the rotation window is considered STALE and cleared. The rotating pattern is a
* tight fix-one → spray → revert → near-green loop, so real near-green visits sit a handful of
* cycles apart; without a bound, two near-green samples could survive an arbitrarily long
* regression and combine with a much later, unrelated near-green result to fire the steer
* falsely (a fresh near-green episode, not the same rotation). */
export const MAX_NEAR_GREEN_SPIKE_GAP = 3;

/** A stable, order- and line-independent per-error identity token for rotation detection. When the
* error has BOTH a `rule` and a `file` (the eslint/type-aware shape), use the `rule|file` FAMILY:
* the key carries the line (`file:line:rule`), so a re-emission at a shifted line must still read
* as the SAME error — the family form gives that. Otherwise (`rule` and/or `file` absent — both are
* OPTIONAL on IErrorItem; custom gates emit key-only errors) fall back to the required `key`: a
* PARTIAL family (`rule|` or `|file`) would collapse DISTINCT errors that merely share the one
* present field (their keys differ), hiding rotations among them — the key keeps them distinct. */
function errorToken(e: IErrorItem): string {
return e.rule !== undefined && e.file !== undefined
? `${e.rule}|${e.file}`
: e.key;
}

/** A stable, order- and line-independent signature of an error SET: the sorted unique per-error
* identity tokens (see errorToken). Re-emitting the SAME errors at shifted lines reads as
* unchanged; a genuinely different set reads as changed — which is what makes rotation detectable.
* Generic: keyed only on the stack-agnostic rule/file/key fields.
* DELIBERATE trade-off: the token DEDUPES by `rule|file` family and drops multiplicity, so a
* plateau rotating between two DISTINCT instances of the SAME rule in the SAME file (e.g. one
* `no-unsafe-call` in x.ts swapped for another `no-unsafe-call` in x.ts at count 1) reads as
* unchanged and is NOT detected. Distinguishing those needs the line (it's the only differing
* field), and the line is exactly what `autofixApps` (prettier/eslint --fix, run every cycle)
* reflows — so a line-sensitive signature would fire FALSE rotations on every autofix. Line-
* independence is the more important property: the observed failure (build17) rotates across
* DIFFERENT rules, which the family form detects. Same-rule-same-file instance rotation is
* knowingly left to the count-based WS-B checkpoint + escalation ladder. */
export function errorSetSignature(errors: readonly IErrorItem[]): string {
return [...new Set(errors.map(errorToken))].sort().join(";");
}

/** One recorded near-green cycle: its error COUNT, the gate FRONTIER phase it sits at, the
* `errorSetSignature` of its error set, and `rev` — an INDEPENDENT worktree revision (a content
* hash of the scope files at gate time; see scopeRevision in turn.ts). Rotation is judged over a
* trailing window of these — the count + phase pin the PLATEAU (a stuck frontier at a stable
* count), the signature pins the changing IDENTITY, and `rev` proves a GENUINE per-cycle EDIT
* happened between two samples: without it, a flaky/stateful gate — or a re-evaluation of the SAME
* unedited files (e.g. the check-tool + settleGate double-run) — could emit A→B→C error signatures
* with no fix-one→spawn-another cycle at all and falsely stand the WS-B rollback net down. */
export interface INearGreenSample {
readonly count: number;
readonly phase: number;
readonly sig: string;
readonly rev: string;
}

/** Whether the recent near-green cycles are ROTATING. `samples` is the trailing list captured on
* cycles whose count was near green (the caller only records them there). Rotation requires BOTH:
* • a PLATEAU — the full window of `k` cycles sits at ONE error count AND ONE gate-frontier phase.
* build17's shape (and the 4/4 diagnosis) is "the count stays at best-ever (1) while the
* fingerprint rotates"; a window whose count MOVES (e.g. 2→1→1, a descent, or 1→2, a
* regression) is progress/regress, NOT rotation. A window whose PHASE moves is also progress:
* a short-circuiting composed gate reveals the next phase's errors only after the current
* phase clears, so A@phase1 → B@phase2 at the same count is genuine frontier advancement (the
* convergence logic counts it as progress), NOT rotation — firing the "don't open new
* routes/modules" steer there would fight exactly the downstream work that just became
* reachable. Requiring a constant phase excludes it. (When the gate sets no phase, all samples
* are phase 0 — constant — so this is a no-op there.)
* • GENUINE per-cycle rotation — on EVERY cycle of the window the signature CHANGES *and* the
* worktree `rev` CHANGES (no two CONSECUTIVE samples share a signature OR a rev). Requiring the
* rev to move is what makes the rotation genuine rather than merely apparent: a changing
* signature ALONE can come from a flaky/stateful gate or a re-evaluation of the SAME unedited
* files (A,B,C with rev held constant) — there was no fix-one→spawn-another edit, so it must NOT
* fire. Only a signature that rotates BECAUSE the model edited every cycle (rev moves in
* lock-step) is the build17 pattern. This is stricter than "≥2 distinct signatures in the
* window": A,A,B (one error merely replaced once, then stable) is NOT rotation, but A,B,C and
* the 2-cycle ring A,B,A both are (provided each carries a fresh rev). The strictness is
* deliberate and load-bearing: setting `nearGreenRotation` DISABLES the WS-B rollback safety net
* (nearGreenRollbackStep stands down so the steered atomic-completion spike isn't reverted).
* Firing on weak evidence (a single error-swap, or gate flake on unchanged files) would disable
* that net for a build that is merely progressing — so the detector must be CONFIDENT the
* frontier is actually cycling under real edits before it fires. A full window of ONE signature
* is a stuck single error (the ladder/expert own it), also not rotation.
* Only the last `k` matter, so a rotation that has since stabilized reads as not-rotating. */
export function isNearGreenRotation(
samples: readonly INearGreenSample[],
k: number = ROTATION_WINDOW
): boolean {
if (samples.length < k) {
return false;
}

const window = samples.slice(-k);
const countPlateau = new Set(window.map((s) => s.count)).size === 1;
const phasePlateau = new Set(window.map((s) => s.phase)).size === 1;

if (!countPlateau || !phasePlateau) {
return false;
}

// Genuine rotation: on every cycle BOTH the identity and the worktree rev must change (no two
// consecutive equal). The sig-change is the rotating frontier; the rev-change proves a real
// per-cycle EDIT drove it — so a flaky gate re-emitting different signatures over UNCHANGED files
// (rev held constant) reads as not-rotating and leaves the WS-B rollback net engaged.
return window.every((s, i) => {
if (i === 0) {
return true;
}

const prev = window[i - 1];

return prev !== undefined && s.sig !== prev.sig && s.rev !== prev.rev;
});
}
11 changes: 11 additions & 0 deletions packages/core/src/loop/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ export function resetDriveConvergence(state: ILoopState): void {
delete state.nearGreenBest;
delete state.nearGreenRollbacks;
delete state.completionPhase;
// #77: the near-green ROTATION window + flag are per-drive too — else a send that ends mid-
// rotation leaks the flag into the next send, injecting the completion-only steer with no
// evidence from the new drive.
delete state.nearGreenSamples;
delete state.nearGreenSpikeGap;
delete state.nearGreenRotation;
}

/** How many times a send recovers from a repetition loop before giving up. */
Expand Down Expand Up @@ -2451,6 +2457,11 @@ export class Session {
this.state.nearGreenCheckpoint = undefined;
this.state.nearGreenBest = undefined;
this.state.nearGreenRollbacks = 0;
// #77: the rotation window + flag are per-drive (a stale flag would inject the completion-only
// steer on a fresh drive with no evidence).
this.state.nearGreenSamples = undefined;
this.state.nearGreenSpikeGap = undefined;
this.state.nearGreenRotation = undefined;

for (let turn = 1; turn <= maxTurns; turn += 1) {
const turnStart = performance.now();
Expand Down
Loading