loopforge 2.0.0 → 2.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.
Files changed (111) hide show
  1. package/README.md +7 -5
  2. package/dist/backends/interface.d.ts +4 -3
  3. package/dist/backends/interface.d.ts.map +1 -1
  4. package/dist/backends/interface.js +4 -1
  5. package/dist/backends/interface.js.map +1 -1
  6. package/dist/canonical-state.d.ts +23 -2
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +90 -6
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +6 -2
  12. package/dist/cli.js.map +1 -1
  13. package/dist/enforcement-gate.d.ts +1 -1
  14. package/dist/enforcement-gate.d.ts.map +1 -1
  15. package/dist/enforcement-gate.js +86 -7
  16. package/dist/enforcement-gate.js.map +1 -1
  17. package/dist/engine.d.ts +3 -33
  18. package/dist/engine.d.ts.map +1 -1
  19. package/dist/engine.js +127 -343
  20. package/dist/engine.js.map +1 -1
  21. package/dist/evidence-provider.d.ts +38 -5
  22. package/dist/evidence-provider.d.ts.map +1 -1
  23. package/dist/evidence-provider.js +132 -46
  24. package/dist/evidence-provider.js.map +1 -1
  25. package/dist/index.d.ts +10 -12
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +6 -9
  28. package/dist/index.js.map +1 -1
  29. package/dist/interop.d.ts +5 -0
  30. package/dist/interop.d.ts.map +1 -1
  31. package/dist/interop.js +5 -0
  32. package/dist/interop.js.map +1 -1
  33. package/dist/loop-compiler.d.ts.map +1 -1
  34. package/dist/loop-compiler.js +515 -50
  35. package/dist/loop-compiler.js.map +1 -1
  36. package/dist/loop-extras-parser.d.ts +82 -0
  37. package/dist/loop-extras-parser.d.ts.map +1 -0
  38. package/dist/loop-extras-parser.js +217 -0
  39. package/dist/loop-extras-parser.js.map +1 -0
  40. package/dist/loop-store.d.ts +26 -4
  41. package/dist/loop-store.d.ts.map +1 -1
  42. package/dist/loop-store.js +118 -8
  43. package/dist/loop-store.js.map +1 -1
  44. package/dist/mcp/server.js +1 -1
  45. package/dist/mcp/session.d.ts +29 -6
  46. package/dist/mcp/session.d.ts.map +1 -1
  47. package/dist/mcp/session.js +187 -161
  48. package/dist/mcp/session.js.map +1 -1
  49. package/dist/mcp/tools.d.ts.map +1 -1
  50. package/dist/mcp/tools.js +33 -5
  51. package/dist/mcp/tools.js.map +1 -1
  52. package/dist/observability.d.ts +5 -36
  53. package/dist/observability.d.ts.map +1 -1
  54. package/dist/observability.js +9 -79
  55. package/dist/observability.js.map +1 -1
  56. package/dist/policy-metrics.d.ts +1 -0
  57. package/dist/policy-metrics.d.ts.map +1 -1
  58. package/dist/policy-metrics.js +2 -0
  59. package/dist/policy-metrics.js.map +1 -1
  60. package/dist/policy.d.ts +58 -8
  61. package/dist/policy.d.ts.map +1 -1
  62. package/dist/policy.js +25 -12
  63. package/dist/policy.js.map +1 -1
  64. package/dist/prompt-assembler.d.ts.map +1 -1
  65. package/dist/prompt-assembler.js +129 -4
  66. package/dist/prompt-assembler.js.map +1 -1
  67. package/dist/protocol.d.ts +118 -87
  68. package/dist/protocol.d.ts.map +1 -1
  69. package/dist/protocol.js +40 -30
  70. package/dist/protocol.js.map +1 -1
  71. package/dist/replay.d.ts +0 -1
  72. package/dist/replay.d.ts.map +1 -1
  73. package/dist/replay.js +1 -87
  74. package/dist/replay.js.map +1 -1
  75. package/dist/round-coordinator.d.ts +4 -6
  76. package/dist/round-coordinator.d.ts.map +1 -1
  77. package/dist/round-coordinator.js +75 -80
  78. package/dist/round-coordinator.js.map +1 -1
  79. package/dist/round-driver.d.ts +1 -0
  80. package/dist/round-driver.d.ts.map +1 -1
  81. package/dist/round-driver.js +2 -0
  82. package/dist/round-driver.js.map +1 -1
  83. package/dist/round-transaction.d.ts.map +1 -1
  84. package/dist/round-transaction.js +2 -22
  85. package/dist/round-transaction.js.map +1 -1
  86. package/dist/run.d.ts +51 -0
  87. package/dist/run.d.ts.map +1 -0
  88. package/dist/run.js +200 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/self-eval.d.ts +23 -0
  91. package/dist/self-eval.d.ts.map +1 -0
  92. package/dist/self-eval.js +151 -0
  93. package/dist/self-eval.js.map +1 -0
  94. package/dist/storage.d.ts +3 -2
  95. package/dist/storage.d.ts.map +1 -1
  96. package/dist/storage.js +70 -68
  97. package/dist/storage.js.map +1 -1
  98. package/dist/token-utils.d.ts +22 -0
  99. package/dist/token-utils.d.ts.map +1 -0
  100. package/dist/token-utils.js +46 -0
  101. package/dist/token-utils.js.map +1 -0
  102. package/dist/verification-gate.d.ts +17 -23
  103. package/dist/verification-gate.d.ts.map +1 -1
  104. package/dist/verification-gate.js +241 -135
  105. package/dist/verification-gate.js.map +1 -1
  106. package/loop_policy.json +21 -12
  107. package/package.json +2 -2
  108. package/dist/runtime.d.ts +0 -94
  109. package/dist/runtime.d.ts.map +0 -1
  110. package/dist/runtime.js +0 -718
  111. package/dist/runtime.js.map +0 -1
