loopforge 3.0.0 → 3.2.1

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 (178) hide show
  1. package/README.md +128 -145
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +158 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +28 -20
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +82 -173
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +25 -16
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +25 -33
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +38 -138
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/enforcement-gate.d.ts +76 -0
  18. package/dist/enforcement-gate.d.ts.map +1 -0
  19. package/dist/enforcement-gate.js +1071 -0
  20. package/dist/enforcement-gate.js.map +1 -0
  21. package/dist/engine.d.ts +99 -0
  22. package/dist/engine.d.ts.map +1 -0
  23. package/dist/engine.js +839 -0
  24. package/dist/engine.js.map +1 -0
  25. package/dist/evidence-claims.d.ts +50 -0
  26. package/dist/evidence-claims.d.ts.map +1 -0
  27. package/dist/evidence-claims.js +134 -0
  28. package/dist/evidence-claims.js.map +1 -0
  29. package/dist/evidence-provider.d.ts +21 -23
  30. package/dist/evidence-provider.d.ts.map +1 -1
  31. package/dist/evidence-provider.js +74 -34
  32. package/dist/evidence-provider.js.map +1 -1
  33. package/dist/generate-schema.js +1 -84
  34. package/dist/generate-schema.js.map +1 -1
  35. package/dist/index.d.ts +19 -22
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +16 -15
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-compiler.d.ts +41 -3
  40. package/dist/loop-compiler.d.ts.map +1 -1
  41. package/dist/loop-compiler.js +632 -273
  42. package/dist/loop-compiler.js.map +1 -1
  43. package/dist/loop-extras-parser.d.ts +2 -2
  44. package/dist/loop-extras-parser.d.ts.map +1 -1
  45. package/dist/loop-extras-parser.js +2 -2
  46. package/dist/loop-extras-parser.js.map +1 -1
  47. package/dist/loop-projection.d.ts +26 -0
  48. package/dist/loop-projection.d.ts.map +1 -0
  49. package/dist/loop-projection.js +142 -0
  50. package/dist/loop-projection.js.map +1 -0
  51. package/dist/loop-store.d.ts +112 -24
  52. package/dist/loop-store.d.ts.map +1 -1
  53. package/dist/loop-store.js +365 -218
  54. package/dist/loop-store.js.map +1 -1
  55. package/dist/mcp/round-lifecycle.d.ts +216 -0
  56. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  57. package/dist/mcp/round-lifecycle.js +926 -0
  58. package/dist/mcp/round-lifecycle.js.map +1 -0
  59. package/dist/mcp/server.d.ts +2 -1
  60. package/dist/mcp/server.d.ts.map +1 -1
  61. package/dist/mcp/server.js +28 -42
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/mcp/session.d.ts +53 -101
  64. package/dist/mcp/session.d.ts.map +1 -1
  65. package/dist/mcp/session.js +306 -1105
  66. package/dist/mcp/session.js.map +1 -1
  67. package/dist/mcp/tools.d.ts +13 -16
  68. package/dist/mcp/tools.d.ts.map +1 -1
  69. package/dist/mcp/tools.js +479 -413
  70. package/dist/mcp/tools.js.map +1 -1
  71. package/dist/policy-metrics.d.ts +12 -17
  72. package/dist/policy-metrics.d.ts.map +1 -1
  73. package/dist/policy-metrics.js +95 -161
  74. package/dist/policy-metrics.js.map +1 -1
  75. package/dist/policy.d.ts +44 -35
  76. package/dist/policy.d.ts.map +1 -1
  77. package/dist/policy.js +31 -115
  78. package/dist/policy.js.map +1 -1
  79. package/dist/prompt-assembler.d.ts +37 -6
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +389 -160
  82. package/dist/prompt-assembler.js.map +1 -1
  83. package/dist/prompt-policy.d.ts +1 -8
  84. package/dist/prompt-policy.d.ts.map +1 -1
  85. package/dist/prompt-policy.js +8 -26
  86. package/dist/prompt-policy.js.map +1 -1
  87. package/dist/protocol.d.ts +357 -229
  88. package/dist/protocol.d.ts.map +1 -1
  89. package/dist/protocol.js +139 -15
  90. package/dist/protocol.js.map +1 -1
  91. package/dist/replay.d.ts +5 -13
  92. package/dist/replay.d.ts.map +1 -1
  93. package/dist/replay.js +19 -55
  94. package/dist/replay.js.map +1 -1
  95. package/dist/round-coordinator.d.ts +20 -24
  96. package/dist/round-coordinator.d.ts.map +1 -1
  97. package/dist/round-coordinator.js +99 -92
  98. package/dist/round-coordinator.js.map +1 -1
  99. package/dist/round-driver.d.ts +14 -13
  100. package/dist/round-driver.d.ts.map +1 -1
  101. package/dist/round-driver.js +26 -14
  102. package/dist/round-driver.js.map +1 -1
  103. package/dist/round-transaction.d.ts +15 -18
  104. package/dist/round-transaction.d.ts.map +1 -1
  105. package/dist/round-transaction.js +38 -47
  106. package/dist/round-transaction.js.map +1 -1
  107. package/dist/self-eval.d.ts +53 -0
  108. package/dist/self-eval.d.ts.map +1 -0
  109. package/dist/self-eval.js +284 -0
  110. package/dist/self-eval.js.map +1 -0
  111. package/dist/storage.d.ts +9 -4
  112. package/dist/storage.d.ts.map +1 -1
  113. package/dist/storage.js +18 -6
  114. package/dist/storage.js.map +1 -1
  115. package/dist/token-utils.d.ts +15 -3
  116. package/dist/token-utils.d.ts.map +1 -1
  117. package/dist/token-utils.js +24 -5
  118. package/dist/token-utils.js.map +1 -1
  119. package/dist/verification-gate.d.ts +108 -0
  120. package/dist/verification-gate.d.ts.map +1 -0
  121. package/dist/verification-gate.js +971 -0
  122. package/dist/verification-gate.js.map +1 -0
  123. package/loop_policy.json +44 -15
  124. package/package.json +5 -10
  125. package/skills/perception/SKILL.md +182 -199
  126. package/dist/canonical-event-writer.d.ts +0 -20
  127. package/dist/canonical-event-writer.d.ts.map +0 -1
  128. package/dist/canonical-event-writer.js +0 -131
  129. package/dist/canonical-event-writer.js.map +0 -1
  130. package/dist/compiler-subgoals.d.ts +0 -7
  131. package/dist/compiler-subgoals.d.ts.map +0 -1
  132. package/dist/compiler-subgoals.js +0 -93
  133. package/dist/compiler-subgoals.js.map +0 -1
  134. package/dist/derive-id.d.ts +0 -12
  135. package/dist/derive-id.d.ts.map +0 -1
  136. package/dist/derive-id.js +0 -31
  137. package/dist/derive-id.js.map +0 -1
  138. package/dist/mcp/session-projection.d.ts +0 -5
  139. package/dist/mcp/session-projection.d.ts.map +0 -1
  140. package/dist/mcp/session-projection.js +0 -18
  141. package/dist/mcp/session-projection.js.map +0 -1
  142. package/dist/mcp/session-queue.d.ts +0 -3
  143. package/dist/mcp/session-queue.d.ts.map +0 -1
  144. package/dist/mcp/session-queue.js +0 -18
  145. package/dist/mcp/session-queue.js.map +0 -1
  146. package/dist/mcp/session-types.d.ts +0 -72
  147. package/dist/mcp/session-types.d.ts.map +0 -1
  148. package/dist/mcp/session-types.js +0 -2
  149. package/dist/mcp/session-types.js.map +0 -1
  150. package/dist/round-commit-store.d.ts +0 -12
  151. package/dist/round-commit-store.d.ts.map +0 -1
  152. package/dist/round-commit-store.js +0 -13
  153. package/dist/round-commit-store.js.map +0 -1
  154. package/dist/round-gate.d.ts +0 -31
  155. package/dist/round-gate.d.ts.map +0 -1
  156. package/dist/round-gate.js +0 -259
  157. package/dist/round-gate.js.map +0 -1
  158. package/dist/round-recovery.d.ts +0 -17
  159. package/dist/round-recovery.d.ts.map +0 -1
  160. package/dist/round-recovery.js +0 -117
  161. package/dist/round-recovery.js.map +0 -1
  162. package/dist/round-report.d.ts +0 -16
  163. package/dist/round-report.d.ts.map +0 -1
  164. package/dist/round-report.js +0 -401
  165. package/dist/round-report.js.map +0 -1
  166. package/dist/runtime-services.d.ts +0 -59
  167. package/dist/runtime-services.d.ts.map +0 -1
  168. package/dist/runtime-services.js +0 -460
  169. package/dist/runtime-services.js.map +0 -1
  170. package/dist/vault-format.d.ts +0 -4
  171. package/dist/vault-format.d.ts.map +0 -1
  172. package/dist/vault-format.js +0 -4
  173. package/dist/vault-format.js.map +0 -1
  174. package/dist/vault-types.d.ts +0 -24
  175. package/dist/vault-types.d.ts.map +0 -1
  176. package/dist/vault-types.js +0 -2
  177. package/dist/vault-types.js.map +0 -1
  178. package/loopforge-policy.json +0 -340
@@ -2,65 +2,70 @@
2
2
  *
3
3
  * Each McpSession = one complete multi-round loop.
4
4
  * SessionManager holds Map<sessionId, McpSession> and drives
5
- * the advance() cycle: validate report → commit round event → check stop → compile next.
5
+ * the advance() cycle: extract → feedback → check stop → compile next.
6
+ *
7
+ * Since v2.14 the round state machine lives in RoundLifecycle
8
+ * (round-lifecycle.ts). SessionManager owns "who may touch a session":
9
+ * the in-memory registry, per-session serialization queue, and cross-process
10
+ * lease fencing. RoundLifecycle owns "what happens to a session": crash
11
+ * recovery, transaction execution, disposition result building, and the
12
+ * advance pipeline. All round processing still goes through the
13
+ * SessionManager → RoundDriver → RoundCoordinator path.
6
14
  */
7
- import { createHash, randomUUID } from "node:crypto";
8
- import { RuntimeServices } from "../runtime-services.js";
15
+ import { randomUUID } from "node:crypto";
16
+ import { LoopForgeEngine } from "../engine.js";
17
+ import { deriveGate } from "../cognitive-governance.js";
18
+ import { makeGateDecision, Mode } from "../protocol.js";
19
+ import { buildLoopProjection } from "../loop-projection.js";
20
+ import { listVerifiedClaims } from "../evidence-claims.js";
21
+ import { buildAudit } from "../audit.js";
9
22
  import { checkLoopHealth } from "../loop-compiler.js";
