loopforge 3.9.4 → 4.0.7

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 (283) hide show
  1. package/README.md +236 -112
  2. package/dist/audit.d.ts +73 -15
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +131 -61
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +114 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +276 -182
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +41 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +0 -2
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +0 -4
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/committed-round.d.ts +97 -156
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +223 -419
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/constraint-lifecycle.d.ts +73 -0
  22. package/dist/constraint-lifecycle.d.ts.map +1 -0
  23. package/dist/constraint-lifecycle.js +174 -0
  24. package/dist/constraint-lifecycle.js.map +1 -0
  25. package/dist/context-boundary.d.ts +83 -0
  26. package/dist/context-boundary.d.ts.map +1 -0
  27. package/dist/context-boundary.js +101 -0
  28. package/dist/context-boundary.js.map +1 -0
  29. package/dist/contract-items.d.ts +12 -8
  30. package/dist/contract-items.d.ts.map +1 -1
  31. package/dist/contract-items.js +31 -26
  32. package/dist/contract-items.js.map +1 -1
  33. package/dist/contract-requirement.d.ts +47 -0
  34. package/dist/contract-requirement.d.ts.map +1 -0
  35. package/dist/contract-requirement.js +51 -0
  36. package/dist/contract-requirement.js.map +1 -0
  37. package/dist/criterion-status.d.ts +35 -0
  38. package/dist/criterion-status.d.ts.map +1 -0
  39. package/dist/criterion-status.js +89 -0
  40. package/dist/criterion-status.js.map +1 -0
  41. package/dist/enforcement-gate.d.ts +26 -38
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +196 -319
  44. package/dist/enforcement-gate.js.map +1 -1
  45. package/dist/engine.d.ts +9 -103
  46. package/dist/engine.d.ts.map +1 -1
  47. package/dist/engine.js +27 -897
  48. package/dist/engine.js.map +1 -1
  49. package/dist/errors.d.ts +41 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +69 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/evidence-claims.d.ts +33 -31
  54. package/dist/evidence-claims.d.ts.map +1 -1
  55. package/dist/evidence-claims.js +58 -72
  56. package/dist/evidence-claims.js.map +1 -1
  57. package/dist/evidence-posture-view.d.ts +19 -0
  58. package/dist/evidence-posture-view.d.ts.map +1 -0
  59. package/dist/evidence-posture-view.js +116 -0
  60. package/dist/evidence-posture-view.js.map +1 -0
  61. package/dist/evidence-posture.d.ts +73 -0
  62. package/dist/evidence-posture.d.ts.map +1 -0
  63. package/dist/evidence-posture.js +281 -0
  64. package/dist/evidence-posture.js.map +1 -0
  65. package/dist/evidence-provider.d.ts +55 -37
  66. package/dist/evidence-provider.d.ts.map +1 -1
  67. package/dist/evidence-provider.js +182 -332
  68. package/dist/evidence-provider.js.map +1 -1
  69. package/dist/explain.d.ts +166 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +372 -58
  72. package/dist/explain.js.map +1 -1
  73. package/dist/field-accounting.d.ts.map +1 -1
  74. package/dist/field-accounting.js +3 -22
  75. package/dist/field-accounting.js.map +1 -1
  76. package/dist/generate-schema.js +13 -8
  77. package/dist/generate-schema.js.map +1 -1
  78. package/dist/git-boundary.d.ts +128 -0
  79. package/dist/git-boundary.d.ts.map +1 -0
  80. package/dist/git-boundary.js +625 -0
  81. package/dist/git-boundary.js.map +1 -0
  82. package/dist/git-facts.d.ts +22 -0
  83. package/dist/git-facts.d.ts.map +1 -0
  84. package/dist/git-facts.js +29 -0
  85. package/dist/git-facts.js.map +1 -0
  86. package/dist/git-motion.d.ts +37 -0
  87. package/dist/git-motion.d.ts.map +1 -0
  88. package/dist/git-motion.js +59 -0
  89. package/dist/git-motion.js.map +1 -0
  90. package/dist/history.d.ts +44 -0
  91. package/dist/history.d.ts.map +1 -0
  92. package/dist/history.js +77 -0
  93. package/dist/history.js.map +1 -0
  94. package/dist/index.d.ts +19 -9
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/init.d.ts +16 -0
  99. package/dist/init.d.ts.map +1 -1
  100. package/dist/init.js +80 -14
  101. package/dist/init.js.map +1 -1
  102. package/dist/loop-compiler.d.ts +39 -23
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +368 -565
  105. package/dist/loop-compiler.js.map +1 -1
  106. package/dist/loop-store.d.ts +6 -5
  107. package/dist/loop-store.d.ts.map +1 -1
  108. package/dist/loop-store.js +153 -35
  109. package/dist/loop-store.js.map +1 -1
  110. package/dist/mcp/round-lifecycle.d.ts +255 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1552 -539
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts.map +1 -1
  115. package/dist/mcp/server.js +42 -18
  116. package/dist/mcp/server.js.map +1 -1
  117. package/dist/mcp/session.d.ts +81 -39
  118. package/dist/mcp/session.d.ts.map +1 -1
  119. package/dist/mcp/session.js +956 -282
  120. package/dist/mcp/session.js.map +1 -1
  121. package/dist/mcp/submission-boundary.d.ts +23 -31
  122. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  123. package/dist/mcp/submission-boundary.js +242 -92
  124. package/dist/mcp/submission-boundary.js.map +1 -1
  125. package/dist/mcp/submission-handle.d.ts +66 -0
  126. package/dist/mcp/submission-handle.d.ts.map +1 -0
  127. package/dist/mcp/submission-handle.js +173 -0
  128. package/dist/mcp/submission-handle.js.map +1 -0
  129. package/dist/mcp/tools.d.ts +17 -7
  130. package/dist/mcp/tools.d.ts.map +1 -1
  131. package/dist/mcp/tools.js +707 -244
  132. package/dist/mcp/tools.js.map +1 -1
  133. package/dist/open-question.d.ts +7 -0
  134. package/dist/open-question.d.ts.map +1 -0
  135. package/dist/open-question.js +14 -0
  136. package/dist/open-question.js.map +1 -0
  137. package/dist/policy-metrics.d.ts +31 -21
  138. package/dist/policy-metrics.d.ts.map +1 -1
  139. package/dist/policy-metrics.js +117 -64
  140. package/dist/policy-metrics.js.map +1 -1
  141. package/dist/policy.d.ts +11 -39
  142. package/dist/policy.d.ts.map +1 -1
  143. package/dist/policy.js +12 -12
  144. package/dist/policy.js.map +1 -1
  145. package/dist/progress.d.ts +45 -0
  146. package/dist/progress.d.ts.map +1 -0
  147. package/dist/progress.js +136 -0
  148. package/dist/progress.js.map +1 -0
  149. package/dist/prompt-assembler.d.ts +20 -6
  150. package/dist/prompt-assembler.d.ts.map +1 -1
  151. package/dist/prompt-assembler.js +475 -479
  152. package/dist/prompt-assembler.js.map +1 -1
  153. package/dist/prompt-coverage.d.ts +0 -6
  154. package/dist/prompt-coverage.d.ts.map +1 -1
  155. package/dist/prompt-coverage.js +4 -8
  156. package/dist/prompt-coverage.js.map +1 -1
  157. package/dist/prompt-freshness.d.ts +6 -17
  158. package/dist/prompt-freshness.d.ts.map +1 -1
  159. package/dist/prompt-freshness.js +27 -22
  160. package/dist/prompt-freshness.js.map +1 -1
  161. package/dist/prompt-policy.d.ts +14 -7
  162. package/dist/prompt-policy.d.ts.map +1 -1
  163. package/dist/prompt-policy.js +21 -5
  164. package/dist/prompt-policy.js.map +1 -1
  165. package/dist/protocol.d.ts +843 -424
  166. package/dist/protocol.d.ts.map +1 -1
  167. package/dist/protocol.js +73 -102
  168. package/dist/protocol.js.map +1 -1
  169. package/dist/recovery-guidance.d.ts +13 -0
  170. package/dist/recovery-guidance.d.ts.map +1 -0
  171. package/dist/recovery-guidance.js +15 -0
  172. package/dist/recovery-guidance.js.map +1 -0
  173. package/dist/recovery.d.ts +68 -0
  174. package/dist/recovery.d.ts.map +1 -0
  175. package/dist/recovery.js +234 -0
  176. package/dist/recovery.js.map +1 -0
  177. package/dist/replay.d.ts +42 -8
  178. package/dist/replay.d.ts.map +1 -1
  179. package/dist/replay.js +311 -84
  180. package/dist/replay.js.map +1 -1
  181. package/dist/result-facts.d.ts +45 -0
  182. package/dist/result-facts.d.ts.map +1 -0
  183. package/dist/result-facts.js +86 -0
  184. package/dist/result-facts.js.map +1 -0
  185. package/dist/round-contract.d.ts +38 -8
  186. package/dist/round-contract.d.ts.map +1 -1
  187. package/dist/round-contract.js +71 -31
  188. package/dist/round-contract.js.map +1 -1
  189. package/dist/round-coordinator.d.ts +41 -89
  190. package/dist/round-coordinator.d.ts.map +1 -1
  191. package/dist/round-coordinator.js +250 -267
  192. package/dist/round-coordinator.js.map +1 -1
  193. package/dist/round-driver.d.ts +56 -16
  194. package/dist/round-driver.d.ts.map +1 -1
  195. package/dist/round-driver.js +225 -39
  196. package/dist/round-driver.js.map +1 -1
  197. package/dist/round-facts.d.ts +33 -52
  198. package/dist/round-facts.d.ts.map +1 -1
  199. package/dist/round-facts.js +51 -160
  200. package/dist/round-facts.js.map +1 -1
  201. package/dist/round-guidance.d.ts +56 -0
  202. package/dist/round-guidance.d.ts.map +1 -0
  203. package/dist/round-guidance.js +161 -0
  204. package/dist/round-guidance.js.map +1 -0
  205. package/dist/round-outcome.d.ts +21 -0
  206. package/dist/round-outcome.d.ts.map +1 -0
  207. package/dist/round-outcome.js +31 -0
  208. package/dist/round-outcome.js.map +1 -0
  209. package/dist/round-transaction.d.ts +168 -21
  210. package/dist/round-transaction.d.ts.map +1 -1
  211. package/dist/round-transaction.js +837 -38
  212. package/dist/round-transaction.js.map +1 -1
  213. package/dist/self-eval.d.ts +37 -61
  214. package/dist/self-eval.d.ts.map +1 -1
  215. package/dist/self-eval.js +413 -392
  216. package/dist/self-eval.js.map +1 -1
  217. package/dist/storage.d.ts +16 -0
  218. package/dist/storage.d.ts.map +1 -1
  219. package/dist/storage.js +54 -4
  220. package/dist/storage.js.map +1 -1
  221. package/dist/subgoal-state.d.ts +2 -3
  222. package/dist/subgoal-state.d.ts.map +1 -1
  223. package/dist/subgoal-state.js +2 -3
  224. package/dist/subgoal-state.js.map +1 -1
  225. package/dist/submission-guidance.d.ts +29 -21
  226. package/dist/submission-guidance.d.ts.map +1 -1
  227. package/dist/submission-guidance.js +308 -260
  228. package/dist/submission-guidance.js.map +1 -1
  229. package/dist/submission-receipt.d.ts +16 -8
  230. package/dist/submission-receipt.d.ts.map +1 -1
  231. package/dist/submission-receipt.js +145 -51
  232. package/dist/submission-receipt.js.map +1 -1
  233. package/dist/token-utils.d.ts +28 -7
  234. package/dist/token-utils.d.ts.map +1 -1
  235. package/dist/token-utils.js +27 -11
  236. package/dist/token-utils.js.map +1 -1
  237. package/dist/transaction-identity.d.ts +9 -0
  238. package/dist/transaction-identity.d.ts.map +1 -0
  239. package/dist/transaction-identity.js +69 -0
  240. package/dist/transaction-identity.js.map +1 -0
  241. package/dist/verification-actions.d.ts.map +1 -1
  242. package/dist/verification-actions.js +10 -11
  243. package/dist/verification-actions.js.map +1 -1
  244. package/dist/verification-gate.d.ts +23 -47
  245. package/dist/verification-gate.d.ts.map +1 -1
  246. package/dist/verification-gate.js +202 -423
  247. package/dist/verification-gate.js.map +1 -1
  248. package/dist/version.d.ts +1 -1
  249. package/dist/version.js +1 -1
  250. package/dist/working-memory.d.ts +187 -0
  251. package/dist/working-memory.d.ts.map +1 -0
  252. package/dist/working-memory.js +767 -0
  253. package/dist/working-memory.js.map +1 -0
  254. package/dist/workspace-checkpoint.d.ts +88 -0
  255. package/dist/workspace-checkpoint.d.ts.map +1 -0
  256. package/dist/workspace-checkpoint.js +326 -0
  257. package/dist/workspace-checkpoint.js.map +1 -0
  258. package/dist/workspace-restore.d.ts +68 -0
  259. package/dist/workspace-restore.d.ts.map +1 -0
  260. package/dist/workspace-restore.js +604 -0
  261. package/dist/workspace-restore.js.map +1 -0
  262. package/dist/workspace.d.ts +0 -9
  263. package/dist/workspace.d.ts.map +1 -1
  264. package/dist/workspace.js +0 -17
  265. package/dist/workspace.js.map +1 -1
  266. package/loop_policy.json +4 -18
  267. package/package.json +68 -68
  268. package/skills/forge/SKILL.md +212 -0
  269. package/skills/forge/references/evidence-and-contracts.md +204 -0
  270. package/skills/forge/references/recovery-and-views.md +188 -0
  271. package/dist/cognitive-facts.d.ts +0 -30
  272. package/dist/cognitive-facts.d.ts.map +0 -1
  273. package/dist/cognitive-facts.js +0 -113
  274. package/dist/cognitive-facts.js.map +0 -1
  275. package/dist/loop-extras-parser.d.ts +0 -81
  276. package/dist/loop-extras-parser.d.ts.map +0 -1
  277. package/dist/loop-extras-parser.js +0 -213
  278. package/dist/loop-extras-parser.js.map +0 -1
  279. package/dist/loop-projection.d.ts +0 -6
  280. package/dist/loop-projection.d.ts.map +0 -1
  281. package/dist/loop-projection.js +0 -28
  282. package/dist/loop-projection.js.map +0 -1
  283. package/skills/perception/SKILL.md +0 -389