@@ -5,22 +5,20 @@
5
5
  * the advance() cycle: extract → feedback → check stop → compile next.
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { LoopForgeEngine, extractSelfEvaluation, heuristicSelfEvaluation } from "../engine.js";
8
+ import { LoopForgeEngine, extractSelfEvaluation } from "../engine.js";
9
9
  import { checkLoopHealth } from "../loop-compiler.js";
10
10
  import { getPolicy } from "../policy.js";
11
- import { Mode, makeLoopCompileRequest } from "../protocol.js";
11
+ import { Mode, makeLoopCompileRequest, makeLoopRoundResult } from "../protocol.js";
12
12
  import { ReplayBackend } from "../replay.js";
13
- import { FileLoopStore, LoopStoreBackend } from "../loop-store.js";
13
+ import { FileLoopStore, LoopStoreBackend, VaultBackendLoopStore } from "../loop-store.js";
14
14
  import { EvidenceCollector } from "../evidence-provider.js";
15
15
  import { parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
16
16
  import { RoundDriver } from "../round-driver.js";
17
17
  import { logEvent } from "../observability.js";
18
18
  import { policyMetrics } from "../policy-metrics.js";
19
19
  import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
20
- import { createCognitiveCheckpoint } from "../interop.js";
21
20
  // ── Helpers ────────────────────────────────────────────────────────────────
22
- function buildLoopRequest(session, lastEval, _lastQuality, // deprecated — kept for backward compat
23
- verificationFlags) {
21
+ function buildLoopRequest(session, lastEval, verificationFlags) {
24
22
  const req = {
25
23
  task: session.task,
26
24
  mode: Mode.LOOP_COMPILE,
@@ -33,7 +31,7 @@ verificationFlags) {
33
31
  verification_flags: verificationFlags ?? [],
34
32
  };
35
33
  if (lastEval) {
36
- req.last_round_result = {
34
+ req.last_round_result = makeLoopRoundResult({
37
35
  round: session.currentRound - 1,
38
36
  success: lastEval.success,
39
37
  output_summary: lastEval.output_summary,
@@ -62,24 +60,14 @@ verificationFlags) {
62
60
  checkpoint_label: lastEval.checkpoint_label ?? "",
63
61
  // v1.16: Agent's declared next action
64
62
  next_action: lastEval.next_action,
65
- };
63
+ // v2.2: Sub-goal lifecycle
64
+ completed_subtasks: lastEval.completed_subtasks ?? [],
65
+ blocked_subtasks: lastEval.blocked_subtasks ?? [],
66
+ canceled_subtasks: lastEval.canceled_subtasks ?? [],
67
+ });
66
68
  }
67
69
  return req;
68
70
  }
69
- function parseWarnings(prompt) {
70
- if (!prompt)
71
- return [];
72
- const warnings = [];
73
- const warnSection = prompt.match(/### Warnings\n([\s\S]*?)(?=\n###|\n\*\*|$)/);
74
- if (warnSection) {
75
- for (const line of warnSection[1].split("\n")) {
76
- const m = line.match(/- ⚠️\s*(.+)/);
77
- if (m)
78
- warnings.push(m[1]);
79
- }
80
- }
81
- return warnings;
82
- }
83
71
  // ── SessionManager ─────────────────────────────────────────────────────────
84
72
  export class SessionManager {
85
73
  sessions = new Map();
@@ -91,18 +79,20 @@ export class SessionManager {
91
79
  leaseMs;
92
80
  leaseRenewIntervalMs;
93
81
  leaseTimer = null;
94
- checkpointSinks = new Set();
95
82
  /** Explicit context provider; never auto-discovered. */
96
83
  contextProvider;
97
84
  terminalSinks = new Set();
98
85
  constructor(storeOrBackend, sessionStore) {
99
- const resolvedBackend = storeOrBackend
100
- ? "readSession" in storeOrBackend
101
- ? new LoopStoreBackend(storeOrBackend)
102
- : storeOrBackend
103
- : new LoopStoreBackend(new FileLoopStore(getPolicy().backend.root_dir));
104
- this.backend = resolvedBackend;
105
- this.sessionStore = sessionStore ?? new VaultSessionStateStore(resolvedBackend);
86
+ const isLoopStore = (v) => "readSession" in v && typeof v.readSession === "function";
87
+ const store = storeOrBackend
88
+ ? isLoopStore(storeOrBackend)
89
+ ? storeOrBackend
90
+ : new VaultBackendLoopStore(storeOrBackend)
91
+ : new FileLoopStore(getPolicy().backend.root_dir);
92
+ this.backend = storeOrBackend && !isLoopStore(storeOrBackend)
93
+ ? storeOrBackend
94
+ : new LoopStoreBackend(store);
95
+ this.sessionStore = sessionStore ?? new VaultSessionStateStore(store);
106
96
  const mcpPolicy = getPolicy().mcp;
107
97
  this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
108
98
  this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
@@ -115,11 +105,6 @@ export class SessionManager {
115
105
  getOwnerId() {
116
106
  return this.ownerId;
117
107
  }
118
- /** Subscribe an external checkpointer; sink failures are isolated. */
119
- addCheckpointSink(sink) {
120
- this.checkpointSinks.add(sink);
121
- return () => this.checkpointSinks.delete(sink);
122
- }
123
108
  addTerminalSink(sink) {
124
109
  this.terminalSinks.add(sink);
125
110
  return () => this.terminalSinks.delete(sink);
@@ -165,6 +150,7 @@ export class SessionManager {
165
150
  round: typeof lineage.current_round === "number" ? lineage.current_round : 0,
166
151
  prompt: null,
167
152
  stopReason: `session_owned_elsewhere:${loopId}`,
153
+ stopDetail: `Another process (PID ${entry?.loop_lineage ? entry.loop_lineage.lease_owner ?? "unknown" : "unknown"}) holds the lease for loop "${loopId}". Wait for the lease to expire or stop the other process.`,
168
154
  };
169
155
  }
170
156
  async withSessionQueue(sessionId, work) {
@@ -190,7 +176,7 @@ export class SessionManager {
190
176
  const sessionId = randomUUID();
191
177
  const loopId = input.loopId ?? randomUUID();
192
178
  const engine = new LoopForgeEngine(this.backend);
193
- const maxRounds = input.maxRounds ?? getPolicy().runtime.max_rounds;
179
+ const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
194
180
  // Populate extra fields for the first round
195
181
  const request = buildLoopRequest({
196
182
  sessionId, loopId, task: input.task, engine, currentRound: 1,
@@ -240,6 +226,7 @@ export class SessionManager {
240
226
  roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
241
227
  currentPrompt: initialPrompt,
242
228
  currentLevel: initialLevel,
229
+ currentWarnings: prepared?.warnings ?? [],
243
230
  };
244
231
  this.sessions.set(sessionId, session);
245
232
  policyMetrics.recordStrategy(loopId, initialLevel);
@@ -258,8 +245,7 @@ export class SessionManager {
258
245
  prompt: initialPrompt,
259
246
  level: initialLevel,
260
247
  roundSuccess: false,
261
- quality: 0,
262
- warnings: parseWarnings(initialPrompt),
248
+ warnings: prepared?.warnings ?? [],
263
249
  };
264
250
  }
265
251
  get(sessionId) {
@@ -290,7 +276,7 @@ export class SessionManager {
290
276
  if (!session)
291
277
  return false;
292
278
  session.status = "stopped";
293
- this.save(session); // Persist stopped status to vault so autoResumeAll won't resurrect
279
+ this.save(session);
294
280
  void this.notifyTerminal(session, "cancelled");
295
281
  this.sessions.delete(sessionId);
296
282
  logEvent("session_end", {
@@ -329,9 +315,8 @@ export class SessionManager {
329
315
  roundId: session.roundSnapshot?.roundId,
330
316
  prompt: session.currentPrompt,
331
317
  level: session.currentLevel ?? "l2",
332
- quality: 0,
333
318
  roundSuccess: undefined,
334
- warnings: parseWarnings(session.currentPrompt),
319
+ warnings: (session.currentWarnings ?? []),
335
320
  };
336
321
  }
337
322
  /** Reconcile the crash window where feedback committed but session_state
@@ -377,14 +362,20 @@ export class SessionManager {
377
362
  roundId: recovered.snapshot.roundId,
378
363
  prompt: null,
379
364
  stopReason: reason,
365
+ stopDetail: reason === "completed"
366
+ ? "Task completed successfully — the agent declared should_continue=false with success=true."
367
+ : reason === "blocked"
368
+ ? "Agent cannot proceed under current constraints. The task may need revised constraints or human intervention."
369
+ : reason === "failed"
370
+ ? "Agent declared should_continue=false with success=false — the task was not achieved."
371
+ : `Loop stopped: ${reason}.`,
380
372
  roundSuccess: pr.roundSuccess,
381
- quality: pr.roundSuccess ? 5 : 1,
382
373
  };
383
374
  }
384
375
  if (pr.action !== "continue")
385
376
  return null;
386
377
  session.currentRound++;
387
- const request = buildLoopRequest(session, pr.newLastSelfEval, pr.roundSuccess ? 5 : 1, pr.verificationFlags);
378
+ const request = buildLoopRequest(session, pr.newLastSelfEval, pr.verificationFlags);
388
379
  const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
389
380
  if (!prepared) {
390
381
  session.status = "stalled";
@@ -394,6 +385,7 @@ export class SessionManager {
394
385
  round: session.currentRound,
395
386
  prompt: null,
396
387
  stopReason: "stalled",
388
+ stopDetail: "RoundDriver.prepareSync returned null — prompt compilation failed during crash recovery. The session state may be inconsistent.",
397
389
  };
398
390
  }
399
391
  const prompt = prepared.prompt;
@@ -401,6 +393,7 @@ export class SessionManager {
401
393
  session.roundSnapshot = prepared.snapshot;
402
394
  session.currentPrompt = prompt;
403
395
  session.currentLevel = prepared.level;
396
+ session.currentWarnings = prepared?.warnings ?? [];
404
397
  policyMetrics.recordStrategy(session.loopId, session.currentLevel);
405
398
  this.save(session);
406
399
  return {
@@ -410,8 +403,7 @@ export class SessionManager {
410
403
  prompt,
411
404
  level: session.currentLevel,
412
405
  roundSuccess: pr.roundSuccess,
413
- quality: pr.roundSuccess ? 5 : 1,
414
- warnings: parseWarnings(prompt),
406
+ warnings: (session.currentWarnings ?? []),
415
407
  };
416
408
  }
417
409
  /** v1.18: Resume a paused session. Reconstructs from vault state and
@@ -490,7 +482,7 @@ export class SessionManager {
490
482
  session.status = "stopped";
491
483
  this.save(session);
492
484
  void this.notifyTerminal(session, "stalled");
493
- return { sessionId: session.sessionId, round: session.currentRound, prompt: null, stopReason: "stalled" };
485
+ return { sessionId: session.sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail: "RoundDriver.prepare returned null during unpause — the compiler could not produce a prompt." };
494
486
  }
495
487
  const prompt = prepared.prompt;
496
488
  const level = prepared.level;
@@ -498,6 +490,7 @@ export class SessionManager {
498
490
  session.roundSnapshot = prepared.snapshot;
499
491
  session.currentPrompt = prompt;
500
492
  session.currentLevel = level;
493
+ session.currentWarnings = prepared?.warnings ?? [];
501
494
  policyMetrics.recordStrategy(session.loopId, level);
502
495
  this.save(session);
503
496
  return {
@@ -506,7 +499,6 @@ export class SessionManager {
506
499
  roundId: session.roundSnapshot.roundId,
507
500
  prompt,
508
501
  level,
509
- quality: 0,
510
502
  roundSuccess: undefined,
511
503
  warnings: [],
512
504
  };
@@ -547,20 +539,6 @@ export class SessionManager {
547
539
  this.sessionStore.save(sessionEntry, {
548
540
  expectedLeaseOwner: this.ownerId,
549
541
  });
550
- if (this.checkpointSinks.size > 0) {
551
- const checkpoint = createCognitiveCheckpoint(session, sessionEntry.timestamp);
552
- for (const sink of this.checkpointSinks) {
553
- try {
554
- const pending = sink.save(checkpoint);
555
- if (pending && typeof pending.then === "function") {
556
- void pending.catch(() => undefined);
557
- }
558
- }
559
- catch {
560
- // External adapters must not affect session durability.
561
- }
562
- }
563
- }
564
542
  }
565
543
  /** Reconstruct a McpSession from a vault session_state entry.
566
544
  * Returns null if the entry is not "running" status.
@@ -575,7 +553,7 @@ export class SessionManager {
575
553
  const currentRound = lineage.current_round ?? 1;
576
554
  const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
577
555
  const task = entry.task ?? "";
578
- const maxRounds = lineage.max_rounds ?? getPolicy().runtime.max_rounds;
556
+ const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
579
557
  const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
580
558
  const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
581
559
  const persistedEval = lineage.last_self_eval;
@@ -635,6 +613,7 @@ export class SessionManager {
635
613
  round: currentRound,
636
614
  prompt: null,
637
615
  stopReason: status,
616
+ stopDetail: `Loop is not running (status: ${status}). It may have already completed or been stopped.`,
638
617
  };
639
618
  }
640
619
  this.sessions.set(session.sessionId, session);
@@ -652,6 +631,7 @@ export class SessionManager {
652
631
  session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, []);
653
632
  session.currentPrompt = prompt;
654
633
  session.currentLevel = prepared?.level ?? "l2";
634
+ session.currentWarnings = prepared?.warnings ?? [];
655
635
  policyMetrics.recordStrategy(session.loopId, session.currentLevel);
656
636
  this.save(session);
657
637
  return {
@@ -661,8 +641,7 @@ export class SessionManager {
661
641
  prompt,
662
642
  level: session.currentLevel,
663
643
  roundSuccess: false,
664
- quality: 0,
665
- warnings: parseWarnings(prompt),
644
+ warnings: (session.currentWarnings ?? []),
666
645
  };
667
646
  }
668
647
  /** Auto-resume all "running" sessions from vault on server startup.
@@ -802,34 +781,30 @@ export class SessionManager {
802
781
  }
803
782
  });
804
783
  }
805
- async advanceUnlocked(sessionId, output, preExtractedEval) {
806
- const session = this.sessions.get(sessionId);
807
- if (!session)
808
- return { sessionId, round: 0, prompt: null, stopReason: "session_not_found" };
809
- if (session.status !== "running") {
810
- return { sessionId, round: session.currentRound, prompt: null, stopReason: session.status };
811
- }
812
- // 1. Extract self-evaluation (structured param preferred → regex → heuristic)
813
- let extractionFailed = false;
814
- let selfEval;
784
+ // ── advanceUnlocked pipeline ───────────────────────────────────────────
785
+ //
786
+ // The method is split into focused private helpers so each phase of the
787
+ // round-boundary decision loop is independently readable and testable.
788
+ //
789
+ // Pipeline: validate → extract → execute transaction → route disposition
790
+ // → advance to next round (continue) or return terminal result.
791
+ /** Extract a SelfEvaluation from agent output.
792
+ * Structured param preferred → regex extraction → heuristic fallback.
793
+ * Returns null only when both regex and heuristic returned null. */
794
+ extractEvaluation(output, preExtractedEval) {
815
795
  if (preExtractedEval) {
816
- selfEval = preExtractedEval;
817
- extractionFailed = false;
818
- }
819
- else {
820
- const structured = extractSelfEvaluation(output);
821
- extractionFailed = structured === null;
822
- selfEval = structured ?? heuristicSelfEvaluation(output);
823
- }
824
- // Guard: if both extraction methods returned null, stop
825
- if (!selfEval) {
826
- session.status = "stalled";
827
- this.save(session);
828
- void this.notifyTerminal(session, "stalled");
829
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
830
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", roundSuccess: false, quality: 0 };
796
+ return { selfEval: preExtractedEval, extractionFailed: false };
831
797
  }
832
- // 1.5. Unified transaction: before → evaluate → verify → commit/reject.
798
+ const structured = extractSelfEvaluation(output);
799
+ if (!structured)
800
+ return null;
801
+ return { selfEval: structured, extractionFailed: false };
802
+ }
803
+ /** Execute the round transaction and apply per-rule rejection tracking.
804
+ * MUTATES: session.roundSnapshot, session.consecutiveRejections,
805
+ * session.lastRejectionCheck, session.lastSelfEval,
806
+ * session.successTrajectory */
807
+ async executeRoundTransaction(session, selfEval, extractionFailed) {
833
808
  const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
834
809
  const completed = await new RoundDriver(session.engine, this.backend).complete({
835
810
  snapshot,
@@ -843,13 +818,12 @@ export class SessionManager {
843
818
  successTrajectory: session.successTrajectory,
844
819
  });
845
820
  const outcome = completed.outcome;
846
- const actualSnapshots = completed.actualEvidence;
821
+ const actualEvidence = completed.actualEvidence;
847
822
  session.roundSnapshot = outcome.snapshot;
848
823
  const pr = outcome.result;
849
824
  policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
850
- const verificationFlags = pr.verificationFlags;
851
- // Track per-rule rejections: only same-check rejections
852
- // accumulate. A different rejection reason resets the counter.
825
+ // Per-rule rejection tracking: same-check rejections accumulate;
826
+ // a different rejection reason resets the counter.
853
827
  if (pr.action === "reject" && pr.rejectionCheck) {
854
828
  session.consecutiveRejections =
855
829
  pr.rejectionCheck === session.lastRejectionCheck
@@ -867,75 +841,93 @@ export class SessionManager {
867
841
  if (pr.shouldPushSuccessTrajectory) {
868
842
  session.successTrajectory.push(pr.roundSuccess);
869
843
  }
870
- // Handle enforcement actions
871
- if (pr.action === "reject") {
872
- const retryRequest = buildLoopRequest(session, undefined, undefined, verificationFlags);
873
- const preparedRetry = await new RoundDriver(session.engine, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
874
- if (!preparedRetry) {
875
- session.status = "stalled";
876
- session.currentPrompt = null;
877
- this.save(session);
878
- return {
879
- sessionId,
880
- round: session.currentRound,
881
- roundId: session.roundSnapshot.roundId,
882
- prompt: null,
883
- stopReason: "stalled",
884
- };
885
- }
886
- session.roundSnapshot = preparedRetry.snapshot;
887
- session.currentPrompt = preparedRetry.prompt;
888
- session.currentLevel = preparedRetry.level;
889
- this.save(session);
890
- return {
891
- sessionId,
892
- round: session.currentRound,
893
- roundId: session.roundSnapshot.roundId,
894
- prompt: preparedRetry.prompt,
895
- level: preparedRetry.level,
896
- enforcementAction: "reject",
897
- enforcementReason: pr.enforcementReason,
898
- };
899
- }
900
- // Accepted rounds advance the before-snapshot. Rejected rounds retain the
901
- // original baseline so their retry is still a zero-commit transaction.
902
- session.evidenceBaseline = actualSnapshots;
903
- if (pr.action === "terminate") {
904
- session.status = "stopped";
844
+ return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
845
+ }
846
+ /** Build a rejection result: compile a retry prompt, persist, return.
847
+ * MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
848
+ async buildRejectionResult(sessionId, session, pr, verificationFlags) {
849
+ const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
850
+ const preparedRetry = await new RoundDriver(session.engine, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
851
+ if (!preparedRetry) {
852
+ session.status = "stalled";
905
853
  session.currentPrompt = null;
906
854
  this.save(session);
907
- void this.notifyTerminal(session, "enforcement_terminated");
908
- logEvent("session_end", {
909
- sessionId, loopId: session.loopId,
910
- stopReason: "enforcement_terminated", round: session.currentRound,
911
- });
912
855
  return {
913
856
  sessionId,
914
857
  round: session.currentRound,
915
- roundId: session.roundSnapshot.roundId,
858
+ roundId: session.roundSnapshot?.roundId,
916
859
  prompt: null,
917
- stopReason: "enforcement_terminated",
918
- enforcementAction: "terminate",
919
- enforcementReason: pr.enforcementReason,
860
+ stopReason: "stalled",
861
+ stopDetail: "RoundDriver.prepareRetry returned null — the retry prompt could not be compiled.",
920
862
  };
921
863
  }
922
- if (pr.action === "stop") {
923
- const reason = pr.stopReason ?? "stalled";
924
- session.status = reason === "stalled" ? "stalled" : "stopped";
925
- session.currentPrompt = null;
926
- this.save(session);
927
- void this.notifyTerminal(session, reason);
928
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
929
- return { sessionId, round: session.currentRound, roundId: session.roundSnapshot.roundId, prompt: null, stopReason: reason, roundSuccess: pr.roundSuccess, quality: pr.roundSuccess ? 5 : 1 };
930
- }
864
+ session.roundSnapshot = preparedRetry.snapshot;
865
+ session.currentPrompt = preparedRetry.prompt;
866
+ session.currentLevel = preparedRetry.level;
867
+ session.currentWarnings = preparedRetry.warnings ?? [];
868
+ this.save(session);
869
+ return {
870
+ sessionId,
871
+ round: session.currentRound,
872
+ roundId: session.roundSnapshot.roundId,
873
+ prompt: preparedRetry.prompt,
874
+ level: preparedRetry.level,
875
+ enforcementAction: "reject",
876
+ enforcementReason: pr.enforcementReason,
877
+ };
878
+ }
879
+ /** Build a termination result: persist stopped status, notify sinks.
880
+ * MUTATES: session.status, session.currentPrompt */
881
+ buildTerminationResult(sessionId, session, pr) {
882
+ session.status = "stopped";
883
+ session.currentPrompt = null;
884
+ this.save(session);
885
+ void this.notifyTerminal(session, "enforcement_terminated");
886
+ logEvent("session_end", {
887
+ sessionId, loopId: session.loopId,
888
+ stopReason: "enforcement_terminated", round: session.currentRound,
889
+ });
890
+ return {
891
+ sessionId,
892
+ round: session.currentRound,
893
+ roundId: session.roundSnapshot?.roundId,
894
+ prompt: null,
895
+ stopReason: "enforcement_terminated",
896
+ stopDetail: pr.enforcementReason ?? "The enforcement gate terminated the loop.",
897
+ enforcementAction: "terminate",
898
+ enforcementReason: pr.enforcementReason,
899
+ };
900
+ }
901
+ /** Build a stop result: persist stopped/stalled status, notify sinks.
902
+ * MUTATES: session.status, session.currentPrompt */
903
+ buildStopResult(sessionId, session, pr) {
904
+ const reason = pr.stopReason ?? "stalled";
905
+ session.status = reason === "stalled" ? "stalled" : "stopped";
906
+ session.currentPrompt = null;
907
+ this.save(session);
908
+ void this.notifyTerminal(session, reason);
909
+ logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
910
+ return {
911
+ sessionId, round: session.currentRound,
912
+ roundId: session.roundSnapshot?.roundId,
913
+ prompt: null, stopReason: reason,
914
+ stopDetail: pr.stopReason === "circuit_breaker"
915
+ ? `${pr.roundSuccess ? "No" : "All"} recent rounds failed, triggering the circuit breaker.`
916
+ : pr.stopReason === "max_rounds"
917
+ ? "The configured maximum number of rounds has been reached."
918
+ : `Loop stopped: ${reason}.`,
919
+ roundSuccess: pr.roundSuccess,
920
+ };
921
+ }
922
+ /** Compile the next round's prompt and advance the session.
923
+ * Includes the commit fence (pause/delete race guard) and context provider.
924
+ * MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
925
+ * session.evidenceBaseline, session.roundSnapshot */
926
+ async advanceToNextRound(sessionId, session, selfEval, pr, verificationFlags, actualEvidence) {
931
927
  const roundSuccess = pr.roundSuccess;
932
- // Feedback + decision metadata were committed by the transaction layer.
933
- // Build deprecated quality alias for backward compat
934
- const deprecatedQuality = roundSuccess ? 5 : 1;
935
- // 4. Compile next round after the accepted commit.
936
928
  session.currentRound++;
937
929
  session.currentPrompt = null;
938
- // v1.8: Memory injection for phases 2/3 — tier-aware
930
+ // v1.8: Memory injection — tier-aware external context.
939
931
  let externalCtx = "";
940
932
  if (this.contextProvider) {
941
933
  try {
@@ -954,32 +946,31 @@ export class SessionManager {
954
946
  });
955
947
  }
956
948
  }
957
- // pause()/delete() are intentionally synchronous for API compatibility.
958
- // They may run while the memory provider above is awaited. Treat the
959
- // session map + status as a commit fence so an in-flight advance cannot
960
- // compile/save another round after a terminal lifecycle transition.
949
+ // Commit fence: pause()/delete() may have run while the context provider
950
+ // was awaited. Do not compile if the session is no longer running.
961
951
  if (this.sessions.get(sessionId) !== session || session.status !== "running") {
962
952
  return {
963
953
  sessionId,
964
954
  round: session.currentRound,
965
955
  prompt: null,
966
956
  stopReason: session.status,
957
+ stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the context provider was running.`,
967
958
  roundSuccess,
968
- quality: deprecatedQuality,
969
959
  };
970
960
  }
971
- const request = buildLoopRequest(session, selfEval, deprecatedQuality, verificationFlags);
961
+ const request = buildLoopRequest(session, selfEval, verificationFlags);
972
962
  if (externalCtx) {
973
963
  request.external_context = externalCtx;
974
964
  }
975
965
  const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, session.currentRound);
976
966
  const nextPrompt = prepared?.prompt ?? null;
977
967
  const nextLevel = prepared?.level ?? "l2";
978
- const nextBaseline = prepared?.evidenceBaseline ?? actualSnapshots;
968
+ const nextBaseline = prepared?.evidenceBaseline ?? actualEvidence;
979
969
  session.evidenceBaseline = nextBaseline;
980
970
  session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
981
971
  session.currentPrompt = nextPrompt;
982
972
  session.currentLevel = nextLevel;
973
+ session.currentWarnings = prepared?.warnings ?? [];
983
974
  policyMetrics.recordStrategy(session.loopId, nextLevel);
984
975
  this.save(session);
985
976
  return {
@@ -989,10 +980,45 @@ export class SessionManager {
989
980
  prompt: nextPrompt,
990
981
  level: nextLevel,
991
982
  roundSuccess,
992
- quality: deprecatedQuality,
993
- warnings: parseWarnings(nextPrompt),
983
+ warnings: prepared?.warnings ?? [],
994
984
  };
995
985
  }
986
+ async advanceUnlocked(sessionId, output, preExtractedEval) {
987
+ // ── 1. Validate session ──────────────────────────────────────────────
988
+ const session = this.sessions.get(sessionId);
989
+ if (!session)
990
+ return { sessionId, round: 0, prompt: null, stopReason: "session_not_found", stopDetail: "No session exists with this ID. It may have expired, been deleted, or never existed." };
991
+ if (session.status !== "running") {
992
+ return { sessionId, round: session.currentRound, prompt: null, stopReason: session.status, stopDetail: `Session is ${session.status}. Use loopforge_resume to restart a paused session, or loopforge_start for a new task.` };
993
+ }
994
+ // ── 2. Extract self-evaluation ───────────────────────────────────────
995
+ const extracted = this.extractEvaluation(output, preExtractedEval);
996
+ if (!extracted) {
997
+ session.status = "stalled";
998
+ this.save(session);
999
+ void this.notifyTerminal(session, "stalled");
1000
+ logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
1001
+ return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail: "Self-evaluation could not be extracted from agent output. Both structured regex and heuristic fallback returned null.", roundSuccess: false };
1002
+ }
1003
+ const { selfEval, extractionFailed } = extracted;
1004
+ // ── 3. Execute round transaction ─────────────────────────────────────
1005
+ const tx = await this.executeRoundTransaction(session, selfEval, extractionFailed);
1006
+ // ── 4. Route disposition ─────────────────────────────────────────────
1007
+ if (tx.pr.action === "reject") {
1008
+ return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
1009
+ }
1010
+ // Accepted rounds advance the before-snapshot baseline. Rejected rounds
1011
+ // retain the original baseline so their retry remains zero-commit.
1012
+ session.evidenceBaseline = tx.actualEvidence;
1013
+ if (tx.pr.action === "terminate") {
1014
+ return this.buildTerminationResult(sessionId, session, tx.pr);
1015
+ }
1016
+ if (tx.pr.action === "stop") {
1017
+ return this.buildStopResult(sessionId, session, tx.pr);
1018
+ }
1019
+ // ── 5. Continue — compile next round ─────────────────────────────────
1020
+ return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.verificationFlags, tx.actualEvidence);
1021
+ }
996
1022
  /** Write back loop knowledge to long-term memory.
997
1023
  * Uses shared base builder from policy.ts. Called when a loop terminates. */
998
1024
  async notifyTerminal(session, stopReason) {