loopforge 3.0.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +126 -143
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +131 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +98 -23
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +257 -181
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +40 -33
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +17 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -0
  15. package/dist/cognitive-facts.js +99 -0
  16. package/dist/cognitive-facts.js.map +1 -0
  17. package/dist/cognitive-governance.d.ts +25 -33
  18. package/dist/cognitive-governance.d.ts.map +1 -1
  19. package/dist/cognitive-governance.js +38 -138
  20. package/dist/cognitive-governance.js.map +1 -1
  21. package/dist/committed-round.d.ts +59 -0
  22. package/dist/committed-round.d.ts.map +1 -0
  23. package/dist/committed-round.js +222 -0
  24. package/dist/committed-round.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +78 -0
  26. package/dist/enforcement-gate.d.ts.map +1 -0
  27. package/dist/enforcement-gate.js +1109 -0
  28. package/dist/enforcement-gate.js.map +1 -0
  29. package/dist/engine.d.ts +111 -0
  30. package/dist/engine.d.ts.map +1 -0
  31. package/dist/engine.js +887 -0
  32. package/dist/engine.js.map +1 -0
  33. package/dist/evidence-claims.d.ts +50 -0
  34. package/dist/evidence-claims.d.ts.map +1 -0
  35. package/dist/evidence-claims.js +116 -0
  36. package/dist/evidence-claims.js.map +1 -0
  37. package/dist/evidence-provider.d.ts +50 -23
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +167 -50
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/generate-schema.js +1 -84
  42. package/dist/generate-schema.js.map +1 -1
  43. package/dist/index.d.ts +19 -22
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +16 -15
  46. package/dist/index.js.map +1 -1
  47. package/dist/loop-compiler.d.ts +58 -3
  48. package/dist/loop-compiler.d.ts.map +1 -1
  49. package/dist/loop-compiler.js +841 -304
  50. package/dist/loop-compiler.js.map +1 -1
  51. package/dist/loop-extras-parser.d.ts +2 -2
  52. package/dist/loop-extras-parser.d.ts.map +1 -1
  53. package/dist/loop-extras-parser.js +2 -2
  54. package/dist/loop-extras-parser.js.map +1 -1
  55. package/dist/loop-projection.d.ts +6 -0
  56. package/dist/loop-projection.d.ts.map +1 -0
  57. package/dist/loop-projection.js +26 -0
  58. package/dist/loop-projection.js.map +1 -0
  59. package/dist/loop-store.d.ts +115 -24
  60. package/dist/loop-store.d.ts.map +1 -1
  61. package/dist/loop-store.js +365 -218
  62. package/dist/loop-store.js.map +1 -1
  63. package/dist/mcp/round-lifecycle.d.ts +214 -0
  64. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  65. package/dist/mcp/round-lifecycle.js +935 -0
  66. package/dist/mcp/round-lifecycle.js.map +1 -0
  67. package/dist/mcp/server.d.ts +2 -1
  68. package/dist/mcp/server.d.ts.map +1 -1
  69. package/dist/mcp/server.js +29 -43
  70. package/dist/mcp/server.js.map +1 -1
  71. package/dist/mcp/session.d.ts +64 -101
  72. package/dist/mcp/session.d.ts.map +1 -1
  73. package/dist/mcp/session.js +433 -1110
  74. package/dist/mcp/session.js.map +1 -1
  75. package/dist/mcp/tools.d.ts +20 -11
  76. package/dist/mcp/tools.d.ts.map +1 -1
  77. package/dist/mcp/tools.js +587 -413
  78. package/dist/mcp/tools.js.map +1 -1
  79. package/dist/policy-metrics.d.ts +12 -24
  80. package/dist/policy-metrics.d.ts.map +1 -1
  81. package/dist/policy-metrics.js +78 -184
  82. package/dist/policy-metrics.js.map +1 -1
  83. package/dist/policy.d.ts +61 -50
  84. package/dist/policy.d.ts.map +1 -1
  85. package/dist/policy.js +48 -146
  86. package/dist/policy.js.map +1 -1
  87. package/dist/prompt-assembler.d.ts +37 -6
  88. package/dist/prompt-assembler.d.ts.map +1 -1
  89. package/dist/prompt-assembler.js +487 -278
  90. package/dist/prompt-assembler.js.map +1 -1
  91. package/dist/prompt-policy.d.ts +1 -8
  92. package/dist/prompt-policy.d.ts.map +1 -1
  93. package/dist/prompt-policy.js +8 -26
  94. package/dist/prompt-policy.js.map +1 -1
  95. package/dist/protocol.d.ts +398 -236
  96. package/dist/protocol.d.ts.map +1 -1
  97. package/dist/protocol.js +147 -17
  98. package/dist/protocol.js.map +1 -1
  99. package/dist/replay.d.ts +5 -13
  100. package/dist/replay.d.ts.map +1 -1
  101. package/dist/replay.js +32 -69
  102. package/dist/replay.js.map +1 -1
  103. package/dist/round-contract.d.ts +64 -0
  104. package/dist/round-contract.d.ts.map +1 -0
  105. package/dist/round-contract.js +80 -0
  106. package/dist/round-contract.js.map +1 -0
  107. package/dist/round-coordinator.d.ts +23 -25
  108. package/dist/round-coordinator.d.ts.map +1 -1
  109. package/dist/round-coordinator.js +103 -92
  110. package/dist/round-coordinator.js.map +1 -1
  111. package/dist/round-driver.d.ts +13 -13
  112. package/dist/round-driver.d.ts.map +1 -1
  113. package/dist/round-driver.js +25 -14
  114. package/dist/round-driver.js.map +1 -1
  115. package/dist/round-transaction.d.ts +14 -18
  116. package/dist/round-transaction.d.ts.map +1 -1
  117. package/dist/round-transaction.js +37 -47
  118. package/dist/round-transaction.js.map +1 -1
  119. package/dist/self-eval.d.ts +48 -0
  120. package/dist/self-eval.d.ts.map +1 -0
  121. package/dist/self-eval.js +248 -0
  122. package/dist/self-eval.js.map +1 -0
  123. package/dist/storage.d.ts +9 -4
  124. package/dist/storage.d.ts.map +1 -1
  125. package/dist/storage.js +18 -6
  126. package/dist/storage.js.map +1 -1
  127. package/dist/token-utils.d.ts +15 -3
  128. package/dist/token-utils.d.ts.map +1 -1
  129. package/dist/token-utils.js +24 -5
  130. package/dist/token-utils.js.map +1 -1
  131. package/dist/verification-gate.d.ts +149 -0
  132. package/dist/verification-gate.d.ts.map +1 -0
  133. package/dist/verification-gate.js +1343 -0
  134. package/dist/verification-gate.js.map +1 -0
  135. package/dist/workspace.d.ts +29 -0
  136. package/dist/workspace.d.ts.map +1 -0
  137. package/dist/workspace.js +61 -0
  138. package/dist/workspace.js.map +1 -0
  139. package/loop_policy.json +37 -16
  140. package/package.json +5 -10
  141. package/skills/perception/SKILL.md +184 -202
  142. package/dist/canonical-event-writer.d.ts +0 -20
  143. package/dist/canonical-event-writer.d.ts.map +0 -1
  144. package/dist/canonical-event-writer.js +0 -131
  145. package/dist/canonical-event-writer.js.map +0 -1
  146. package/dist/compiler-subgoals.d.ts +0 -7
  147. package/dist/compiler-subgoals.d.ts.map +0 -1
  148. package/dist/compiler-subgoals.js +0 -93
  149. package/dist/compiler-subgoals.js.map +0 -1
  150. package/dist/derive-id.d.ts +0 -12
  151. package/dist/derive-id.d.ts.map +0 -1
  152. package/dist/derive-id.js +0 -31
  153. package/dist/derive-id.js.map +0 -1
  154. package/dist/mcp/session-projection.d.ts +0 -5
  155. package/dist/mcp/session-projection.d.ts.map +0 -1
  156. package/dist/mcp/session-projection.js +0 -18
  157. package/dist/mcp/session-projection.js.map +0 -1
  158. package/dist/mcp/session-queue.d.ts +0 -3
  159. package/dist/mcp/session-queue.d.ts.map +0 -1
  160. package/dist/mcp/session-queue.js +0 -18
  161. package/dist/mcp/session-queue.js.map +0 -1
  162. package/dist/mcp/session-types.d.ts +0 -72
  163. package/dist/mcp/session-types.d.ts.map +0 -1
  164. package/dist/mcp/session-types.js +0 -2
  165. package/dist/mcp/session-types.js.map +0 -1
  166. package/dist/round-commit-store.d.ts +0 -12
  167. package/dist/round-commit-store.d.ts.map +0 -1
  168. package/dist/round-commit-store.js +0 -13
  169. package/dist/round-commit-store.js.map +0 -1
  170. package/dist/round-gate.d.ts +0 -31
  171. package/dist/round-gate.d.ts.map +0 -1
  172. package/dist/round-gate.js +0 -259
  173. package/dist/round-gate.js.map +0 -1
  174. package/dist/round-recovery.d.ts +0 -17
  175. package/dist/round-recovery.d.ts.map +0 -1
  176. package/dist/round-recovery.js +0 -117
  177. package/dist/round-recovery.js.map +0 -1
  178. package/dist/round-report.d.ts +0 -16
  179. package/dist/round-report.d.ts.map +0 -1
  180. package/dist/round-report.js +0 -401
  181. package/dist/round-report.js.map +0 -1
  182. package/dist/runtime-services.d.ts +0 -59
  183. package/dist/runtime-services.d.ts.map +0 -1
  184. package/dist/runtime-services.js +0 -460
  185. package/dist/runtime-services.js.map +0 -1
  186. package/dist/vault-format.d.ts +0 -4
  187. package/dist/vault-format.d.ts.map +0 -1
  188. package/dist/vault-format.js +0 -4
  189. package/dist/vault-format.js.map +0 -1
  190. package/dist/vault-types.d.ts +0 -24
  191. package/dist/vault-types.d.ts.map +0 -1
  192. package/dist/vault-types.js +0 -2
  193. package/dist/vault-types.js.map +0 -1
  194. package/loopforge-policy.json +0 -340