@@ -16,27 +16,30 @@ import { randomUUID } from "node:crypto";
16
16
  import { LoopForgeEngine } from "../engine.js";
17
17
  import { canonicalizeGateAction, deriveGate, preflightStructuredGate, } from "../cognitive-governance.js";
18
18
  import { makeGateDecision, makeLoopObjective } from "../protocol.js";
19
- import { buildLoopProjection } from "../loop-projection.js";
20
- import { deriveCognitiveFacts } from "../cognitive-facts.js";
21
- import { NO_IN_FLIGHT_ROUND, deriveRoundFacts } from "../round-facts.js";
22
- import { listVerifiedClaims } from "../evidence-claims.js";
19
+ import { loadCommittedHistory } from "../history.js";
23
20
  import { buildAudit } from "../audit.js";
24
21
  import { buildExplain } from "../explain.js";
25
22
  import { CHECK_CONTRACT_ITEMS_UNVERIFIED } from "../verification-gate.js";
26
- import { readOnlyRounds } from "../committed-round.js";
27
23
  import { deriveCommittedSubmissionView } from "../submission-receipt.js";
28
24
  import { getPolicy, validateLoopId } from "../policy.js";
29
25
  import { isRecord, unique } from "../token-utils.js";
30
26
  import { ReplayBackend } from "../replay.js";
31
- import { FileLoopStore, queryLoopEntries } from "../loop-store.js";
32
- import { makeRoundId, prepareRoundTransaction } from "../round-transaction.js";
27
+ import { FileLoopStore } from "../loop-store.js";
28
+ import { MAX_MID_CHECKPOINTS_PER_TRANSACTION, } from "../round-transaction.js";
29
+ import { findCheckpointRef } from "../recovery.js";
30
+ import { buildCheckpointRef, CheckpointError, FileWorkspaceCheckpointStore, } from "../workspace-checkpoint.js";
31
+ import { resolveStateDirectory } from "../policy.js";
32
+ import { resolve, sep } from "node:path";
33
33
  import { RoundDriver } from "../round-driver.js";
34
34
  import { logEvent } from "../observability.js";
35
+ import { CompileFailureError, LegacyHistoryError, LoopExistsError, StartFailureError, } from "../errors.js";
35
36
  import { policyMetrics, derivePolicyMetrics, mergePolicyMetrics, } from "../policy-metrics.js";
36
37
  import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
37
38
  import { StorageCorruptionError } from "../loop-store.js";
38
- import { RoundLifecycle, buildLoopRequest, normalizeStartCriteria, } from "./round-lifecycle.js";
39
- import { activeContractOf, compileSessionContext, validateSubmission, } from "./submission-boundary.js";
39
+ import { LOST_HANDLE_ROUTING } from "../recovery-guidance.js";
40
+ import { MANUAL_STOP_REASON, RoundLifecycle, buildLoopRequest, normalizeInvocationInputs, normalizeStartCriteria, } from "./round-lifecycle.js";
41
+ import { activeContractOf, validateSubmission, } from "./submission-boundary.js";
42
+ import { makeSubmissionHandle, parseSubmissionHandle, staleHandleWarning, validateSubmissionHandle, } from "./submission-handle.js";
40
43
  // ── SessionManager ─────────────────────────────────────────────────────────