10
- import { getPolicy } from "../policy.js";
11
- import { Mode, makeLoopCompileRequest } from "../protocol.js";
23
+ import { getPolicy, validateLoopId } from "../policy.js";
24
+ import { makeLoopCompileRequest } from "../protocol.js";
12
25
  import { ReplayBackend } from "../replay.js";
13
- import { FileLoopStore } from "../loop-store.js";
14
- import { EvidenceCollector } from "../evidence-provider.js";
15
- import { parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
26
+ import { FileLoopStore, queryLoopEntries } from "../loop-store.js";
27
+ import { makeRoundId, prepareRoundTransaction } from "../round-transaction.js";
16
28
  import { RoundDriver } from "../round-driver.js";
17
29
  import { logEvent } from "../observability.js";
18
- import { derivePolicyMetrics, mergePolicyMetrics, policyMetrics, } from "../policy-metrics.js";
30
+ import { policyMetrics, derivePolicyMetrics, mergePolicyMetrics, } from "../policy-metrics.js";
19
31
  import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
20
- import { auditEntries, deriveStructuredGate } from "../cognitive-governance.js";
21
- import { isRecord } from "../token-utils.js";
22
- import { parseRoundReport, deriveCompilerProjection } from "../round-report.js";
23
- import { collectSkippedRoundFiles, findSafeRestorePoint, findBacktrackTargetGitHead, } from "../round-recovery.js";
24
- import { CanonicalEventWriter } from "../canonical-event-writer.js";
25
- import { buildLoopRequest } from "./session-projection.js";
26
- import { withSessionQueue } from "./session-queue.js";
27
- // ── Helpers ────────────────────────────────────────────────────────────────
32
+ import { StorageCorruptionError } from "../loop-store.js";
33
+ import { RoundLifecycle, buildLoopRequest } from "./round-lifecycle.js";
28
34
  // ── SessionManager ─────────────────────────────────────────────────────────
29
35
  export class SessionManager {
30
36
  sessions = new Map();
31
37
  /** Serializes state transitions for each session. */
32
38
  sessionQueues = new Map();
33
- backend;
34
- eventWriter;
39
+ loopStore;
35
40
  sessionStore;
36
41
  ownerId = `${process.pid}:${randomUUID()}`;
37
42
  leaseMs;
38
43
  leaseRenewIntervalMs;
39
44
  leaseTimer = null;
45
+ lifecycle;
40
46
  /** Explicit context provider; never auto-discovered. */
41
47
  contextProvider;
42
48
  terminalSinks = new Set();
43
- /** Vault-replayed policy metrics, keyed by loop. The signature covers every
44
- * entry's task_id/timestamp/sequence so both appends and idempotent
45
- * replacements (same task_id) invalidate the cache. */
46
- derivedMetricsCache = new Map();
47
- constructor(storeOrBackend, sessionStore) {
48
- const store = storeOrBackend ?? new FileLoopStore(getPolicy().backend.root_dir);
49
- this.backend = store;
50
- this.sessionStore = sessionStore ?? new VaultSessionStateStore(store);
49
+ constructor(store, sessionStore) {
50
+ this.loopStore = store ?? new FileLoopStore(getPolicy().backend.root_dir);
51
+ this.sessionStore = sessionStore ?? new VaultSessionStateStore(this.loopStore);
51
52
  const mcpPolicy = getPolicy().mcp;
52
53
  this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
53
54
  this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
55
+ this.lifecycle = new RoundLifecycle({
56
+ store: this.loopStore,
57
+ sessionStore: this.sessionStore,
58
+ registry: this,
59
+ terminalSinks: this.terminalSinks,
60
+ ownerId: this.ownerId,
61
+ leaseMs: this.leaseMs,
62
+ getContext: () => this.contextProvider,
63
+ });
54
64
  if (this.sessionStore?.renewLease) {
55
65
  this.leaseTimer = setInterval(() => this.renewOwnedLeases(), this.leaseRenewIntervalMs);
56
66
  this.leaseTimer.unref?.();
57
67
  }
58
68
  }
59
- appendCanonicalEvent(entry) {
60
- if (!this.backend)
61
- throw new Error("Vault backend is not initialized");
62
- (this.eventWriter ??= new CanonicalEventWriter(this.backend)).append(entry);
63
- }
64
69
  /** Stable process-local owner token used for cross-process session leases. */
65
70
  getOwnerId() {
66
71
  return this.ownerId;
@@ -78,6 +83,15 @@ export class SessionManager {
78
83
  this.sessionStore?.releaseLease?.(session.loopId, this.ownerId);
79
84
  }
80
85
  }
86
+ // ── SessionRegistry (view for RoundLifecycle) ────────────────────────────
87
+ values() {
88
+ return this.sessions.values();
89
+ }
90
+ /** Register (or replace) a session in the in-memory registry. */
91
+ upsert(session) {
92
+ this.sessions.set(session.sessionId, session);
93
+ }
94
+ // ── Lease helpers ────────────────────────────────────────────────────────
81
95
  findSessionEntry(loopId) {
82
96
  return this.sessionStore?.load(loopId);
83
97
  }
@@ -113,29 +127,67 @@ export class SessionManager {
113
127
  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.`,
114
128
  };
115
129
  }
130
+ async withSessionQueue(sessionId, work) {
131
+ const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
132
+ let release;
133
+ const gate = new Promise((resolve) => {
134
+ release = resolve;
135
+ });
136
+ const tail = previous.then(() => gate);
137
+ this.sessionQueues.set(sessionId, tail);
138
+ await previous;
139
+ try {
140
+ return await work();
141
+ }
142
+ finally {
143
+ release();
144
+ if (this.sessionQueues.get(sessionId) === tail) {
145
+ this.sessionQueues.delete(sessionId);
146
+ }
147
+ }
148
+ }
149
+ // ── Lifecycle entry points ───────────────────────────────────────────────
116
150
  async create(input) {
117
- const policyMaxRounds = getPolicy().engine.max_rounds;
118
- if (input.maxRounds !== undefined &&
119
- (!Number.isInteger(input.maxRounds) || input.maxRounds < 1 || input.maxRounds > policyMaxRounds)) {
151
+ const sessionId = randomUUID();
152
+ const loopId = input.loopId ?? randomUUID();
153
+ // v2.14: same-process duplicate guard. Cross-process duplicates are
154
+ // fenced by the lease (SessionLeaseConflictError on save); within one
155
+ // process the lease owner is identical, so a second create for the
156
+ // same loopId would silently overwrite the first session's persisted
157
+ // state and leave two in-memory sessions pointing at one loop.
158
+ const existing = [...this.sessions.values()].find((s) => s.loopId === loopId);
159
+ if (existing) {
120
160
  return {
121
161
  sessionId: "",
122
- round: 0,
162
+ round: existing.currentRound,
123
163
  prompt: null,
124
- stopReason: "invalid_input",
125
- stopDetail: `maxRounds must be an integer between 1 and ${policyMaxRounds}.`,
126
- error: { code: "invalid_max_rounds", message: `maxRounds must be an integer between 1 and ${policyMaxRounds}.` },
164
+ stopReason: `loop_already_running:${loopId}`,
165
+ stopDetail: `A session for loop "${loopId}" already exists in this process (session ${existing.sessionId}). Inspect or resume it instead of starting a duplicate.`,
127
166
  };
128
167
  }
129
- const sessionId = randomUUID();
130
- const loopId = input.loopId ?? randomUUID();
131
- const engine = new RuntimeServices(this.backend);
168
+ // v3.2.1: pre-flight the cross-process lease BEFORE compiling — the
169
+ // compile persists round-1 lineage and the state file into the loop's
170
+ // vault, which would corrupt another process's live loop (round-1
171
+ // lineage replaced, metadata overwritten). Mirrors the save-time check
172
+ // (VaultSessionStateStore.save: a non-empty lease_owner that is not ours
173
+ // conflicts), so observable behavior is unchanged — only the side
174
+ // effects are avoided. The save below remains the atomic final gate.
175
+ const persistedSession = this.sessionStore?.load(loopId);
176
+ const persistedLineage = persistedSession?.loop_lineage;
177
+ const persistedOwner = persistedLineage && typeof persistedLineage === "object" && !Array.isArray(persistedLineage)
178
+ ? persistedLineage.lease_owner
179
+ : "";
180
+ if (typeof persistedOwner === "string" && persistedOwner && persistedOwner !== this.ownerId) {
181
+ throw new SessionLeaseConflictError(loopId);
182
+ }
183
+ const engine = new LoopForgeEngine(this.loopStore);
132
184
  const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
133
185
  // Populate extra fields for the first round
134
186
  const request = buildLoopRequest({
135
- sessionId, loopId, task: input.task, runtime: engine, currentRound: 1,
187
+ sessionId, loopId, task: input.task, engine, currentRound: 1,
136
188
  maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
137
189
  consecutiveRejections: 0,
138
- escalatedRejectionChecks: [],
190
+ lastRejectionCheck: "",
139
191
  driftClarificationStreak: 0,
140
192
  backtrackSkippedFiles: [],
141
193
  evidenceBaseline: [],
@@ -143,21 +195,6 @@ export class SessionManager {
143
195
  request.domain = input.domain ?? "";
144
196
  request.plan_source = input.planSource ?? null;
145
197
  request.constraints_from_plan = input.constraints ?? [];
146
- const successCriteria = (input.successCriteria ?? [])
147
- .filter((criterion) => typeof criterion === "string")
148
- .map((criterion) => criterion.trim())
149
- .filter(Boolean);
150
- if (successCriteria.length > 0) {
151
- request.loop_objective = {
152
- objective: input.task,
153
- success_criteria: successCriteria,
154
- hard_constraints: input.constraints ?? [],
155
- created_at_round: 1,
156
- loop_id: loopId,
157
- version: 1,
158
- refinement_history: [],
159
- };
160
- }
161
198
  // Explicit context is requested by the MCP embedding; no auto-discovery.
162
199
  if (this.contextProvider) {
163
200
  try {
@@ -181,27 +218,17 @@ export class SessionManager {
181
218
  logEvent("context_provider_error", { loopId, round: 1 });
182
219
  }
183
220
  }
184
- const prepared = await new RoundDriver(engine, this.backend).prepare(request, loopId, 1);
185
- if (!prepared) {
186
- return {
187
- sessionId,
188
- round: 1,
189
- prompt: null,
190
- stopReason: "prompt_compile_failed",
191
- stopDetail: "Prompt compilation failed; no session state was persisted.",
192
- error: { code: "prompt_compile_failed", message: "Prompt compilation failed while starting.", retryable: true },
193
- };
194
- }
221
+ const prepared = await new RoundDriver(engine, this.loopStore).prepare(request, loopId, 1);
195
222
  const initialPrompt = prepared?.prompt ?? null;
196
223
  const initialLevel = prepared?.level ?? "l2";
197
224
  const evidenceBaseline = prepared?.evidenceBaseline ?? [];
198
225
  const session = {
199
- sessionId, loopId, task: input.task, runtime: engine,
226
+ sessionId, loopId, task: input.task, engine,
200
227
  currentRound: 1, maxRounds, successTrajectory: [],
201
228
  status: "running", createdAt: Date.now(),
202
229
  // v1.13: Enforcement gate state
203
230
  consecutiveRejections: 0,
204
- escalatedRejectionChecks: [],
231
+ lastRejectionCheck: "",
205
232
  driftClarificationStreak: 0,
206
233
  backtrackSkippedFiles: [],
207
234
  evidenceBaseline,
@@ -209,14 +236,23 @@ export class SessionManager {
209
236
  currentPrompt: initialPrompt,
210
237
  currentLevel: initialLevel,
211
238
  currentWarnings: prepared?.warnings ?? [],
212
- currentCanonicalState: isRecord(prepared?.canonicalState)
213
- ? prepared.canonicalState
214
- : undefined,
239
+ lastCompileResponse: prepared?.compileResponse ?? null,
215
240
  };
216
241
  this.sessions.set(sessionId, session);
217
242
  policyMetrics.recordStrategy(loopId, initialLevel);
218
- // Persist to vault for cross-process recovery
219
- this.save(session);
243
+ // Persist to vault for cross-process recovery. The save is the atomic
244
+ // lease gate — a TOCTOU window between the pre-flight check above and
245
+ // this write can still lose the lease race to another process.
246
+ try {
247
+ this.lifecycle.save(session);
248
+ }
249
+ catch (error) {
250
+ // v3.2.1: roll back the in-memory registration — a registered-but-
251
+ // unsaved session would make every later create for this loopId
252
+ // return loop_already_running until this process restarts.
253
+ this.sessions.delete(sessionId);
254
+ throw error;
255
+ }
220
256
  logEvent("session_start", {
221
257
  sessionId,
222
258
  loopId,
@@ -256,156 +292,13 @@ export class SessionManager {
256
292
  : 0,
257
293
  };
258
294
  }
259
- audit(loopId) {
260
- return { ...auditEntries(loopId, this.backend.queryLoopEvents(loopId)) };
261
- }
262
- inspect(loopId, round) {
263
- const taskId = `loop:${loopId}:r${round}`;
264
- const loopEntries = this.backend.queryLoopEvents(loopId);
265
- const lineage = loopEntries
266
- .find((entry) => entry.task_id === taskId);
267
- const roundEvent = loopEntries.find((entry) => entry.task_id === `${taskId}:event`);
268
- if (!lineage && !roundEvent)
269
- return null;
270
- const report = parseRoundReport(roundEvent?.round_report);
271
- return {
272
- format: "loopforge.inspect",
273
- loopId,
274
- round,
275
- lineage: lineage ?? null,
276
- roundEvent: roundEvent ? {
277
- taskId: roundEvent.task_id,
278
- timestamp: roundEvent.timestamp,
279
- success: roundEvent.success === true,
280
- report,
281
- evidence: report?.evidence ?? null,
282
- transaction: roundEvent.loop_lineage?.round_transaction ?? null,
283
- } : null,
284
- };
285
- }
286
- async retry(sessionId, report) {
287
- const session = this.sessions.get(sessionId);
288
- if (!session)
289
- return { sessionId, round: 0, prompt: null, stopReason: "session_not_found" };
290
- if ((session.roundSnapshot?.attempt ?? 1) <= 1) {
291
- return {
292
- sessionId,
293
- round: session.currentRound,
294
- prompt: session.currentPrompt ?? null,
295
- stopReason: "retry_not_pending",
296
- stopDetail: "The current round has not been rejected; submit it with loopforge_next.",
297
- };
298
- }
299
- return this.advance(sessionId, report);
300
- }
301
- async backtrack(sessionId) {
302
- return withSessionQueue(this.sessionQueues, sessionId, async () => {
303
- const session = this.sessions.get(sessionId);
304
- if (!session)
305
- return { sessionId, round: 0, prompt: null, stopReason: "session_not_found" };
306
- if (session.status !== "running") {
307
- return { sessionId, round: session.currentRound, prompt: null, stopReason: session.status };
308
- }
309
- const entries = this.backend
310
- ? this.backend.queryLoopEvents(session.loopId)
311
- .filter((entry) => String(entry.task_id ?? "").startsWith(`loop:${session.loopId}:r`))
312
- : [];
313
- const restore = findSafeRestorePoint(session.currentRound, entries, getPolicy().engine.backtrack_max_depth);
314
- if (!restore) {
315
- return {
316
- sessionId,
317
- round: session.currentRound,
318
- prompt: session.currentPrompt ?? null,
319
- stopReason: "no_clean_restore_point",
320
- stopDetail: "No clean round exists within the configured backtrack depth.",
321
- };
322
- }
323
- const skippedFiles = collectSkippedRoundFiles(restore.round, session.currentRound, entries);
324
- return this.buildBacktrackResult(sessionId, session, {
325
- action: "backtrack",
326
- roundSuccess: false,
327
- shouldPushSuccessTrajectory: false,
328
- newConsecutiveRejections: 0,
329
- verificationFlags: [],
330
- gateContradicted: false,
331
- backtrackTarget: restore.round,
332
- backtrackTargetGitHead: findBacktrackTargetGitHead(restore.round, entries),
333
- backtrackSkippedDiscoveries: restore.skippedDiscoveries,
334
- backtrackSkippedFiles: skippedFiles,
335
- backtrackTriggerRule: "manual",
336
- backtrackPrompt: `# Manual Backtrack\n\nRestore the workspace to the state after Round ${restore.round}, ` +
337
- "then verify the affected files before continuing. LoopForge does not execute restore commands.",
338
- enforcementReason: `Manual backtrack to clean Round ${restore.round}`,
339
- });
340
- });
341
- }
342
- cognitiveProjection(sessionId) {
343
- const session = this.sessions.get(sessionId);
344
- if (!session)
345
- return null;
346
- if (session.currentCanonicalState)
347
- return session.currentCanonicalState;
348
- const request = buildLoopRequest(session, session.lastRoundProjection);
349
- const context = session.runtime.hydrateLoopContext(session.loopId);
350
- const result = session.runtime.invokeLoopCompile(request, context, {
351
- persistLineage: false,
352
- });
353
- return isRecord(result.response?.canonical_state)
354
- ? result.response.canonical_state
355
- : null;
356
- }
357
- checkGate(action) {
358
- return deriveStructuredGate(action);
359
- }
360
- resolveGate(sessionId, gateId, action, approved, note) {
361
- const session = this.sessions.get(sessionId);
362
- if (!session)
363
- return { error: `session not found: ${sessionId}` };
364
- const descriptor = action;
365
- const classified = deriveStructuredGate(descriptor);
366
- if (classified.id !== gateId) {
367
- return { error: "gateId does not match the supplied action and scope" };
368
- }
369
- if (classified.gate.kind !== "user") {
370
- return { error: "agent gates are resolved by submitting the required evidence" };
371
- }
372
- const decidedAt = new Date().toISOString();
373
- const decision = {
374
- gateId,
375
- kind: classified.gate.kind,
376
- approved,
377
- scope: descriptor.scope,
378
- note,
379
- decidedAt,
380
- actionHash: classified.actionHash,
381
- };
382
- this.appendCanonicalEvent({
383
- task_id: `loop:${session.loopId}:gate:${gateId}`,
384
- task_type: "gate_decision",
385
- loop_id: session.loopId,
386
- gate_id: gateId,
387
- approved,
388
- decision_note: note,
389
- gate_decision: decision,
390
- timestamp: decidedAt,
391
- loop_lineage: { round: session.currentRound, gate_id: gateId, action_hash: classified.actionHash },
392
- });
393
- // Gate decisions change the derived state immediately. Do not let the
394
- // status view serve a pre-decision canonical-state cache.
395
- session.currentCanonicalState = undefined;
396
- return { sessionId, loopId: session.loopId, gateId, approved };
397
- }
398
295
  delete(sessionId) {
399
296
  const session = this.sessions.get(sessionId);
400
297
  if (!session)
401
298
  return false;
402
299
  session.status = "stopped";
403
- session.stopReason = "cancelled";
404
- this.save(session);
405
- // Durable terminal event after the session_state write, before the
406
- // in-memory session is dropped (A4).
407
- this.persistTerminalEvent(session, "cancelled");
408
- void this.notifyTerminal(session, "cancelled");
300
+ this.lifecycle.save(session);
301
+ void this.lifecycle.notifyTerminal(session, "cancelled");
409
302
  this.sessions.delete(sessionId);
410
303
  this.sessionQueues.delete(sessionId);
411
304
  logEvent("session_end", {
@@ -427,7 +320,7 @@ export class SessionManager {
427
320
  return { sessionId, round: session.currentRound, status: session.status };
428
321
  }
429
322
  session.status = "paused";
430
- this.save(session);
323
+ this.lifecycle.save(session);
431
324
  logEvent("session_paused", {
432
325
  sessionId,
433
326
  loopId: session.loopId,
@@ -435,329 +328,15 @@ export class SessionManager {
435
328
  });
436
329
  return { sessionId, round: session.currentRound, status: "paused" };
437
330
  }
438
- restoredPromptResult(session) {
439
- if (!session.currentPrompt)
440
- return null;
441
- return {
442
- sessionId: session.sessionId,
443
- round: session.currentRound,
444
- roundId: session.roundSnapshot?.roundId,
445
- prompt: session.currentPrompt,
446
- level: session.currentLevel ?? "l2",
447
- roundSuccess: undefined,
448
- warnings: (session.currentWarnings ?? []),
449
- };
450
- }
451
- /** Reconcile the crash window where a round event committed but session_state
452
- * still points at the old prompt. Returns null when no commit is pending. */
453
- async reconcileCommittedRound(session) {
454
- if (!session.roundSnapshot)
455
- return null;
456
- const recovered = new RoundDriver(session.runtime, this.backend).recover(session.roundSnapshot);
457
- if (!recovered)
458
- return null;
459
- const pr = recovered.result;
460
- session.roundSnapshot = recovered.snapshot;
461
- // Replay the committed counter. Rejections accumulate globally across
462
- // all checks (v2.14) so alternating rejection reasons cannot dodge R6.
463
- session.consecutiveRejections = pr.newConsecutiveRejections;
464
- this.updateEscalationChecks(session, pr);
465
- if (pr.newRoundProjection)
466
- session.lastRoundProjection = pr.newRoundProjection;
467
- if (pr.shouldPushSuccessTrajectory &&
468
- session.successTrajectory.length < session.currentRound) {
469
- session.successTrajectory.push(pr.roundSuccess);
470
- }
471
- session.currentPrompt = null;
472
- if (pr.action === "stop" || pr.action === "terminate") {
473
- const reason = pr.action === "terminate"
474
- ? "enforcement_terminated"
475
- : pr.stopReason ?? "stalled";
476
- session.status = reason === "stalled" ? "stalled" : "stopped";
477
- this.save(session);
478
- void this.notifyTerminal(session, reason);
479
- return {
480
- sessionId: session.sessionId,
481
- round: session.currentRound,
482
- roundId: recovered.snapshot.roundId,
483
- prompt: null,
484
- stopReason: reason,
485
- stopDetail: reason === "completed"
486
- ? "Task completed successfully — the agent declared should_continue=false with success=true."
487
- : reason === "blocked"
488
- ? "Agent cannot proceed under current constraints. The task may need revised constraints or human intervention."
489
- : reason === "failed"
490
- ? "Agent declared should_continue=false with success=false — the task was not achieved."
491
- : `Loop stopped: ${reason}.`,
492
- roundSuccess: pr.roundSuccess,
493
- };
494
- }
495
- if (pr.action === "backtrack") {
496
- // Backtrack committed but prompt not delivered (crash window).
497
- // Reset round counter to the restore target + 1 so the next
498
- // resume / unpause compiles from the correct restored state.
499
- const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
500
- session.currentRound = restoreTarget + 1;
501
- session.consecutiveRejections = 0;
502
- session.escalatedRejectionChecks = [];
503
- session.driftClarificationStreak = 0;
504
- session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
505
- session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
506
- session.lastRoundProjection = undefined;
507
- session.currentPrompt = null;
508
- this.save(session);
509
- // Return null — caller (resume/unpause) will recompile normally
510
- return null;
511
- }
512
- if (pr.action !== "continue")
513
- return null;
514
- session.currentRound++;
515
- const request = buildLoopRequest(session, session.lastRoundProjection, pr.verificationFlags);
516
- const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, session.currentRound);
517
- if (!prepared) {
518
- return {
519
- sessionId: session.sessionId,
520
- round: session.currentRound,
521
- prompt: null,
522
- stopReason: "prompt_compile_failed",
523
- stopDetail: "Prompt compilation failed during crash recovery; the persisted round remains unchanged and can be retried.",
524
- error: { code: "prompt_compile_failed", message: "Prompt compilation failed during crash recovery.", retryable: true },
525
- };
526
- }
527
- const prompt = prepared.prompt;
528
- session.evidenceBaseline = prepared.evidenceBaseline;
529
- session.roundSnapshot = prepared.snapshot;
530
- session.currentPrompt = prompt;
531
- session.currentLevel = prepared.level;
532
- session.currentWarnings = prepared?.warnings ?? [];
533
- session.currentCanonicalState = isRecord(prepared.canonicalState)
534
- ? prepared.canonicalState
535
- : undefined;
536
- policyMetrics.recordStrategy(session.loopId, session.currentLevel);
537
- this.save(session);
538
- return {
539
- sessionId: session.sessionId,
540
- round: session.currentRound,
541
- roundId: session.roundSnapshot.roundId,
542
- prompt,
543
- level: session.currentLevel,
544
- roundSuccess: pr.roundSuccess,
545
- warnings: (session.currentWarnings ?? []),
546
- };
547
- }
548
- /** v1.18: Resume a paused session. Reconstructs from vault state and
549
- * compiles the next prompt. Returns null if no paused session exists
550
- * for this loopId. */
551
- async unpause(loopId) {
552
- const persistedEntry = this.findSessionEntry(loopId);
553
- if (!persistedEntry)
554
- return null;
555
- const lineage = (persistedEntry.loop_lineage ?? {});
556
- const status = lineage.status ?? "running";
557
- if (status !== "paused")
558
- return null;
559
- const sessionEntry = this.claimSessionEntry(loopId);
560
- if (!sessionEntry)
561
- return this.leaseConflictResult(loopId, persistedEntry);
562
- const session = this.reconstructSession(sessionEntry, true);
563
- if (!session)
564
- return null;
565
- // Set to running so advance() works
566
- session.status = "running";
567
- this.sessions.set(session.sessionId, session);
568
- // Replace the sync-fallback evidence baseline with async evidence
569
- // so resumed sessions don't silently drop async provider data.
570
- try {
571
- const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
572
- loopId: session.loopId,
573
- phase: "before",
574
- });
575
- if (asyncEvidence.length > 0) {
576
- session.evidenceBaseline = asyncEvidence;
577
- if (!session.roundSnapshot?.beforeEvidence?.length) {
578
- session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
579
- }
580
- }
581
- }
582
- catch {
583
- // Async evidence is best-effort; fall back to sync baseline.
584
- }
585
- const reconciled = await this.reconcileCommittedRound(session);
586
- if (reconciled)
587
- return reconciled;
588
- const restored = this.restoredPromptResult(session);
589
- if (restored) {
590
- this.save(session);
591
- return restored;
592
- }
593
- // Compile the next prompt from the current round state
594
- const lcr = makeLoopCompileRequest({
595
- loop_id: session.loopId,
596
- round: session.currentRound,
597
- goal_id: "",
598
- task: session.task,
599
- domain: undefined,
600
- plan_source: null,
601
- constraints_from_plan: [],
602
- health_check_interval: 1,
603
- });
604
- const compileRequest = {
605
- task: session.task,
606
- mode: Mode.LOOP_COMPILE,
607
- skill_name: null,
608
- task_id: null,
609
- loop_id: lcr.loop_id,
610
- round: lcr.round,
611
- goal_id: lcr.goal_id,
612
- domain: lcr.domain ?? "",
613
- plan_source: lcr.plan_source ?? null,
614
- constraints_from_plan: lcr.constraints_from_plan ?? [],
615
- health_check_interval: lcr.health_check_interval,
616
- max_rounds: session.maxRounds,
617
- };
618
- const prepared = await new RoundDriver(session.runtime, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
619
- if (!prepared) {
620
- return {
621
- sessionId: session.sessionId,
622
- round: session.currentRound,
623
- prompt: null,
624
- stopReason: "prompt_compile_failed",
625
- stopDetail: "Prompt compilation failed while resuming; the paused session remains recoverable.",
626
- error: { code: "prompt_compile_failed", message: "Prompt compilation failed during resume.", retryable: true },
627
- };
628
- }
629
- const prompt = prepared.prompt;
630
- const level = prepared.level;
631
- session.evidenceBaseline = prepared.evidenceBaseline;
632
- session.roundSnapshot = prepared.snapshot;
633
- session.currentPrompt = prompt;
634
- session.currentLevel = level;
635
- session.currentWarnings = prepared?.warnings ?? [];
636
- session.currentCanonicalState = isRecord(prepared.canonicalState)
637
- ? prepared.canonicalState
638
- : undefined;
639
- policyMetrics.recordStrategy(session.loopId, level);
640
- this.save(session);
641
- return {
642
- sessionId: session.sessionId,
643
- round: session.currentRound,
644
- roundId: session.roundSnapshot.roundId,
645
- prompt,
646
- level,
647
- roundSuccess: undefined,
648
- warnings: [],
649
- };
650
- }
651
- /** Persist session state to vault for cross-process recovery.
652
- * The filtered vault and replacement entry are written once under the
653
- * backend lock, so recovery never observes the old two-write gap. */
331
+ /** Persist session state to vault for cross-process recovery. Delegates to
332
+ * the round lifecycle, which owns the durable session document shape. */
654
333
  save(session) {
655
- if (!this.sessionStore)
656
- return;
657
- const leaseActive = session.status === "running";
658
- const sessionEntry = {
659
- task_id: `loop:${session.loopId}:session`,
660
- task_type: "session_state",
661
- timestamp: new Date().toISOString(),
662
- loop_id: session.loopId,
663
- task: session.task,
664
- loop_lineage: {
665
- session_id: session.sessionId,
666
- current_round: session.currentRound,
667
- max_rounds: session.maxRounds,
668
- success_trajectory: session.successTrajectory,
669
- status: session.status,
670
- created_at: session.createdAt,
671
- // v1.13: Enforcement gate state
672
- consecutive_rejections: session.consecutiveRejections,
673
- // Enforcement escalation is tracked per terminal rule.
674
- escalated_rejection_checks: session.escalatedRejectionChecks,
675
- // v2.12: Clarification streak
676
- drift_clarification_streak: session.driftClarificationStreak,
677
- // v2.13: Backtrack skipped files for workspace restore check
678
- backtrack_skipped_files: session.backtrackSkippedFiles,
679
- // v2.13: Git HEAD of the active backtrack restore point
680
- backtrack_target_git_head: session.backtrackTargetGitHead ?? "",
681
- // v1.19: durable round transaction state
682
- round_snapshot: session.roundSnapshot ?? null,
683
- current_prompt: session.currentPrompt ?? null,
684
- current_level: session.currentLevel ?? "",
685
- stop_reason: session.stopReason ?? "",
686
- // v1.20: cross-process single-owner lease
687
- lease_owner: leaseActive ? this.ownerId : "",
688
- lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
689
- },
690
- };
691
- this.sessionStore.save(sessionEntry, {
692
- expectedLeaseOwner: this.ownerId,
693
- });
694
- }
695
- /** Reconstruct a McpSession from a vault session_state entry.
696
- * Returns null if the entry is not "running" status.
697
- * Shared by resume() and autoResumeAll(). */
698
- reconstructSession(entry, allowPaused = false) {
699
- const lineage = (entry.loop_lineage ?? {});
700
- const status = lineage.status ?? "running";
701
- if (status !== "running" && !(allowPaused && status === "paused")) {
702
- return null;
703
- }
704
- const loopId = entry.loop_id;
705
- const currentRound = lineage.current_round ?? 1;
706
- const successTrajectory = Array.isArray(lineage.success_trajectory)
707
- ? lineage.success_trajectory.filter((value) => typeof value === "boolean")
708
- : [];
709
- const task = entry.task ?? "";
710
- const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
711
- const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
712
- const latestReport = this.backend.queryLoopEvents(loopId)
713
- .filter((entry) => String(entry.task_id ?? "").startsWith(`loop:${loopId}:r`) && String(entry.task_id ?? "").endsWith(":event"))
714
- .map((roundEvent) => ({
715
- round: typeof roundEvent.loop_lineage?.round === "number"
716
- ? roundEvent.loop_lineage.round
717
- : 0,
718
- report: parseRoundReport(roundEvent.round_report),
719
- })).filter((candidate) => candidate.report !== null)
720
- .sort((a, b) => b.round - a.round)[0]?.report;
721
- const lastRoundProjection = latestReport
722
- ? deriveCompilerProjection(latestReport, currentRound - 1)
723
- : undefined;
724
- const engine = new RuntimeServices(this.backend);
725
- return {
726
- sessionId: typeof lineage.session_id === "string" && lineage.session_id
727
- ? lineage.session_id
728
- : randomUUID(),
729
- loopId, task, runtime: engine,
730
- currentRound, maxRounds, successTrajectory,
731
- status: status,
732
- createdAt: lineage.created_at ?? Date.now(),
733
- consecutiveRejections: lineage.consecutive_rejections ?? 0,
734
- escalatedRejectionChecks: Array.isArray(lineage.escalated_rejection_checks)
735
- ? lineage.escalated_rejection_checks.filter((value) => typeof value === "string")
736
- : [],
737
- driftClarificationStreak: lineage.drift_clarification_streak ?? 0,
738
- backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
739
- backtrackTargetGitHead: typeof lineage.backtrack_target_git_head === "string" &&
740
- lineage.backtrack_target_git_head
741
- ? lineage.backtrack_target_git_head
742
- : undefined,
743
- evidenceBaseline: roundSnapshot?.beforeEvidence ?? [],
744
- roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, []),
745
- lastRoundProjection,
746
- currentPrompt: typeof lineage.current_prompt === "string"
747
- ? lineage.current_prompt
748
- : null,
749
- currentLevel: typeof lineage.current_level === "string"
750
- ? lineage.current_level
751
- : undefined,
752
- stopReason: typeof lineage.stop_reason === "string" && lineage.stop_reason
753
- ? lineage.stop_reason
754
- : undefined,
755
- };
334
+ this.lifecycle.save(session);
756
335
  }