@@ -2,65 +2,75 @@
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 { deriveCognitiveFacts } from "../cognitive-facts.js";
21
+ import { listVerifiedClaims } from "../evidence-claims.js";
22
+ import { buildAudit } from "../audit.js";
9
23
  import { checkLoopHealth } from "../loop-compiler.js";
10
- import { getPolicy } from "../policy.js";
11
- import { Mode, makeLoopCompileRequest } from "../protocol.js";
24
+ import { committedRoundsFromEntries } from "../committed-round.js";
25
+ import { contractRoundEvaluations, deriveActiveRoundContract, } from "../round-contract.js";
26
+ import { getPolicy, validateLoopId } from "../policy.js";
27
+ import { isRecord } from "../token-utils.js";
28
+ import { buildSelfEvaluation, validateCoreSelfEvaluation, } from "../self-eval.js";
29
+ import { makeLoopCompileRequest } from "../protocol.js";
12
30
  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";
31
+ import { FileLoopStore, queryLoopEntries } from "../loop-store.js";
32
+ import { makeRoundId, prepareRoundTransaction } from "../round-transaction.js";
16
33
  import { RoundDriver } from "../round-driver.js";
17
34
  import { logEvent } from "../observability.js";
18
- import { derivePolicyMetrics, mergePolicyMetrics, policyMetrics, } from "../policy-metrics.js";
35
+ import { policyMetrics, derivePolicyMetrics, mergePolicyMetrics, } from "../policy-metrics.js";
19
36
  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 ────────────────────────────────────────────────────────────────
