loopforge 1.15.1 → 2.0.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 (185) hide show
  1. package/README.md +172 -95
  2. package/dist/backends/interface.d.ts +3 -9
  3. package/dist/backends/interface.d.ts.map +1 -1
  4. package/dist/backends/interface.js +1 -5
  5. package/dist/backends/interface.js.map +1 -1
  6. package/dist/canonical-state.d.ts +53 -0
  7. package/dist/canonical-state.d.ts.map +1 -0
  8. package/dist/canonical-state.js +161 -0
  9. package/dist/canonical-state.js.map +1 -0
  10. package/dist/cli.d.ts +4 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +227 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/enforcement-gate.d.ts.map +1 -1
  15. package/dist/enforcement-gate.js +28 -10
  16. package/dist/enforcement-gate.js.map +1 -1
  17. package/dist/engine.d.ts +7 -4
  18. package/dist/engine.d.ts.map +1 -1
  19. package/dist/engine.js +78 -138
  20. package/dist/engine.js.map +1 -1
  21. package/dist/evidence-provider.d.ts +113 -0
  22. package/dist/evidence-provider.d.ts.map +1 -0
  23. package/dist/evidence-provider.js +419 -0
  24. package/dist/evidence-provider.js.map +1 -0
  25. package/dist/index.d.ts +18 -10
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +15 -9
  28. package/dist/index.js.map +1 -1
  29. package/dist/init.d.ts +15 -0
  30. package/dist/init.d.ts.map +1 -0
  31. package/dist/init.js +49 -0
  32. package/dist/init.js.map +1 -0
  33. package/dist/interop.d.ts +30 -0
  34. package/dist/interop.d.ts.map +1 -0
  35. package/dist/interop.js +29 -0
  36. package/dist/interop.js.map +1 -0
  37. package/dist/loop-compiler.d.ts +14 -64
  38. package/dist/loop-compiler.d.ts.map +1 -1
  39. package/dist/loop-compiler.js +303 -2206
  40. package/dist/loop-compiler.js.map +1 -1
  41. package/dist/loop-store.d.ts +82 -0
  42. package/dist/loop-store.d.ts.map +1 -0
  43. package/dist/loop-store.js +332 -0
  44. package/dist/loop-store.js.map +1 -0
  45. package/dist/mcp/server.d.ts +9 -6
  46. package/dist/mcp/server.d.ts.map +1 -1
  47. package/dist/mcp/server.js +109 -73
  48. package/dist/mcp/server.js.map +1 -1
  49. package/dist/mcp/session.d.ts +75 -19
  50. package/dist/mcp/session.d.ts.map +1 -1
  51. package/dist/mcp/session.js +707 -361
  52. package/dist/mcp/session.js.map +1 -1
  53. package/dist/mcp/tools.d.ts +18 -268
  54. package/dist/mcp/tools.d.ts.map +1 -1
  55. package/dist/mcp/tools.js +225 -12
  56. package/dist/mcp/tools.js.map +1 -1
  57. package/dist/observability.d.ts +36 -16
  58. package/dist/observability.d.ts.map +1 -1
  59. package/dist/observability.js +82 -23
  60. package/dist/observability.js.map +1 -1
  61. package/dist/policy-metrics.d.ts +46 -0
  62. package/dist/policy-metrics.d.ts.map +1 -0
  63. package/dist/policy-metrics.js +146 -0
  64. package/dist/policy-metrics.js.map +1 -0
  65. package/dist/policy.d.ts +53 -140
  66. package/dist/policy.d.ts.map +1 -1
  67. package/dist/policy.js +119 -184
  68. package/dist/policy.js.map +1 -1
  69. package/dist/prompt-assembler.d.ts +30 -0
  70. package/dist/prompt-assembler.d.ts.map +1 -0
  71. package/dist/prompt-assembler.js +257 -0
  72. package/dist/prompt-assembler.js.map +1 -0
  73. package/dist/prompt-policy.d.ts +29 -0
  74. package/dist/prompt-policy.d.ts.map +1 -0
  75. package/dist/prompt-policy.js +51 -0
  76. package/dist/prompt-policy.js.map +1 -0
  77. package/dist/protocol.d.ts +88 -93
  78. package/dist/protocol.d.ts.map +1 -1
  79. package/dist/protocol.js +23 -39
  80. package/dist/protocol.js.map +1 -1
  81. package/dist/replay.d.ts.map +1 -1
  82. package/dist/replay.js +1 -27
  83. package/dist/replay.js.map +1 -1
  84. package/dist/round-coordinator.d.ts +90 -0
  85. package/dist/round-coordinator.d.ts.map +1 -0
  86. package/dist/round-coordinator.js +186 -0
  87. package/dist/round-coordinator.js.map +1 -0
  88. package/dist/round-driver.d.ts +53 -0
  89. package/dist/round-driver.d.ts.map +1 -0
  90. package/dist/round-driver.js +101 -0
  91. package/dist/round-driver.js.map +1 -0
  92. package/dist/round-transaction.d.ts +70 -0
  93. package/dist/round-transaction.d.ts.map +1 -0
  94. package/dist/round-transaction.js +290 -0
  95. package/dist/round-transaction.js.map +1 -0
  96. package/dist/runtime.d.ts +33 -19
  97. package/dist/runtime.d.ts.map +1 -1
  98. package/dist/runtime.js +355 -336
  99. package/dist/runtime.js.map +1 -1
  100. package/dist/storage.d.ts +38 -0
  101. package/dist/storage.d.ts.map +1 -0
  102. package/dist/storage.js +201 -0
  103. package/dist/storage.js.map +1 -0
  104. package/dist/verification-gate.d.ts +33 -7
  105. package/dist/verification-gate.d.ts.map +1 -1
  106. package/dist/verification-gate.js +195 -7
  107. package/dist/verification-gate.js.map +1 -1
  108. package/loop_policy.json +22 -48
  109. package/package.json +13 -10
  110. package/skills/perception/SKILL.md +80 -267
  111. package/dist/backends/fs.d.ts +0 -36
  112. package/dist/backends/fs.d.ts.map +0 -1
  113. package/dist/backends/fs.js +0 -362
  114. package/dist/backends/fs.js.map +0 -1
  115. package/dist/builder.d.ts +0 -19
  116. package/dist/builder.d.ts.map +0 -1
  117. package/dist/builder.js +0 -215
  118. package/dist/builder.js.map +0 -1
  119. package/dist/mcp-server.d.ts +0 -12
  120. package/dist/mcp-server.d.ts.map +0 -1
  121. package/dist/mcp-server.js +0 -14
  122. package/dist/mcp-server.js.map +0 -1
  123. package/dist/memory-bridge.d.ts +0 -47
  124. package/dist/memory-bridge.d.ts.map +0 -1
  125. package/dist/memory-bridge.js +0 -290
  126. package/dist/memory-bridge.js.map +0 -1
  127. package/dist/tests/_helpers.d.ts +0 -25
  128. package/dist/tests/_helpers.d.ts.map +0 -1
  129. package/dist/tests/_helpers.js +0 -47
  130. package/dist/tests/_helpers.js.map +0 -1
  131. package/dist/tests/builder.test.d.ts +0 -2
  132. package/dist/tests/builder.test.d.ts.map +0 -1
  133. package/dist/tests/builder.test.js +0 -112
  134. package/dist/tests/builder.test.js.map +0 -1
  135. package/dist/tests/enforcement-gate.test.d.ts +0 -2
  136. package/dist/tests/enforcement-gate.test.d.ts.map +0 -1
  137. package/dist/tests/enforcement-gate.test.js +0 -328
  138. package/dist/tests/enforcement-gate.test.js.map +0 -1
  139. package/dist/tests/engine.test.d.ts +0 -2
  140. package/dist/tests/engine.test.d.ts.map +0 -1
  141. package/dist/tests/engine.test.js +0 -375
  142. package/dist/tests/engine.test.js.map +0 -1
  143. package/dist/tests/generate-schema.test.d.ts +0 -8
  144. package/dist/tests/generate-schema.test.d.ts.map +0 -1
  145. package/dist/tests/generate-schema.test.js +0 -199
  146. package/dist/tests/generate-schema.test.js.map +0 -1
  147. package/dist/tests/loop-compiler.test.d.ts +0 -2
  148. package/dist/tests/loop-compiler.test.d.ts.map +0 -1
  149. package/dist/tests/loop-compiler.test.js +0 -1059
  150. package/dist/tests/loop-compiler.test.js.map +0 -1
  151. package/dist/tests/mcp.test.d.ts +0 -7
  152. package/dist/tests/mcp.test.d.ts.map +0 -1
  153. package/dist/tests/mcp.test.js +0 -505
  154. package/dist/tests/mcp.test.js.map +0 -1
  155. package/dist/tests/memory-bridge.test.d.ts +0 -8
  156. package/dist/tests/memory-bridge.test.d.ts.map +0 -1
  157. package/dist/tests/memory-bridge.test.js +0 -383
  158. package/dist/tests/memory-bridge.test.js.map +0 -1
  159. package/dist/tests/policy.test.d.ts +0 -2
  160. package/dist/tests/policy.test.d.ts.map +0 -1
  161. package/dist/tests/policy.test.js +0 -49
  162. package/dist/tests/policy.test.js.map +0 -1
  163. package/dist/tests/protocol.test.d.ts +0 -2
  164. package/dist/tests/protocol.test.d.ts.map +0 -1
  165. package/dist/tests/protocol.test.js +0 -73
  166. package/dist/tests/protocol.test.js.map +0 -1
  167. package/dist/tests/replay.test.d.ts +0 -2
  168. package/dist/tests/replay.test.d.ts.map +0 -1
  169. package/dist/tests/replay.test.js +0 -222
  170. package/dist/tests/replay.test.js.map +0 -1
  171. package/dist/tests/runtime.test.d.ts +0 -7
  172. package/dist/tests/runtime.test.d.ts.map +0 -1
  173. package/dist/tests/runtime.test.js +0 -337
  174. package/dist/tests/runtime.test.js.map +0 -1
  175. package/dist/tests/verification-gate.test.d.ts +0 -2
  176. package/dist/tests/verification-gate.test.d.ts.map +0 -1
  177. package/dist/tests/verification-gate.test.js +0 -407
  178. package/dist/tests/verification-gate.test.js.map +0 -1
  179. package/skills/prompt-techniques/SKILL.md +0 -52
  180. package/skills/prompt-techniques/references/chain-of-thought.md +0 -207
  181. package/skills/prompt-techniques/references/few-shot.md +0 -174
  182. package/skills/prompt-techniques/references/least-to-most.md +0 -182
  183. package/skills/prompt-techniques/references/step-back.md +0 -160
  184. package/skills/prompt-techniques/references/tree-of-thought.md +0 -191
  185. package/skills/prompt-techniques/references/zero-shot.md +0 -97