757
336
  /** Resume a loop from vault state.
758
337
  * Reconstructs the session and compiles the prompt for the next round.
759
338
  * Returns null if no session_state entry exists for this loopId. */
760
- async resume(loopId) {
339
+ resume(loopId) {
761
340
  const persistedEntry = this.findSessionEntry(loopId);
762
341
  if (!persistedEntry)
763
342
  return null;
@@ -768,7 +347,7 @@ export class SessionManager {
768
347
  : persistedEntry;
769
348
  if (!sessionEntry)
770
349
  return this.leaseConflictResult(loopId, persistedEntry);
771
- const session = this.reconstructSession(sessionEntry);
350
+ const session = this.lifecycle.reconstructSession(sessionEntry);
772
351
  if (!session) {
773
352
  // was not "running" — return stopped/stalled status
774
353
  const lineage = (sessionEntry.loop_lineage ?? {});
@@ -783,47 +362,29 @@ export class SessionManager {
783
362
  };
784
363
  }
785
364
  this.sessions.set(session.sessionId, session);
786
- const reconciled = await this.reconcileCommittedRound(session);
787
- if (reconciled)
788
- return reconciled;
789
- const restored = this.restoredPromptResult(session);
790
- if (restored)
791
- return restored;
792
- // A persisted session may lack a prompt after an interrupted handoff;
793
- // compile the current canonical state once and persist the replacement.
794
- const request = buildLoopRequest(session);
795
- const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, session.currentRound);
796
- if (!prepared) {
797
- return {
798
- sessionId: session.sessionId,
799
- round: session.currentRound,
800
- roundId: session.roundSnapshot?.roundId,
801
- prompt: null,
802
- stopReason: "prompt_compile_failed",
803
- stopDetail: "Prompt compilation failed; the persisted session remains unchanged and can be retried.",
804
- error: { code: "prompt_compile_failed", message: "Prompt compilation failed while resuming.", retryable: true },
805
- };
806
- }
807
- const prompt = prepared.prompt;
808
- session.evidenceBaseline = prepared.evidenceBaseline;
809
- session.roundSnapshot = prepared.snapshot;
810
- session.currentPrompt = prompt;
811
- session.currentLevel = prepared?.level ?? "l2";
812
- session.currentWarnings = prepared?.warnings ?? [];
813
- session.currentCanonicalState = isRecord(prepared?.canonicalState)
814
- ? prepared.canonicalState
815
- : undefined;
816
- policyMetrics.recordStrategy(session.loopId, session.currentLevel);
817
- this.save(session);
818
- return {
819
- sessionId: session.sessionId,
820
- round: session.currentRound,
821
- roundId: session.roundSnapshot.roundId,
822
- prompt,
823
- level: session.currentLevel,
824
- roundSuccess: false,
825
- warnings: (session.currentWarnings ?? []),
826
- };
365
+ return this.lifecycle.resume(session);
366
+ }
367
+ /** v1.18: Resume a paused session. Reconstructs from vault state and
368
+ * compiles the next prompt. Returns null if no paused session exists
369
+ * for this loopId. */
370
+ async unpause(loopId) {
371
+ const persistedEntry = this.findSessionEntry(loopId);
372
+ if (!persistedEntry)
373
+ return null;
374
+ const lineage = (persistedEntry.loop_lineage ?? {});
375
+ const status = lineage.status ?? "running";
376
+ if (status !== "paused")
377
+ return null;
378
+ const sessionEntry = this.claimSessionEntry(loopId);
379
+ if (!sessionEntry)
380
+ return this.leaseConflictResult(loopId, persistedEntry);
381
+ const session = this.lifecycle.reconstructSession(sessionEntry, true);
382
+ if (!session)
383
+ return null;
384
+ // Set to running so advance() works
385
+ session.status = "running";
386
+ this.sessions.set(session.sessionId, session);
387
+ return this.lifecycle.unpause(session);
827
388
  }
828
389
  /** Auto-resume all "running" sessions from vault on server startup.
829
390
  * Scans vault for session_state entries, reconstructs each as an in-memory
@@ -852,7 +413,20 @@ export class SessionManager {
852
413
  const claimedEntry = this.claimSessionEntry(lid);
853
414
  if (!claimedEntry)
854
415
  continue;
855
- const session = this.reconstructSession(claimedEntry, true);
416
+ // v2.14: a bulk startup scan must not die on one corrupt loop —
417
+ // recoverable gaps are recorded and skipped; corrupted storage
418
+ // (invalid JSON/format/sequence) still surfaces to the caller.
419
+ let session = null;
420
+ try {
421
+ session = this.lifecycle.reconstructSession(claimedEntry, true);
422
+ }
423
+ catch (error) {
424
+ if (error instanceof StorageCorruptionError && error.recoverable) {
425
+ logEvent("session_skip_gap", { loopId: lid, error: error.message });
426
+ continue;
427
+ }
428
+ throw error;
429
+ }
856
430
  if (session) {
857
431
  this.sessions.set(session.sessionId, session);
858
432
  activeLoopIds.add(lid);
@@ -895,55 +469,143 @@ export class SessionManager {
895
469
  }
896
470
  return result;
897
471
  }
898
- /** Stable signature over the vault event list. A changed signature means the
899
- * vault has grown or an idempotent replacement landed, so the cached
900
- * snapshot must be recomputed. Physical order is stable: writeEntry appends,
901
- * and replacements update in place. */
902
- metricsSignature(entries) {
903
- const hash = createHash("sha256");
904
- for (const entry of entries) {
905
- hash.update(entry.task_id ?? "");
906
- hash.update("\\u0000");
907
- hash.update(entry.timestamp ?? "");
908
- hash.update("\\u0000");
909
- hash.update(String(entry.event_sequence ?? ""));
910
- hash.update("\\u0000");
911
- hash.update(entry.task_type ?? "");
912
- }
913
- return hash.digest("hex");
914
- }
915
- /** Policy metrics that survive process restarts: durable events are
916
- * replayed through derivePolicyMetrics and merged with live in-process
917
- * observations (rejections never reach the vault). */
918
- getLoopPolicyMetrics(loopId) {
919
- const entries = this.backend.queryLoopEvents(loopId);
920
- const signature = this.metricsSignature(entries);
921
- const cached = this.derivedMetricsCache.get(loopId);
922
- if (cached && cached.signature === signature)
923
- return cached.snapshot;
924
- const snapshot = mergePolicyMetrics(derivePolicyMetrics(loopId, entries), policyMetrics.snapshot(loopId));
925
- this.derivedMetricsCache.set(loopId, { signature, snapshot });
926
- return snapshot;
472
+ // ── v2.12: User/Agent gates ────────────────────────────────────────────
473
+ /** Classify a flat gate text. Read-only — no vault writes. */
474
+ checkGate(gateText) {
475
+ const { id, gate } = deriveGate(gateText);
476
+ return {
477
+ id,
478
+ kind: gate.kind,
479
+ ...(gate.kind === "user"
480
+ ? {
481
+ question: gate.question,
482
+ blockedScope: gate.blockedScope,
483
+ allowedWork: gate.allowedWork,
484
+ }
485
+ : {
486
+ problem: gate.problem,
487
+ requiredEvidence: gate.requiredEvidence,
488
+ suggestedResolution: gate.suggestedResolution,
489
+ }),
490
+ };
927
491
  }
928
- /** Append a durable session_end event for a terminal transition. Call after
929
- * the session_state write so a failure here only loses a metric, never the
930
- * stopped status. The fixed task_id makes repeated terminals idempotent. */
931
- persistTerminalEvent(session, stopReason, extra) {
932
- this.appendCanonicalEvent({
933
- task_id: `loop:${session.loopId}:end`,
934
- task_type: "session_end",
492
+ /** Record a user decision for a recorded gate. The gateId embeds the
493
+ * canonicalized action hash — if the action changed, the match fails and
494
+ * the old approval expires automatically. */
495
+ resolveGate(sessionId, gateId, approved, note) {
496
+ const session = this.sessions.get(sessionId);
497
+ if (!session)
498
+ return { error: `session not found: ${sessionId}` };
499
+ const prefix = `loop:${session.loopId}:gate:`;
500
+ const opened = queryLoopEntries(this.loopStore, session.loopId, { prefix })
501
+ .find((entry) => entry.task_type === "gate_opened" && entry.gate_id === gateId);
502
+ if (!opened) {
503
+ return { error: `no gate_opened record for gate ${gateId} — the gate may have expired or was never recorded` };
504
+ }
505
+ const actionText = typeof opened.gate_action === "string" ? opened.gate_action : "";
506
+ const { id, gate, actionHash } = deriveGate(actionText);
507
+ if (id !== gateId) {
508
+ return { error: "gateId does not match the recorded action — the action changed, old approvals expire" };
509
+ }
510
+ if (gate.kind !== "user") {
511
+ return { error: "agent gates are resolved by submitting the required evidence, not by user approval" };
512
+ }
513
+ const decision = makeGateDecision({
514
+ gateId,
515
+ kind: "user",
516
+ approved: approved === true,
517
+ scope: gate.blockedScope ?? [],
518
+ note: typeof note === "string" ? note : "",
519
+ decidedAt: new Date().toISOString(),
520
+ actionHash: actionHash ?? "",
521
+ });
522
+ const entry = {
523
+ id: randomUUID(),
524
+ task_id: `loop:${session.loopId}:gate:${gateId}:decision`,
525
+ task_type: "gate_decision",
935
526
  loop_id: session.loopId,
936
- task: session.task,
937
527
  timestamp: new Date().toISOString(),
528
+ gate_id: gateId,
529
+ approved: decision.approved,
530
+ decision_note: decision.note,
531
+ gate_decision: decision,
938
532
  loop_lineage: {
939
533
  round: session.currentRound,
940
- session_id: session.sessionId,
941
- stop_reason: stopReason,
942
- success_trajectory: session.successTrajectory,
943
- consecutive_rejections: session.consecutiveRejections,
944
- ...extra,
534
+ gate_id: gateId,
535
+ action_hash: actionHash,
945
536
  },
537
+ };
538
+ this.loopStore.appendEntry(entry);
539
+ return { sessionId, loopId: session.loopId, gateId, approved: decision.approved };
540
+ }
541
+ // ── v2.12: Typed projection + audit ────────────────────────────────────
542
+ /** Typed cognitive state projection for an active session. Derived on
543
+ * demand — zero persistence. Null when nothing meaningful exists yet. */
544
+ getProjection(sessionId) {
545
+ const session = this.sessions.get(sessionId);
546
+ if (!session)
547
+ return null;
548
+ const entries = queryLoopEntries(this.loopStore, session.loopId, { prefix: `loop:${session.loopId}:` });
549
+ let compileResponse = null;
550
+ // v3.0.1: the round boundary already compiled this round — derive the
551
+ // projection from that response instead of recompiling the whole vault.
552
+ // The cache is invalidated by every compile path (it is rebuilt with the
553
+ // new round's response); the artifact's deterministic roundId guards
554
+ // against stale reuse.
555
+ const cached = session.lastCompileResponse;
556
+ const expectedRoundId = session.roundSnapshot?.roundId
557
+ ?? makeRoundId(session.loopId, session.currentRound);
558
+ if (cached?.prompt_artifact && cached.prompt_artifact.roundId === expectedRoundId) {
559
+ compileResponse = cached;
560
+ }
561
+ else {
562
+ try {
563
+ const request = {
564
+ task: session.task,
565
+ mode: Mode.LOOP_COMPILE,
566
+ feedback: null,
567
+ skill_name: null,
568
+ task_id: null,
569
+ loop_id: session.loopId,
570
+ round: session.currentRound,
571
+ max_rounds: session.maxRounds,
572
+ verification_flags: [],
573
+ };
574
+ const compiled = session.engine.invokeLoopCompile(request, undefined, { persistLineage: false });
575
+ compileResponse = compiled.response ?? null;
576
+ }
577
+ catch {
578
+ compileResponse = null; // projection degrades gracefully
579
+ }
580
+ }
581
+ const openGates = this.lifecycle.listOpenGateDescriptions(session.loopId);
582
+ const projection = buildLoopProjection({
583
+ loopId: session.loopId,
584
+ currentRound: session.currentRound,
585
+ compileResponse,
586
+ vaultEntries: entries,
587
+ verifiedClaims: listVerifiedClaims(entries, session.loopId),
588
+ openGates,
946
589
  });
590
+ return projection ? { ...projection } : null;
591
+ }
592
+ /** Read-only end-of-loop audit (verification view). Never writes. */
593
+ getAudit(loopId) {
594
+ validateLoopId(loopId);
595
+ const entries = queryLoopEntries(this.loopStore, loopId, { prefix: `loop:${loopId}:` });
596
+ const audit = buildAudit(loopId, entries, this.loopStore);
597
+ return { ...audit };
598
+ }
599
+ /** v2.12: Policy metrics that survive restarts — vault-derived round
600
+ * statistics (A4 port) folded with this process's live observations.
601
+ * Non-durable fields (evidence, vault errors) come from live only. */
602
+ getPolicyMetrics(loopId) {
603
+ const entries = queryLoopEntries(this.loopStore, loopId, { prefix: `loop:${loopId}:` });
604
+ const derived = derivePolicyMetrics(loopId, entries);
605
+ const live = policyMetrics.snapshot(loopId);
606
+ if (derived.committedRounds === 0)
607
+ return live;
608
+ return mergePolicyMetrics(derived, live);
947
609
  }
948
610
  /** Get loop health for a loop (in-memory or vault).
949
611
  * Computes goal alignment, constraint integrity, drift, strategy stability. */
@@ -954,8 +616,8 @@ export class SessionManager {
954
616
  for (const s of this.sessions.values()) {
955
617
  if (s.loopId === loopId) {
956
618
  task = s.task;
957
- if (s.runtime.state?.task_id) {
958
- goalId = s.runtime.state.task_id;
619
+ if (s.engine.state?.task_id) {
620
+ goalId = s.engine.state.task_id;
959
621
  }
960
622
  break;
961
623
  }
@@ -970,7 +632,7 @@ export class SessionManager {
970
632
  if (!task)
971
633
  return null;
972
634
  // Hydrate vault context
973
- const engine = new RuntimeServices(this.backend);
635
+ const engine = new LoopForgeEngine(this.loopStore);
974
636
  const vaultContext = engine.hydrateLoopContext(loopId);
975
637
  // Build a minimal request for health check
976
638
  const request = makeLoopCompileRequest({
@@ -987,32 +649,27 @@ export class SessionManager {
987
649
  drift_detected: health.drift_detected,
988
650
  strategy_stability: health.strategy_stability,
989
651
  task_continuity: health.task_continuity,
990
- policy_metrics: this.getLoopPolicyMetrics(loopId),
652
+ policy_metrics: this.getPolicyMetrics(loopId),
991
653
  };
992
654
  }
993
- /** Core cycle for a canonical structured round report. Free-form output is
994
- * intentionally ignored; the runtime never guesses state from text. */
995
- async advance(sessionId, report) {
996
- return withSessionQueue(this.sessionQueues, sessionId, async () => {
655
+ /** Core cycle: extract self-eval → record feedback → check stop → compile next.
656
+ * The lease + per-session queue wrap the RoundLifecycle state machine.
657
+ * @param preExtractedEval Optional pre-built SelfEvaluation from MCP tool parameter.
658
+ * When provided (MCP path with evaluation parameter), skips regex extraction.
659
+ * When undefined (runtime/CLI path), falls back to regex extraction from output.
660
+ * @param roundId v3.0.1: The roundId of the round this submission reports on
661
+ * (from the last start/next/resume response). Anchors the submission so a
662
+ * stale or duplicate submission is not processed against a later round.
663
+ * Optional for library callers — when absent the anchor check is skipped. */
664
+ async advance(sessionId, output, preExtractedEval, roundId) {
665
+ return this.withSessionQueue(sessionId, async () => {
997
666
  const session = this.sessions.get(sessionId);
998
667
  if (session?.status === "running" &&
999
668
  !this.renewSessionLease(session.loopId)) {
1000
669
  return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
1001
670
  }
1002
671
  try {
1003
- const normalized = parseRoundReport(report);
1004
- if (!normalized) {
1005
- return {
1006
- sessionId,
1007
- round: session?.currentRound ?? 0,
1008
- roundId: session?.roundSnapshot?.roundId,
1009
- prompt: session?.currentPrompt ?? null,
1010
- stopReason: "invalid_round_report",
1011
- stopDetail: "The submitted report is not a valid canonical RoundReport. The current round remains unchanged and can be retried.",
1012
- roundSuccess: false,
1013
- };
1014
- }
1015
- return await this.advanceUnlocked(sessionId, normalized.summary, normalized);
672
+ return await this.lifecycle.advance(sessionId, output, preExtractedEval, roundId);
1016
673
  }
1017
674
  catch (error) {
1018
675
  if (error instanceof SessionLeaseConflictError) {
@@ -1022,469 +679,13 @@ export class SessionManager {
1022
679
  }
1023
680
  });
1024
681
  }
1025
- // ── advanceUnlocked pipeline ───────────────────────────────────────────
1026
- //
1027
- // The method is split into focused private helpers so each phase of the
1028
- // round-boundary decision loop is independently readable and testable.
1029
- //
1030
- // Pipeline: validate → extract → execute transaction → route disposition
1031
- // → advance to next round (continue) or return terminal result.
1032
- /** Execute the round transaction and apply global rejection tracking.
1033
- * MUTATES: session.roundSnapshot, session.consecutiveRejections,
1034
- * session.escalatedRejectionChecks, session.lastRoundProjection,
1035
- * session.successTrajectory, session.driftClarificationStreak */
1036
- async executeRoundTransaction(session, roundReport) {
1037
- const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
1038
- const completed = await new RoundDriver(session.runtime, this.backend).complete({
1039
- snapshot,
1040
- loopId: session.loopId,
1041
- task: session.task,
1042
- maxRounds: session.maxRounds,
1043
- roundReport,
1044
- previousProjection: session.lastRoundProjection,
1045
- consecutiveRejections: session.consecutiveRejections,
1046
- successTrajectory: session.successTrajectory,
1047
- driftClarificationStreak: session.driftClarificationStreak,
1048
- escalatedRejectionChecks: session.escalatedRejectionChecks,
1049
- backtrackSkippedFiles: session.backtrackSkippedFiles,
1050
- backtrackTargetGitHead: session.backtrackTargetGitHead,
1051
- expectedAction: isRecord(session.currentCanonicalState?.todoProjection) &&
1052
- session.currentCanonicalState.todoProjection.sourceId !== "objective" &&
1053
- typeof session.currentCanonicalState.suggestedNextAction === "string"
1054
- ? session.currentCanonicalState.suggestedNextAction
1055
- : undefined,
1056
- remainingCriteria: Array.isArray(session.currentCanonicalState?.remainingCriteria)
1057
- ? session.currentCanonicalState.remainingCriteria.filter((value) => typeof value === "string")
1058
- : [],
1059
- });
1060
- const outcome = completed.outcome;
1061
- const actualEvidence = completed.actualEvidence;
1062
- session.roundSnapshot = outcome.snapshot;
1063
- const pr = outcome.result;
1064
- policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
1065
- // v2.12: Clarification streak tracking — independent of rejection tracking.
1066
- // Reset on rounds without intent_drift; track substantive vs weak clarifications.
1067
- this.updateClarificationStreak(session, pr);
1068
- // v2.14: Global rejection tracking — rejections accumulate across ALL
1069
- // checks. The coordinator increments unconditionally; alternating
1070
- // rejection reasons can no longer reset the R6 counter.
1071
- session.consecutiveRejections = pr.newConsecutiveRejections;
1072
- this.updateEscalationChecks(session, pr);
1073
- // v2.13: A new backtrack decision supersedes the previous restore point.
1074
- if (pr.action === "backtrack") {
1075
- session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
1076
- }
1077
- if (pr.newRoundProjection)
1078
- session.lastRoundProjection = pr.newRoundProjection;
1079
- if (pr.shouldPushSuccessTrajectory) {
1080
- session.successTrajectory.push(pr.roundSuccess);
1081
- }
1082
- if (pr.action === "continue" || pr.action === "stop") {
1083
- session.currentCanonicalState = undefined;
1084
- }
1085
- return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
1086
- }
1087
- /** v2.12: Update the clarification streak based on the round result.
1088
- * - Substantive clarification (anchors present): keep streak — genuine pivot.
1089
- * - No intent_drift this round: reset streak to 0.
1090
- * - Weak clarification rejected by R7: streak was already consumed by
1091
- * enforceIntentDrift to decide reject vs terminate; staleness is handled
1092
- * on the enforcement side. We sync the persisted counter for crash recovery.
1093
- * MUTATES: session.driftClarificationStreak */
1094
- updateClarificationStreak(session, pr) {
1095
- const hasIntentDrift = pr.verificationFlags.some((f) => f.check === "intent_drift");
1096
- if (!hasIntentDrift) {
1097
- // No drift this round — reset streak
1098
- session.driftClarificationStreak = 0;
1099
- return;
1100
- }
1101
- // A runtime-verified clarification closes the drift episode.
1102
- if (pr.clarificationAccepted) {
1103
- session.driftClarificationStreak = 0;
1104
- return;
1105
- }
1106
- // R7 rejected (weak or no clarification). The enforcement gate
1107
- // already used the streak to decide reject vs terminate.
1108
- // Increment the persisted counter so crash recovery sees it.
1109
- session.driftClarificationStreak += 1;
1110
- }
1111
- /** Persist terminal-rule escalation independently for each enforcement
1112
- * check. A non-rejection ends the current rejection episode and clears the
1113
- * set; rejected rules retain their marker until the episode resolves. */
1114
- updateEscalationChecks(session, pr) {
1115
- if (pr.action !== "reject") {
1116
- session.escalatedRejectionChecks = [];
1117
- return;
1118
- }
1119
- if (pr.escalationIssued && pr.rejectionCheck) {
1120
- const checks = new Set(session.escalatedRejectionChecks);
1121
- checks.add(pr.rejectionCheck);
1122
- session.escalatedRejectionChecks = [...checks].sort();
1123
- }
1124
- }
1125
- /** Build a rejection result: compile a retry prompt, persist, return.
1126
- * MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
1127
- async buildRejectionResult(sessionId, session, pr, verificationFlags) {
1128
- const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
1129
- const preparedRetry = await new RoundDriver(session.runtime, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
1130
- if (!preparedRetry) {
1131
- return {
1132
- sessionId,
1133
- round: session.currentRound,
1134
- roundId: session.roundSnapshot?.roundId,
1135
- prompt: null,
1136
- stopReason: "prompt_compile_failed",
1137
- stopDetail: "Retry prompt compilation failed; the rejected attempt remains retryable.",
1138
- error: { code: "prompt_compile_failed", message: "Retry prompt compilation failed.", retryable: true },
1139
- };
1140
- }
1141
- session.roundSnapshot = preparedRetry.snapshot;
1142
- session.currentPrompt = preparedRetry.prompt;
1143
- session.currentLevel = preparedRetry.level;
1144
- session.currentWarnings = preparedRetry.warnings ?? [];
1145
- session.currentCanonicalState = isRecord(preparedRetry.canonicalState)
1146
- ? preparedRetry.canonicalState
1147
- : undefined;
1148
- this.save(session);
1149
- return {
1150
- sessionId,
1151
- round: session.currentRound,
1152
- roundId: session.roundSnapshot.roundId,
1153
- prompt: preparedRetry.prompt,
1154
- level: preparedRetry.level,
1155
- enforcementAction: "reject",
1156
- enforcementReason: pr.enforcementReason,
1157
- };
1158
- }
1159
- /** v2.10: Build a backtrack result — roll back to last clean round.
1160
- * Resets the round counter to the restore target + 1, merges preserved
1161
- * discoveries, compiles from the restored state, and injects the
1162
- * backtrack prompt at the top.
1163
- * MUTATES: session.currentRound, session.roundSnapshot,
1164
- * session.currentPrompt, session.currentLevel,
1165
- * session.consecutiveRejections, session.lastRoundProjection */
1166
- async buildBacktrackResult(sessionId, session, pr) {
1167
- const skippedUntilRound = session.currentRound;
1168
- const restoreTarget = pr.backtrackTarget ?? (skippedUntilRound - 1);
1169
- const newRound = restoreTarget + 1;
1170
- logEvent("session_backtrack", {
1171
- sessionId,
1172
- loopId: session.loopId,
1173
- fromRound: skippedUntilRound,
1174
- toRound: newRound,
1175
- triggerRule: pr.backtrackTriggerRule ?? "",
1176
- skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
1177
- });
1178
- // Reset session to the restored round
1179
- session.currentRound = newRound;
1180
- session.consecutiveRejections = 0;
1181
- session.escalatedRejectionChecks = [];
1182
- session.driftClarificationStreak = 0;
1183
- session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
1184
- session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
1185
- session.lastRoundProjection = undefined; // force L2 recompile from vault state
1186
- session.currentPrompt = null;
1187
- const skippedRounds = Array.from({ length: Math.max(0, skippedUntilRound - restoreTarget - 1) }, (_, index) => restoreTarget + 1 + index);
1188
- this.appendCanonicalEvent({
1189
- task_id: `loop:${session.loopId}:backtrack:${randomUUID()}`,
1190
- task_type: "backtrack",
1191
- loop_id: session.loopId,
1192
- format: "loopforge.event",
1193
- timestamp: new Date().toISOString(),
1194
- loop_lineage: {
1195
- round: newRound,
1196
- restore_round: restoreTarget,
1197
- skipped_rounds: skippedRounds,
1198
- skipped_files: session.backtrackSkippedFiles,
1199
- trigger_rule: pr.backtrackTriggerRule ?? "",
1200
- reason: pr.enforcementReason ?? "",
1201
- },
1202
- });
1203
- // Preserve discoveries from skipped rounds as constraints_from_plan
1204
- // so they survive the compilation and re-enter active constraints.
1205
- const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
1206
- // Compile the restored prompt — use a fresh request with the restored round
1207
- const request = {
1208
- task: session.task,
1209
- mode: Mode.LOOP_COMPILE,
1210
- skill_name: null,
1211
- task_id: null,
1212
- loop_id: session.loopId,
1213
- round: newRound,
1214
- max_rounds: session.maxRounds,
1215
- verification_flags: pr.verificationFlags,
1216
- constraints_from_plan: preservedDiscoveries,
1217
- };
1218
- const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, newRound);
1219
- if (!prepared || !prepared.prompt) {
1220
- return {
1221
- sessionId,
1222
- round: newRound,
1223
- prompt: null,
1224
- stopReason: "prompt_compile_failed",
1225
- stopDetail: "Backtrack prompt compilation failed; the restore decision remains persisted and retryable.",
1226
- error: { code: "prompt_compile_failed", message: "Backtrack prompt compilation failed.", retryable: true },
1227
- enforcementAction: "backtrack",
1228
- enforcementReason: pr.enforcementReason,
1229
- restoreRound: restoreTarget,
1230
- skippedRounds,
1231
- skippedFiles: session.backtrackSkippedFiles,
1232
- recoveryRequirements: [
1233
- "Restore the workspace to the selected clean round.",
1234
- "Verify every skipped file before continuing.",
1235
- ],
1236
- };
1237
- }
1238
- // Inject backtrack prompt at the top of the compiled prompt
1239
- const backtrackHeader = pr.backtrackPrompt ?? "";
1240
- const fullPrompt = backtrackHeader
1241
- ? backtrackHeader + "\n" + (prepared.prompt ?? "")
1242
- : prepared.prompt ?? "";
1243
- session.evidenceBaseline = prepared.evidenceBaseline;
1244
- session.roundSnapshot = prepared.snapshot;
1245
- session.currentPrompt = fullPrompt;
1246
- session.currentLevel = prepared.level;
1247
- session.currentWarnings = prepared?.warnings ?? [];
1248
- session.currentCanonicalState = isRecord(prepared.canonicalState)
1249
- ? prepared.canonicalState
1250
- : undefined;
1251
- this.save(session);
1252
- return {
1253
- sessionId,
1254
- round: newRound,
1255
- roundId: session.roundSnapshot?.roundId,
1256
- prompt: fullPrompt,
1257
- level: prepared.level,
1258
- enforcementAction: "backtrack",
1259
- enforcementReason: pr.enforcementReason,
1260
- warnings: prepared?.warnings ?? [],
1261
- restoreRound: restoreTarget,
1262
- skippedRounds,
1263
- skippedFiles: session.backtrackSkippedFiles,
1264
- recoveryRequirements: [
1265
- "Restore the workspace to the selected clean round.",
1266
- "Verify every skipped file before continuing.",
1267
- ],
1268
- };
1269
- }
1270
- /** Build a termination result: persist stopped status, notify sinks.
1271
- * MUTATES: session.status, session.currentPrompt */
1272
- buildTerminationResult(sessionId, session, pr) {
1273
- session.status = "stopped";
1274
- session.currentPrompt = null;
1275
- this.save(session);
1276
- // Durable terminal event after the session_state write (A4): a failure
1277
- // here only loses a metric, never the stopped status.
1278
- this.persistTerminalEvent(session, "enforcement_terminated", {
1279
- enforcement_reason: pr.enforcementReason,
1280
- });
1281
- void this.notifyTerminal(session, "enforcement_terminated");
1282
- logEvent("session_end", {
1283
- sessionId, loopId: session.loopId,
1284
- stopReason: "enforcement_terminated", round: session.currentRound,
1285
- });
1286
- return {
1287
- sessionId,
1288
- round: session.currentRound,
1289
- roundId: session.roundSnapshot?.roundId,
1290
- prompt: null,
1291
- stopReason: "enforcement_terminated",
1292
- stopDetail: pr.enforcementReason ?? "The enforcement gate terminated the loop.",
1293
- enforcementAction: "terminate",
1294
- enforcementReason: pr.enforcementReason,
1295
- };
1296
- }
1297
- /** Build a stop result: persist stopped/stalled status, notify sinks.
1298
- * MUTATES: session.status, session.currentPrompt */
1299
- buildStopResult(sessionId, session, pr) {
1300
- const reason = pr.stopReason ?? "stalled";
1301
- session.status = reason === "stalled" ? "stalled" : "stopped";
1302
- session.currentPrompt = null;
1303
- this.save(session);
1304
- // Durable terminal event after the session_state write (A4).
1305
- this.persistTerminalEvent(session, reason);
1306
- void this.notifyTerminal(session, reason);
1307
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
1308
- return {
1309
- sessionId, round: session.currentRound,
1310
- roundId: session.roundSnapshot?.roundId,
1311
- prompt: null, stopReason: reason,
1312
- stopDetail: pr.stopReason === "circuit_breaker"
1313
- ? "The last consecutive accepted rounds all failed without recovery, triggering the circuit breaker."
1314
- : pr.stopReason === "max_rounds"
1315
- ? "The configured maximum number of rounds has been reached."
1316
- : `Loop stopped: ${reason}.`,
1317
- roundSuccess: pr.roundSuccess,
1318
- };
1319
- }
1320
- /** Compile the next round's prompt and advance the session.
1321
- * Includes the commit fence (pause/delete race guard) and context provider.
1322
- * MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
1323
- * session.evidenceBaseline, session.roundSnapshot */
1324
- async advanceToNextRound(sessionId, session, pr, verificationFlags, actualEvidence) {
1325
- const roundSuccess = pr.roundSuccess;
1326
- const nextRound = session.currentRound + 1;
1327
- // v1.8: Memory injection — tier-aware external context.
1328
- let externalCtx = "";
1329
- if (this.contextProvider) {
1330
- try {
1331
- externalCtx = (await this.contextProvider({
1332
- loopId: session.loopId,
1333
- round: nextRound,
1334
- task: session.task,
1335
- domain: "",
1336
- lastRoundView: session.lastRoundProjection,
1337
- })).trim();
1338
- }
1339
- catch {
1340
- logEvent("context_provider_error", {
1341
- loopId: session.loopId,
1342
- round: nextRound,
1343
- });
1344
- }
1345
- }
1346
- // Commit fence: pause()/delete() may have run while the context provider
1347
- // was awaited. Do not compile if the session is no longer running.
1348
- if (this.sessions.get(sessionId) !== session || session.status !== "running") {
1349
- return {
1350
- sessionId,
1351
- round: session.currentRound,
1352
- prompt: null,
1353
- stopReason: session.status,
1354
- stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the context provider was running.`,
1355
- roundSuccess,
1356
- };
1357
- }
1358
- const request = buildLoopRequest({ ...session, currentRound: nextRound }, session.lastRoundProjection, verificationFlags);
1359
- if (externalCtx) {
1360
- request.external_context = externalCtx;
1361
- }
1362
- const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, nextRound);
1363
- if (!prepared) {
1364
- return {
1365
- sessionId,
1366
- round: session.currentRound,
1367
- roundId: session.roundSnapshot?.roundId,
1368
- prompt: null,
1369
- stopReason: "prompt_compile_failed",
1370
- stopDetail: "Prompt compilation failed; the current round remains unchanged and can be retried.",
1371
- roundSuccess,
1372
- error: { code: "prompt_compile_failed", message: "Prompt compilation failed while advancing.", retryable: true },
1373
- };
1374
- }
1375
- const nextPrompt = prepared.prompt;
1376
- const nextLevel = prepared.level;
1377
- const nextBaseline = prepared.evidenceBaseline ?? actualEvidence;
1378
- // Clear backtrack restore state only after the next prompt is ready.
1379
- session.backtrackSkippedFiles = [];
1380
- session.backtrackTargetGitHead = undefined;
1381
- session.currentRound = nextRound;
1382
- session.currentPrompt = null;
1383
- session.evidenceBaseline = nextBaseline;
1384
- session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
1385
- session.currentPrompt = nextPrompt;
1386
- session.currentLevel = nextLevel;
1387
- session.currentWarnings = prepared?.warnings ?? [];
1388
- session.currentCanonicalState = isRecord(prepared?.canonicalState)
1389
- ? prepared.canonicalState
1390
- : undefined;
1391
- policyMetrics.recordStrategy(session.loopId, nextLevel);
1392
- this.save(session);
1393
- return {
1394
- sessionId,
1395
- round: session.currentRound,
1396
- roundId: session.roundSnapshot.roundId,
1397
- prompt: nextPrompt,
1398
- level: nextLevel,
1399
- roundSuccess,
1400
- warnings: prepared?.warnings ?? [],
1401
- };
1402
- }
1403
- async advanceUnlocked(sessionId, output, roundReport) {
1404
- // ── 1. Validate session ──────────────────────────────────────────────
1405
- const session = this.sessions.get(sessionId);
1406
- if (!session)
1407
- 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." };
1408
- if (session.status !== "running") {
1409
- 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.` };
1410
- }
1411
- // ── 2. Require canonical report ───────────────────────────────────────
1412
- if (!roundReport) {
1413
- logEvent("invalid_round_report", {
1414
- sessionId,
1415
- loopId: session.loopId,
1416
- round: session.currentRound,
1417
- });
1418
- return {
1419
- sessionId,
1420
- round: session.currentRound,
1421
- roundId: session.roundSnapshot?.roundId,
1422
- prompt: session.currentPrompt ?? null,
1423
- stopReason: "invalid_round_report",
1424
- stopDetail: "A canonical RoundReport is required; the current round remains unchanged and can be retried.",
1425
- roundSuccess: false,
1426
- };
1427
- }
1428
- // ── 3. Execute round transaction ─────────────────────────────────────
1429
- const tx = await this.executeRoundTransaction(session, roundReport);
1430
- // ── 4. Route disposition ─────────────────────────────────────────────
1431
- if (tx.pr.action === "reject") {
1432
- return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
1433
- }
1434
- // ── v2.10: Backtrack — roll back to last clean round ──────────────────
1435
- if (tx.pr.action === "backtrack") {
1436
- return this.buildBacktrackResult(sessionId, session, tx.pr);
1437
- }
1438
- // Accepted rounds advance the before-snapshot baseline. Rejected rounds
1439
- // retain the original baseline so their retry remains zero-commit.
1440
- session.evidenceBaseline = tx.actualEvidence;
1441
- if (tx.pr.action === "terminate") {
1442
- return this.buildTerminationResult(sessionId, session, tx.pr);
1443
- }
1444
- if (tx.pr.action === "stop") {
1445
- return this.buildStopResult(sessionId, session, tx.pr);
1446
- }
1447
- // ── 5. Continue — compile next round ─────────────────────────────────
1448
- return this.advanceToNextRound(sessionId, session, tx.pr, tx.verificationFlags, tx.actualEvidence);
1449
- }
1450
- /** Write back loop knowledge to long-term memory.
1451
- * Uses shared base builder from policy.ts. Called when a loop terminates. */
1452
- async notifyTerminal(session, stopReason) {
1453
- if (this.terminalSinks.size === 0)
1454
- return;
1455
- const event = {
1456
- loopId: session.loopId,
1457
- task: session.task,
1458
- success: stopReason === "completed",
1459
- stopReason: stopReason,
1460
- roundsCompleted: session.currentRound,
1461
- successTrajectory: [...session.successTrajectory],
1462
- lastRoundView: session.lastRoundProjection,
1463
- };
1464
- await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
1465
- }
1466
682
  /** Replay timeline for a session — creates ReplayBackend from the stored backend. */
1467
683
  replayTimeline(sessionId) {
1468
684
  const session = this.sessions.get(sessionId);
1469
685
  if (!session)
1470
686
  return null;
1471
- const replay = new ReplayBackend(this.backend);
1472
- return replay.eventTimeline(session.loopId);
1473
- }
1474
- replayTimelineByLoop(loopId, fromSequence, toSequence, limit = 50) {
1475
- const replay = new ReplayBackend(this.backend);
1476
- const timeline = replay.eventTimeline(loopId)
1477
- .filter((entry) => {
1478
- const sequence = typeof entry.event_sequence === "number" ? entry.event_sequence : null;
1479
- if (sequence === null)
1480
- return fromSequence === undefined && toSequence === undefined;
1481
- return (fromSequence === undefined || sequence >= fromSequence) &&
1482
- (toSequence === undefined || sequence <= toSequence);
1483
- })
1484
- .slice(0, Math.max(1, Math.min(200, limit)));
1485
- const last = timeline.at(-1);
1486
- const lastSequence = last && typeof last.event_sequence === "number" ? last.event_sequence : null;
1487
- return { timeline, nextSequence: lastSequence === null ? null : lastSequence + 1 };
687
+ const replay = new ReplayBackend(this.loopStore);
688
+ return replay.timeline(session.loopId);
1488
689
  }
1489
690
  }
1490
691
  //# sourceMappingURL=session.js.map