@agjs/tsforge 0.2.6 → 0.2.8
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/package.json +1 -1
- package/src/cli.ts +41 -10
- package/src/loop/loop.constants.ts +18 -4
- package/src/loop/loop.types.ts +5 -0
- package/src/loop/memory/consolidate.ts +254 -0
- package/src/loop/memory/index.ts +18 -0
- package/src/loop/memory/memory.types.ts +65 -0
- package/src/loop/memory/mine.ts +76 -0
- package/src/loop/rule-docs.generated.json +136 -1
- package/src/loop/run.ts +76 -82
- package/src/loop/session.ts +156 -14
- package/src/loop/ttsr-init.ts +111 -0
- package/src/loop/turn.ts +76 -1
package/src/loop/session.ts
CHANGED
|
@@ -28,7 +28,10 @@ import {
|
|
|
28
28
|
import { connectMcpServers } from "../mcp";
|
|
29
29
|
import { loadAndRegisterPlugins } from "../config/external-plugins";
|
|
30
30
|
import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
|
|
31
|
-
import type { Reporter } from "./loop.types";
|
|
31
|
+
import type { Reporter, ILoopEvent } from "./loop.types";
|
|
32
|
+
import type { TtsrManager } from "./ttsr";
|
|
33
|
+
import { initTtsrManager, applyTtsrInterrupt } from "./ttsr-init";
|
|
34
|
+
import { mineLessons, consolidate as consolidateMemory } from "./memory";
|
|
32
35
|
import { CHAT_SYSTEM, COMPACT_SYSTEM } from "./prompt";
|
|
33
36
|
import {
|
|
34
37
|
buildTsService,
|
|
@@ -396,6 +399,12 @@ export class Session {
|
|
|
396
399
|
private readonly forceTools: boolean;
|
|
397
400
|
/** Mid-session turn-cap override (setMaxTurns) — a web scaffold raises it. */
|
|
398
401
|
private maxTurnsOverride?: number;
|
|
402
|
+
/** TTSR manager (built-in + project + memory-learned rules). Null when TTSR is
|
|
403
|
+
* disabled. Built in `create` (needs async rule loading). */
|
|
404
|
+
private ttsrManager: TtsrManager | null = null;
|
|
405
|
+
/** Events of the CURRENT send (reset each drive), buffered off ctx.report so the
|
|
406
|
+
* post-send memory hook can mine the run for failure→fix lessons. */
|
|
407
|
+
private readonly sendEvents: ILoopEvent[] = [];
|
|
399
408
|
|
|
400
409
|
private constructor(cfg: ISessionConfig, ctx: ILoopCtx) {
|
|
401
410
|
this.provider = cfg.provider;
|
|
@@ -443,9 +452,19 @@ export class Session {
|
|
|
443
452
|
}
|
|
444
453
|
|
|
445
454
|
this.ctx = ctx;
|
|
455
|
+
// Buffer events off ctx.report (where edit/create/validated flow) so the
|
|
456
|
+
// post-send memory hook can mine them; still forward to the original reporter.
|
|
457
|
+
const rawCtxReport = ctx.report;
|
|
458
|
+
|
|
459
|
+
this.ctx.report = (event) => {
|
|
460
|
+
this.sendEvents.push(event);
|
|
461
|
+
rawCtxReport(event);
|
|
462
|
+
};
|
|
463
|
+
|
|
446
464
|
this.state = {
|
|
447
465
|
prevGateErrors: [],
|
|
448
466
|
gateNoProgress: 0,
|
|
467
|
+
errorAge: new Map(),
|
|
449
468
|
lastGateCount: -1,
|
|
450
469
|
edits: 0,
|
|
451
470
|
regressions: 0,
|
|
@@ -522,7 +541,14 @@ export class Session {
|
|
|
522
541
|
},
|
|
523
542
|
};
|
|
524
543
|
|
|
525
|
-
|
|
544
|
+
const session = new Session(cfg, ctx);
|
|
545
|
+
|
|
546
|
+
// Build the TTSR manager (built-in + project + memory-learned rules) so the
|
|
547
|
+
// interactive loop gets the SAME mid-stream guidance the headless loop does —
|
|
548
|
+
// including the failure→fix lessons learned in this repo.
|
|
549
|
+
session.ttsrManager = await initTtsrManager(cfg.cwd, report, SESSION_ID);
|
|
550
|
+
|
|
551
|
+
return session;
|
|
526
552
|
}
|
|
527
553
|
|
|
528
554
|
/** The current gate command (empty when none). */
|
|
@@ -698,8 +724,13 @@ export class Session {
|
|
|
698
724
|
*/
|
|
699
725
|
async send(text: string, opts: ISendOptions = {}): Promise<ISendResult> {
|
|
700
726
|
const { ctx, report } = this;
|
|
727
|
+
// Interactive ceiling is a RUNAWAY backstop, not the primary stop — the
|
|
728
|
+
// progress guards (samePersist / gateNoProgress) pull the agent out the moment
|
|
729
|
+
// it stops converging. Set high so normal long back-and-forth never trips it.
|
|
701
730
|
const maxTurns =
|
|
702
|
-
this.maxTurnsOverride ??
|
|
731
|
+
this.maxTurnsOverride ??
|
|
732
|
+
this.cfg.maxTurns ??
|
|
733
|
+
LOOP_LIMITS.interactiveBackstopTurns;
|
|
703
734
|
const sendStart = performance.now();
|
|
704
735
|
|
|
705
736
|
// Thread cancellation to the tool `run` commands and the gate (not just the
|
|
@@ -1018,6 +1049,9 @@ export class Session {
|
|
|
1018
1049
|
mcpSchemas.length > 0 ? [...baseTools, ...mcpSchemas] : baseTools;
|
|
1019
1050
|
const callStart = performance.now();
|
|
1020
1051
|
let firstTokenAt = 0;
|
|
1052
|
+
|
|
1053
|
+
this.ttsrManager?.resetBuffer();
|
|
1054
|
+
|
|
1021
1055
|
const res = await this.provider.complete(ctx.messages, {
|
|
1022
1056
|
tools: offeredTools,
|
|
1023
1057
|
temperature: this.cfg.temperature ?? 0,
|
|
@@ -1026,6 +1060,7 @@ export class Session {
|
|
|
1026
1060
|
...(this.cfg.thinkingTokenBudget === undefined
|
|
1027
1061
|
? {}
|
|
1028
1062
|
: { thinkingTokenBudget: this.cfg.thinkingTokenBudget }),
|
|
1063
|
+
...this.ttsrCallOption(),
|
|
1029
1064
|
...(signal === undefined ? {} : { signal }),
|
|
1030
1065
|
onToken: (token, channel) => {
|
|
1031
1066
|
// Stamp the first token so tokens/sec measures generation rate (excluding
|
|
@@ -1067,6 +1102,10 @@ export class Session {
|
|
|
1067
1102
|
|
|
1068
1103
|
ctx.messages.push(assistantMessage(res));
|
|
1069
1104
|
|
|
1105
|
+
// Every model call advances TTSR cooldown accounting (including interrupted
|
|
1106
|
+
// ones, so repeatGap rules count correctly after a retry).
|
|
1107
|
+
this.ttsrManager?.incrementTurnCount();
|
|
1108
|
+
|
|
1070
1109
|
if (res.salvaged !== undefined && res.salvaged > 0) {
|
|
1071
1110
|
report({
|
|
1072
1111
|
kind: "tool",
|
|
@@ -1364,10 +1403,103 @@ export class Session {
|
|
|
1364
1403
|
}
|
|
1365
1404
|
}
|
|
1366
1405
|
|
|
1406
|
+
/** Drive one send to a terminal result, then mine the send's events for
|
|
1407
|
+
* failure→fix lessons (best-effort, never affects the result). The buffer is
|
|
1408
|
+
* reset per send so each maps to one "run". */
|
|
1367
1409
|
private async drive(
|
|
1368
1410
|
maxTurns: number,
|
|
1369
1411
|
sendStart: number,
|
|
1370
1412
|
opts: ISendOptions
|
|
1413
|
+
): Promise<ISendResult> {
|
|
1414
|
+
this.sendEvents.length = 0;
|
|
1415
|
+
|
|
1416
|
+
try {
|
|
1417
|
+
return await this.driveInner(maxTurns, sendStart, opts);
|
|
1418
|
+
} finally {
|
|
1419
|
+
await this.consolidateLessons();
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Mine the current send's events into the project's learned-rules memory.
|
|
1424
|
+
* Gated on the TTSR flag (learned rules are recalled via TTSR). */
|
|
1425
|
+
private async consolidateLessons(): Promise<void> {
|
|
1426
|
+
if (!flags.ttsr()) {
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
const candidates = mineLessons(this.sendEvents);
|
|
1432
|
+
const runId = `${SESSION_ID}-${Date.now().toString(36)}`;
|
|
1433
|
+
const active = await consolidateMemory(this.ctx.cwd, candidates, runId);
|
|
1434
|
+
|
|
1435
|
+
if (active > 0) {
|
|
1436
|
+
this.report({
|
|
1437
|
+
kind: "ttsr",
|
|
1438
|
+
task: SESSION_ID,
|
|
1439
|
+
message: `memory: ${String(active)} learned rule(s) active in .tsforge/learned-rules.json`,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
} catch {
|
|
1443
|
+
// Memory is supplementary — never let it break a send.
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** The `ttsrManager` completion option, or nothing when TTSR is off. */
|
|
1448
|
+
private ttsrCallOption():
|
|
1449
|
+
| { ttsrManager: TtsrManager }
|
|
1450
|
+
| Record<string, never> {
|
|
1451
|
+
return this.ttsrManager === null ? {} : { ttsrManager: this.ttsrManager };
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Apply a mid-stream TTSR fire (inject guidance, retry). Returns true when it
|
|
1455
|
+
* fired (the caller should `continue`). */
|
|
1456
|
+
private handleTtsrFired(
|
|
1457
|
+
res: IModelResponse,
|
|
1458
|
+
turn: number,
|
|
1459
|
+
turnStart: number,
|
|
1460
|
+
sendStart: number
|
|
1461
|
+
): boolean {
|
|
1462
|
+
if (res.ttsrFired === undefined) {
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
applyTtsrInterrupt(
|
|
1467
|
+
res.ttsrFired,
|
|
1468
|
+
this.state,
|
|
1469
|
+
this.ctx.messages,
|
|
1470
|
+
this.report,
|
|
1471
|
+
SESSION_ID,
|
|
1472
|
+
this.ttsrManager
|
|
1473
|
+
);
|
|
1474
|
+
emitTiming(this.report, SESSION_ID, turn, turnStart, sendStart);
|
|
1475
|
+
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Handle a degenerate stream: a bounded recovery or a terminal stop. Returns a
|
|
1480
|
+
* stop result, "retry" to continue with a forced tool, or null if not degenerate. */
|
|
1481
|
+
private degenerationStop(
|
|
1482
|
+
res: IModelResponse,
|
|
1483
|
+
degenerations: number,
|
|
1484
|
+
turn: number,
|
|
1485
|
+
turnStart: number,
|
|
1486
|
+
sendStart: number
|
|
1487
|
+
): ISendResult | "retry" | null {
|
|
1488
|
+
if (res.degenerated !== true) {
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const stop = this.degenerationRecovery(degenerations, turn);
|
|
1493
|
+
|
|
1494
|
+
emitTiming(this.report, SESSION_ID, turn, turnStart, sendStart);
|
|
1495
|
+
|
|
1496
|
+
return stop ?? "retry";
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private async driveInner(
|
|
1500
|
+
maxTurns: number,
|
|
1501
|
+
sendStart: number,
|
|
1502
|
+
opts: ISendOptions
|
|
1371
1503
|
): Promise<ISendResult> {
|
|
1372
1504
|
const { ctx, report } = this;
|
|
1373
1505
|
// The gate confirms CHANGES, not answers: it fires only once the model has
|
|
@@ -1433,24 +1565,34 @@ export class Session {
|
|
|
1433
1565
|
|
|
1434
1566
|
forceTool = false;
|
|
1435
1567
|
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
if (res
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
emitTiming(report, SESSION_ID, turn, turnStart, sendStart);
|
|
1568
|
+
// A learned/built-in TTSR rule fired mid-stream — inject its corrective
|
|
1569
|
+
// guidance and retry (checked before degeneration so the fix lands first).
|
|
1570
|
+
// This is how memory's failure→fix lessons reach an interactive session.
|
|
1571
|
+
if (this.handleTtsrFired(res, turn, turnStart, sendStart)) {
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1443
1574
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1575
|
+
// The stream caught a degenerate repetition loop. Bounded recovery (force a
|
|
1576
|
+
// concrete tool call next turn) before giving up; see degenerationRecovery.
|
|
1577
|
+
const deg = this.degenerationStop(
|
|
1578
|
+
res,
|
|
1579
|
+
degenerations,
|
|
1580
|
+
turn,
|
|
1581
|
+
turnStart,
|
|
1582
|
+
sendStart
|
|
1583
|
+
);
|
|
1447
1584
|
|
|
1585
|
+
if (deg === "retry") {
|
|
1448
1586
|
degenerations += 1;
|
|
1449
1587
|
forceTool = true;
|
|
1450
1588
|
|
|
1451
1589
|
continue;
|
|
1452
1590
|
}
|
|
1453
1591
|
|
|
1592
|
+
if (deg !== null) {
|
|
1593
|
+
return deg;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1454
1596
|
// FORCED-TOOLS: a lone yield_status call becomes a normal stop.
|
|
1455
1597
|
this.resolveYieldCalls(res);
|
|
1456
1598
|
|
|
@@ -1505,7 +1647,7 @@ export class Session {
|
|
|
1505
1647
|
kind: "stuck",
|
|
1506
1648
|
task: SESSION_ID,
|
|
1507
1649
|
cycles: maxTurns,
|
|
1508
|
-
message: `stuck (hit ${maxTurns}-turn
|
|
1650
|
+
message: `stuck (hit the ${maxTurns}-turn runaway backstop — progress guards never tripped, which is unusual; re-steer or narrow the task)`,
|
|
1509
1651
|
});
|
|
1510
1652
|
|
|
1511
1653
|
return { status: "stuck", turns: maxTurns };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { Reporter } from "./loop.types";
|
|
4
|
+
import type { ILoopState } from "./turn";
|
|
5
|
+
import type { IChatMessage } from "../inference";
|
|
6
|
+
import { flags } from "../config";
|
|
7
|
+
import { TtsrManager, parseProjectRules, type ITtsrRule } from "./ttsr";
|
|
8
|
+
import { DEFAULT_TTSR_RULES } from "./ttsr-defaults";
|
|
9
|
+
|
|
10
|
+
const TTSR_INTERRUPT_CAP = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load a project's TTSR rules: hand-authored `.tsforge/rules.json` AND the
|
|
14
|
+
* memory-learned `.tsforge/learned-rules.json` (the failure→fix lessons the
|
|
15
|
+
* harness wrote itself). Both are tolerated-if-missing. Learned rules are named
|
|
16
|
+
* `learned-*`, so they never collide with hand or built-in rules on dedup.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadProjectTtsrRules(cwd: string): Promise<ITtsrRule[]> {
|
|
19
|
+
const files = [
|
|
20
|
+
join(cwd, ".tsforge", "rules.json"),
|
|
21
|
+
join(cwd, ".tsforge", "learned-rules.json"),
|
|
22
|
+
];
|
|
23
|
+
const rules: ITtsrRule[] = [];
|
|
24
|
+
|
|
25
|
+
for (const path of files) {
|
|
26
|
+
const file = Bun.file(path);
|
|
27
|
+
|
|
28
|
+
if (await file.exists()) {
|
|
29
|
+
rules.push(...parseProjectRules(await file.text()));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return rules;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the TTSR manager for a run: built-in defaults + project + learned rules.
|
|
38
|
+
* Shared by the headless loop (run.ts) and the interactive session (session.ts).
|
|
39
|
+
* Returns null when TTSR is disabled by flag.
|
|
40
|
+
*/
|
|
41
|
+
export async function initTtsrManager(
|
|
42
|
+
cwd: string,
|
|
43
|
+
report: Reporter,
|
|
44
|
+
taskId: string
|
|
45
|
+
): Promise<TtsrManager | null> {
|
|
46
|
+
if (!flags.ttsr()) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const manager = new TtsrManager();
|
|
51
|
+
|
|
52
|
+
for (const rule of DEFAULT_TTSR_RULES) {
|
|
53
|
+
manager.addRule(rule);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let added = 0;
|
|
57
|
+
|
|
58
|
+
for (const rule of await loadProjectTtsrRules(cwd)) {
|
|
59
|
+
if (manager.addRule(rule)) {
|
|
60
|
+
added += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (added > 0) {
|
|
65
|
+
report({
|
|
66
|
+
kind: "ttsr",
|
|
67
|
+
task: taskId,
|
|
68
|
+
message: `loaded ${added} project/learned TTSR rule(s) from .tsforge/`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return manager;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Apply a TTSR interrupt: count it, report it, inject the corrective guidance as
|
|
77
|
+
* a user message, and disable the manager once the per-run cap is hit (so a
|
|
78
|
+
* stubborn pattern can't loop forever). Shared by both loops; the caller decides
|
|
79
|
+
* what to do next (retry the turn). Timing emission stays with the caller.
|
|
80
|
+
*/
|
|
81
|
+
export function applyTtsrInterrupt(
|
|
82
|
+
ttsrFired: { ruleName: string; guidance: string },
|
|
83
|
+
state: ILoopState,
|
|
84
|
+
messages: IChatMessage[],
|
|
85
|
+
report: Reporter,
|
|
86
|
+
taskId: string,
|
|
87
|
+
ttsrManager: TtsrManager | null
|
|
88
|
+
): void {
|
|
89
|
+
state.ttsrInterrupts += 1;
|
|
90
|
+
|
|
91
|
+
report({
|
|
92
|
+
kind: "ttsr",
|
|
93
|
+
task: taskId,
|
|
94
|
+
message: `⚠ TTSR interrupted: ${ttsrFired.ruleName}`,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (state.ttsrInterrupts >= TTSR_INTERRUPT_CAP) {
|
|
98
|
+
report({
|
|
99
|
+
kind: "tool",
|
|
100
|
+
task: taskId,
|
|
101
|
+
message: `TTSR disabled after ${state.ttsrInterrupts} interrupts (hit cap)`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
ttsrManager?.disable();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
messages.push({
|
|
108
|
+
role: "user",
|
|
109
|
+
content: `⚠ generation interrupted: ${ttsrFired.guidance} Rewrite the affected part without that pattern.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
package/src/loop/turn.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
sameErrorSet,
|
|
9
9
|
type ErrorParser,
|
|
10
10
|
type ErrorSet,
|
|
11
|
+
type IErrorItem,
|
|
11
12
|
} from "../validate";
|
|
12
13
|
import { isInScope } from "../lib/scope";
|
|
13
14
|
import { fileExists, resolveScopeFiles } from "../lib/fs";
|
|
@@ -126,6 +127,9 @@ export interface ILoopCtx {
|
|
|
126
127
|
export interface ILoopState {
|
|
127
128
|
prevGateErrors: ErrorSet;
|
|
128
129
|
gateNoProgress: number;
|
|
130
|
+
/** Per-error-key (file:rule) survival count: how many consecutive gate cycles
|
|
131
|
+
* each error has persisted. Drives the primary `samePersist` no-progress stop. */
|
|
132
|
+
errorAge: Map<string, number>;
|
|
129
133
|
lastGateCount: number;
|
|
130
134
|
edits: number;
|
|
131
135
|
regressions: number;
|
|
@@ -688,6 +692,46 @@ function autoFixNotice(files: string[]): string {
|
|
|
688
692
|
);
|
|
689
693
|
}
|
|
690
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Advance each error's per-(file:rule) survival count and return the first error
|
|
697
|
+
* that has now persisted for `samePersist` consecutive gate cycles — the model
|
|
698
|
+
* keeps failing at the SAME thing — or null. Rebuilds the map from the CURRENT
|
|
699
|
+
* keys, so a fixed error's age drops out (no stale growth) and an error that
|
|
700
|
+
* comes back later starts fresh. Catches "stuck on X" even while OTHER errors
|
|
701
|
+
* churn around it (which the whole-set `gateNoProgress` guard misses).
|
|
702
|
+
*/
|
|
703
|
+
export function trackErrorAges(
|
|
704
|
+
state: ILoopState,
|
|
705
|
+
gateErrors: ErrorSet
|
|
706
|
+
): IErrorItem | null {
|
|
707
|
+
const next = new Map<string, number>();
|
|
708
|
+
let stuck: IErrorItem | null = null;
|
|
709
|
+
|
|
710
|
+
for (const e of gateErrors) {
|
|
711
|
+
const age = (state.errorAge.get(e.key) ?? 0) + 1;
|
|
712
|
+
|
|
713
|
+
next.set(e.key, age);
|
|
714
|
+
|
|
715
|
+
if (age >= LOOP_LIMITS.samePersist && stuck === null) {
|
|
716
|
+
stuck = e;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
state.errorAge = next;
|
|
721
|
+
|
|
722
|
+
return stuck;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The blocker diagnosis surfaced when a single error persists too long — names
|
|
726
|
+
* the rule + file + attempt count + the last message, so an interactive session
|
|
727
|
+
* hands back something the user can act on. */
|
|
728
|
+
export function persistDetail(e: IErrorItem): string {
|
|
729
|
+
const where = e.file !== undefined ? ` in ${e.file}` : "";
|
|
730
|
+
const rule = e.rule ?? "the same error";
|
|
731
|
+
|
|
732
|
+
return `stuck on ${rule}${where} after ${String(LOOP_LIMITS.samePersist)} attempts (last: ${e.message.slice(0, 140)})`;
|
|
733
|
+
}
|
|
734
|
+
|
|
691
735
|
/**
|
|
692
736
|
* The deterministic gate — the only authority on "done". Auto-fix, run the
|
|
693
737
|
* optional fix command, validate, and return a terminal result (done/stuck) or
|
|
@@ -817,17 +861,47 @@ export async function settleGate(
|
|
|
817
861
|
};
|
|
818
862
|
}
|
|
819
863
|
|
|
864
|
+
// PRIMARY no-progress stop: the model keeps failing at the SAME (file,rule)
|
|
865
|
+
// for `samePersist` cycles running — even if other errors churn. Hand back a
|
|
866
|
+
// concrete blocker rather than spinning to a raw turn cap.
|
|
867
|
+
const persisted = trackErrorAges(state, gateErrors);
|
|
868
|
+
|
|
869
|
+
if (persisted !== null) {
|
|
870
|
+
const detail = persistDetail(persisted);
|
|
871
|
+
|
|
872
|
+
report({
|
|
873
|
+
kind: "stuck",
|
|
874
|
+
task: task.id,
|
|
875
|
+
cycles: turn,
|
|
876
|
+
detail,
|
|
877
|
+
message: `task ${task.id}: ${detail}`,
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
task: task.id,
|
|
882
|
+
redConfirmed: true,
|
|
883
|
+
status: RUN_STATUS.stuck,
|
|
884
|
+
cycles: turn,
|
|
885
|
+
reason: STUCK_REASON.stalled,
|
|
886
|
+
detail,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Coarser secondary net: the WHOLE error set unchanged this many cycles.
|
|
820
891
|
state.gateNoProgress = sameErrorSet(state.prevGateErrors, gateErrors)
|
|
821
892
|
? state.gateNoProgress + 1
|
|
822
893
|
: 0;
|
|
823
894
|
state.prevGateErrors = gateErrors;
|
|
824
895
|
|
|
825
896
|
if (state.gateNoProgress >= LOOP_LIMITS.gateStuckRepeats) {
|
|
897
|
+
const detail = `gate unchanged ${String(LOOP_LIMITS.gateStuckRepeats)} cycles (${String(gateErrors.length)} error(s) not converging)`;
|
|
898
|
+
|
|
826
899
|
report({
|
|
827
900
|
kind: "stuck",
|
|
828
901
|
task: task.id,
|
|
829
902
|
cycles: turn,
|
|
830
|
-
|
|
903
|
+
detail,
|
|
904
|
+
message: `task ${task.id}: stuck — ${detail}`,
|
|
831
905
|
});
|
|
832
906
|
|
|
833
907
|
return {
|
|
@@ -836,6 +910,7 @@ export async function settleGate(
|
|
|
836
910
|
status: RUN_STATUS.stuck,
|
|
837
911
|
cycles: turn,
|
|
838
912
|
reason: STUCK_REASON.stalled,
|
|
913
|
+
detail,
|
|
839
914
|
};
|
|
840
915
|
}
|
|
841
916
|
|