@@ -5,24 +5,25 @@
5
5
  * the advance() cycle: extract → feedback → check stop → compile next.
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { writeFileSync, mkdirSync } from "node:fs";
9
- import { join } from "node:path";
10
8
  import { LoopForgeEngine, extractSelfEvaluation, heuristicSelfEvaluation } from "../engine.js";
11
- import { checkLoopHealth, tokenize, jaccard } from "../loop-compiler.js";
12
- import { getPolicy, resolveAllowedPhases, resolveInjectionPhase, buildAccumulatedMemoryContext, buildBaseMemoryWriteback } from "../policy.js";
13
- import { Mode, makeLoopCompileRequest, makeVaultConfig } from "../protocol.js";
9
+ import { checkLoopHealth } from "../loop-compiler.js";
10
+ import { getPolicy } from "../policy.js";
11
+ import { Mode, makeLoopCompileRequest } from "../protocol.js";
14
12
  import { ReplayBackend } from "../replay.js";
15
- import { FSBackend } from "../backends/fs.js";
16
- import { verifySelfEvaluation } from "../verification-gate.js";
17
- import { enforceRound, buildRejectionPrompt } from "../enforcement-gate.js";
13
+ import { FileLoopStore, LoopStoreBackend } from "../loop-store.js";
14
+ import { EvidenceCollector } from "../evidence-provider.js";
15
+ import { parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
16
+ import { RoundDriver } from "../round-driver.js";
18
17
  import { logEvent } from "../observability.js";
18
+ import { policyMetrics } from "../policy-metrics.js";
19
+ import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
20
+ import { createCognitiveCheckpoint } from "../interop.js";
19
21
  // ── Helpers ────────────────────────────────────────────────────────────────
20
22
  function buildLoopRequest(session, lastEval, _lastQuality, // deprecated — kept for backward compat
21
23
  verificationFlags) {
22
24
  const req = {
23
25
  task: session.task,
24
26
  mode: Mode.LOOP_COMPILE,
25
- vault_config: makeVaultConfig(),
26
27
  feedback: null,
27
28
  skill_name: null,
28
29
  task_id: null,
@@ -59,19 +60,12 @@ verificationFlags) {
59
60
  // v1.10: Checkpoint boundary
60
61
  compression_checkpoint: lastEval.compression_checkpoint ?? false,
61
62
  checkpoint_label: lastEval.checkpoint_label ?? "",
63
+ // v1.16: Agent's declared next action
64
+ next_action: lastEval.next_action,
62
65
  };
63
66
  }
64
67
  return req;
65
68
  }