37
+ import { StorageCorruptionError } from "../loop-store.js";
38
+ import { RoundLifecycle, buildLoopRequest } from "./round-lifecycle.js";
28
39
  // ── SessionManager ─────────────────────────────────────────────────────────
29
40
  export class SessionManager {
30
41
  sessions = new Map();
31
42
  /** Serializes state transitions for each session. */
32
43
  sessionQueues = new Map();
33
- backend;
34
- eventWriter;
44
+ loopStore;
35
45
  sessionStore;
36
46
  ownerId = `${process.pid}:${randomUUID()}`;
37
47
  leaseMs;
38
48
  leaseRenewIntervalMs;
39
49
  leaseTimer = null;
50
+ lifecycle;
40
51
  /** Explicit context provider; never auto-discovered. */
41
52
  contextProvider;
42
53
  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);
54
+ constructor(store, sessionStore) {
55
+ this.loopStore = store ?? new FileLoopStore(getPolicy().backend.root_dir);
56
+ this.sessionStore = sessionStore ?? new VaultSessionStateStore(this.loopStore);
51
57
  const mcpPolicy = getPolicy().mcp;
52
58
  this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
53
59
  this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
60
+ this.lifecycle = new RoundLifecycle({
61
+ store: this.loopStore,
62
+ sessionStore: this.sessionStore,
63
+ registry: this,
64
+ terminalSinks: this.terminalSinks,
65
+ ownerId: this.ownerId,
66
+ leaseMs: this.leaseMs,
67
+ getContext: () => this.contextProvider,
68
+ });
54
69
  if (this.sessionStore?.renewLease) {
55
70
  this.leaseTimer = setInterval(() => this.renewOwnedLeases(), this.leaseRenewIntervalMs);
56
71
  this.leaseTimer.unref?.();
57
72
  }
58
73
  }
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
74
  /** Stable process-local owner token used for cross-process session leases. */
65
75
  getOwnerId() {
66
76
  return this.ownerId;
@@ -78,6 +88,15 @@ export class SessionManager {
78
88
  this.sessionStore?.releaseLease?.(session.loopId, this.ownerId);
79
89
  }
80
90
  }
91
+ // ── SessionRegistry (view for RoundLifecycle) ────────────────────────────
92
+ values() {
93
+ return this.sessions.values();
94
+ }
95
+ /** Register (or replace) a session in the in-memory registry. */
96
+ upsert(session) {
97
+ this.sessions.set(session.sessionId, session);
98
+ }
99
+ // ── Lease helpers ────────────────────────────────────────────────────────
81
100
  findSessionEntry(loopId) {
82
101
  return this.sessionStore?.load(loopId);
83
102
  }
@@ -113,29 +132,67 @@ export class SessionManager {
113
132
  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
133
  };
115
134
  }