41
44
  export class SessionManager {
42
45
  sessions = new Map();
@@ -49,12 +52,23 @@ export class SessionManager {
49
52
  leaseRenewIntervalMs;
50
53
  leaseTimer = null;
51
54
  lifecycle;
52
- /** Explicit context provider; never auto-discovered. */
53
- contextProvider;
54
55
  terminalSinks = new Set();
55
- constructor(store, sessionStore) {
56
+ checkpointStoreFor;
57
+ constructor(store, sessionStore, checkpointStoreFor) {
56
58
  this.loopStore = store ?? new FileLoopStore(getPolicy().backend.root_dir);
57
59
  this.sessionStore = sessionStore ?? new VaultSessionStateStore(this.loopStore);
60
+ const stores = new Map();
61
+ const fileStore = this.loopStore instanceof FileLoopStore ? this.loopStore : undefined;
62
+ this.checkpointStoreFor = checkpointStoreFor ?? (fileStore
63
+ ? (loopId) => {
64
+ let storeForLoop = stores.get(loopId);
65
+ if (!storeForLoop) {
66
+ storeForLoop = new FileWorkspaceCheckpointStore(fileStore.loopDir(loopId), loopId);
67
+ stores.set(loopId, storeForLoop);
68
+ }
69
+ return storeForLoop;
70
+ }
71
+ : undefined);
58
72
  const mcpPolicy = getPolicy().mcp;
59
73
  this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
60
74
  this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
@@ -65,7 +79,9 @@ export class SessionManager {
65
79
  terminalSinks: this.terminalSinks,
66
80
  ownerId: this.ownerId,
67
81
  leaseMs: this.leaseMs,
68
- getContext: () => this.contextProvider,
82
+ ...(this.checkpointStoreFor
83
+ ? { checkpointStoreFor: this.checkpointStoreFor }
84
+ : {}),
69
85
  });
70
86
  if (this.sessionStore?.renewLease) {
71
87
  this.leaseTimer = setInterval(() => this.renewOwnedLeases(), this.leaseRenewIntervalMs);
@@ -101,6 +117,15 @@ export class SessionManager {
101
117
  findSessionEntry(loopId) {
102
118
  return this.sessionStore?.load(loopId);
103
119
  }
120
+ /** Whether the loop already has durable round history. A lineage-only round
121
+ * document from a failed start is NOT history and must remain retryable;
122
+ * only a committed feedback envelope counts. */
123
+ hasCommittedRounds(loopId) {
124
+ const history = loadCommittedHistory(this.loopStore, loopId);
125
+ return history.rounds.length > 0 ||
126
+ history.directives.length > 0 ||
127
+ history.legacyRounds.length > 0;
128
+ }
104
129
  claimSessionEntry(loopId) {
105
130
  if (this.sessionStore?.acquireLease) {
106
131
  return this.sessionStore.acquireLease(loopId, this.ownerId, this.leaseMs);
@@ -125,12 +150,19 @@ export class SessionManager {
125
150
  }
126
151
  leaseConflictResult(loopId, entry) {
127
152
  const lineage = (entry?.loop_lineage ?? {});
153
+ const owner = entry?.loop_lineage
154
+ ? entry.loop_lineage.lease_owner ?? "unknown"
155
+ : "unknown";
128
156
  return {
129
157
  sessionId: "",
130
158
  round: typeof lineage.current_round === "number" ? lineage.current_round : 0,
131
159
  prompt: null,
132
- stopReason: `session_owned_elsewhere:${loopId}`,
133
- 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.`,
160
+ stopDetail: `Another process (PID ${String(owner)}) holds the lease for loop ` +
161
+ `"${loopId}". Wait for the lease to expire or stop the other process.`,
162
+ submissionError: {
163
+ code: "lease_conflict",
164
+ details: { loopId, owner: String(owner) },
165
+ },
134
166
  };
135
167
  }
136
168
  async withSessionQueue(sessionId, work) {
@@ -163,21 +195,12 @@ export class SessionManager {
163
195
  // state and leave two in-memory sessions pointing at one loop.
164
196
  const existing = [...this.sessions.values()].find((s) => s.loopId === loopId);
165
197
  if (existing) {
166
- return {
167
- sessionId: "",
168
- round: existing.currentRound,
169
- prompt: null,
170
- stopReason: `loop_already_running:${loopId}`,
171
- stopDetail: `A session for loop "${loopId}" already exists in this process (session ${existing.sessionId}). Inspect or resume it instead of starting a duplicate.`,
172
- };
198
+ throw new LoopExistsError(loopId, "active");
173
199
  }
174
- // v3.2.1: pre-flight the cross-process lease BEFORE compiling — the
175
- // compile persists round-1 lineage and the state file into the loop's
176
- // vault, which would corrupt another process's live loop (round-1
177
- // lineage replaced, metadata overwritten). Mirrors the save-time check
178
- // (VaultSessionStateStore.save: a non-empty lease_owner that is not ours
179
- // conflicts), so observable behavior is unchanged — only the side
180
- // effects are avoided. The save below remains the atomic final gate.
200
+ // v3.2.1: pre-flight the cross-process lease BEFORE compiling. A compile
201
+ // no longer writes round history, but it still writes the derived state
202
+ // file and would waste evidence work against another process's loop.
203
+ // The save below remains the atomic final gate.
181
204
  const persistedSession = this.sessionStore?.load(loopId);
182
205
  const persistedLineage = persistedSession?.loop_lineage;
183
206
  const persistedOwner = persistedLineage && typeof persistedLineage === "object" && !Array.isArray(persistedLineage)
@@ -186,94 +209,93 @@ export class SessionManager {
186
209
  if (typeof persistedOwner === "string" && persistedOwner && persistedOwner !== this.ownerId) {
187
210
  throw new SessionLeaseConflictError(loopId);
188
211
  }
212
+ // A persisted session in ANY status, or any committed round document,
213
+ // means this loopId already names durable history. Starting a new round 1
214
+ // would overwrite it. The atomic `expectAbsent` save below is the final
215
+ // gate; this preflight avoids compiling and collecting evidence first.
216
+ if (persistedSession) {
217
+ const persistedStatus = isRecord(persistedLineage)
218
+ ? persistedLineage.status
219
+ : undefined;
220
+ throw new LoopExistsError(loopId, persistedStatus === "stopped" ? "stopped" : "session");
221
+ }
222
+ if (this.hasCommittedRounds(loopId)) {
223
+ throw new LoopExistsError(loopId, "rounds");
224
+ }
189
225
  // v3.9.4: normalize initial criteria before any runtime object is created.
190
226
  // A structural defect throws StartCriteriaError (mapped to
191
227
  // invalid_argument); empty/duplicate/over-cap entries produce warnings.
192
228
  const startCriteria = normalizeStartCriteria(input.criteria);
193
229
  const engine = new LoopForgeEngine(this.loopStore);
194
230
  const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
195
- // Populate extra fields for the first round
231
+ const operatorAcceptance = {
232
+ task: input.task,
233
+ success_criteria: startCriteria.criteria.length > 0
234
+ ? [...startCriteria.criteria]
235
+ : ["Task goal achieved with verifiable evidence"],
236
+ hard_constraints: unique(input.constraints ?? []),
237
+ };
238
+ // Populate the typed request for the first round. The operator surface is
239
+ // the one acceptance object; the objective is derived from it.
196
240
  const request = buildLoopRequest({
197
241
  sessionId, loopId, task: input.task, engine, currentRound: 1,
198
- maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
242
+ maxRounds, status: "running", createdAt: Date.now(),
199
243
  consecutiveRejections: 0,
200
244
  lastRejectionCheck: "",
201
- backtrackSkippedFiles: [],
202
- backtrackSkippedFingerprints: {},
203
- evidenceBaseline: [],
204
245
  });
205
- request.domain = input.domain ?? "";
206
246
  request.plan_source = input.planSource ?? null;
207
247
  request.constraints_from_plan = input.constraints ?? [];
208
- // The initial criteria are real objective criteria, not a second ledger.
209
- // Supplying the objective here means the first compile persists these
210
- // texts (and therefore their cr- ids) through the existing lineage.
211
- if (startCriteria.criteria.length > 0) {
212
- request.loop_objective = makeLoopObjective({
213
- objective: input.task,
214
- success_criteria: startCriteria.criteria,
215
- hard_constraints: unique(input.constraints ?? []),
216
- created_at_round: 1,
217
- loop_id: loopId,
218
- version: 1,
219
- refinement_history: [],
220
- });
248
+ // v4.0.5: the start task IS the initial invocation instruction. The
249
+ // prompt de-duplicates it against Current Task/Objective, but the round-1
250
+ // artifact records it. Later invocations only receive an instruction the
251
+ // host supplies explicitly.
252
+ if (input.task.trim()) {
253
+ request.current_user_instruction = input.task.trim();
221
254
  }
222
- // Explicit context is requested by the MCP embedding; no auto-discovery.
223
- if (this.contextProvider) {
224
- try {
225
- const ctx = {
226
- loopId,
227
- round: 1,
228
- task: input.task,
229
- domain: input.domain ?? "",
230
- };
231
- const rawContext = await this.contextProvider(ctx);
232
- if (rawContext?.trim()) {
233
- request.external_context = rawContext.trim();
234
- logEvent("external_context_loaded", {
235
- loopId, round: 1,
236
- contextLength: request.external_context.length,
237
- });
238
- }
239
- }
240
- catch {
241
- // Provider failures are isolated from session creation.
242
- logEvent("context_provider_error", { loopId, round: 1 });
255
+ request.loop_objective = makeLoopObjective({
256
+ objective: operatorAcceptance.task,
257
+ success_criteria: [...operatorAcceptance.success_criteria],
258
+ hard_constraints: [...operatorAcceptance.hard_constraints],
259
+ created_at_round: 1,
260
+ loop_id: loopId,
261
+ });
262
+ let prepared;
263
+ try {
264
+ prepared = await new RoundDriver(engine, this.loopStore, this.checkpointStoreFor?.(loopId)).prepare(request, loopId, 1, { operatorAcceptance });
265
+ }
266
+ catch (error) {
267
+ if (error instanceof CompileFailureError) {
268
+ throw new StartFailureError(error.message);
243
269
  }
270
+ throw error;
244
271
  }
245
- const prepared = await new RoundDriver(engine, this.loopStore).prepare(request, loopId, 1);
246
- const initialPrompt = prepared?.prompt ?? null;
247
- const initialLevel = prepared?.level ?? "l2";
248
- const evidenceBaseline = prepared?.evidenceBaseline ?? [];
272
+ const initialPrompt = prepared.prompt;
273
+ const initialLevel = prepared.level;
249
274
  const session = {
250
275
  sessionId, loopId, task: input.task, engine,
251
- currentRound: 1, maxRounds, successTrajectory: [],
276
+ currentRound: 1, maxRounds,
252
277
  status: "running", createdAt: Date.now(),
253
278
  // v1.13: Enforcement gate state
254
279
  consecutiveRejections: 0,
255
280
  lastRejectionCheck: "",
256
- backtrackSkippedFiles: [],
257
- backtrackSkippedFingerprints: {},
258
- evidenceBaseline,
259
- roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
260
- currentPrompt: initialPrompt,
261
- currentLevel: initialLevel,
262
- currentWarnings: [...startCriteria.warnings, ...(prepared?.warnings ?? [])],
263
- lastCompileResponse: prepared?.compileResponse ?? null,
281
+ operatorAcceptance,
282
+ roundSnapshot: prepared.snapshot,
264
283
  };
284
+ // v4.0.3: seed the round-1 trust baseline BEFORE the first prompt is
285
+ // delivered, so the first round already has the absolute entrypoint arm.
286
+ this.lifecycle.seedEntrypointTrust(session);
265
287
  this.sessions.set(sessionId, session);
266
288
  policyMetrics.recordStrategy(loopId, initialLevel);
267
289
  // Persist to vault for cross-process recovery. The save is the atomic
268
- // lease gate — a TOCTOU window between the pre-flight check above and
269
- // this write can still lose the lease race to another process.
290
+ // lease/existence gate — a TOCTOU window between the pre-flight checks
291
+ // above and this write can still lose the race to another process.
270
292
  try {
271
- this.lifecycle.save(session);
293
+ this.lifecycle.save(session, { expectAbsent: true });
272
294
  }
273
295
  catch (error) {
274
- // v3.2.1: roll back the in-memory registration — a registered-but-
275
- // unsaved session would make every later create for this loopId
276
- // return loop_already_running until this process restarts.
296
+ // Roll back the in-memory registration: a registered-but-unsaved
297
+ // session would make every later create for this loopId fail until
298
+ // this process restarts.
277
299
  this.sessions.delete(sessionId);
278
300
  throw error;
279
301
  }
@@ -283,14 +305,37 @@ export class SessionManager {
283
305
  task: input.task.slice(0, 80),
284
306
  maxRounds,
285
307
  });
308
+ const snapshot = session.roundSnapshot;
309
+ const handle = makeSubmissionHandle(session);
286
310
  return {
287
311
  sessionId,
312
+ loopId,
288
313
  round: 1,
289
- roundId: session.roundSnapshot?.roundId,
314
+ ...(handle ? { submissionHandle: handle } : {}),
315
+ // v4.0.2: start is a prompt-bearing result like next/resume; the
316
+ // boundary is projected from the same compiled artifact.
317
+ ...(snapshot?.promptArtifact
318
+ ? {
319
+ contextMode: snapshot.promptArtifact.contextMode,
320
+ contextReason: snapshot.promptArtifact.contextReason,
321
+ }
322
+ : {}),
323
+ ...(snapshot?.beforeCheckpoint
324
+ ? { checkpoints: [snapshot.beforeCheckpoint] }
325
+ : {}),
326
+ ...(snapshot?.beforeCheckpointError
327
+ ? {
328
+ beforeCheckpointError: {
329
+ code: snapshot.beforeCheckpointError.code,
330
+ detail: snapshot.beforeCheckpointError.detail,
331
+ },
332
+ }
333
+ : {}),
290
334
  prompt: initialPrompt,
291
335
  level: initialLevel,
292
- roundSuccess: false,
293
- warnings: [...startCriteria.warnings, ...(prepared?.warnings ?? [])],
336
+ agentSuccessClaim: false,
337
+ agentOutcome: null,
338
+ warnings: [...startCriteria.warnings, ...(prepared.warnings ?? [])],
294
339
  submission_guidance: this.lifecycle.guidanceFor(session),
295
340
  };
296
341
  }
@@ -300,28 +345,55 @@ export class SessionManager {
300
345
  const session = this.sessions.get(sessionId);
301
346
  return session ? this.lifecycle.guidanceFor(session) : null;
302
347
  }
303
- /** v3.9.1: the most recent committed round's receipt, rebuilt from the
304
- * transaction + shared read model. Rejected/terminated attempts are not in
305
- * history; their receipt exists only on their own response. */
306
- getLastSubmissionReceipt(sessionId) {
307
- const session = this.sessions.get(sessionId);
308
- if (!session)
348
+ /** v4.0.0: the current held handle, derived on demand. A read-only
349
+ * projection; never persisted. */
350
+ getSubmissionHandle(sessionId) {
351
+ return makeSubmissionHandle(this.sessions.get(sessionId));
352
+ }
353
+ /** v3.9.1: the most recent committed round's receipt for a loop, rebuilt
354
+ * from committed history. Rejected/terminated attempts are not history;
355
+ * their receipt exists only on their own response. This intentionally takes
356
+ * the durable loopId: it never reads live-session state. */
357
+ getLastSubmissionReceiptByLoopId(loopId) {
358
+ if (!loopId)
309
359
  return null;
310
- const prefix = `loop:${session.loopId}:r`;
311
- const entries = [
312
- ...queryLoopEntries(this.loopStore, session.loopId, { prefix }),
313
- ...queryLoopEntries(this.loopStore, session.loopId, {
314
- prefix,
315
- feedbackOnly: true,
316
- }),
317
- ];
318
- const views = readOnlyRounds(entries);
319
- const latest = views[views.length - 1];
320
- return latest ? deriveCommittedSubmissionView(latest, views) : null;
360
+ const history = loadCommittedHistory(this.loopStore, loopId);
361
+ const views = history.rounds;
362
+ const rollbacks = history.directives;
363
+ const latestRound = views[views.length - 1];
364
+ const latestRollback = rollbacks[rollbacks.length - 1];
365
+ if (latestRollback &&
366
+ (!latestRound || latestRollback.round > latestRound.round)) {
367
+ return deriveCommittedSubmissionView(latestRollback, views, rollbacks);
368
+ }
369
+ return latestRound
370
+ ? deriveCommittedSubmissionView(latestRound, views, rollbacks)
371
+ : null;
372
+ }
373
+ /** v4.0.3: the machine-backed success trajectory is a projection of
374
+ * committed branch rounds, not a session counter. Unbacked claims never
375
+ * enter it. */
376
+ getMachineBackedSuccessTrajectory(loopId) {
377
+ return loadCommittedHistory(this.loopStore, loopId).rounds
378
+ .filter((view) => view.result?.shouldPushSuccessTrajectory === true &&
379
+ view.result?.evidencePosture?.machineBackedSuccess === true)
380
+ .map((view) => view.agentSuccessClaim);
321
381
  }
322
382
  get(sessionId) {
323
383
  return this.sessions.get(sessionId);
324
384
  }
385
+ /** Resolve a live session by its durable loop identity. `create` rejects a
386
+ * second live session for the same loop, so the scan is unambiguous. A
387
+ * persisted-only loop has no live session: call `resume` to rebuild it. */
388
+ getByLoopId(loopId) {
389
+ if (!loopId)
390
+ return undefined;
391
+ for (const session of this.sessions.values()) {
392
+ if (session.loopId === loopId)
393
+ return session;
394
+ }
395
+ return undefined;
396
+ }
325
397
  getLeaseStatus(loopId) {
326
398
  const entry = this.findSessionEntry(loopId);
327
399
  if (!entry)
@@ -348,13 +420,13 @@ export class SessionManager {
348
420
  return false;
349
421
  session.status = "stopped";
350
422
  this.lifecycle.save(session);
351
- void this.lifecycle.notifyTerminal(session, "cancelled");
423
+ void this.lifecycle.notifyTerminal(session, MANUAL_STOP_REASON);
352
424
  this.sessions.delete(sessionId);
353
425
  this.sessionQueues.delete(sessionId);
354
426
  logEvent("session_end", {
355
427
  sessionId,
356
428
  loopId: session.loopId,
357
- stopReason: "cancelled",
429
+ stopReason: MANUAL_STOP_REASON,
358
430
  roundsCompleted: session.currentRound,
359
431
  });
360
432
  return true;
@@ -387,38 +459,67 @@ export class SessionManager {
387
459
  * Reconstructs the session and compiles the prompt for the next round
388
460
  * (async — compilation collects evidence through RoundDriver.prepare).
389
461
  * Returns null if no session_state entry exists for this loopId. */
390
- async resume(loopId) {
462
+ async resume(loopId, inputs) {
463
+ // v4.0.5: validate invocation input before any state is read or saved.
464
+ const normalizedInputs = normalizeInvocationInputs(inputs);
391
465
  const persistedEntry = this.findSessionEntry(loopId);
392
466
  if (!persistedEntry)
393
467
  return null;
394
468
  const persistedLineage = (persistedEntry.loop_lineage ?? {});
395
469
  const persistedStatus = persistedLineage.status ?? "running";
396
- const sessionEntry = persistedStatus === "running"
470
+ // v3.9.7: stalled is a RESUMABLE recovery state. It is how a committed
471
+ // directive with an unfinished restore survives a restart.
472
+ const sessionEntry = persistedStatus === "running" || persistedStatus === "stalled"
397
473
  ? this.claimSessionEntry(loopId)
398
474
  : persistedEntry;
399
475
  if (!sessionEntry)
400
476
  return this.leaseConflictResult(loopId, persistedEntry);
401
477
  const session = this.lifecycle.reconstructSession(sessionEntry);
402
478
  if (!session) {
403
- // was not "running" — return stopped/stalled status
479
+ // was not "running"/"stalled" — report the SESSION status and, for a
480
+ // stopped loop, the committed loop terminal reason.
404
481
  const lineage = (sessionEntry.loop_lineage ?? {});
405
482
  const currentRound = lineage.current_round ?? 1;
406
483
  const status = lineage.status ?? "stopped";
407
- return {
408
- sessionId: "",
409
- round: currentRound,
410
- prompt: null,
411
- stopReason: status,
412
- stopDetail: `Loop is not running (status: ${status}). It may have already completed or been stopped.`,
413
- };
484
+ if (status !== "stopped") {
485
+ return {
486
+ sessionId: "",
487
+ round: currentRound,
488
+ prompt: null,
489
+ sessionStatus: status,
490
+ stopDetail: `Loop is not running (session status: ${status}).`,
491
+ };
492
+ }
493
+ // v4.0.4: resume of a stopped loop reports the committed `StopReason`
494
+ // (completed/failed/blocked/max_rounds/...) and the SAME three fact
495
+ // blocks as a live stop. Never a fake "stopped", never a legacy guess.
496
+ return this.lifecycle.stoppedLoopResult(loopId, currentRound);
414
497
  }
415
- this.sessions.set(session.sessionId, session);
416
- return this.lifecycle.resume(session);
498
+ // v4.0.5: the queue only serializes work if it runs against the ONE live
499
+ // object. If this process still holds a running/stalled session for the
500
+ // same identity, prefer it over a document reconstructed from a possibly
501
+ // pre-commit save; replacing it here would let an in-flight advance and
502
+ // this resume operate on two different snapshots.
503
+ const live = this.sessions.get(session.sessionId);
504
+ const target = live && live.loopId === loopId &&
505
+ (live.status === "running" || live.status === "stalled")
506
+ ? live
507
+ : session;
508
+ // Resume is a recovery attempt: set running so the lease is renewed while
509
+ // the (possibly long) restore executes. A failure saves stalled again.
510
+ if (target.status === "stalled")
511
+ target.status = "running";
512
+ if (target !== live)
513
+ this.sessions.set(target.sessionId, target);
514
+ // Serialize with any in-process advance holding this session's queue, so
515
+ // a recompile cannot race an in-flight held-handle submission.
516
+ return this.withSessionQueue(target.sessionId, () => this.lifecycle.resume(target, normalizedInputs));
417
517
  }
418
518
  /** v1.18: Resume a paused session. Reconstructs from vault state and
419
519
  * compiles the next prompt. Returns null if no paused session exists
420
520
  * for this loopId. */
421
- async unpause(loopId) {
521
+ async unpause(loopId, inputs) {
522
+ const normalizedInputs = normalizeInvocationInputs(inputs);
422
523
  const persistedEntry = this.findSessionEntry(loopId);
423
524
  if (!persistedEntry)
424
525
  return null;
@@ -432,10 +533,17 @@ export class SessionManager {
432
533
  const session = this.lifecycle.reconstructSession(sessionEntry, true);
433
534
  if (!session)
434
535
  return null;
435
- // Set to running so advance() works
436
- session.status = "running";
437
- this.sessions.set(session.sessionId, session);
438
- return this.lifecycle.unpause(session);
536
+ // v4.0.5: keep the ONE live object when this process already holds it,
537
+ // for the same queue-visibility reason as resume().
538
+ const live = this.sessions.get(session.sessionId);
539
+ const target = live && live.loopId === loopId && live.status !== "stopped"
540
+ ? live
541
+ : session;
542
+ // Set to running so advance() works.
543
+ target.status = "running";
544
+ if (target !== live)
545
+ this.sessions.set(target.sessionId, target);
546
+ return this.withSessionQueue(target.sessionId, () => this.lifecycle.unpause(target, normalizedInputs));
439
547
  }
440
548
  /** Auto-resume all "running" sessions from vault on server startup.
441
549
  * Scans vault for session_state entries, reconstructs each as an in-memory
@@ -459,7 +567,7 @@ export class SessionManager {
459
567
  continue;
460
568
  const lineage = (entry.loop_lineage ?? {});
461
569
  const status = lineage.status ?? "running";
462
- if (status !== "running" && status !== "paused")
570
+ if (status !== "running" && status !== "paused" && status !== "stalled")
463
571
  continue;
464
572
  const claimedEntry = this.claimSessionEntry(lid);
465
573
  if (!claimedEntry)
@@ -472,6 +580,11 @@ export class SessionManager {
472
580
  session = this.lifecycle.reconstructSession(claimedEntry, true);
473
581
  }
474
582
  catch (error) {
583
+ if (error instanceof LegacyHistoryError) {
584
+ // A bulk startup scan must not rewrite history it cannot decode.
585
+ logEvent("session_skip_legacy", { loopId: lid, error: error.message });
586
+ continue;
587
+ }
475
588
  if (error instanceof StorageCorruptionError && error.recoverable) {
476
589
  logEvent("session_skip_gap", { loopId: lid, error: error.message });
477
590
  continue;
@@ -542,6 +655,25 @@ export class SessionManager {
542
655
  },
543
656
  });
544
657
  }
658
+ gateInvalidResult(session) {
659
+ const handle = makeSubmissionHandle(session);
660
+ return {
661
+ error: "gate_invalid",
662
+ errorMessage: `Session is ${session.status}; a gate check requires a running held evaluation.`,
663
+ sessionId: session.sessionId,
664
+ ...(handle ? { submissionHandle: handle } : {}),
665
+ details: { reason_code: "session_not_running" },
666
+ };
667
+ }
668
+ gateLeaseConflictResult(session) {
669
+ const conflict = this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
670
+ return {
671
+ error: "lease_conflict",
672
+ errorMessage: conflict.stopDetail ?? "Another process owns this loop.",
673
+ sessionId: session.sessionId,
674
+ details: { reason_code: "lease_conflict" },
675
+ };
676
+ }
545
677
  /** v3.7.1: structured gate preflight. The agent submits a
546
678
  * GateActionDescriptor; the runtime classifies it (conservative:
547
679
  * anything not provably safe is user_required with reason codes). A
@@ -549,49 +681,71 @@ export class SessionManager {
549
681
  * loop + round + actionHash; agent_allowed records nothing and returns
550
682
  * the evidence suggestion instead. No action is executed and no round
551
683
  * advances from this call. */
552
- checkGate(sessionId, roundId, action) {
553
- const session = this.sessions.get(sessionId);
554
- if (!session) {
684
+ async checkGate(handle, action) {
685
+ const parsed = parseSubmissionHandle(handle);
686
+ if (!parsed) {
687
+ const detail = "submissionHandle must be an object with exactly sessionId, roundId, " +
688
+ "and transactionId as non-empty strings.";
555
689
  return {
556
- error: "session_not_found",
557
- errorMessage: `session not found: ${sessionId}`,
558
- sessionId,
690
+ error: "submission_handle_required",
691
+ errorMessage: `${detail} Copy it from the latest LoopForge response. ${LOST_HANDLE_ROUTING}`,
692
+ details: { reason_code: "shape", detail },
559
693
  };
560
694
  }
561
- const expectedRoundId = session.roundSnapshot?.roundId
562
- ?? makeRoundId(session.loopId, session.currentRound);
563
- if (expectedRoundId !== roundId) {
695
+ const session = this.sessions.get(parsed.sessionId);
696
+ if (!session) {
564
697
  return {
565
- error: "round_id_mismatch",
566
- errorMessage: "roundId does not match the current round — re-run loopforge_gate_check " +
567
- "with the roundId from the latest response",
568
- sessionId,
569
- roundId,
698
+ error: "session_not_found",
699
+ errorMessage: `session not found: ${parsed.sessionId}`,
700
+ sessionId: parsed.sessionId,
570
701
  };
571
702
  }
572
- const verdict = preflightStructuredGate(action);
573
- if (verdict.kind === "user") {
574
- this.recordOpenedGate(session.loopId, session.currentRound, verdict, canonicalizeGateAction(action));
703
+ if (session.status !== "running") {
704
+ return this.gateInvalidResult(session);
575
705
  }
576
- return {
577
- sessionId,
578
- gateId: verdict.gateId,
579
- risk: verdict.risk,
580
- decision: verdict.decision,
581
- reasonCodes: verdict.reasonCodes,
582
- ...(verdict.kind === "user"
583
- ? {
584
- blockedScope: verdict.blockedScope,
585
- allowedBeforeApproval: verdict.allowedBeforeApproval,
586
- requiredEvidence: [],
587
- approvalQuestion: verdict.approvalQuestion,
588
- }
589
- : {
590
- blockedScope: [],
591
- allowedBeforeApproval: [],
592
- requiredEvidence: verdict.requiredEvidence,
593
- }),
594
- };
706
+ const check = validateSubmissionHandle(parsed, session);
707
+ if (!check.ok)
708
+ return this.checkpointMismatchResult(check, session);
709
+ return this.withSessionQueue(session.sessionId, async () => {
710
+ const current = this.sessions.get(session.sessionId);
711
+ if (!current)
712
+ return { ...this.sessionNotFoundResult(session.sessionId) };
713
+ if (current.status !== "running") {
714
+ return this.gateInvalidResult(current);
715
+ }
716
+ // The authoritative re-check is inside the queue. A gate record must
717
+ // never be opened for a handle that lost the held attempt while this
718
+ // call was waiting behind another session mutation.
719
+ const inside = validateSubmissionHandle(parsed, current);
720
+ if (!inside.ok)
721
+ return this.checkpointMismatchResult(inside, current);
722
+ if (!this.renewSessionLease(current.loopId)) {
723
+ return this.gateLeaseConflictResult(current);
724
+ }
725
+ const verdict = preflightStructuredGate(action);
726
+ if (verdict.kind === "user") {
727
+ this.recordOpenedGate(current.loopId, current.currentRound, verdict, canonicalizeGateAction(action));
728
+ }
729
+ return {
730
+ submissionHandle: inside.handle,
731
+ gateId: verdict.gateId,
732
+ risk: verdict.risk,
733
+ decision: verdict.decision,
734
+ reasonCodes: verdict.reasonCodes,
735
+ ...(verdict.kind === "user"
736
+ ? {
737
+ blockedScope: verdict.blockedScope,
738
+ allowedBeforeApproval: verdict.allowedBeforeApproval,
739
+ requiredEvidence: [],
740
+ approvalQuestion: verdict.approvalQuestion,
741
+ }
742
+ : {
743
+ blockedScope: [],
744
+ allowedBeforeApproval: [],
745
+ requiredEvidence: verdict.requiredEvidence,
746
+ }),
747
+ };
748
+ });
595
749
  }
596
750
  /** Record a user decision for a recorded gate. The gateId embeds the
597
751
  * canonicalized action hash — if the action changed, the match fails and
@@ -639,8 +793,9 @@ export class SessionManager {
639
793
  };
640
794
  }
641
795
  try {
642
- const prefix = `loop:${loopId}:gate:`;
643
- const opened = queryLoopEntries(this.loopStore, loopId, { prefix })
796
+ const gatePrefix = `loop:${loopId}:gate:`;
797
+ const opened = loadCommittedHistory(this.loopStore, loopId).entries
798
+ .filter((entry) => String(entry.task_id ?? "").startsWith(gatePrefix))
644
799
  .find((entry) => entry.task_type === "gate_opened" && entry.gate_id === gateId);
645
800
  if (!opened) {
646
801
  return {
@@ -743,79 +898,51 @@ export class SessionManager {
743
898
  * the runtime's submission boundary reads the same context (the sub-goal
744
899
  * reference space is exactly the compiled set the prompt carried), and two
745
900
  * copies would let the projection and the boundary disagree about it. */
746
- compileContext(session) {
747
- return compileSessionContext(session);
748
- }
749
- /** Typed cognitive state projection for an active session. Derived on
750
- * demand — zero persistence. Null when nothing meaningful exists yet. */
751
- getProjection(sessionId) {
752
- const session = this.sessions.get(sessionId);
753
- if (!session)
901
+ /** v4.0.4: read-only terminal summary of a persisted STOPPED loop.
902
+ *
903
+ * Returns null unless the persisted session status is exactly "stopped":
904
+ * a running/paused/stalled loop without a live session must still go
905
+ * through `loopforge_resume`, so "no live session" is never mistaken for
906
+ * "already stopped". It never claims a lease, saves a session or writes
907
+ * history; the committed view supplies every field. */
908
+ getStoppedLoopSummary(loopId) {
909
+ validateLoopId(loopId);
910
+ const entry = this.findSessionEntry(loopId);
911
+ if (!entry)
754
912
  return null;
755
- const prefix = `loop:${session.loopId}:`;
756
- const entries = [
757
- ...queryLoopEntries(this.loopStore, session.loopId, { prefix }),
758
- ...queryLoopEntries(this.loopStore, session.loopId, { prefix, feedbackOnly: true }),
759
- ];
760
- const compileResponse = this.compileContext(session);
761
- const openGates = this.lifecycle.listOpenGateDescriptions(session.loopId);
762
- // v3.8: the projection reads the WHOLE committed history. The old
763
- // `beforeRound: session.currentRound` bound is only correct while the loop
764
- // is running, where currentRound is always one past the last committed
765
- // round; a stop / terminate / max_rounds leaves currentRound ON the round
766
- // it just committed, so the bound silently dropped the loop's final round
767
- // and the projection disagreed with audit and explain about it.
768
- // v3.8.1: one window, one decode, one derivation. The contract facts come
769
- // from the same `deriveRoundFacts` the compile path calls, so the prompt
770
- // and the projection cannot tell different stories about what the machine
771
- // verified.
772
- const rounds = readOnlyRounds(entries);
773
- const projection = buildLoopProjection(deriveCognitiveFacts({
774
- compileResponse,
775
- rounds,
776
- facts: deriveRoundFacts({
777
- rounds,
778
- currentRound: (rounds[rounds.length - 1]?.round ?? 0) + 1,
779
- inFlight: NO_IN_FLIGHT_ROUND,
780
- subGoals: compileResponse?.sub_goals ?? [],
781
- commands: getPolicy().evidence.commands ?? [],
782
- }),
783
- verifiedClaims: listVerifiedClaims(entries, session.loopId),
784
- openGates,
785
- }));
786
- return projection ? { ...projection } : null;
913
+ const lineage = (entry.loop_lineage ?? {});
914
+ if (lineage.status !== "stopped")
915
+ return null;
916
+ const currentRound = typeof lineage.current_round === "number"
917
+ ? lineage.current_round
918
+ : 1;
919
+ return this.lifecycle.stoppedLoopResult(loopId, currentRound);
787
920
  }
788
921
  /** v3.8: Read-only per-round "why" view over committed facts. Never
789
922
  * rebuilds history and never writes. */
790
923
  getExplain(loopId, round) {
791
924
  validateLoopId(loopId);
792
- const prefix = `loop:${loopId}:`;
793
- const entries = [
794
- ...queryLoopEntries(this.loopStore, loopId, { prefix }),
795
- ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
796
- ];
797
- return buildExplain(loopId, entries, round);
925
+ // v4.0.4: the SAME loader reports the legacy count Explain prints; Explain
926
+ // never re-scans for it and never decodes a legacy round into a fake row.
927
+ const history = loadCommittedHistory(this.loopStore, loopId);
928
+ return buildExplain(loopId, history.entries, round, history.legacyRounds.length);
798
929
  }
799
930
  /** Read-only end-of-loop audit (verification view). Never writes. */
800
931
  getAudit(loopId) {
801
932
  validateLoopId(loopId);
802
- // v3.3.1: audit judges COMMITTED decisions, which live on :feedback
803
- // entries — but queryLoopEntries excludes feedback by default, so the
804
- // audit view structurally never saw a committed round (it always
805
- // reported "passed" on empty history). Merge both result sets like the
806
- // round coordinator does.
807
- const prefix = `loop:${loopId}:`;
808
- const entries = [
809
- ...queryLoopEntries(this.loopStore, loopId, { prefix }),
810
- ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
811
- ];
812
- // Zero committed decisions → nothing to audit. Returning null lets the
813
- // tools layer report "no audit data" instead of the external auditor
814
- // solemnly passing a loop that never ran (or a mistyped loopId).
815
- const hasCommittedDecision = readOnlyRounds(entries).length > 0;
816
- if (!hasCommittedDecision)
933
+ // Audit judges COMMITTED decisions. The shared history loader returns the
934
+ // flat entry view and the decoded branch; there is no second query pair.
935
+ const history = loadCommittedHistory(this.loopStore, loopId);
936
+ // No committed decision and no legacy envelope → nothing to audit.
937
+ // Returning null lets the tools layer report "no audit data" instead of
938
+ // the external auditor solemnly passing a loop that never ran (or a
939
+ // mistyped loopId). Legacy-only loops still get a report so the gap is
940
+ // explicit, never silently empty.
941
+ if (history.rounds.length === 0 && history.directives.length === 0 &&
942
+ history.legacyRounds.length === 0) {
817
943
  return null;
818
- const audit = buildAudit(loopId, entries, this.loopStore);
944
+ }
945
+ const audit = buildAudit(loopId, history.entries, this.loopStore);
819
946
  return { ...audit };
820
947
  }
821
948
  /** v3.5: The ACTIVE Round Contract governing the session's next round —
@@ -833,16 +960,10 @@ export class SessionManager {
833
960
  * statistics (A4 port) folded with this process's live observations.
834
961
  * Non-durable fields (evidence, vault errors) come from live only. */
835
962
  getPolicyMetrics(loopId) {
836
- // v3.3.1: derived metrics judge committed decisions on :feedback entries,
837
- // which queryLoopEntries excludes by default — merge both result sets so
838
- // restart-surviving metrics actually see the committed rounds (before,
839
- // derived.committedRounds was structurally 0 and the merge fell back to
840
- // the live-only snapshot every time).
841
- const prefix = `loop:${loopId}:`;
842
- const entries = [
843
- ...queryLoopEntries(this.loopStore, loopId, { prefix }),
844
- ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
845
- ];
963
+ // Derived metrics judge committed decisions through the same history
964
+ // loader as every other read path. Live-only counters are folded in
965
+ // afterwards; they never become the source of round facts.
966
+ const entries = loadCommittedHistory(this.loopStore, loopId).entries;
846
967
  const derived = derivePolicyMetrics(loopId, entries);
847
968
  const live = policyMetrics.snapshot(loopId);
848
969
  if (derived.committedRounds === 0)
@@ -860,20 +981,32 @@ export class SessionManager {
860
981
  * `strategy_stability` was a literal `true`. What remains is counted
861
982
  * directly from committed round flags. */
862
983
  getHealth(loopId) {
863
- const engine = new LoopForgeEngine(this.loopStore);
864
- const context = engine.hydrateLoopContext(loopId);
865
- // No hydratable context at all → unknown loop. A started-but-uncommitted
866
- // loop DOES have a context (the compile persists a lineage entry), so it
867
- // still gets a view — with zeros — exactly as it did before.
868
- if (!context)
984
+ const history = loadCommittedHistory(this.loopStore, loopId);
985
+ // No stored session or round at all → unknown loop. A started-but-
986
+ // uncommitted loop has a session entry, so it still gets a zeroed view.
987
+ if (history.entries.length === 0)
869
988
  return null;
870
- const entries = Array.isArray(context.results) ? context.results : [];
871
- const views = readOnlyRounds(entries);
989
+ const views = history.rounds;
872
990
  const roundsWithUnverifiedItems = views.filter((view) => view.verificationFlags.some((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED)).length;
991
+ // v4.0.3: the ONE persisted posture is the source of this distribution.
992
+ const roundsByEvidenceStatus = {
993
+ verified: 0,
994
+ insufficient: 0,
995
+ contradicted: 0,
996
+ unknown: 0,
997
+ not_applicable: 0,
998
+ };
999
+ for (const view of views) {
1000
+ if (view.evidencePosture) {
1001
+ roundsByEvidenceStatus[view.evidencePosture.status]++;
1002
+ }
1003
+ }
873
1004
  return {
874
1005
  loopId,
875
1006
  committed_rounds: views.length,
1007
+ legacy_rounds: history.legacyRounds.length,
876
1008
  rounds_with_unverified_items: roundsWithUnverifiedItems,
1009
+ rounds_by_evidence_status: roundsByEvidenceStatus,
877
1010
  // The policy value the enforcement gate escalates on. The streak itself
878
1011
  // is derived in the gate — re-deriving it here would be a second
879
1012
  // implementation of the same rule, which is what this release removes.
@@ -881,53 +1014,231 @@ export class SessionManager {
881
1014
  policy_metrics: this.getPolicyMetrics(loopId),
882
1015
  };
883
1016
  }
1017
+ /** v4.0.0: the stable result for a missing/malformed handle. */
1018
+ handleRequiredResult() {
1019
+ const detail = "submissionHandle must be an object with exactly sessionId, roundId, " +
1020
+ "and transactionId as non-empty strings.";
1021
+ return {
1022
+ sessionId: "",
1023
+ round: 0,
1024
+ prompt: null,
1025
+ stopDetail: `${detail} Copy it from the latest LoopForge response. ` +
1026
+ LOST_HANDLE_ROUTING,
1027
+ submissionError: {
1028
+ code: "submission_handle_required",
1029
+ details: { reason_code: "shape", detail },
1030
+ },
1031
+ };
1032
+ }
1033
+ sessionNotFoundResult(sessionId) {
1034
+ return {
1035
+ sessionId,
1036
+ round: 0,
1037
+ prompt: null,
1038
+ stopDetail: "No session exists with this ID. It may have expired, been deleted, " +
1039
+ "or never existed. Call loopforge_status with view=\"all\" to find the " +
1040
+ "loopId; if the held prompt is still in context, call loopforge_status " +
1041
+ "with that loopId to fetch the current handle; otherwise call " +
1042
+ "loopforge_resume with it.",
1043
+ submissionError: {
1044
+ code: "session_not_found",
1045
+ details: { sessionId },
1046
+ },
1047
+ };
1048
+ }
1049
+ sessionStateResult(session) {
1050
+ const handle = makeSubmissionHandle(session);
1051
+ return {
1052
+ sessionId: session.sessionId,
1053
+ round: session.currentRound,
1054
+ ...(handle ? { submissionHandle: handle } : {}),
1055
+ prompt: null,
1056
+ sessionStatus: session.status,
1057
+ stopDetail: `Session is ${session.status}. Use loopforge_resume to restart a ` +
1058
+ "paused session, or loopforge_start for a new task.",
1059
+ };
1060
+ }
1061
+ /** v4.0.0: one stored-prompt re-delivery for next/recover. A held prompt is
1062
+ * returned with the current handle and a warning; without one, the stable
1063
+ * mismatch error carries the current handle when it exists. */
1064
+ handleMismatchResult(check, session) {
1065
+ if (check.reason !== "no_held_prompt") {
1066
+ const held = this.lifecycle.heldPromptResult(session, staleHandleWarning());
1067
+ if (held)
1068
+ return held;
1069
+ }
1070
+ return {
1071
+ sessionId: session.sessionId,
1072
+ round: session.currentRound,
1073
+ ...(check.current ? { submissionHandle: check.current } : {}),
1074
+ prompt: null,
1075
+ stopDetail: check.detail,
1076
+ submissionError: {
1077
+ code: "submission_handle_mismatch",
1078
+ details: {
1079
+ reason_code: check.reason,
1080
+ ...(check.mismatched.length > 0 ? { mismatched: [...check.mismatched] } : {}),
1081
+ },
1082
+ },
1083
+ };
1084
+ }
1085
+ /** v4.0.0: the checkpoint variant: never re-deliver a prompt, never create a
1086
+ * checkpoint. The current handle rides the error for re-anchoring. */
1087
+ checkpointMismatchResult(check, session) {
1088
+ return {
1089
+ error: "submission_handle_mismatch",
1090
+ errorMessage: check.detail,
1091
+ sessionId: session.sessionId,
1092
+ ...(check.current ? { submissionHandle: check.current } : {}),
1093
+ details: {
1094
+ reason_code: check.reason,
1095
+ ...(check.mismatched.length > 0 ? { mismatched: [...check.mismatched] } : {}),
1096
+ },
1097
+ };
1098
+ }
1099
+ /** v3.9.7: a pending rollback directive whose restore is not yet verified.
1100
+ * Read-only; used before the queue/lease so a stale handle cannot mask the
1101
+ * "call resume" recovery UX. */
1102
+ restorePendingResult(session) {
1103
+ const pending = loadCommittedHistory(this.loopStore, session.loopId)
1104
+ .pendingDirective;
1105
+ if (!pending?.recovery)
1106
+ return null;
1107
+ if (session.roundSnapshot?.restore?.directiveTransactionId ===
1108
+ pending.recovery.transaction_id &&
1109
+ session.roundSnapshot?.round === pending.recovery.target.redo_round) {
1110
+ return null;
1111
+ }
1112
+ const handle = makeSubmissionHandle(session);
1113
+ return {
1114
+ sessionId: session.sessionId,
1115
+ round: session.currentRound,
1116
+ ...(handle ? { submissionHandle: handle } : {}),
1117
+ prompt: null,
1118
+ stopReason: "stalled",
1119
+ submissionError: {
1120
+ code: "restore_pending",
1121
+ details: {
1122
+ loopId: session.loopId,
1123
+ directive_transaction_id: pending.recovery.transaction_id,
1124
+ redo_round: pending.recovery.target.redo_round,
1125
+ detail: "A pending restore must be completed by loopforge_resume before work can be submitted.",
1126
+ },
1127
+ },
1128
+ };
1129
+ }
884
1130
  /** Core cycle: extract self-eval → record feedback → check stop → compile next.
885
- * The lease + per-session queue wrap the RoundLifecycle state machine.
886
- * @param preExtractedEval Structured SelfEvaluation supplied by the caller.
887
- * An absent value is returned as evaluation_invalid without mutating state.
888
- * @param roundId v3.0.1: The roundId of the round this submission reports on
889
- * (from the last start/next/resume response). Anchors the submission so a
890
- * stale or duplicate submission is not processed against a later round.
891
- * Optional for library callers — when absent the anchor check is skipped. */
892
- /** \`submission\` is the RAW evaluation payload, not a built one.
893
1131
  *
894
- * v3.8.1: this method used to carry a partial copy of the submission
895
- * boundary (an object check plus core-field validation) while the MCP tool
896
- * handler carried the rest — one rule, three copies, across two layers.
897
- * The boundary now lives entirely in the runtime
898
- * (`RoundLifecycle.submissionBoundary`), so every caller — this manager, a
899
- * library user, a future CLI command — gets the same strictness. */
900
- /** \`submission\` is the RAW evaluation payload, not a built one.
901
- *
902
- * v3.8.1: this method used to carry a partial copy of the submission
903
- * boundary (an object check plus core-field validation) while the MCP tool
904
- * handler carried the rest — one rule, three copies, across two layers.
905
- * The whole boundary now runs HERE, before the queue and before the lease
906
- * heartbeat, so every caller of the public API gets it and a rejected
907
- * payload leaves nothing durable behind. */
908
- async advance(sessionId, output, submission, roundId) {
909
- // Judged before the session is even resolved: a malformed evaluation is a
910
- // payload defect whatever the session's state. See submission-boundary.ts.
911
- const boundary = validateSubmission(this.loopStore, this.sessions.get(sessionId), submission);
1132
+ * v4.0.0: the caller supplies the ONE `submissionHandle`; identity is
1133
+ * checked before the payload boundary and before the queue/lease heartbeat.
1134
+ * `submission` is the RAW evaluation payload. The whole payload boundary
1135
+ * still runs here, so every public caller gets the same strictness. */
1136
+ async advance(handle, output, submission, inputs) {
1137
+ const normalizedInputs = normalizeInvocationInputs(inputs);
1138
+ const parsed = parseSubmissionHandle(handle);
1139
+ if (!parsed)
1140
+ return this.handleRequiredResult();
1141
+ const session = this.sessions.get(parsed.sessionId);
1142
+ if (!session)
1143
+ return this.sessionNotFoundResult(parsed.sessionId);
1144
+ if (session.status !== "running" && session.status !== "stalled") {
1145
+ return this.sessionStateResult(session);
1146
+ }
1147
+ const pending = this.restorePendingResult(session);
1148
+ if (pending)
1149
+ return pending;
1150
+ const check = validateSubmissionHandle(parsed, session);
1151
+ if (!check.ok)
1152
+ return this.handleMismatchResult(check, session);
1153
+ // Identity is established for the attempt named by the handle. Only now
1154
+ // does the payload boundary judge the submission against that attempt's
1155
+ // reference space.
1156
+ const boundary = validateSubmission(this.loopStore, session, submission);
912
1157
  if (!boundary.ok) {
1158
+ const current = makeSubmissionHandle(session);
913
1159
  return {
914
- sessionId,
915
- round: this.sessions.get(sessionId)?.currentRound ?? 0,
916
- ...(roundId !== undefined ? { roundId } : {}),
1160
+ sessionId: session.sessionId,
1161
+ round: session.currentRound,
1162
+ ...(current ? { submissionHandle: current } : {}),
917
1163
  prompt: null,
918
- stopReason: boundary.submissionError.code,
919
1164
  stopDetail: boundary.stopDetail,
920
1165
  submissionError: boundary.submissionError,
921
1166
  };
922
1167
  }
923
- return this.withSessionQueue(sessionId, async () => {
924
- const session = this.sessions.get(sessionId);
925
- if (session?.status === "running" &&
926
- !this.renewSessionLease(session.loopId)) {
927
- return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
1168
+ return this.withSessionQueue(session.sessionId, async () => {
1169
+ const current = this.sessions.get(session.sessionId);
1170
+ if (!current)
1171
+ return this.sessionNotFoundResult(session.sessionId);
1172
+ if (current.status !== "running" && current.status !== "stalled") {
1173
+ return this.sessionStateResult(current);
1174
+ }
1175
+ const pendingInside = this.restorePendingResult(current);
1176
+ if (pendingInside)
1177
+ return pendingInside;
1178
+ // The authoritative re-check. It runs AFTER the queue is acquired but
1179
+ // BEFORE the lease heartbeat, so a concurrent commit cannot make this
1180
+ // call renew a lease for a handle it is about to reject.
1181
+ const inside = validateSubmissionHandle(parsed, current);
1182
+ if (!inside.ok)
1183
+ return this.handleMismatchResult(inside, current);
1184
+ if (current.status === "running" &&
1185
+ !this.renewSessionLease(current.loopId)) {
1186
+ return this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
1187
+ }
1188
+ try {
1189
+ return await this.lifecycle.advance(current.sessionId, output, boundary.evaluation, boundary.diagnostics, boundary.submittedFields, normalizedInputs);
1190
+ }
1191
+ catch (error) {
1192
+ if (error instanceof SessionLeaseConflictError) {
1193
+ return this.leaseConflictResult(error.loopId, this.findSessionEntry(error.loopId));
1194
+ }
1195
+ throw error;
1196
+ }
1197
+ });
1198
+ }
1199
+ /** v4.0.5: recompile the held prompt for a new host-provided instruction.
1200
+ * This does NOT submit an evaluation or advance the round: the exact held
1201
+ * handle is required, and the same round/attempt/context boundary is
1202
+ * preserved. The handle is re-checked inside the queue before the lease is
1203
+ * renewed, so a stale handle has zero side effects. */
1204
+ async recompileHeld(handle, instruction) {
1205
+ const normalizedInputs = normalizeInvocationInputs({
1206
+ currentUserInstruction: instruction,
1207
+ });
1208
+ const parsed = parseSubmissionHandle(handle);
1209
+ if (!parsed)
1210
+ return this.handleRequiredResult();
1211
+ const session = this.sessions.get(parsed.sessionId);
1212
+ if (!session)
1213
+ return this.sessionNotFoundResult(parsed.sessionId);
1214
+ if (session.status !== "running" && session.status !== "stalled") {
1215
+ return this.sessionStateResult(session);
1216
+ }
1217
+ const pending = this.restorePendingResult(session);
1218
+ if (pending)
1219
+ return pending;
1220
+ const check = validateSubmissionHandle(parsed, session);
1221
+ if (!check.ok)
1222
+ return this.handleMismatchResult(check, session);
1223
+ return this.withSessionQueue(session.sessionId, async () => {
1224
+ const current = this.sessions.get(session.sessionId);
1225
+ if (!current)
1226
+ return this.sessionNotFoundResult(session.sessionId);
1227
+ if (current.status !== "running" && current.status !== "stalled") {
1228
+ return this.sessionStateResult(current);
1229
+ }
1230
+ const pendingInside = this.restorePendingResult(current);
1231
+ if (pendingInside)
1232
+ return pendingInside;
1233
+ const inside = validateSubmissionHandle(parsed, current);
1234
+ if (!inside.ok)
1235
+ return this.handleMismatchResult(inside, current);
1236
+ if (current.status === "running" &&
1237
+ !this.renewSessionLease(current.loopId)) {
1238
+ return this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
928
1239
  }
929
1240
  try {
930
- return await this.lifecycle.advance(sessionId, output, boundary.evaluation, roundId, boundary.diagnostics, boundary.submittedFields);
1241
+ return await this.lifecycle.recompileHeldWithInstruction(current, normalizedInputs.currentUserInstruction);
931
1242
  }
932
1243
  catch (error) {
933
1244
  if (error instanceof SessionLeaseConflictError) {
@@ -937,6 +1248,330 @@ export class SessionManager {
937
1248
  }
938
1249
  });
939
1250
  }
1251
+ /** v3.9.7: submit an Agent-initiated recovery. v4.0.0: the handle is the
1252
+ * only anchor; shape and target visibility are checked before the
1253
+ * queue/lease, and the exact handle is re-checked before lease renewal. */
1254
+ async recover(handle, recovery, inputs) {
1255
+ const normalizedInputs = normalizeInvocationInputs(inputs);
1256
+ const parsed = parseSubmissionHandle(handle);
1257
+ if (!parsed)
1258
+ return this.handleRequiredResult();
1259
+ const session = this.sessions.get(parsed.sessionId);
1260
+ if (!session)
1261
+ return this.sessionNotFoundResult(parsed.sessionId);
1262
+ if (session.status !== "running" && session.status !== "stalled") {
1263
+ return this.sessionStateResult(session);
1264
+ }
1265
+ const pending = this.restorePendingResult(session);
1266
+ if (pending)
1267
+ return pending;
1268
+ const check = validateSubmissionHandle(parsed, session);
1269
+ if (!check.ok)
1270
+ return this.handleMismatchResult(check, session);
1271
+ if (!getPolicy().engine.agent_recovery_enabled) {
1272
+ return this.recoveryDisabledResult(check.handle, session);
1273
+ }
1274
+ const invalid = (detail) => ({
1275
+ sessionId: session.sessionId,
1276
+ round: session.currentRound,
1277
+ submissionHandle: check.handle,
1278
+ prompt: null,
1279
+ submissionError: {
1280
+ code: "recovery_invalid",
1281
+ details: { reason_code: "invalid_shape", detail },
1282
+ },
1283
+ });
1284
+ if (!isRecord(recovery))
1285
+ return invalid("recovery must be an object");
1286
+ const keys = Object.keys(recovery);
1287
+ if (keys.some((key) => key !== "target_checkpoint_id" && key !== "reason")) {
1288
+ return invalid("recovery accepts only target_checkpoint_id and reason");
1289
+ }
1290
+ if (typeof recovery.target_checkpoint_id !== "string" ||
1291
+ recovery.target_checkpoint_id.trim().length === 0) {
1292
+ return invalid("target_checkpoint_id must be a non-empty string");
1293
+ }
1294
+ if (typeof recovery.reason !== "string" ||
1295
+ recovery.reason.trim().length === 0 ||
1296
+ recovery.reason.length > 500) {
1297
+ return invalid("reason must be a non-empty string of at most 500 characters");
1298
+ }
1299
+ const targetCheckpointId = recovery.target_checkpoint_id;
1300
+ const reason = recovery.reason;
1301
+ // Read-only visibility check against the exact held snapshot. An invalid
1302
+ // target must not cause a lease renewal or a directive.
1303
+ const history = loadCommittedHistory(this.loopStore, session.loopId);
1304
+ const entries = history.entries;
1305
+ // A pending directive outranks target validation; the lifecycle retries
1306
+ // the same restore and never commits a second directive.
1307
+ if (!history.pendingDirective) {
1308
+ const resolved = findCheckpointRef({
1309
+ entries,
1310
+ held: check.snapshot,
1311
+ checkpointId: targetCheckpointId,
1312
+ });
1313
+ if (!resolved.ok) {
1314
+ return {
1315
+ sessionId: session.sessionId,
1316
+ round: session.currentRound,
1317
+ submissionHandle: check.handle,
1318
+ prompt: null,
1319
+ submissionError: {
1320
+ code: resolved.code,
1321
+ details: {
1322
+ target_checkpoint_id: targetCheckpointId,
1323
+ reason_code: resolved.reason,
1324
+ detail: resolved.detail,
1325
+ current_round: session.currentRound,
1326
+ },
1327
+ },
1328
+ };
1329
+ }
1330
+ }
1331
+ const validatedRecovery = {
1332
+ target_checkpoint_id: targetCheckpointId,
1333
+ reason,
1334
+ };
1335
+ return this.withSessionQueue(session.sessionId, async () => {
1336
+ const current = this.sessions.get(session.sessionId);
1337
+ if (!current)
1338
+ return this.sessionNotFoundResult(session.sessionId);
1339
+ if (current.status !== "running" && current.status !== "stalled") {
1340
+ return this.sessionStateResult(current);
1341
+ }
1342
+ const pendingInside = this.restorePendingResult(current);
1343
+ if (pendingInside)
1344
+ return pendingInside;
1345
+ const inside = validateSubmissionHandle(parsed, current);
1346
+ if (!inside.ok)
1347
+ return this.handleMismatchResult(inside, current);
1348
+ if (!getPolicy().engine.agent_recovery_enabled) {
1349
+ return this.recoveryDisabledResult(inside.handle, current);
1350
+ }
1351
+ if (current.status === "running" &&
1352
+ !this.renewSessionLease(current.loopId)) {
1353
+ return this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
1354
+ }
1355
+ try {
1356
+ return await this.lifecycle.recover(current.sessionId, validatedRecovery, normalizedInputs);
1357
+ }
1358
+ catch (error) {
1359
+ if (error instanceof SessionLeaseConflictError) {
1360
+ return this.leaseConflictResult(error.loopId, this.findSessionEntry(error.loopId));
1361
+ }
1362
+ throw error;
1363
+ }
1364
+ });
1365
+ }
1366
+ recoveryDisabledResult(handle, session) {
1367
+ return {
1368
+ sessionId: session.sessionId,
1369
+ round: session.currentRound,
1370
+ submissionHandle: handle,
1371
+ prompt: null,
1372
+ submissionError: {
1373
+ code: "recovery_disabled",
1374
+ details: { policy: "engine.agent_recovery_enabled" },
1375
+ },
1376
+ };
1377
+ }
1378
+ /** v3.9.7: Agent-created mid-round savepoint. v4.0.0: handle identity is
1379
+ * checked before the queue and again before lease renewal; capture/save
1380
+ * happen only after both. A checkpoint never changes the handle. */
1381
+ async checkpoint(handle) {
1382
+ const parsed = parseSubmissionHandle(handle);
1383
+ if (!parsed) {
1384
+ const detail = "submissionHandle must be an object with exactly sessionId, roundId, " +
1385
+ "and transactionId as non-empty strings.";
1386
+ return {
1387
+ error: "submission_handle_required",
1388
+ errorMessage: `${detail} Copy it from the latest LoopForge response. ${LOST_HANDLE_ROUTING}`,
1389
+ details: { reason_code: "shape", detail },
1390
+ };
1391
+ }
1392
+ const session = this.sessions.get(parsed.sessionId);
1393
+ if (!session) {
1394
+ return {
1395
+ error: "session_not_found",
1396
+ errorMessage: "No session exists with this ID.",
1397
+ sessionId: parsed.sessionId,
1398
+ };
1399
+ }
1400
+ if (session.status !== "running") {
1401
+ const current = makeSubmissionHandle(session);
1402
+ return {
1403
+ error: "checkpoint_invalid",
1404
+ errorMessage: `Session is ${session.status}; a checkpoint requires a running held evaluation.`,
1405
+ sessionId: session.sessionId,
1406
+ ...(current ? { submissionHandle: current } : {}),
1407
+ details: { reason_code: "session_not_running" },
1408
+ };
1409
+ }
1410
+ const check = validateSubmissionHandle(parsed, session);
1411
+ if (!check.ok)
1412
+ return this.checkpointMismatchResult(check, session);
1413
+ return this.withSessionQueue(session.sessionId, async () => {
1414
+ const current = this.sessions.get(session.sessionId);
1415
+ if (!current) {
1416
+ return {
1417
+ error: "session_not_found",
1418
+ errorMessage: "No session exists with this ID.",
1419
+ sessionId: session.sessionId,
1420
+ };
1421
+ }
1422
+ if (current.status !== "running") {
1423
+ const currentHandle = makeSubmissionHandle(current);
1424
+ return {
1425
+ error: "checkpoint_invalid",
1426
+ errorMessage: `Session is ${current.status}; a checkpoint requires a running held evaluation.`,
1427
+ sessionId: current.sessionId,
1428
+ ...(currentHandle ? { submissionHandle: currentHandle } : {}),
1429
+ details: { reason_code: "session_not_running" },
1430
+ };
1431
+ }
1432
+ // Authoritative re-check: after the queue is acquired, before the lease
1433
+ // heartbeat. A checkpoint must never capture or save for a stale handle.
1434
+ const inside = validateSubmissionHandle(parsed, current);
1435
+ if (!inside.ok)
1436
+ return this.checkpointMismatchResult(inside, current);
1437
+ const pending = this.restorePendingResult(current);
1438
+ if (pending) {
1439
+ return {
1440
+ error: "restore_pending",
1441
+ errorMessage: "A pending restore must be completed by loopforge_resume before creating a checkpoint.",
1442
+ sessionId: current.sessionId,
1443
+ submissionHandle: inside.handle,
1444
+ details: { reason_code: "restore_pending" },
1445
+ };
1446
+ }
1447
+ if (!this.renewSessionLease(current.loopId)) {
1448
+ const conflict = this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
1449
+ return {
1450
+ error: "lease_conflict",
1451
+ errorMessage: conflict.stopDetail ?? "Another process owns this loop.",
1452
+ sessionId: current.sessionId,
1453
+ details: { reason_code: "lease_conflict" },
1454
+ };
1455
+ }
1456
+ const held = inside.snapshot;
1457
+ const store = this.checkpointStoreFor?.(current.loopId);
1458
+ if (!store) {
1459
+ return {
1460
+ error: "checkpoint_unavailable",
1461
+ errorMessage: "No workspace checkpoint store is configured for this loop.",
1462
+ sessionId: current.sessionId,
1463
+ submissionHandle: inside.handle,
1464
+ details: { reason_code: "store_unavailable" },
1465
+ };
1466
+ }
1467
+ let stateDir;
1468
+ try {
1469
+ stateDir = resolveStateDirectory(process.cwd(), getPolicy().backend.root_dir);
1470
+ }
1471
+ catch (error) {
1472
+ return {
1473
+ error: "checkpoint_unavailable",
1474
+ errorMessage: `The runtime state directory could not be resolved: ${String(error)}`,
1475
+ sessionId: current.sessionId,
1476
+ submissionHandle: inside.handle,
1477
+ details: { reason_code: "state_directory_invalid" },
1478
+ };
1479
+ }
1480
+ const exclude = (path) => {
1481
+ const abs = resolve(path);
1482
+ const prefixAbs = stateDir.endsWith(sep) ? stateDir : `${stateDir}${sep}`;
1483
+ return abs === stateDir || abs.startsWith(prefixAbs);
1484
+ };
1485
+ let manifest;
1486
+ try {
1487
+ manifest = await store.capture({
1488
+ round: held.round,
1489
+ role: "mid",
1490
+ cwd: process.cwd(),
1491
+ exclude,
1492
+ timeoutMs: Math.max(60_000, getPolicy().evidence.timeout_ms),
1493
+ });
1494
+ }
1495
+ catch (error) {
1496
+ if (error instanceof CheckpointError) {
1497
+ return {
1498
+ error: error.code,
1499
+ errorMessage: error.message,
1500
+ sessionId: current.sessionId,
1501
+ submissionHandle: inside.handle,
1502
+ details: { reason_code: error.reasonCode },
1503
+ };
1504
+ }
1505
+ return {
1506
+ error: "checkpoint_failed",
1507
+ errorMessage: String(error),
1508
+ sessionId: current.sessionId,
1509
+ submissionHandle: inside.handle,
1510
+ details: { reason_code: "capture_error" },
1511
+ };
1512
+ }
1513
+ // The capture yielded to the event loop; a concurrent advance/pause may
1514
+ // have invalidated the held transaction. Never attach an orphan
1515
+ // checkpoint to a different attempt.
1516
+ const after = validateSubmissionHandle(parsed, this.sessions.get(session.sessionId) ?? current);
1517
+ if (!after.ok) {
1518
+ return {
1519
+ error: "checkpoint_invalid",
1520
+ errorMessage: "The held attempt changed while the checkpoint was captured.",
1521
+ sessionId: current.sessionId,
1522
+ ...(after.current ? { submissionHandle: after.current } : {}),
1523
+ details: { reason_code: "identity_changed" },
1524
+ };
1525
+ }
1526
+ const ref = buildCheckpointRef(manifest, {
1527
+ transactionId: held.transactionId,
1528
+ branchId: held.branchId,
1529
+ });
1530
+ const previous = held.checkpoints ?? [];
1531
+ const duplicate = previous.find((item) => item.checkpointId === ref.checkpointId);
1532
+ // The cap counts DISTINCT savepoints. Re-capturing an already visible
1533
+ // state is idempotent and must still return the existing ref at the cap.
1534
+ if (!duplicate &&
1535
+ previous.length >= MAX_MID_CHECKPOINTS_PER_TRANSACTION) {
1536
+ return {
1537
+ error: "checkpoint_limit",
1538
+ errorMessage: `This attempt already has ${MAX_MID_CHECKPOINTS_PER_TRANSACTION} mid-round checkpoints.`,
1539
+ sessionId: current.sessionId,
1540
+ submissionHandle: after.handle,
1541
+ details: { reason_code: "mid_checkpoint_cap" },
1542
+ };
1543
+ }
1544
+ if (!duplicate) {
1545
+ held.checkpoints = [...previous, ref];
1546
+ try {
1547
+ this.lifecycle.save(current);
1548
+ }
1549
+ catch (error) {
1550
+ held.checkpoints = previous;
1551
+ return {
1552
+ error: "checkpoint_failed",
1553
+ errorMessage: `The checkpoint manifest was written but the session could not be saved: ${String(error)}. Retry returns the same id.`,
1554
+ sessionId: current.sessionId,
1555
+ submissionHandle: after.handle,
1556
+ details: { reason_code: "session_save_failed" },
1557
+ };
1558
+ }
1559
+ }
1560
+ return {
1561
+ sessionId: current.sessionId,
1562
+ submissionHandle: after.handle,
1563
+ checkpointId: manifest.checkpointId,
1564
+ round: held.round,
1565
+ role: "mid",
1566
+ redoRound: manifest.round,
1567
+ createdAt: (duplicate ?? ref).createdAt,
1568
+ ...(manifest.head ? { head: manifest.head } : {}),
1569
+ fileCount: Object.keys(manifest.fingerprints).length,
1570
+ contentCount: manifest.contents.length,
1571
+ contentHash: manifest.contentHash,
1572
+ };
1573
+ });
1574
+ }
940
1575
  /** Replay timeline for a session — creates ReplayBackend from the stored backend. */
941
1576
  replayTimeline(sessionId) {
942
1577
  const session = this.sessions.get(sessionId);
@@ -956,6 +1591,45 @@ export class SessionManager {
956
1591
  const timeline = replay.timeline(loopId);
957
1592
  return timeline.length > 0 ? timeline : null;
958
1593
  }
1594
+ /** v4.0.2: a bounded replay page with its window metadata. `null` only when
1595
+ * the session does not exist; an empty loop returns an empty page. */
1596
+ replayPage(sessionId, opts) {
1597
+ const session = this.sessions.get(sessionId);
1598
+ if (!session)
1599
+ return null;
1600
+ return this.replayPageByLoop(session.loopId, opts);
1601
+ }
1602
+ replayPageByLoop(loopId, opts) {
1603
+ validateLoopId(loopId);
1604
+ return new ReplayBackend(this.loopStore).page(loopId, opts);
1605
+ }
1606
+ /** v3.9.6: committed rollback directives for the replay view. They are
1607
+ * events, not normal rounds; the timeline stays a normal-round projection. */
1608
+ replayRollbacks(loopId) {
1609
+ validateLoopId(loopId);
1610
+ return loadCommittedHistory(this.loopStore, loopId).directives.map((view) => ({
1611
+ round: view.round,
1612
+ roundId: view.roundId,
1613
+ transactionId: view.transactionId ?? view.roundId,
1614
+ branchId: view.branchId ?? null,
1615
+ origin: view.origin === "agent" ? "agent" : "machine",
1616
+ redoRound: view.recovery?.target.redo_round ?? 0,
1617
+ targetCheckpointId: view.recovery?.target.checkpoint_id ?? "",
1618
+ reason: view.recovery?.reason ?? "",
1619
+ observedSkippedFiles: [...(view.recovery?.observed_skipped_files ??
1620
+ view.recovery?.skipped_files ?? [])],
1621
+ reportedSkippedFiles: [...(view.recovery?.reported_skipped_files ?? [])],
1622
+ skippedFiles: [...(view.recovery?.skipped_files ?? [])],
1623
+ abandonedRounds: [...(view.recovery?.abandoned_rounds ?? [])],
1624
+ triggerFindings: [...(view.recovery?.trigger_findings ?? [])],
1625
+ preservedDiscoveries: [...(view.recovery?.skipped_discoveries ?? [])],
1626
+ // v4.0.2: the committed abandoned-path facts, same display caps as
1627
+ // Working Memory / replay.
1628
+ failedRounds: [...(view.recovery?.failed_rounds ?? [])],
1629
+ approaches: [...(view.recovery?.approaches ?? [])].map((value) => value.slice(0, 200)),
1630
+ wrongAssumptions: [...(view.recovery?.wrong_assumptions ?? [])].map((value) => value.slice(0, 500)),
1631
+ }));
1632
+ }
959
1633
  /** v3.9.1: unsupported envelope versions for a loop, reported explicitly
960
1634
  * rather than silently dropped from replay/audit. */
961
1635
  replayLegacyRounds(loopId) {