66
- function parseLevel(rationale) {
67
- if (!rationale)
68
- return "l2";
69
- if (rationale.includes("l0"))
70
- return "l0";
71
- if (rationale.includes("l1"))
72
- return "l1";
73
- return "l2";
74
- }
75
69
  function parseWarnings(prompt) {
76
70
  if (!prompt)
77
71
  return [];
@@ -89,85 +83,166 @@ function parseWarnings(prompt) {
89
83
  // ── SessionManager ─────────────────────────────────────────────────────────
90
84
  export class SessionManager {
91
85
  sessions = new Map();
86
+ /** Serializes state transitions for each session. */
87
+ sessionQueues = new Map();
92
88
  backend;
93
- /** Optional provider for long-term memory context retrieval. */
94
- memoryProvider;
95
- /** Optional writer for persisting loop knowledge back to long-term memory. */
96
- memoryWriter;
97
- constructor(backend) {
98
- this.backend = backend;
89
+ sessionStore;
90
+ ownerId = `${process.pid}:${randomUUID()}`;
91
+ leaseMs;
92
+ leaseRenewIntervalMs;
93
+ leaseTimer = null;
94
+ checkpointSinks = new Set();
95
+ /** Explicit context provider; never auto-discovered. */
96
+ contextProvider;
97
+ terminalSinks = new Set();
98
+ 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);
106
+ const mcpPolicy = getPolicy().mcp;
107
+ this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
108
+ this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
109
+ if (this.sessionStore?.renewLease) {
110
+ this.leaseTimer = setInterval(() => this.renewOwnedLeases(), this.leaseRenewIntervalMs);
111
+ this.leaseTimer.unref?.();
112
+ }
113
+ }
114
+ /** Stable process-local owner token used for cross-process session leases. */
115
+ getOwnerId() {
116
+ return this.ownerId;
117
+ }
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
+ addTerminalSink(sink) {
124
+ this.terminalSinks.add(sink);
125
+ return () => this.terminalSinks.delete(sink);
126
+ }
127
+ /** Release owned sessions and stop lease maintenance. */
128
+ close() {
129
+ if (this.leaseTimer)
130
+ clearInterval(this.leaseTimer);
131
+ this.leaseTimer = null;
132
+ for (const session of this.sessions.values()) {
133
+ this.sessionStore?.releaseLease?.(session.loopId, this.ownerId);
134
+ }
135
+ }
136
+ findSessionEntry(loopId) {
137
+ return this.sessionStore?.load(loopId);
138
+ }
139
+ claimSessionEntry(loopId) {
140
+ if (this.sessionStore?.acquireLease) {
141
+ return this.sessionStore.acquireLease(loopId, this.ownerId, this.leaseMs);
142
+ }
143
+ return this.findSessionEntry(loopId);
144
+ }
145
+ renewSessionLease(loopId) {
146
+ if (!this.sessionStore?.renewLease)
147
+ return true;
148
+ try {
149
+ return this.sessionStore.renewLease(loopId, this.ownerId, this.leaseMs);
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ renewOwnedLeases() {
156
+ for (const session of this.sessions.values()) {
157
+ if (session.status === "running")
158
+ this.renewSessionLease(session.loopId);
159
+ }
160
+ }
161
+ leaseConflictResult(loopId, entry) {
162
+ const lineage = (entry?.loop_lineage ?? {});
163
+ return {
164
+ sessionId: "",
165
+ round: typeof lineage.current_round === "number" ? lineage.current_round : 0,
166
+ prompt: null,
167
+ stopReason: `session_owned_elsewhere:${loopId}`,
168
+ };
169
+ }
170
+ async withSessionQueue(sessionId, work) {
171
+ const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
172
+ let release;
173
+ const gate = new Promise((resolve) => {
174
+ release = resolve;
175
+ });
176
+ const tail = previous.then(() => gate);
177
+ this.sessionQueues.set(sessionId, tail);
178
+ await previous;
179
+ try {
180
+ return await work();
181
+ }
182
+ finally {
183
+ release();
184
+ if (this.sessionQueues.get(sessionId) === tail) {
185
+ this.sessionQueues.delete(sessionId);
186
+ }
187
+ }
99
188
  }
100
189
  async create(input) {
101
190
  const sessionId = randomUUID();
102
191
  const loopId = input.loopId ?? randomUUID();
103
- const engine = new LoopForgeEngine("skills", this.backend);
192
+ const engine = new LoopForgeEngine(this.backend);
104
193
  const maxRounds = input.maxRounds ?? getPolicy().runtime.max_rounds;
105
194
  // Populate extra fields for the first round
106
195
  const request = buildLoopRequest({
107
196
  sessionId, loopId, task: input.task, engine, currentRound: 1,
108
197
  maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
109
- injectionCount: 0, lastInjectionRound: 0, injectedContexts: [],
110
- phase2Triggered: false, phase3Triggered: false, consecutiveRejections: 0,
198
+ consecutiveRejections: 0,
199
+ lastRejectionCheck: "",
200
+ evidenceBaseline: [],
111
201
  });
112
202
  request.domain = input.domain ?? "";
113
203
  request.plan_source = input.planSource ?? null;
114
204
  request.constraints_from_plan = input.constraints ?? [];
115
- // v1.8: Phase 1 memory injection (Round 1) — only if allowed by tier
116
- const miPolicy = getPolicy().memory_injection;
117
- const allowedPhases = new Set(resolveAllowedPhases(maxRounds, miPolicy.round_tiers));
118
- if (miPolicy.enabled && this.memoryProvider && allowedPhases.has(1)) {
205
+ // Explicit context is requested by the MCP embedding; no auto-discovery.
206
+ if (this.contextProvider) {
119
207
  try {
120
208
  const ctx = {
121
209
  loopId,
122
210
  round: 1,
123
211
  task: input.task,
124
212
  domain: input.domain ?? "",
125
- phase: 1,
126
- progressEstimate: 0,
127
- accumulatedContext: {
128
- recurringIssues: [],
129
- failedPatterns: [],
130
- keyLessons: [],
131
- remainingCriteria: [],
132
- },
133
213
  };
134
- const rawContext = await this.memoryProvider(ctx);
214
+ const rawContext = await this.contextProvider(ctx);
135
215
  if (rawContext?.trim()) {
136
- request.external_context = rawContext.trim().slice(0, miPolicy.max_context_length);
137
- logEvent("memory_injected", {
138
- loopId, round: 1, phase: 1, injectionCount: 1,
216
+ request.external_context = rawContext.trim();
217
+ logEvent("external_context_loaded", {
218
+ loopId, round: 1,
139
219
  contextLength: request.external_context.length,
140
220
  });
141
221
  }
142
222
  }
143
223
  catch {
144
- // memoryProvider failed — degrade gracefully
145
- logEvent("memory_provider_error", { loopId, round: 1 });
224
+ // Provider failures are isolated from session creation.
225
+ logEvent("context_provider_error", { loopId, round: 1 });
146
226
  }
147
227
  }
148
- const result = engine.invokeLoopCompile(request);
149
- // v1.14: Write state file if the compiler produced one and policy allows it
150
- const sfContent = result.response?.state_file_content;
151
- if (sfContent && getPolicy().state_file.enabled) {
152
- const sfDir = join(process.cwd(), getPolicy().state_file.directory);
153
- mkdirSync(sfDir, { recursive: true });
154
- writeFileSync(join(sfDir, `${loopId}-state.md`), sfContent, "utf-8");
155
- }
156
- const injectedCtx = request.external_context;
228
+ const prepared = await new RoundDriver(engine, this.backend).prepare(request, loopId, 1);
229
+ const initialPrompt = prepared?.prompt ?? null;
230
+ const initialLevel = prepared?.level ?? "l2";
231
+ const evidenceBaseline = prepared?.evidenceBaseline ?? [];
157
232
  const session = {
158
233
  sessionId, loopId, task: input.task, engine,
159
234
  currentRound: 1, maxRounds, successTrajectory: [],
160
235
  status: "running", createdAt: Date.now(),
161
- // v1.7: Memory integration state
162
- injectionCount: injectedCtx ? 1 : 0,
163
- lastInjectionRound: injectedCtx ? 1 : 0,
164
- injectedContexts: injectedCtx ? [injectedCtx] : [],
165
- phase2Triggered: false,
166
- phase3Triggered: false,
167
236
  // v1.13: Enforcement gate state
168
237
  consecutiveRejections: 0,
238
+ lastRejectionCheck: "",
239
+ evidenceBaseline,
240
+ roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
241
+ currentPrompt: initialPrompt,
242
+ currentLevel: initialLevel,
169
243
  };
170
244
  this.sessions.set(sessionId, session);
245
+ policyMetrics.recordStrategy(loopId, initialLevel);
171
246
  // Persist to vault for cross-process recovery
172
247
  this.save(session);
173
248
  logEvent("session_start", {
@@ -179,99 +254,382 @@ export class SessionManager {
179
254
  return {
180
255
  sessionId,
181
256
  round: 1,
182
- prompt: result.response?.prompt ?? null,
183
- technique: result.response?.analysis?.technique ?? "zero-shot",
184
- level: parseLevel(result.response?.analysis?.rationale),
257
+ roundId: session.roundSnapshot?.roundId,
258
+ prompt: initialPrompt,
259
+ level: initialLevel,
185
260
  roundSuccess: false,
186
261
  quality: 0,
187
- warnings: parseWarnings(result.response?.prompt ?? null),
262
+ warnings: parseWarnings(initialPrompt),
188
263
  };
189
264
  }
190
265
  get(sessionId) {
191
266
  return this.sessions.get(sessionId);
192
267
  }
268
+ getLeaseStatus(loopId) {
269
+ const entry = this.findSessionEntry(loopId);
270
+ if (!entry)
271
+ return null;
272
+ const lineage = (entry.loop_lineage ?? {});
273
+ const owner = typeof lineage.lease_owner === "string"
274
+ ? lineage.lease_owner
275
+ : "";
276
+ const ownerPid = owner.match(/^(\d+):/)?.[1];
277
+ return {
278
+ ownedByThisProcess: owner === this.ownerId,
279
+ ownerPid: ownerPid ? Number(ownerPid) : null,
280
+ expiresAt: typeof lineage.lease_expires_at === "number"
281
+ ? new Date(lineage.lease_expires_at).toISOString()
282
+ : null,
283
+ epoch: typeof lineage.lease_epoch === "number"
284
+ ? lineage.lease_epoch
285
+ : 0,
286
+ };
287
+ }
193
288
  delete(sessionId) {
194
289
  const session = this.sessions.get(sessionId);
195
290
  if (!session)
196
291
  return false;
197
292
  session.status = "stopped";
198
- void this.doWriteback(session, "stopped");
293
+ this.save(session); // Persist stopped status to vault so autoResumeAll won't resurrect
294
+ void this.notifyTerminal(session, "cancelled");
199
295
  this.sessions.delete(sessionId);
200
296
  logEvent("session_end", {
201
297
  sessionId,
202
298
  loopId: session.loopId,
203
- stopReason: "stopped",
299
+ stopReason: "cancelled",
204
300
  roundsCompleted: session.currentRound,
205
301
  });
206
302
  return true;
207
303
  }
304
+ /** v1.18: Pause a running session. The session state is persisted to
305
+ * vault so it survives process restarts. Returns the session status.
306
+ * Paused sessions cannot be advanced — they must be resumed first. */
307
+ pause(sessionId) {
308
+ const session = this.sessions.get(sessionId);
309
+ if (!session)
310
+ return { sessionId, round: 0, status: "not_found" };
311
+ if (session.status !== "running") {
312
+ return { sessionId, round: session.currentRound, status: session.status };
313
+ }
314
+ session.status = "paused";
315
+ this.save(session);
316
+ logEvent("session_paused", {
317
+ sessionId,
318
+ loopId: session.loopId,
319
+ round: session.currentRound,
320
+ });
321
+ return { sessionId, round: session.currentRound, status: "paused" };
322
+ }
323
+ restoredPromptResult(session) {
324
+ if (!session.currentPrompt)
325
+ return null;
326
+ return {
327
+ sessionId: session.sessionId,
328
+ round: session.currentRound,
329
+ roundId: session.roundSnapshot?.roundId,
330
+ prompt: session.currentPrompt,
331
+ level: session.currentLevel ?? "l2",
332
+ quality: 0,
333
+ roundSuccess: undefined,
334
+ warnings: parseWarnings(session.currentPrompt),
335
+ };
336
+ }
337
+ /** Reconcile the crash window where feedback committed but session_state
338
+ * still points at the old prompt. Returns null when no commit is pending. */
339
+ reconcileCommittedRound(session) {
340
+ if (!session.roundSnapshot)
341
+ return null;
342
+ const recovered = new RoundDriver(session.engine, this.backend).recover(session.roundSnapshot);
343
+ if (!recovered)
344
+ return null;
345
+ const pr = recovered.result;
346
+ session.roundSnapshot = recovered.snapshot;
347
+ // Replay the committed counter — but with per-rule tracking.
348
+ if (pr.action === "reject" && pr.rejectionCheck) {
349
+ session.consecutiveRejections =
350
+ pr.rejectionCheck === session.lastRejectionCheck
351
+ ? pr.newConsecutiveRejections
352
+ : 1;
353
+ session.lastRejectionCheck = pr.rejectionCheck;
354
+ }
355
+ else {
356
+ session.consecutiveRejections = pr.newConsecutiveRejections;
357
+ if (pr.action !== "reject")
358
+ session.lastRejectionCheck = "";
359
+ }
360
+ if (pr.newLastSelfEval)
361
+ session.lastSelfEval = pr.newLastSelfEval;
362
+ if (pr.shouldPushSuccessTrajectory &&
363
+ session.successTrajectory.length < session.currentRound) {
364
+ session.successTrajectory.push(pr.roundSuccess);
365
+ }
366
+ session.currentPrompt = null;
367
+ if (pr.action === "stop" || pr.action === "terminate") {
368
+ const reason = pr.action === "terminate"
369
+ ? "enforcement_terminated"
370
+ : pr.stopReason ?? "stalled";
371
+ session.status = reason === "stalled" ? "stalled" : "stopped";
372
+ this.save(session);
373
+ void this.notifyTerminal(session, reason);
374
+ return {
375
+ sessionId: session.sessionId,
376
+ round: session.currentRound,
377
+ roundId: recovered.snapshot.roundId,
378
+ prompt: null,
379
+ stopReason: reason,
380
+ roundSuccess: pr.roundSuccess,
381
+ quality: pr.roundSuccess ? 5 : 1,
382
+ };
383
+ }
384
+ if (pr.action !== "continue")
385
+ return null;
386
+ session.currentRound++;
387
+ const request = buildLoopRequest(session, pr.newLastSelfEval, pr.roundSuccess ? 5 : 1, pr.verificationFlags);
388
+ const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
389
+ if (!prepared) {
390
+ session.status = "stalled";
391
+ this.save(session);
392
+ return {
393
+ sessionId: session.sessionId,
394
+ round: session.currentRound,
395
+ prompt: null,
396
+ stopReason: "stalled",
397
+ };
398
+ }
399
+ const prompt = prepared.prompt;
400
+ session.evidenceBaseline = prepared.evidenceBaseline;
401
+ session.roundSnapshot = prepared.snapshot;
402
+ session.currentPrompt = prompt;
403
+ session.currentLevel = prepared.level;
404
+ policyMetrics.recordStrategy(session.loopId, session.currentLevel);
405
+ this.save(session);
406
+ return {
407
+ sessionId: session.sessionId,
408
+ round: session.currentRound,
409
+ roundId: session.roundSnapshot.roundId,
410
+ prompt,
411
+ level: session.currentLevel,
412
+ roundSuccess: pr.roundSuccess,
413
+ quality: pr.roundSuccess ? 5 : 1,
414
+ warnings: parseWarnings(prompt),
415
+ };
416
+ }
417
+ /** v1.18: Resume a paused session. Reconstructs from vault state and
418
+ * compiles the next prompt. Returns null if no paused session exists
419
+ * for this loopId. */
420
+ async unpause(loopId) {
421
+ const persistedEntry = this.findSessionEntry(loopId);
422
+ if (!persistedEntry)
423
+ return null;
424
+ const lineage = (persistedEntry.loop_lineage ?? {});
425
+ const status = lineage.status ?? "running";
426
+ if (status !== "paused")
427
+ return null;
428
+ const sessionEntry = this.claimSessionEntry(loopId);
429
+ if (!sessionEntry)
430
+ return this.leaseConflictResult(loopId, persistedEntry);
431
+ const session = this.reconstructSession(sessionEntry, true);
432
+ if (!session)
433
+ return null;
434
+ // Set to running so advance() works
435
+ session.status = "running";
436
+ this.sessions.set(session.sessionId, session);
437
+ // Replace the sync-fallback evidence baseline with async evidence
438
+ // so resumed sessions don't silently drop async provider data.
439
+ try {
440
+ const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
441
+ loopId: session.loopId,
442
+ phase: "before",
443
+ });
444
+ if (asyncEvidence.length > 0) {
445
+ session.evidenceBaseline = asyncEvidence;
446
+ if (!session.roundSnapshot?.beforeEvidence?.length) {
447
+ session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
448
+ }
449
+ }
450
+ }
451
+ catch {
452
+ // Async evidence is best-effort; fall back to sync baseline.
453
+ }
454
+ const reconciled = this.reconcileCommittedRound(session);
455
+ if (reconciled)
456
+ return reconciled;
457
+ const restored = this.restoredPromptResult(session);
458
+ if (restored) {
459
+ this.save(session);
460
+ return restored;
461
+ }
462
+ // Compile the next prompt from the current round state
463
+ const lcr = makeLoopCompileRequest({
464
+ loop_id: session.loopId,
465
+ round: session.currentRound,
466
+ goal_id: "",
467
+ task: session.task,
468
+ domain: undefined,
469
+ plan_source: null,
470
+ constraints_from_plan: [],
471
+ health_check_interval: 1,
472
+ });
473
+ const compileRequest = {
474
+ task: session.task,
475
+ mode: Mode.LOOP_COMPILE,
476
+ feedback: null,
477
+ skill_name: null,
478
+ task_id: null,
479
+ loop_id: lcr.loop_id,
480
+ round: lcr.round,
481
+ goal_id: lcr.goal_id,
482
+ domain: lcr.domain ?? "",
483
+ plan_source: lcr.plan_source ?? null,
484
+ constraints_from_plan: lcr.constraints_from_plan ?? [],
485
+ health_check_interval: lcr.health_check_interval,
486
+ max_rounds: session.maxRounds,
487
+ };
488
+ const prepared = await new RoundDriver(session.engine, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
489
+ if (!prepared) {
490
+ session.status = "stopped";
491
+ this.save(session);
492
+ void this.notifyTerminal(session, "stalled");
493
+ return { sessionId: session.sessionId, round: session.currentRound, prompt: null, stopReason: "stalled" };
494
+ }
495
+ const prompt = prepared.prompt;
496
+ const level = prepared.level;
497
+ session.evidenceBaseline = prepared.evidenceBaseline;
498
+ session.roundSnapshot = prepared.snapshot;
499
+ session.currentPrompt = prompt;
500
+ session.currentLevel = level;
501
+ policyMetrics.recordStrategy(session.loopId, level);
502
+ this.save(session);
503
+ return {
504
+ sessionId: session.sessionId,
505
+ round: session.currentRound,
506
+ roundId: session.roundSnapshot.roundId,
507
+ prompt,
508
+ level,
509
+ quality: 0,
510
+ roundSuccess: undefined,
511
+ warnings: [],
512
+ };
513
+ }
208
514
  /** Persist session state to vault for cross-process recovery.
209
- * Uses upsert: removes any previous session_state entry for this loop,
210
- * then appends a new one with current state.
211
- * Entire read→filter→write→append is wrapped in a file lock to prevent
212
- * lost updates from concurrent processes. */
515
+ * The filtered vault and replacement entry are written once under the
516
+ * backend lock, so recovery never observes the old two-write gap. */
213
517
  save(session) {
214
- if (!this.backend)
518
+ if (!this.sessionStore)
215
519
  return;
216
- const doSave = () => {
217
- // Upsert: remove old session_state entries for this loop
218
- const vault = this.backend.readVault();
219
- const entries = vault.entries || [];
220
- vault.entries = entries.filter((e) => !(e.task_type === "session_state" && e.loop_id === session.loopId));
221
- this.backend.writeVault(vault);
222
- // Append fresh session state
223
- this.backend.appendEntry({
224
- task_id: `loop:${session.loopId}:session`,
225
- task_type: "session_state",
226
- timestamp: new Date().toISOString(),
227
- loop_id: session.loopId,
228
- task: session.task,
229
- loop_lineage: {
230
- session_id: session.sessionId,
231
- current_round: session.currentRound,
232
- max_rounds: session.maxRounds,
233
- success_trajectory: session.successTrajectory,
234
- status: session.status,
235
- created_at: session.createdAt,
236
- // v1.7: Memory integration state for cross-process recovery
237
- injection_count: session.injectionCount,
238
- last_injection_round: session.lastInjectionRound,
239
- phase2_triggered: session.phase2Triggered,
240
- phase3_triggered: session.phase3Triggered,
241
- // v1.13: Enforcement gate state
242
- consecutive_rejections: session.consecutiveRejections,
243
- },
244
- });
520
+ const leaseActive = session.status === "running";
521
+ const sessionEntry = {
522
+ task_id: `loop:${session.loopId}:session`,
523
+ task_type: "session_state",
524
+ timestamp: new Date().toISOString(),
525
+ loop_id: session.loopId,
526
+ task: session.task,
527
+ loop_lineage: {
528
+ session_id: session.sessionId,
529
+ current_round: session.currentRound,
530
+ max_rounds: session.maxRounds,
531
+ success_trajectory: session.successTrajectory,
532
+ status: session.status,
533
+ created_at: session.createdAt,
534
+ // v1.13: Enforcement gate state
535
+ consecutive_rejections: session.consecutiveRejections,
536
+ last_rejection_check: session.lastRejectionCheck,
537
+ // v1.19: durable round transaction state
538
+ round_snapshot: session.roundSnapshot ?? null,
539
+ last_self_eval: session.lastSelfEval ?? null,
540
+ current_prompt: session.currentPrompt ?? null,
541
+ current_level: session.currentLevel ?? "",
542
+ // v1.20: cross-process single-owner lease
543
+ lease_owner: leaseActive ? this.ownerId : "",
544
+ lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
545
+ },
245
546
  };
246
- // Use FSBackend's file lock if available (only FSBackend implements withLock)
247
- if ("withLock" in this.backend &&
248
- typeof this.backend.withLock === "function") {
249
- this.backend.withLock(doSave);
547
+ this.sessionStore.save(sessionEntry, {
548
+ expectedLeaseOwner: this.ownerId,
549
+ });
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
+ }
250
563
  }
251
- else {
252
- doSave();
564
+ }
565
+ /** Reconstruct a McpSession from a vault session_state entry.
566
+ * Returns null if the entry is not "running" status.
567
+ * Shared by resume() and autoResumeAll(). */
568
+ reconstructSession(entry, allowPaused = false) {
569
+ const lineage = (entry.loop_lineage ?? {});
570
+ const status = lineage.status ?? "running";
571
+ if (status !== "running" && !(allowPaused && status === "paused")) {
572
+ return null;
253
573
  }
574
+ const loopId = entry.loop_id;
575
+ const currentRound = lineage.current_round ?? 1;
576
+ const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
577
+ const task = entry.task ?? "";
578
+ const maxRounds = lineage.max_rounds ?? getPolicy().runtime.max_rounds;
579
+ const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
580
+ const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
581
+ const persistedEval = lineage.last_self_eval;
582
+ const lastSelfEval = persistedEval !== null &&
583
+ typeof persistedEval === "object" &&
584
+ !Array.isArray(persistedEval) &&
585
+ typeof persistedEval.success === "boolean" &&
586
+ typeof persistedEval.output_summary === "string"
587
+ ? persistedEval
588
+ : undefined;
589
+ const engine = new LoopForgeEngine(this.backend);
590
+ return {
591
+ sessionId: typeof lineage.session_id === "string" && lineage.session_id
592
+ ? lineage.session_id
593
+ : randomUUID(),
594
+ loopId, task, engine,
595
+ currentRound, maxRounds, successTrajectory,
596
+ status: status,
597
+ createdAt: lineage.created_at ?? Date.now(),
598
+ consecutiveRejections: lineage.consecutive_rejections ?? 0,
599
+ lastRejectionCheck: typeof lineage.last_rejection_check === "string"
600
+ ? lineage.last_rejection_check
601
+ : "",
602
+ evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
603
+ roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
604
+ lastSelfEval,
605
+ currentPrompt: typeof lineage.current_prompt === "string"
606
+ ? lineage.current_prompt
607
+ : null,
608
+ currentLevel: typeof lineage.current_level === "string"
609
+ ? lineage.current_level
610
+ : undefined,
611
+ };
254
612
  }
255
613
  /** Resume a loop from vault state.
256
614
  * Reconstructs the session and compiles the prompt for the next round.
257
615
  * Returns null if no session_state entry exists for this loopId. */
258
616
  resume(loopId) {
259
- if (!this.backend)
617
+ const persistedEntry = this.findSessionEntry(loopId);
618
+ if (!persistedEntry)
260
619
  return null;
261
- const entries = this.backend.queryEntries({
262
- prefix: `loop:${loopId}:session`,
263
- });
264
- const sessionEntry = entries.find((e) => e.task_type === "session_state");
620
+ const persistedLineage = (persistedEntry.loop_lineage ?? {});
621
+ const persistedStatus = persistedLineage.status ?? "running";
622
+ const sessionEntry = persistedStatus === "running"
623
+ ? this.claimSessionEntry(loopId)
624
+ : persistedEntry;
265
625
  if (!sessionEntry)
266
- return null;
267
- const lineage = (sessionEntry.loop_lineage ?? {});
268
- const status = lineage.status ?? "running";
269
- const currentRound = lineage.current_round ?? 1;
270
- const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
271
- const task = sessionEntry.task ?? "";
272
- const maxRounds = lineage.max_rounds ?? getPolicy().runtime.max_rounds;
273
- // If the loop was already stopped or stalled, return immediately
274
- if (status !== "running") {
626
+ return this.leaseConflictResult(loopId, persistedEntry);
627
+ const session = this.reconstructSession(sessionEntry);
628
+ if (!session) {
629
+ // was not "running" — return stopped/stalled status
630
+ const lineage = (sessionEntry.loop_lineage ?? {});
631
+ const currentRound = lineage.current_round ?? 1;
632
+ const status = lineage.status ?? "stopped";
275
633
  return {
276
634
  sessionId: "",
277
635
  round: currentRound,
@@ -279,43 +637,70 @@ export class SessionManager {
279
637
  stopReason: status,
280
638
  };
281
639
  }
282
- // Reconstruct session with a fresh engine
283
- const engine = new LoopForgeEngine("skills", this.backend);
284
- const sessionId = randomUUID();
285
- const session = {
286
- sessionId,
287
- loopId,
288
- task,
289
- engine,
290
- currentRound,
291
- maxRounds,
292
- successTrajectory,
293
- status: "running",
294
- createdAt: lineage.created_at ?? Date.now(),
295
- // v1.7: Memory integration state restored from vault
296
- injectionCount: lineage.injection_count ?? 0,
297
- lastInjectionRound: lineage.last_injection_round ?? 0,
298
- injectedContexts: [],
299
- phase2Triggered: lineage.phase2_triggered ?? false,
300
- phase3Triggered: lineage.phase3_triggered ?? false,
301
- // v1.13: Enforcement gate state restored from vault
302
- consecutiveRejections: lineage.consecutive_rejections ?? 0,
303
- };
304
- this.sessions.set(sessionId, session);
305
- // Compile the next round's prompt from vault lineage
640
+ this.sessions.set(session.sessionId, session);
641
+ const reconciled = this.reconcileCommittedRound(session);
642
+ if (reconciled)
643
+ return reconciled;
644
+ const restored = this.restoredPromptResult(session);
645
+ if (restored)
646
+ return restored;
647
+ // Legacy session without a stored prompt: compile once, then persist it.
306
648
  const request = buildLoopRequest(session);
307
- const result = engine.invokeLoopCompile(request);
649
+ const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
650
+ const prompt = prepared?.prompt ?? null;
651
+ session.evidenceBaseline = prepared?.evidenceBaseline ?? [];
652
+ session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, []);
653
+ session.currentPrompt = prompt;
654
+ session.currentLevel = prepared?.level ?? "l2";
655
+ policyMetrics.recordStrategy(session.loopId, session.currentLevel);
656
+ this.save(session);
308
657
  return {
309
- sessionId,
310
- round: currentRound,
311
- prompt: result.response?.prompt ?? null,
312
- technique: result.response?.analysis?.technique ?? "zero-shot",
313
- level: parseLevel(result.response?.analysis?.rationale),
658
+ sessionId: session.sessionId,
659
+ round: session.currentRound,
660
+ roundId: session.roundSnapshot.roundId,
661
+ prompt,
662
+ level: session.currentLevel,
314
663
  roundSuccess: false,
315
664
  quality: 0,
316
- warnings: parseWarnings(result.response?.prompt ?? null),
665
+ warnings: parseWarnings(prompt),
317
666
  };
318
667
  }
668
+ /** Auto-resume all "running" sessions from vault on server startup.
669
+ * Scans vault for session_state entries, reconstructs each as an in-memory
670
+ * McpSession (without compiling — the next loopforge_next will do that).
671
+ * Returns the number of sessions resumed. */
672
+ autoResumeAll() {
673
+ if (!this.sessionStore)
674
+ return 0;
675
+ const entries = this.sessionStore.list();
676
+ // Build set of already-active loopIds
677
+ const activeLoopIds = new Set();
678
+ for (const s of this.sessions.values()) {
679
+ activeLoopIds.add(s.loopId);
680
+ }
681
+ let count = 0;
682
+ for (const entry of entries) {
683
+ if (entry.task_type !== "session_state")
684
+ continue;
685
+ const lid = entry.loop_id;
686
+ if (!lid || activeLoopIds.has(lid))
687
+ continue;
688
+ const lineage = (entry.loop_lineage ?? {});
689
+ const status = lineage.status ?? "running";
690
+ if (status !== "running" && status !== "paused")
691
+ continue;
692
+ const claimedEntry = this.claimSessionEntry(lid);
693
+ if (!claimedEntry)
694
+ continue;
695
+ const session = this.reconstructSession(claimedEntry, true);
696
+ if (session) {
697
+ this.sessions.set(session.sessionId, session);
698
+ activeLoopIds.add(lid);
699
+ count++;
700
+ }
701
+ }
702
+ return count;
703
+ }
319
704
  list() {
320
705
  const seen = new Set();
321
706
  const result = [];
@@ -329,10 +714,9 @@ export class SessionManager {
329
714
  status: s.status,
330
715
  });
331
716
  }
332
- // Merge vault-persisted sessions not already in memory
333
- if (this.backend) {
334
- const vault = this.backend.readVault();
335
- const entries = vault.entries ?? [];
717
+ // Merge persisted sessions not already in memory
718
+ if (this.sessionStore) {
719
+ const entries = this.sessionStore.list();
336
720
  for (const e of entries) {
337
721
  if (e.task_type !== "session_state")
338
722
  continue;
@@ -367,11 +751,8 @@ export class SessionManager {
367
751
  }
368
752
  }
369
753
  // Fall back to vault for task
370
- if (!task && this.backend) {
371
- const entries = this.backend.queryEntries({
372
- prefix: `loop:${loopId}:session`,
373
- });
374
- const sessionEntry = entries.find((e) => e.task_type === "session_state");
754
+ if (!task) {
755
+ const sessionEntry = this.findSessionEntry(loopId);
375
756
  if (sessionEntry) {
376
757
  task = sessionEntry.task ?? "";
377
758
  }
@@ -379,7 +760,7 @@ export class SessionManager {
379
760
  if (!task)
380
761
  return null;
381
762
  // Hydrate vault context
382
- const engine = new LoopForgeEngine("skills", this.backend);
763
+ const engine = new LoopForgeEngine(this.backend);
383
764
  const vaultContext = engine.hydrateLoopContext(loopId);
384
765
  // Build a minimal request for health check
385
766
  const request = makeLoopCompileRequest({
@@ -396,6 +777,7 @@ export class SessionManager {
396
777
  drift_detected: health.drift_detected,
397
778
  strategy_stability: health.strategy_stability,
398
779
  task_continuity: health.task_continuity,
780
+ policy_metrics: policyMetrics.snapshot(loopId),
399
781
  };
400
782
  }
401
783
  /** Core cycle: extract self-eval → record feedback → check stop → compile next.
@@ -403,6 +785,24 @@ export class SessionManager {
403
785
  * When provided (MCP path with evaluation parameter), skips regex extraction.
404
786
  * When undefined (runtime/CLI path), falls back to regex extraction from output. */
405
787
  async advance(sessionId, output, preExtractedEval) {
788
+ return this.withSessionQueue(sessionId, async () => {
789
+ const session = this.sessions.get(sessionId);
790
+ if (session?.status === "running" &&
791
+ !this.renewSessionLease(session.loopId)) {
792
+ return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
793
+ }
794
+ try {
795
+ return await this.advanceUnlocked(sessionId, output, preExtractedEval);
796
+ }
797
+ catch (error) {
798
+ if (error instanceof SessionLeaseConflictError) {
799
+ return this.leaseConflictResult(error.loopId, this.findSessionEntry(error.loopId));
800
+ }
801
+ throw error;
802
+ }
803
+ });
804
+ }
805
+ async advanceUnlocked(sessionId, output, preExtractedEval) {
406
806
  const session = this.sessions.get(sessionId);
407
807
  if (!session)
408
808
  return { sessionId, round: 0, prompt: null, stopReason: "session_not_found" };
@@ -425,250 +825,196 @@ export class SessionManager {
425
825
  if (!selfEval) {
426
826
  session.status = "stalled";
427
827
  this.save(session);
428
- void this.doWriteback(session, "stalled");
828
+ void this.notifyTerminal(session, "stalled");
429
829
  logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
430
830
  return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", roundSuccess: false, quality: 0 };
431
831
  }
432
- // 1.5. Verification gate — cross-round consistency check (v1.6)
433
- let verificationFlags = [];
434
- let gateVerdict = "trusted";
435
- let vaultEntries = [];
436
- {
437
- vaultEntries = this.backend
438
- ? this.backend.queryEntries({ prefix: `loop:${session.loopId}:r` })
439
- : [];
440
- const verifyResult = verifySelfEvaluation(selfEval, session.currentRound, vaultEntries, session.lastSelfEval ?? null);
441
- verificationFlags = verifyResult.flags;
442
- gateVerdict = verifyResult.verdict;
443
- }
444
- // 1.6. Enforcement gate — round-boundary runtime enforcement (v1.13)
445
- // Runs AFTER verification so it can use the flags as input.
446
- // Runs BEFORE feedback recording so rejected rounds don't pollute the vault.
447
- // Only runs when the self-eval came from structured extraction (not heuristic
448
- // fallback) — heuristic evaluations have no reliable execution_evidence.
449
- if (!extractionFailed) {
450
- const enforceResult = enforceRound(selfEval, { verdict: gateVerdict, flags: verificationFlags }, session.currentRound, vaultEntries, session.consecutiveRejections);
451
- if (enforceResult.action === "reject") {
452
- session.consecutiveRejections++;
453
- this.save(session);
454
- const rejectionPrompt = buildRejectionPrompt(session.currentRound, session.task, enforceResult);
455
- logEvent("enforcement_reject", {
456
- sessionId,
457
- loopId: session.loopId,
458
- round: session.currentRound,
459
- reason: enforceResult.reason.slice(0, 120),
460
- consecutiveRejections: session.consecutiveRejections,
461
- });
462
- return {
463
- sessionId,
464
- round: session.currentRound,
465
- prompt: rejectionPrompt,
466
- enforcementAction: "reject",
467
- enforcementReason: enforceResult.reason,
468
- };
469
- }
470
- if (enforceResult.action === "terminate") {
471
- session.status = "stopped";
832
+ // 1.5. Unified transaction: before → evaluate → verify → commit/reject.
833
+ const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
834
+ const completed = await new RoundDriver(session.engine, this.backend).complete({
835
+ snapshot,
836
+ loopId: session.loopId,
837
+ task: session.task,
838
+ maxRounds: session.maxRounds,
839
+ selfEval,
840
+ extractionSucceeded: !extractionFailed,
841
+ lastSelfEval: session.lastSelfEval,
842
+ consecutiveRejections: session.consecutiveRejections,
843
+ successTrajectory: session.successTrajectory,
844
+ });
845
+ const outcome = completed.outcome;
846
+ const actualSnapshots = completed.actualEvidence;
847
+ session.roundSnapshot = outcome.snapshot;
848
+ const pr = outcome.result;
849
+ 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.
853
+ if (pr.action === "reject" && pr.rejectionCheck) {
854
+ session.consecutiveRejections =
855
+ pr.rejectionCheck === session.lastRejectionCheck
856
+ ? pr.newConsecutiveRejections
857
+ : 1;
858
+ session.lastRejectionCheck = pr.rejectionCheck;
859
+ }
860
+ else {
861
+ session.consecutiveRejections = pr.newConsecutiveRejections;
862
+ if (pr.action !== "reject")
863
+ session.lastRejectionCheck = "";
864
+ }
865
+ if (pr.newLastSelfEval)
866
+ session.lastSelfEval = pr.newLastSelfEval;
867
+ if (pr.shouldPushSuccessTrajectory) {
868
+ session.successTrajectory.push(pr.roundSuccess);
869
+ }
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;
472
877
  this.save(session);
473
- void this.doWriteback(session, "enforcement_terminated");
474
- logEvent("session_end", {
475
- sessionId, loopId: session.loopId,
476
- stopReason: "enforcement_terminated", round: session.currentRound,
477
- });
478
878
  return {
479
879
  sessionId,
480
880
  round: session.currentRound,
881
+ roundId: session.roundSnapshot.roundId,
481
882
  prompt: null,
482
- stopReason: "enforcement_terminated",
483
- enforcementAction: "terminate",
484
- enforcementReason: enforceResult.reason,
883
+ stopReason: "stalled",
485
884
  };
486
885
  }
487
- // Accept: reset rejection counter and continue
488
- session.consecutiveRejections = 0;
489
- }
490
- // 2. Record feedback (flushes immediately so next compile sees success flags)
491
- const roundSuccess = session.engine.autoFeedback(selfEval, session.loopId, session.currentRound, session.task);
492
- // Contradicted verdict: skip success trend
493
- if (gateVerdict !== "contradicted") {
494
- session.successTrajectory.push(roundSuccess);
495
- }
496
- // Note: feedback vault entry is always persisted via autoFeedback above.
497
- // Only the in-memory trend is skipped — the raw data stays for audit.
498
- // Store selfEval for next round's verification gate.
499
- // NOTE: lastSelfEval is intentionally NOT persisted to vault (save()).
500
- // A resumed session starts with lastSelfEval=undefined, which means the
501
- // first round after resumption runs with degraded verification (most
502
- // checks skip without prevSelfEval). The gate recovers on the next round.
503
- session.lastSelfEval = selfEval;
504
- // Build deprecated quality alias for backward compat
505
- const deprecatedQuality = roundSuccess ? 5 : 1;
506
- // 3. Stop conditions (extraction-first order — see memory)
507
- if (extractionFailed) {
508
- session.status = "stalled";
886
+ session.roundSnapshot = preparedRetry.snapshot;
887
+ session.currentPrompt = preparedRetry.prompt;
888
+ session.currentLevel = preparedRetry.level;
509
889
  this.save(session);
510
- void this.doWriteback(session, "stalled");
511
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
512
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", roundSuccess, quality: deprecatedQuality };
513
- }
514
- if (!selfEval.should_continue) {
515
- session.status = "stopped";
516
- this.save(session);
517
- void this.doWriteback(session, "task_complete");
518
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "task_complete", round: session.currentRound });
519
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "task_complete", roundSuccess, quality: deprecatedQuality };
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
+ };
520
899
  }
521
- if (session.engine.shouldBreak()) {
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") {
522
904
  session.status = "stopped";
905
+ session.currentPrompt = null;
523
906
  this.save(session);
524
- void this.doWriteback(session, "circuit_breaker");
525
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "circuit_breaker", round: session.currentRound });
526
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "circuit_breaker", roundSuccess, quality: deprecatedQuality };
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
+ return {
913
+ sessionId,
914
+ round: session.currentRound,
915
+ roundId: session.roundSnapshot.roundId,
916
+ prompt: null,
917
+ stopReason: "enforcement_terminated",
918
+ enforcementAction: "terminate",
919
+ enforcementReason: pr.enforcementReason,
920
+ };
527
921
  }
528
- if (session.currentRound >= session.maxRounds) {
529
- session.status = "stopped";
922
+ if (pr.action === "stop") {
923
+ const reason = pr.stopReason ?? "stalled";
924
+ session.status = reason === "stalled" ? "stalled" : "stopped";
925
+ session.currentPrompt = null;
530
926
  this.save(session);
531
- void this.doWriteback(session, "max_rounds");
532
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "max_rounds", round: session.currentRound });
533
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "max_rounds", roundSuccess, quality: deprecatedQuality };
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 };
534
930
  }
535
- // 4. Compile next round
931
+ 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.
536
936
  session.currentRound++;
937
+ session.currentPrompt = null;
537
938
  // v1.8: Memory injection for phases 2/3 — tier-aware
538
939
  let externalCtx = "";
539
- const miPolicy = getPolicy().memory_injection;
540
- if (miPolicy.enabled && this.memoryProvider) {
541
- const allowedPhases = new Set(resolveAllowedPhases(session.maxRounds, miPolicy.round_tiers));
542
- if (session.injectionCount < allowedPhases.size &&
543
- session.currentRound - session.lastInjectionRound >= miPolicy.min_rounds_between_injections) {
544
- const progress = selfEval.execution_evidence?.progress_estimate ?? -1;
545
- const phase = resolveInjectionPhase(session.currentRound, session.injectionCount, allowedPhases, typeof progress === "number" ? progress : -1, session.phase2Triggered, session.phase3Triggered, {
546
- phase2: miPolicy.phase_thresholds.phase2.threshold,
547
- phase3: miPolicy.phase_thresholds.phase3.threshold,
940
+ if (this.contextProvider) {
941
+ try {
942
+ externalCtx = (await this.contextProvider({
943
+ loopId: session.loopId,
944
+ round: session.currentRound,
945
+ task: session.task,
946
+ domain: "",
947
+ lastEvaluation: selfEval,
948
+ })).trim();
949
+ }
950
+ catch {
951
+ logEvent("context_provider_error", {
952
+ loopId: session.loopId,
953
+ round: session.currentRound,
548
954
  });
549
- if (phase === 2)
550
- session.phase2Triggered = true;
551
- if (phase === 3)
552
- session.phase3Triggered = true;
553
- if (phase !== 0) {
554
- try {
555
- const accCtx = buildAccumulatedMemoryContext(selfEval);
556
- const ctx = {
557
- loopId: session.loopId,
558
- round: session.currentRound,
559
- task: session.task,
560
- domain: "",
561
- phase,
562
- progressEstimate: typeof progress === "number" && progress >= 0 ? progress : -1,
563
- accumulatedContext: accCtx,
564
- };
565
- const rawContext = await this.memoryProvider(ctx);
566
- if (rawContext?.trim()) {
567
- // Dedup
568
- const newTokens = tokenize(rawContext);
569
- let isDuplicate = false;
570
- for (const old of session.injectedContexts) {
571
- if (jaccard(newTokens, tokenize(old)) > miPolicy.dedup_threshold) {
572
- isDuplicate = true;
573
- break;
574
- }
575
- }
576
- if (!isDuplicate) {
577
- externalCtx = rawContext.trim().slice(0, miPolicy.max_context_length);
578
- session.injectionCount++;
579
- session.lastInjectionRound = session.currentRound;
580
- session.injectedContexts.push(externalCtx);
581
- logEvent("memory_injected", {
582
- loopId: session.loopId, round: session.currentRound,
583
- phase, injectionCount: session.injectionCount,
584
- contextLength: externalCtx.length,
585
- });
586
- }
587
- }
588
- }
589
- catch {
590
- logEvent("memory_provider_error", {
591
- loopId: session.loopId, round: session.currentRound,
592
- });
593
- }
594
- }
595
955
  }
596
956
  }
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.
961
+ if (this.sessions.get(sessionId) !== session || session.status !== "running") {
962
+ return {
963
+ sessionId,
964
+ round: session.currentRound,
965
+ prompt: null,
966
+ stopReason: session.status,
967
+ roundSuccess,
968
+ quality: deprecatedQuality,
969
+ };
970
+ }
597
971
  const request = buildLoopRequest(session, selfEval, deprecatedQuality, verificationFlags);
598
972
  if (externalCtx) {
599
973
  request.external_context = externalCtx;
600
974
  }
601
- const result = session.engine.invokeLoopCompile(request);
602
- // v1.14: Write state file if the compiler produced one and policy allows it
603
- const sfContent = result.response?.state_file_content;
604
- if (sfContent && getPolicy().state_file.enabled) {
605
- const sfDir = join(process.cwd(), getPolicy().state_file.directory);
606
- mkdirSync(sfDir, { recursive: true });
607
- writeFileSync(join(sfDir, `${session.loopId}-state.md`), sfContent, "utf-8");
608
- }
975
+ const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, session.currentRound);
976
+ const nextPrompt = prepared?.prompt ?? null;
977
+ const nextLevel = prepared?.level ?? "l2";
978
+ const nextBaseline = prepared?.evidenceBaseline ?? actualSnapshots;
979
+ session.evidenceBaseline = nextBaseline;
980
+ session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
981
+ session.currentPrompt = nextPrompt;
982
+ session.currentLevel = nextLevel;
983
+ policyMetrics.recordStrategy(session.loopId, nextLevel);
609
984
  this.save(session);
610
985
  return {
611
986
  sessionId,
612
987
  round: session.currentRound,
613
- prompt: result.response?.prompt ?? null,
614
- technique: result.response?.analysis?.technique ?? "zero-shot",
615
- level: parseLevel(result.response?.analysis?.rationale),
988
+ roundId: session.roundSnapshot.roundId,
989
+ prompt: nextPrompt,
990
+ level: nextLevel,
616
991
  roundSuccess,
617
992
  quality: deprecatedQuality,
618
- warnings: parseWarnings(result.response?.prompt ?? null),
993
+ warnings: parseWarnings(nextPrompt),
619
994
  };
620
995
  }
621
996
  /** Write back loop knowledge to long-term memory.
622
997
  * Uses shared base builder from policy.ts. Called when a loop terminates. */
623
- async doWriteback(session, stopReason) {
624
- if (!this.memoryWriter)
625
- return;
626
- const wp = getPolicy().memory_writeback;
627
- if (!wp.enabled)
628
- return;
629
- if (wp.write_on_outcomes.length > 0 && !wp.write_on_outcomes.includes(stopReason))
630
- return;
631
- if (session.currentRound < 1)
998
+ async notifyTerminal(session, stopReason) {
999
+ if (this.terminalSinks.size === 0)
632
1000
  return;
633
- try {
634
- const lastEval = session.lastSelfEval;
635
- const discoveries = lastEval
636
- ? [
637
- ...(lastEval.wrong_assumptions ?? []),
638
- ...(lastEval.emerged_subtasks ?? []),
639
- ...(lastEval.discovered_constraints ?? []),
640
- ].slice(0, wp.max_discoveries_in_project)
641
- : [];
642
- const base = buildBaseMemoryWriteback({
643
- loopId: session.loopId,
644
- task: session.task,
645
- stopReason,
646
- roundsCompleted: session.currentRound,
647
- discoveries,
648
- });
649
- const payload = {
650
- ...base,
651
- feedbackEntries: [],
652
- };
653
- await this.memoryWriter(payload);
654
- logEvent("memory_writeback", {
655
- loopId: session.loopId, stopReason,
656
- feedbackCount: 0,
657
- });
658
- }
659
- catch {
660
- logEvent("memory_writeback_error", {
661
- loopId: session.loopId, stopReason,
662
- });
663
- }
1001
+ const event = {
1002
+ loopId: session.loopId,
1003
+ task: session.task,
1004
+ success: stopReason === "completed",
1005
+ stopReason: stopReason,
1006
+ roundsCompleted: session.currentRound,
1007
+ successTrajectory: [...session.successTrajectory],
1008
+ lastEvaluation: session.lastSelfEval,
1009
+ };
1010
+ await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
664
1011
  }
665
1012
  /** Replay timeline for a session — creates ReplayBackend from the stored backend. */
666
1013
  replayTimeline(sessionId) {
667
1014
  const session = this.sessions.get(sessionId);
668
1015
  if (!session)
669
1016
  return null;
670
- const vaultBackend = this.backend ?? new FSBackend();
671
- const replay = new ReplayBackend(vaultBackend);
1017
+ const replay = new ReplayBackend(this.backend);
672
1018
  return replay.timeline(session.loopId);
673
1019
  }
674
1020
  }