135
+ async withSessionQueue(sessionId, work) {
136
+ const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
137
+ let release;
138
+ const gate = new Promise((resolve) => {
139
+ release = resolve;
140
+ });
141
+ const tail = previous.then(() => gate);
142
+ this.sessionQueues.set(sessionId, tail);
143
+ await previous;
144
+ try {
145
+ return await work();
146
+ }
147
+ finally {
148
+ release();
149
+ if (this.sessionQueues.get(sessionId) === tail) {
150
+ this.sessionQueues.delete(sessionId);
151
+ }
152
+ }
153
+ }
154
+ // ── Lifecycle entry points ───────────────────────────────────────────────
116
155
  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)) {
156
+ const sessionId = randomUUID();
157
+ const loopId = input.loopId ?? randomUUID();
158
+ // v2.14: same-process duplicate guard. Cross-process duplicates are
159
+ // fenced by the lease (SessionLeaseConflictError on save); within one
160
+ // process the lease owner is identical, so a second create for the
161
+ // same loopId would silently overwrite the first session's persisted
162
+ // state and leave two in-memory sessions pointing at one loop.
163
+ const existing = [...this.sessions.values()].find((s) => s.loopId === loopId);
164
+ if (existing) {
120
165
  return {
121
166
  sessionId: "",
122
- round: 0,
167
+ round: existing.currentRound,
123
168
  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}.` },
169
+ stopReason: `loop_already_running:${loopId}`,
170
+ stopDetail: `A session for loop "${loopId}" already exists in this process (session ${existing.sessionId}). Inspect or resume it instead of starting a duplicate.`,
127
171
  };
128
172
  }
129
- const sessionId = randomUUID();
130
- const loopId = input.loopId ?? randomUUID();
131
- const engine = new RuntimeServices(this.backend);
173
+ // v3.2.1: pre-flight the cross-process lease BEFORE compiling — the
174
+ // compile persists round-1 lineage and the state file into the loop's
175
+ // vault, which would corrupt another process's live loop (round-1
176
+ // lineage replaced, metadata overwritten). Mirrors the save-time check
177
+ // (VaultSessionStateStore.save: a non-empty lease_owner that is not ours
178
+ // conflicts), so observable behavior is unchanged — only the side
179
+ // effects are avoided. The save below remains the atomic final gate.
180
+ const persistedSession = this.sessionStore?.load(loopId);
181
+ const persistedLineage = persistedSession?.loop_lineage;
182
+ const persistedOwner = persistedLineage && typeof persistedLineage === "object" && !Array.isArray(persistedLineage)
183
+ ? persistedLineage.lease_owner
184
+ : "";
185
+ if (typeof persistedOwner === "string" && persistedOwner && persistedOwner !== this.ownerId) {
186
+ throw new SessionLeaseConflictError(loopId);
187
+ }
188
+ const engine = new LoopForgeEngine(this.loopStore);
132
189
  const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
133
190
  // Populate extra fields for the first round
134
191
  const request = buildLoopRequest({
135
- sessionId, loopId, task: input.task, runtime: engine, currentRound: 1,
192
+ sessionId, loopId, task: input.task, engine, currentRound: 1,
136
193
  maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
137
194
  consecutiveRejections: 0,
138
- escalatedRejectionChecks: [],
195
+ lastRejectionCheck: "",
139
196
  driftClarificationStreak: 0,
140
197
  backtrackSkippedFiles: [],
141
198
  evidenceBaseline: [],
@@ -143,21 +200,6 @@ export class SessionManager {
143
200
  request.domain = input.domain ?? "";
144
201
  request.plan_source = input.planSource ?? null;
145
202
  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
203
  // Explicit context is requested by the MCP embedding; no auto-discovery.
162
204
  if (this.contextProvider) {
163
205
  try {
@@ -181,27 +223,17 @@ export class SessionManager {
181
223
  logEvent("context_provider_error", { loopId, round: 1 });
182
224
  }
183
225
  }
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
- }
226
+ const prepared = await new RoundDriver(engine, this.loopStore).prepare(request, loopId, 1);
195
227
  const initialPrompt = prepared?.prompt ?? null;
196
228
  const initialLevel = prepared?.level ?? "l2";
197
229
  const evidenceBaseline = prepared?.evidenceBaseline ?? [];
198
230
  const session = {
199
- sessionId, loopId, task: input.task, runtime: engine,
231
+ sessionId, loopId, task: input.task, engine,
200
232
  currentRound: 1, maxRounds, successTrajectory: [],
201
233
  status: "running", createdAt: Date.now(),
202
234
  // v1.13: Enforcement gate state
203
235
  consecutiveRejections: 0,
204
- escalatedRejectionChecks: [],
236
+ lastRejectionCheck: "",
205
237
  driftClarificationStreak: 0,
206
238
  backtrackSkippedFiles: [],
207
239
  evidenceBaseline,
@@ -209,14 +241,23 @@ export class SessionManager {
209
241
  currentPrompt: initialPrompt,
210
242
  currentLevel: initialLevel,
211
243
  currentWarnings: prepared?.warnings ?? [],
212
- currentCanonicalState: isRecord(prepared?.canonicalState)
213
- ? prepared.canonicalState
214
- : undefined,
244
+ lastCompileResponse: prepared?.compileResponse ?? null,
215
245
  };
216
246
  this.sessions.set(sessionId, session);
217
247
  policyMetrics.recordStrategy(loopId, initialLevel);
218
- // Persist to vault for cross-process recovery
219
- this.save(session);
248
+ // Persist to vault for cross-process recovery. The save is the atomic
249
+ // lease gate — a TOCTOU window between the pre-flight check above and
250
+ // this write can still lose the lease race to another process.
251
+ try {
252
+ this.lifecycle.save(session);
253
+ }
254
+ catch (error) {
255
+ // v3.2.1: roll back the in-memory registration — a registered-but-
256
+ // unsaved session would make every later create for this loopId
257
+ // return loop_already_running until this process restarts.
258
+ this.sessions.delete(sessionId);
259
+ throw error;
260
+ }
220
261
  logEvent("session_start", {
221
262
  sessionId,
222
263
  loopId,
@@ -256,156 +297,13 @@ export class SessionManager {
256
297
  : 0,
257
298
  };
258
299
  }
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
300
  delete(sessionId) {
399
301
  const session = this.sessions.get(sessionId);
400
302
  if (!session)
401
303
  return false;
402
304
  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");
305
+ this.lifecycle.save(session);
306
+ void this.lifecycle.notifyTerminal(session, "cancelled");
409
307
  this.sessions.delete(sessionId);
410
308
  this.sessionQueues.delete(sessionId);
411
309
  logEvent("session_end", {
@@ -427,7 +325,7 @@ export class SessionManager {
427
325
  return { sessionId, round: session.currentRound, status: session.status };
428
326
  }
429
327
  session.status = "paused";
430
- this.save(session);
328
+ this.lifecycle.save(session);
431
329
  logEvent("session_paused", {
432
330
  sessionId,
433
331
  loopId: session.loopId,
@@ -435,329 +333,15 @@ export class SessionManager {
435
333
  });
436
334
  return { sessionId, round: session.currentRound, status: "paused" };
437
335
  }
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. */
336
+ /** Persist session state to vault for cross-process recovery. Delegates to
337
+ * the round lifecycle, which owns the durable session document shape. */
654
338
  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
- };
339
+ this.lifecycle.save(session);
756
340
  }
757
341
  /** Resume a loop from vault state.
758
342
  * Reconstructs the session and compiles the prompt for the next round.
759
343
  * Returns null if no session_state entry exists for this loopId. */
760
- async resume(loopId) {
344
+ resume(loopId) {
761
345
  const persistedEntry = this.findSessionEntry(loopId);
762
346
  if (!persistedEntry)
763
347
  return null;
@@ -768,7 +352,7 @@ export class SessionManager {
768
352
  : persistedEntry;
769
353
  if (!sessionEntry)
770
354
  return this.leaseConflictResult(loopId, persistedEntry);
771
- const session = this.reconstructSession(sessionEntry);
355
+ const session = this.lifecycle.reconstructSession(sessionEntry);
772
356
  if (!session) {
773
357
  // was not "running" — return stopped/stalled status
774
358
  const lineage = (sessionEntry.loop_lineage ?? {});
@@ -783,47 +367,29 @@ export class SessionManager {
783
367
  };
784
368
  }
785
369
  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
- };
370
+ return this.lifecycle.resume(session);
371
+ }
372
+ /** v1.18: Resume a paused session. Reconstructs from vault state and
373
+ * compiles the next prompt. Returns null if no paused session exists
374
+ * for this loopId. */
375
+ async unpause(loopId) {
376
+ const persistedEntry = this.findSessionEntry(loopId);
377
+ if (!persistedEntry)
378
+ return null;
379
+ const lineage = (persistedEntry.loop_lineage ?? {});
380
+ const status = lineage.status ?? "running";
381
+ if (status !== "paused")
382
+ return null;
383
+ const sessionEntry = this.claimSessionEntry(loopId);
384
+ if (!sessionEntry)
385
+ return this.leaseConflictResult(loopId, persistedEntry);
386
+ const session = this.lifecycle.reconstructSession(sessionEntry, true);
387
+ if (!session)
388
+ return null;
389
+ // Set to running so advance() works
390
+ session.status = "running";
391
+ this.sessions.set(session.sessionId, session);
392
+ return this.lifecycle.unpause(session);
827
393
  }
828
394
  /** Auto-resume all "running" sessions from vault on server startup.
829
395
  * Scans vault for session_state entries, reconstructs each as an in-memory
@@ -852,7 +418,20 @@ export class SessionManager {
852
418
  const claimedEntry = this.claimSessionEntry(lid);
853
419
  if (!claimedEntry)
854
420
  continue;
855
- const session = this.reconstructSession(claimedEntry, true);
421
+ // v2.14: a bulk startup scan must not die on one corrupt loop —
422
+ // recoverable gaps are recorded and skipped; corrupted storage
423
+ // (invalid JSON/format/sequence) still surfaces to the caller.
424
+ let session = null;
425
+ try {
426
+ session = this.lifecycle.reconstructSession(claimedEntry, true);
427
+ }
428
+ catch (error) {
429
+ if (error instanceof StorageCorruptionError && error.recoverable) {
430
+ logEvent("session_skip_gap", { loopId: lid, error: error.message });
431
+ continue;
432
+ }
433
+ throw error;
434
+ }
856
435
  if (session) {
857
436
  this.sessions.set(session.sessionId, session);
858
437
  activeLoopIds.add(lid);
@@ -895,55 +474,222 @@ export class SessionManager {
895
474
  }
896
475
  return result;
897
476
  }
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");
477
+ // ── v2.12: User/Agent gates ────────────────────────────────────────────
478
+ /** Classify a flat gate text. Read-only — no vault writes. */
479
+ checkGate(gateText) {
480
+ const { id, gate } = deriveGate(gateText);
481
+ return {
482
+ id,
483
+ kind: gate.kind,
484
+ ...(gate.kind === "user"
485
+ ? {
486
+ question: gate.question,
487
+ blockedScope: gate.blockedScope,
488
+ allowedWork: gate.allowedWork,
489
+ }
490
+ : {
491
+ problem: gate.problem,
492
+ requiredEvidence: gate.requiredEvidence,
493
+ suggestedResolution: gate.suggestedResolution,
494
+ }),
495
+ };
914
496
  }
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;
497
+ /** Record a user decision for a recorded gate. The gateId embeds the
498
+ * canonicalized action hash — if the action changed, the match fails and
499
+ * the old approval expires automatically. */
500
+ resolveGate(sessionId, gateId, approved, note) {
501
+ const session = this.sessions.get(sessionId);
502
+ let loopId = session?.loopId;
503
+ let currentRound = session?.currentRound ?? 0;
504
+ let temporaryLease = false;
505
+ // A gate is durable independently of the process that opened it. On a
506
+ // cold process, locate the owning session by its persisted session_id and
507
+ // claim a short lease for the decision write; no in-memory reconstruction
508
+ // is needed for this read/append operation.
509
+ let persistedEntry;
510
+ if (!session) {
511
+ persistedEntry = this.sessionStore?.list().find((entry) => {
512
+ if (entry.task_type !== "session_state")
513
+ return false;
514
+ const lineage = isRecord(entry.loop_lineage) ? entry.loop_lineage : {};
515
+ return lineage.session_id === sessionId;
516
+ });
517
+ loopId = persistedEntry?.loop_id;
518
+ if (!loopId)
519
+ return { error: `session not found: ${sessionId}` };
520
+ const claimed = this.claimSessionEntry(loopId);
521
+ if (!claimed) {
522
+ return { ...this.leaseConflictResult(loopId, persistedEntry) };
523
+ }
524
+ temporaryLease = true;
525
+ const lineage = isRecord(claimed.loop_lineage) ? claimed.loop_lineage : {};
526
+ currentRound = typeof lineage.current_round === "number"
527
+ ? lineage.current_round
528
+ : 0;
529
+ }
530
+ if (!loopId)
531
+ return { error: `session not found: ${sessionId}` };
532
+ try {
533
+ const prefix = `loop:${loopId}:gate:`;
534
+ const opened = queryLoopEntries(this.loopStore, loopId, { prefix })
535
+ .find((entry) => entry.task_type === "gate_opened" && entry.gate_id === gateId);
536
+ if (!opened) {
537
+ return { error: `no gate_opened record for gate ${gateId} — the gate may have expired or was never recorded` };
538
+ }
539
+ const actionText = typeof opened.gate_action === "string" ? opened.gate_action : "";
540
+ const openedLineage = isRecord(opened.loop_lineage) ? opened.loop_lineage : {};
541
+ const gateRound = typeof openedLineage.round === "number"
542
+ ? openedLineage.round
543
+ : currentRound;
544
+ const { id, gate, actionHash } = deriveGate(actionText);
545
+ if (id !== gateId) {
546
+ return { error: "gateId does not match the recorded action — the action changed, old approvals expire" };
547
+ }
548
+ if (gate.kind !== "user") {
549
+ return { error: "agent gates are resolved by submitting the required evidence, not by user approval" };
550
+ }
551
+ const decision = makeGateDecision({
552
+ gateId,
553
+ kind: "user",
554
+ approved: approved === true,
555
+ scope: gate.blockedScope ?? [],
556
+ note: typeof note === "string" ? note : "",
557
+ decidedAt: new Date().toISOString(),
558
+ actionHash: actionHash ?? "",
559
+ });
560
+ const entry = {
561
+ id: randomUUID(),
562
+ task_id: `loop:${loopId}:gate:${gateId}:decision`,
563
+ task_type: "gate_decision",
564
+ loop_id: loopId,
565
+ timestamp: new Date().toISOString(),
566
+ gate_id: gateId,
567
+ approved: decision.approved,
568
+ decision_note: decision.note,
569
+ gate_decision: decision,
570
+ loop_lineage: {
571
+ round: gateRound,
572
+ gate_id: gateId,
573
+ action_hash: actionHash,
574
+ },
575
+ };
576
+ this.loopStore.appendEntry(entry);
577
+ return { sessionId, loopId, gateId, approved: decision.approved };
578
+ }
579
+ finally {
580
+ if (temporaryLease)
581
+ this.sessionStore?.releaseLease?.(loopId, this.ownerId);
582
+ }
927
583
  }
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",
935
- loop_id: session.loopId,
936
- task: session.task,
937
- timestamp: new Date().toISOString(),
938
- loop_lineage: {
939
- round: session.currentRound,
940
- session_id: session.sessionId,
941
- stop_reason: stopReason,
942
- success_trajectory: session.successTrajectory,
943
- consecutive_rejections: session.consecutiveRejections,
944
- ...extra,
945
- },
946
- });
584
+ // ── v2.12: Typed projection + audit ────────────────────────────────────
585
+ /** Typed cognitive state projection for an active session. Derived on
586
+ * demand — zero persistence. Null when nothing meaningful exists yet. */
587
+ getProjection(sessionId) {
588
+ const session = this.sessions.get(sessionId);
589
+ if (!session)
590
+ return null;
591
+ const prefix = `loop:${session.loopId}:`;
592
+ const entries = [
593
+ ...queryLoopEntries(this.loopStore, session.loopId, { prefix }),
594
+ ...queryLoopEntries(this.loopStore, session.loopId, { prefix, feedbackOnly: true }),
595
+ ];
596
+ let compileResponse = null;
597
+ // v3.0.1: the round boundary already compiled this round — derive the
598
+ // projection from that response instead of recompiling the whole vault.
599
+ // The cache is invalidated by every compile path (it is rebuilt with the
600
+ // new round's response); the artifact's deterministic roundId guards
601
+ // against stale reuse.
602
+ const cached = session.lastCompileResponse;
603
+ const expectedRoundId = session.roundSnapshot?.roundId
604
+ ?? makeRoundId(session.loopId, session.currentRound);
605
+ if (cached?.prompt_artifact && cached.prompt_artifact.roundId === expectedRoundId) {
606
+ compileResponse = cached;
607
+ }
608
+ else {
609
+ try {
610
+ const request = {
611
+ task: session.task,
612
+ mode: Mode.LOOP_COMPILE,
613
+ feedback: null,
614
+ skill_name: null,
615
+ task_id: null,
616
+ loop_id: session.loopId,
617
+ round: session.currentRound,
618
+ max_rounds: session.maxRounds,
619
+ verification_flags: [],
620
+ };
621
+ const compiled = session.engine.invokeLoopCompile(request, undefined, { persistLineage: false });
622
+ compileResponse = compiled.response ?? null;
623
+ }
624
+ catch {
625
+ compileResponse = null; // projection degrades gracefully
626
+ }
627
+ }
628
+ const openGates = this.lifecycle.listOpenGateDescriptions(session.loopId);
629
+ const projection = buildLoopProjection(deriveCognitiveFacts({
630
+ compileResponse,
631
+ rounds: committedRoundsFromEntries(entries, session.currentRound),
632
+ verifiedClaims: listVerifiedClaims(entries, session.loopId),
633
+ openGates,
634
+ }));
635
+ return projection ? { ...projection } : null;
636
+ }
637
+ /** Read-only end-of-loop audit (verification view). Never writes. */
638
+ getAudit(loopId) {
639
+ validateLoopId(loopId);
640
+ // v3.3.1: audit judges COMMITTED decisions, which live on :feedback
641
+ // entries — but queryLoopEntries excludes feedback by default, so the
642
+ // audit view structurally never saw a committed round (it always
643
+ // reported "passed" on empty history). Merge both result sets like the
644
+ // round coordinator does.
645
+ const prefix = `loop:${loopId}:`;
646
+ const entries = [
647
+ ...queryLoopEntries(this.loopStore, loopId, { prefix }),
648
+ ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
649
+ ];
650
+ // Zero committed decisions → nothing to audit. Returning null lets the
651
+ // tools layer report "no audit data" instead of the external auditor
652
+ // solemnly passing a loop that never ran (or a mistyped loopId).
653
+ const hasCommittedDecision = committedRoundsFromEntries(entries).length > 0;
654
+ if (!hasCommittedDecision)
655
+ return null;
656
+ const audit = buildAudit(loopId, entries, this.loopStore);
657
+ return { ...audit };
658
+ }
659
+ /** v3.5: The ACTIVE Round Contract governing the session's next round —
660
+ * derived from the committed :feedback evals (the SAME adapter + walker
661
+ * the verification gate uses; display-only, zero persistence). Null when
662
+ * nothing is active (whole-task round). */
663
+ getActiveContract(sessionId) {
664
+ const session = this.sessions.get(sessionId);
665
+ if (!session)
666
+ return null;
667
+ const prefix = `loop:${session.loopId}:`;
668
+ const entries = [
669
+ ...queryLoopEntries(this.loopStore, session.loopId, { prefix }),
670
+ ...queryLoopEntries(this.loopStore, session.loopId, { prefix, feedbackOnly: true }),
671
+ ];
672
+ return deriveActiveRoundContract(contractRoundEvaluations(committedRoundsFromEntries(entries, session.currentRound)));
673
+ }
674
+ /** v2.12: Policy metrics that survive restarts — vault-derived round
675
+ * statistics (A4 port) folded with this process's live observations.
676
+ * Non-durable fields (evidence, vault errors) come from live only. */
677
+ getPolicyMetrics(loopId) {
678
+ // v3.3.1: derived metrics judge committed decisions on :feedback entries,
679
+ // which queryLoopEntries excludes by default — merge both result sets so
680
+ // restart-surviving metrics actually see the committed rounds (before,
681
+ // derived.committedRounds was structurally 0 and the merge fell back to
682
+ // the live-only snapshot every time).
683
+ const prefix = `loop:${loopId}:`;
684
+ const entries = [
685
+ ...queryLoopEntries(this.loopStore, loopId, { prefix }),
686
+ ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
687
+ ];
688
+ const derived = derivePolicyMetrics(loopId, entries);
689
+ const live = policyMetrics.snapshot(loopId);
690
+ if (derived.committedRounds === 0)
691
+ return live;
692
+ return mergePolicyMetrics(derived, live);
947
693
  }
948
694
  /** Get loop health for a loop (in-memory or vault).
949
695
  * Computes goal alignment, constraint integrity, drift, strategy stability. */
@@ -954,8 +700,8 @@ export class SessionManager {
954
700
  for (const s of this.sessions.values()) {
955
701
  if (s.loopId === loopId) {
956
702
  task = s.task;
957
- if (s.runtime.state?.task_id) {
958
- goalId = s.runtime.state.task_id;
703
+ if (s.engine.state?.task_id) {
704
+ goalId = s.engine.state.task_id;
959
705
  }
960
706
  break;
961
707
  }
@@ -970,7 +716,7 @@ export class SessionManager {
970
716
  if (!task)
971
717
  return null;
972
718
  // Hydrate vault context
973
- const engine = new RuntimeServices(this.backend);
719
+ const engine = new LoopForgeEngine(this.loopStore);
974
720
  const vaultContext = engine.hydrateLoopContext(loopId);
975
721
  // Build a minimal request for health check
976
722
  const request = makeLoopCompileRequest({
@@ -987,32 +733,54 @@ export class SessionManager {
987
733
  drift_detected: health.drift_detected,
988
734
  strategy_stability: health.strategy_stability,
989
735
  task_continuity: health.task_continuity,
990
- policy_metrics: this.getLoopPolicyMetrics(loopId),
736
+ policy_metrics: this.getPolicyMetrics(loopId),
991
737
  };
992
738
  }
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 () => {
739
+ /** Core cycle: extract self-eval → record feedback → check stop → compile next.
740
+ * The lease + per-session queue wrap the RoundLifecycle state machine.
741
+ * @param preExtractedEval Structured SelfEvaluation supplied by the caller.
742
+ * An absent value is returned as evaluation_invalid without mutating state.
743
+ * @param roundId v3.0.1: The roundId of the round this submission reports on
744
+ * (from the last start/next/resume response). Anchors the submission so a
745
+ * stale or duplicate submission is not processed against a later round.
746
+ * Optional for library callers — when absent the anchor check is skipped. */
747
+ async advance(sessionId, output, preExtractedEval, roundId) {
748
+ const current = this.sessions.get(sessionId);
749
+ if (current && (current.status === "running" || current.status === "stalled")) {
750
+ const rawEvaluation = preExtractedEval;
751
+ if (!isRecord(rawEvaluation)) {
752
+ return {
753
+ sessionId,
754
+ round: current.currentRound,
755
+ roundId: current.roundSnapshot?.roundId
756
+ ?? makeRoundId(current.loopId, current.currentRound),
757
+ prompt: null,
758
+ stopReason: "evaluation_invalid",
759
+ stopDetail: "A structured evaluation object is required. Resubmit the same roundId with success, output_summary, constraint_violations, and should_continue.",
760
+ };
761
+ }
762
+ const validation = validateCoreSelfEvaluation(rawEvaluation);
763
+ if (validation.missing.length > 0 || validation.invalid.length > 0) {
764
+ return {
765
+ sessionId,
766
+ round: current.currentRound,
767
+ roundId: current.roundSnapshot?.roundId
768
+ ?? makeRoundId(current.loopId, current.currentRound),
769
+ prompt: null,
770
+ stopReason: "evaluation_invalid",
771
+ stopDetail: "The structured evaluation has missing or invalid core fields. Correct it and resubmit the same roundId.",
772
+ };
773
+ }
774
+ preExtractedEval = buildSelfEvaluation(rawEvaluation);
775
+ }
776
+ return this.withSessionQueue(sessionId, async () => {
997
777
  const session = this.sessions.get(sessionId);
998
778
  if (session?.status === "running" &&
999
779
  !this.renewSessionLease(session.loopId)) {
1000
780
  return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
1001
781
  }
1002
782
  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);
783
+ return await this.lifecycle.advance(sessionId, output, preExtractedEval, roundId);
1016
784
  }
1017
785
  catch (error) {
1018
786
  if (error instanceof SessionLeaseConflictError) {
@@ -1022,469 +790,24 @@ export class SessionManager {
1022
790
  }
1023
791
  });
1024
792
  }
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
793
  /** Replay timeline for a session — creates ReplayBackend from the stored backend. */
1467
794
  replayTimeline(sessionId) {
1468
795
  const session = this.sessions.get(sessionId);
1469
796
  if (!session)
1470
797
  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 };
798
+ return this.replayByLoop(session.loopId);
799
+ }
800
+ /** v3.3.1: Replay a loop straight from the vault — no in-memory session
801
+ * needed. Time travel over committed rounds is a property of the store
802
+ * (ReplayBackend reads round documents), so a process restart must not
803
+ * revoke it: the session registry was an artificial prerequisite that
804
+ * made every vault loop unreplayable after restart.
805
+ * Returns null when the loop has no committed rounds. */
806
+ replayByLoop(loopId) {
807
+ validateLoopId(loopId);
808
+ const replay = new ReplayBackend(this.loopStore);
809
+ const timeline = replay.timeline(loopId);
810
+ return timeline.length > 0 ? timeline : null;
1488
811
  }
1489
812
  }
1490
813
  //# sourceMappingURL=session.js.map