loopforge 3.9.2 → 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 (285) 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 +152 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +382 -206
  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 +105 -141
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +249 -372
  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 +32 -67
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +203 -439
  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 +28 -887
  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 +167 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +373 -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 +51 -19
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +423 -507
  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 +285 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1629 -574
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts +5 -0
  115. package/dist/mcp/server.d.ts.map +1 -1
  116. package/dist/mcp/server.js +61 -19
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/mcp/session.d.ts +81 -39
  119. package/dist/mcp/session.d.ts.map +1 -1
  120. package/dist/mcp/session.js +963 -271
  121. package/dist/mcp/session.js.map +1 -1
  122. package/dist/mcp/submission-boundary.d.ts +23 -31
  123. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  124. package/dist/mcp/submission-boundary.js +242 -82
  125. package/dist/mcp/submission-boundary.js.map +1 -1
  126. package/dist/mcp/submission-handle.d.ts +66 -0
  127. package/dist/mcp/submission-handle.d.ts.map +1 -0
  128. package/dist/mcp/submission-handle.js +173 -0
  129. package/dist/mcp/submission-handle.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +17 -7
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +763 -244
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/open-question.d.ts +7 -0
  135. package/dist/open-question.d.ts.map +1 -0
  136. package/dist/open-question.js +14 -0
  137. package/dist/open-question.js.map +1 -0
  138. package/dist/policy-metrics.d.ts +31 -21
  139. package/dist/policy-metrics.d.ts.map +1 -1
  140. package/dist/policy-metrics.js +117 -64
  141. package/dist/policy-metrics.js.map +1 -1
  142. package/dist/policy.d.ts +15 -40
  143. package/dist/policy.d.ts.map +1 -1
  144. package/dist/policy.js +13 -13
  145. package/dist/policy.js.map +1 -1
  146. package/dist/progress.d.ts +45 -0
  147. package/dist/progress.d.ts.map +1 -0
  148. package/dist/progress.js +136 -0
  149. package/dist/progress.js.map +1 -0
  150. package/dist/prompt-assembler.d.ts +28 -9
  151. package/dist/prompt-assembler.d.ts.map +1 -1
  152. package/dist/prompt-assembler.js +592 -603
  153. package/dist/prompt-assembler.js.map +1 -1
  154. package/dist/prompt-coverage.d.ts +39 -0
  155. package/dist/prompt-coverage.d.ts.map +1 -0
  156. package/dist/prompt-coverage.js +80 -0
  157. package/dist/prompt-coverage.js.map +1 -0
  158. package/dist/prompt-freshness.d.ts +25 -0
  159. package/dist/prompt-freshness.d.ts.map +1 -0
  160. package/dist/prompt-freshness.js +88 -0
  161. package/dist/prompt-freshness.js.map +1 -0
  162. package/dist/prompt-policy.d.ts +14 -4
  163. package/dist/prompt-policy.d.ts.map +1 -1
  164. package/dist/prompt-policy.js +29 -10
  165. package/dist/prompt-policy.js.map +1 -1
  166. package/dist/protocol.d.ts +875 -419
  167. package/dist/protocol.d.ts.map +1 -1
  168. package/dist/protocol.js +73 -99
  169. package/dist/protocol.js.map +1 -1
  170. package/dist/recovery-guidance.d.ts +13 -0
  171. package/dist/recovery-guidance.d.ts.map +1 -0
  172. package/dist/recovery-guidance.js +15 -0
  173. package/dist/recovery-guidance.js.map +1 -0
  174. package/dist/recovery.d.ts +68 -0
  175. package/dist/recovery.d.ts.map +1 -0
  176. package/dist/recovery.js +234 -0
  177. package/dist/recovery.js.map +1 -0
  178. package/dist/replay.d.ts +42 -8
  179. package/dist/replay.d.ts.map +1 -1
  180. package/dist/replay.js +311 -84
  181. package/dist/replay.js.map +1 -1
  182. package/dist/result-facts.d.ts +45 -0
  183. package/dist/result-facts.d.ts.map +1 -0
  184. package/dist/result-facts.js +86 -0
  185. package/dist/result-facts.js.map +1 -0
  186. package/dist/round-contract.d.ts +38 -8
  187. package/dist/round-contract.d.ts.map +1 -1
  188. package/dist/round-contract.js +71 -31
  189. package/dist/round-contract.js.map +1 -1
  190. package/dist/round-coordinator.d.ts +45 -91
  191. package/dist/round-coordinator.d.ts.map +1 -1
  192. package/dist/round-coordinator.js +254 -271
  193. package/dist/round-coordinator.js.map +1 -1
  194. package/dist/round-driver.d.ts +56 -16
  195. package/dist/round-driver.d.ts.map +1 -1
  196. package/dist/round-driver.js +225 -39
  197. package/dist/round-driver.js.map +1 -1
  198. package/dist/round-facts.d.ts +33 -52
  199. package/dist/round-facts.d.ts.map +1 -1
  200. package/dist/round-facts.js +54 -157
  201. package/dist/round-facts.js.map +1 -1
  202. package/dist/round-guidance.d.ts +56 -0
  203. package/dist/round-guidance.d.ts.map +1 -0
  204. package/dist/round-guidance.js +161 -0
  205. package/dist/round-guidance.js.map +1 -0
  206. package/dist/round-outcome.d.ts +21 -0
  207. package/dist/round-outcome.d.ts.map +1 -0
  208. package/dist/round-outcome.js +31 -0
  209. package/dist/round-outcome.js.map +1 -0
  210. package/dist/round-transaction.d.ts +168 -21
  211. package/dist/round-transaction.d.ts.map +1 -1
  212. package/dist/round-transaction.js +856 -39
  213. package/dist/round-transaction.js.map +1 -1
  214. package/dist/self-eval.d.ts +40 -60
  215. package/dist/self-eval.d.ts.map +1 -1
  216. package/dist/self-eval.js +455 -384
  217. package/dist/self-eval.js.map +1 -1
  218. package/dist/storage.d.ts +16 -0
  219. package/dist/storage.d.ts.map +1 -1
  220. package/dist/storage.js +54 -4
  221. package/dist/storage.js.map +1 -1
  222. package/dist/subgoal-state.d.ts +2 -3
  223. package/dist/subgoal-state.d.ts.map +1 -1
  224. package/dist/subgoal-state.js +2 -3
  225. package/dist/subgoal-state.js.map +1 -1
  226. package/dist/submission-guidance.d.ts +43 -21
  227. package/dist/submission-guidance.d.ts.map +1 -1
  228. package/dist/submission-guidance.js +368 -262
  229. package/dist/submission-guidance.js.map +1 -1
  230. package/dist/submission-receipt.d.ts +16 -8
  231. package/dist/submission-receipt.d.ts.map +1 -1
  232. package/dist/submission-receipt.js +146 -51
  233. package/dist/submission-receipt.js.map +1 -1
  234. package/dist/token-utils.d.ts +47 -7
  235. package/dist/token-utils.d.ts.map +1 -1
  236. package/dist/token-utils.js +66 -15
  237. package/dist/token-utils.js.map +1 -1
  238. package/dist/transaction-identity.d.ts +9 -0
  239. package/dist/transaction-identity.d.ts.map +1 -0
  240. package/dist/transaction-identity.js +69 -0
  241. package/dist/transaction-identity.js.map +1 -0
  242. package/dist/verification-actions.d.ts +13 -0
  243. package/dist/verification-actions.d.ts.map +1 -0
  244. package/dist/verification-actions.js +44 -0
  245. package/dist/verification-actions.js.map +1 -0
  246. package/dist/verification-gate.d.ts +26 -46
  247. package/dist/verification-gate.d.ts.map +1 -1
  248. package/dist/verification-gate.js +218 -427
  249. package/dist/verification-gate.js.map +1 -1
  250. package/dist/version.d.ts +1 -1
  251. package/dist/version.js +1 -1
  252. package/dist/working-memory.d.ts +187 -0
  253. package/dist/working-memory.d.ts.map +1 -0
  254. package/dist/working-memory.js +767 -0
  255. package/dist/working-memory.js.map +1 -0
  256. package/dist/workspace-checkpoint.d.ts +88 -0
  257. package/dist/workspace-checkpoint.d.ts.map +1 -0
  258. package/dist/workspace-checkpoint.js +326 -0
  259. package/dist/workspace-checkpoint.js.map +1 -0
  260. package/dist/workspace-restore.d.ts +68 -0
  261. package/dist/workspace-restore.d.ts.map +1 -0
  262. package/dist/workspace-restore.js +604 -0
  263. package/dist/workspace-restore.js.map +1 -0
  264. package/dist/workspace.d.ts +0 -9
  265. package/dist/workspace.d.ts.map +1 -1
  266. package/dist/workspace.js +0 -17
  267. package/dist/workspace.js.map +1 -1
  268. package/loop_policy.json +5 -19
  269. package/package.json +2 -2
  270. package/skills/forge/SKILL.md +212 -0
  271. package/skills/forge/references/evidence-and-contracts.md +204 -0
  272. package/skills/forge/references/recovery-and-views.md +188 -0
  273. package/dist/cognitive-facts.d.ts +0 -30
  274. package/dist/cognitive-facts.d.ts.map +0 -1
  275. package/dist/cognitive-facts.js +0 -113
  276. package/dist/cognitive-facts.js.map +0 -1
  277. package/dist/loop-extras-parser.d.ts +0 -81
  278. package/dist/loop-extras-parser.d.ts.map +0 -1
  279. package/dist/loop-extras-parser.js +0 -213
  280. package/dist/loop-extras-parser.js.map +0 -1
  281. package/dist/loop-projection.d.ts +0 -6
  282. package/dist/loop-projection.d.ts.map +0 -1
  283. package/dist/loop-projection.js +0 -28
  284. package/dist/loop-projection.js.map +0 -1
  285. package/skills/perception/SKILL.md +0 -370
@@ -15,28 +15,31 @@
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { LoopForgeEngine } from "../engine.js";
17
17
  import { canonicalizeGateAction, deriveGate, preflightStructuredGate, } from "../cognitive-governance.js";
18
- import { makeGateDecision } 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";
18
+ import { makeGateDecision, makeLoopObjective } from "../protocol.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
- import { isRecord } from "../token-utils.js";
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 } 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,76 +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
+ }
225
+ // v3.9.4: normalize initial criteria before any runtime object is created.
226
+ // A structural defect throws StartCriteriaError (mapped to
227
+ // invalid_argument); empty/duplicate/over-cap entries produce warnings.
228
+ const startCriteria = normalizeStartCriteria(input.criteria);
189
229
  const engine = new LoopForgeEngine(this.loopStore);
190
230
  const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
191
- // 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.
192
240
  const request = buildLoopRequest({
193
241
  sessionId, loopId, task: input.task, engine, currentRound: 1,
194
- maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
242
+ maxRounds, status: "running", createdAt: Date.now(),
195
243
  consecutiveRejections: 0,
196
244
  lastRejectionCheck: "",
197
- backtrackSkippedFiles: [],
198
- backtrackSkippedFingerprints: {},
199
- evidenceBaseline: [],
200
245
  });
201
- request.domain = input.domain ?? "";
202
246
  request.plan_source = input.planSource ?? null;
203
247
  request.constraints_from_plan = input.constraints ?? [];
204
- // Explicit context is requested by the MCP embedding; no auto-discovery.
205
- if (this.contextProvider) {
206
- try {
207
- const ctx = {
208
- loopId,
209
- round: 1,
210
- task: input.task,
211
- domain: input.domain ?? "",
212
- };
213
- const rawContext = await this.contextProvider(ctx);
214
- if (rawContext?.trim()) {
215
- request.external_context = rawContext.trim();
216
- logEvent("external_context_loaded", {
217
- loopId, round: 1,
218
- contextLength: request.external_context.length,
219
- });
220
- }
221
- }
222
- catch {
223
- // Provider failures are isolated from session creation.
224
- logEvent("context_provider_error", { loopId, round: 1 });
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();
254
+ }
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);
225
269
  }
270
+ throw error;
226
271
  }
227
- const prepared = await new RoundDriver(engine, this.loopStore).prepare(request, loopId, 1);
228
- const initialPrompt = prepared?.prompt ?? null;
229
- const initialLevel = prepared?.level ?? "l2";
230
- const evidenceBaseline = prepared?.evidenceBaseline ?? [];
272
+ const initialPrompt = prepared.prompt;
273
+ const initialLevel = prepared.level;
231
274
  const session = {
232
275
  sessionId, loopId, task: input.task, engine,
233
- currentRound: 1, maxRounds, successTrajectory: [],
276
+ currentRound: 1, maxRounds,
234
277
  status: "running", createdAt: Date.now(),
235
278
  // v1.13: Enforcement gate state
236
279
  consecutiveRejections: 0,
237
280
  lastRejectionCheck: "",
238
- backtrackSkippedFiles: [],
239
- backtrackSkippedFingerprints: {},
240
- evidenceBaseline,
241
- roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
242
- currentPrompt: initialPrompt,
243
- currentLevel: initialLevel,
244
- currentWarnings: prepared?.warnings ?? [],
245
- lastCompileResponse: prepared?.compileResponse ?? null,
281
+ operatorAcceptance,
282
+ roundSnapshot: prepared.snapshot,
246
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);
247
287
  this.sessions.set(sessionId, session);
248
288
  policyMetrics.recordStrategy(loopId, initialLevel);
249
289
  // Persist to vault for cross-process recovery. The save is the atomic
250
- // lease gate — a TOCTOU window between the pre-flight check above and
251
- // 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.
252
292
  try {
253
- this.lifecycle.save(session);
293
+ this.lifecycle.save(session, { expectAbsent: true });
254
294
  }
255
295
  catch (error) {
256
- // v3.2.1: roll back the in-memory registration — a registered-but-
257
- // unsaved session would make every later create for this loopId
258
- // 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.
259
299
  this.sessions.delete(sessionId);
260
300
  throw error;
261
301
  }
@@ -265,14 +305,37 @@ export class SessionManager {
265
305
  task: input.task.slice(0, 80),
266
306
  maxRounds,
267
307
  });
308
+ const snapshot = session.roundSnapshot;
309
+ const handle = makeSubmissionHandle(session);
268
310
  return {
269
311
  sessionId,
312
+ loopId,
270
313
  round: 1,
271
- 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
+ : {}),
272
334
  prompt: initialPrompt,
273
335
  level: initialLevel,
274
- roundSuccess: false,
275
- warnings: prepared?.warnings ?? [],
336
+ agentSuccessClaim: false,
337
+ agentOutcome: null,
338
+ warnings: [...startCriteria.warnings, ...(prepared.warnings ?? [])],
276
339
  submission_guidance: this.lifecycle.guidanceFor(session),
277
340
  };
278
341
  }
@@ -282,28 +345,55 @@ export class SessionManager {
282
345
  const session = this.sessions.get(sessionId);
283
346
  return session ? this.lifecycle.guidanceFor(session) : null;
284
347
  }
285
- /** v3.9.1: the most recent committed round's receipt, rebuilt from the
286
- * transaction + shared read model. Rejected/terminated attempts are not in
287
- * history; their receipt exists only on their own response. */
288
- getLastSubmissionReceipt(sessionId) {
289
- const session = this.sessions.get(sessionId);
290
- 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)
291
359
  return null;
292
- const prefix = `loop:${session.loopId}:r`;
293
- const entries = [
294
- ...queryLoopEntries(this.loopStore, session.loopId, { prefix }),
295
- ...queryLoopEntries(this.loopStore, session.loopId, {
296
- prefix,
297
- feedbackOnly: true,
298
- }),
299
- ];
300
- const views = readOnlyRounds(entries);
301
- const latest = views[views.length - 1];
302
- 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);
303
381
  }
304
382
  get(sessionId) {
305
383
  return this.sessions.get(sessionId);
306
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
+ }
307
397
  getLeaseStatus(loopId) {
308
398
  const entry = this.findSessionEntry(loopId);
309
399
  if (!entry)
@@ -330,13 +420,13 @@ export class SessionManager {
330
420
  return false;
331
421
  session.status = "stopped";
332
422
  this.lifecycle.save(session);
333
- void this.lifecycle.notifyTerminal(session, "cancelled");
423
+ void this.lifecycle.notifyTerminal(session, MANUAL_STOP_REASON);
334
424
  this.sessions.delete(sessionId);
335
425
  this.sessionQueues.delete(sessionId);
336
426
  logEvent("session_end", {
337
427
  sessionId,
338
428
  loopId: session.loopId,
339
- stopReason: "cancelled",
429
+ stopReason: MANUAL_STOP_REASON,
340
430
  roundsCompleted: session.currentRound,
341
431
  });
342
432
  return true;
@@ -369,38 +459,67 @@ export class SessionManager {
369
459
  * Reconstructs the session and compiles the prompt for the next round
370
460
  * (async — compilation collects evidence through RoundDriver.prepare).
371
461
  * Returns null if no session_state entry exists for this loopId. */
372
- 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);
373
465
  const persistedEntry = this.findSessionEntry(loopId);
374
466
  if (!persistedEntry)
375
467
  return null;
376
468
  const persistedLineage = (persistedEntry.loop_lineage ?? {});
377
469
  const persistedStatus = persistedLineage.status ?? "running";
378
- 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"
379
473
  ? this.claimSessionEntry(loopId)
380
474
  : persistedEntry;
381
475
  if (!sessionEntry)
382
476
  return this.leaseConflictResult(loopId, persistedEntry);
383
477
  const session = this.lifecycle.reconstructSession(sessionEntry);
384
478
  if (!session) {
385
- // 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.
386
481
  const lineage = (sessionEntry.loop_lineage ?? {});
387
482
  const currentRound = lineage.current_round ?? 1;
388
483
  const status = lineage.status ?? "stopped";
389
- return {
390
- sessionId: "",
391
- round: currentRound,
392
- prompt: null,
393
- stopReason: status,
394
- stopDetail: `Loop is not running (status: ${status}). It may have already completed or been stopped.`,
395
- };
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);
396
497
  }
397
- this.sessions.set(session.sessionId, session);
398
- 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));
399
517
  }
400
518
  /** v1.18: Resume a paused session. Reconstructs from vault state and
401
519
  * compiles the next prompt. Returns null if no paused session exists
402
520
  * for this loopId. */
403
- async unpause(loopId) {
521
+ async unpause(loopId, inputs) {
522
+ const normalizedInputs = normalizeInvocationInputs(inputs);
404
523
  const persistedEntry = this.findSessionEntry(loopId);
405
524
  if (!persistedEntry)
406
525
  return null;
@@ -414,10 +533,17 @@ export class SessionManager {
414
533
  const session = this.lifecycle.reconstructSession(sessionEntry, true);
415
534
  if (!session)
416
535
  return null;
417
- // Set to running so advance() works
418
- session.status = "running";
419
- this.sessions.set(session.sessionId, session);
420
- 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));
421
547
  }
422
548
  /** Auto-resume all "running" sessions from vault on server startup.
423
549
  * Scans vault for session_state entries, reconstructs each as an in-memory
@@ -441,7 +567,7 @@ export class SessionManager {
441
567
  continue;
442
568
  const lineage = (entry.loop_lineage ?? {});
443
569
  const status = lineage.status ?? "running";
444
- if (status !== "running" && status !== "paused")
570
+ if (status !== "running" && status !== "paused" && status !== "stalled")
445
571
  continue;
446
572
  const claimedEntry = this.claimSessionEntry(lid);
447
573
  if (!claimedEntry)
@@ -454,6 +580,11 @@ export class SessionManager {
454
580
  session = this.lifecycle.reconstructSession(claimedEntry, true);
455
581
  }
456
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
+ }
457
588
  if (error instanceof StorageCorruptionError && error.recoverable) {
458
589
  logEvent("session_skip_gap", { loopId: lid, error: error.message });
459
590
  continue;
@@ -524,6 +655,25 @@ export class SessionManager {
524
655
  },
525
656
  });
526
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
+ }
527
677
  /** v3.7.1: structured gate preflight. The agent submits a
528
678
  * GateActionDescriptor; the runtime classifies it (conservative:
529
679
  * anything not provably safe is user_required with reason codes). A
@@ -531,49 +681,71 @@ export class SessionManager {
531
681
  * loop + round + actionHash; agent_allowed records nothing and returns
532
682
  * the evidence suggestion instead. No action is executed and no round
533
683
  * advances from this call. */
534
- checkGate(sessionId, roundId, action) {
535
- const session = this.sessions.get(sessionId);
536
- 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.";
537
689
  return {
538
- error: "session_not_found",
539
- errorMessage: `session not found: ${sessionId}`,
540
- 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 },
541
693
  };
542
694
  }
543
- const expectedRoundId = session.roundSnapshot?.roundId
544
- ?? makeRoundId(session.loopId, session.currentRound);
545
- if (expectedRoundId !== roundId) {
695
+ const session = this.sessions.get(parsed.sessionId);
696
+ if (!session) {
546
697
  return {
547
- error: "round_id_mismatch",
548
- errorMessage: "roundId does not match the current round — re-run loopforge_gate_check " +
549
- "with the roundId from the latest response",
550
- sessionId,
551
- roundId,
698
+ error: "session_not_found",
699
+ errorMessage: `session not found: ${parsed.sessionId}`,
700
+ sessionId: parsed.sessionId,
552
701
  };
553
702
  }
554
- const verdict = preflightStructuredGate(action);
555
- if (verdict.kind === "user") {
556
- this.recordOpenedGate(session.loopId, session.currentRound, verdict, canonicalizeGateAction(action));
703
+ if (session.status !== "running") {
704
+ return this.gateInvalidResult(session);
557
705
  }
558
- return {
559
- sessionId,
560
- gateId: verdict.gateId,
561
- risk: verdict.risk,
562
- decision: verdict.decision,
563
- reasonCodes: verdict.reasonCodes,
564
- ...(verdict.kind === "user"
565
- ? {
566
- blockedScope: verdict.blockedScope,
567
- allowedBeforeApproval: verdict.allowedBeforeApproval,
568
- requiredEvidence: [],
569
- approvalQuestion: verdict.approvalQuestion,
570
- }
571
- : {
572
- blockedScope: [],
573
- allowedBeforeApproval: [],
574
- requiredEvidence: verdict.requiredEvidence,
575
- }),
576
- };
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
+ });
577
749
  }
578
750
  /** Record a user decision for a recorded gate. The gateId embeds the
579
751
  * canonicalized action hash — if the action changed, the match fails and
@@ -621,8 +793,9 @@ export class SessionManager {
621
793
  };
622
794
  }
623
795
  try {
624
- const prefix = `loop:${loopId}:gate:`;
625
- 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))
626
799
  .find((entry) => entry.task_type === "gate_opened" && entry.gate_id === gateId);
627
800
  if (!opened) {
628
801
  return {
@@ -725,79 +898,51 @@ export class SessionManager {
725
898
  * the runtime's submission boundary reads the same context (the sub-goal
726
899
  * reference space is exactly the compiled set the prompt carried), and two
727
900
  * copies would let the projection and the boundary disagree about it. */
728
- compileContext(session) {
729
- return compileSessionContext(session);
730
- }
731
- /** Typed cognitive state projection for an active session. Derived on
732
- * demand — zero persistence. Null when nothing meaningful exists yet. */
733
- getProjection(sessionId) {
734
- const session = this.sessions.get(sessionId);
735
- 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)
736
912
  return null;
737
- const prefix = `loop:${session.loopId}:`;
738
- const entries = [
739
- ...queryLoopEntries(this.loopStore, session.loopId, { prefix }),
740
- ...queryLoopEntries(this.loopStore, session.loopId, { prefix, feedbackOnly: true }),
741
- ];
742
- const compileResponse = this.compileContext(session);
743
- const openGates = this.lifecycle.listOpenGateDescriptions(session.loopId);
744
- // v3.8: the projection reads the WHOLE committed history. The old
745
- // `beforeRound: session.currentRound` bound is only correct while the loop
746
- // is running, where currentRound is always one past the last committed
747
- // round; a stop / terminate / max_rounds leaves currentRound ON the round
748
- // it just committed, so the bound silently dropped the loop's final round
749
- // and the projection disagreed with audit and explain about it.
750
- // v3.8.1: one window, one decode, one derivation. The contract facts come
751
- // from the same `deriveRoundFacts` the compile path calls, so the prompt
752
- // and the projection cannot tell different stories about what the machine
753
- // verified.
754
- const rounds = readOnlyRounds(entries);
755
- const projection = buildLoopProjection(deriveCognitiveFacts({
756
- compileResponse,
757
- rounds,
758
- facts: deriveRoundFacts({
759
- rounds,
760
- currentRound: (rounds[rounds.length - 1]?.round ?? 0) + 1,
761
- inFlight: NO_IN_FLIGHT_ROUND,
762
- subGoals: compileResponse?.sub_goals ?? [],
763
- commands: getPolicy().evidence.commands ?? [],
764
- }),
765
- verifiedClaims: listVerifiedClaims(entries, session.loopId),
766
- openGates,
767
- }));
768
- 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);
769
920
  }
770
921
  /** v3.8: Read-only per-round "why" view over committed facts. Never
771
922
  * rebuilds history and never writes. */
772
923
  getExplain(loopId, round) {
773
924
  validateLoopId(loopId);
774
- const prefix = `loop:${loopId}:`;
775
- const entries = [
776
- ...queryLoopEntries(this.loopStore, loopId, { prefix }),
777
- ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
778
- ];
779
- 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);
780
929
  }
781
930
  /** Read-only end-of-loop audit (verification view). Never writes. */
782
931
  getAudit(loopId) {
783
932
  validateLoopId(loopId);
784
- // v3.3.1: audit judges COMMITTED decisions, which live on :feedback
785
- // entries — but queryLoopEntries excludes feedback by default, so the
786
- // audit view structurally never saw a committed round (it always
787
- // reported "passed" on empty history). Merge both result sets like the
788
- // round coordinator does.
789
- const prefix = `loop:${loopId}:`;
790
- const entries = [
791
- ...queryLoopEntries(this.loopStore, loopId, { prefix }),
792
- ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
793
- ];
794
- // Zero committed decisions → nothing to audit. Returning null lets the
795
- // tools layer report "no audit data" instead of the external auditor
796
- // solemnly passing a loop that never ran (or a mistyped loopId).
797
- const hasCommittedDecision = readOnlyRounds(entries).length > 0;
798
- 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) {
799
943
  return null;
800
- const audit = buildAudit(loopId, entries, this.loopStore);
944
+ }
945
+ const audit = buildAudit(loopId, history.entries, this.loopStore);
801
946
  return { ...audit };
802
947
  }
803
948
  /** v3.5: The ACTIVE Round Contract governing the session's next round —
@@ -815,16 +960,10 @@ export class SessionManager {
815
960
  * statistics (A4 port) folded with this process's live observations.
816
961
  * Non-durable fields (evidence, vault errors) come from live only. */
817
962
  getPolicyMetrics(loopId) {
818
- // v3.3.1: derived metrics judge committed decisions on :feedback entries,
819
- // which queryLoopEntries excludes by default — merge both result sets so
820
- // restart-surviving metrics actually see the committed rounds (before,
821
- // derived.committedRounds was structurally 0 and the merge fell back to
822
- // the live-only snapshot every time).
823
- const prefix = `loop:${loopId}:`;
824
- const entries = [
825
- ...queryLoopEntries(this.loopStore, loopId, { prefix }),
826
- ...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
827
- ];
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;
828
967
  const derived = derivePolicyMetrics(loopId, entries);
829
968
  const live = policyMetrics.snapshot(loopId);
830
969
  if (derived.committedRounds === 0)
@@ -842,20 +981,32 @@ export class SessionManager {
842
981
  * `strategy_stability` was a literal `true`. What remains is counted
843
982
  * directly from committed round flags. */
844
983
  getHealth(loopId) {
845
- const engine = new LoopForgeEngine(this.loopStore);
846
- const context = engine.hydrateLoopContext(loopId);
847
- // No hydratable context at all → unknown loop. A started-but-uncommitted
848
- // loop DOES have a context (the compile persists a lineage entry), so it
849
- // still gets a view — with zeros — exactly as it did before.
850
- 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)
851
988
  return null;
852
- const entries = Array.isArray(context.results) ? context.results : [];
853
- const views = readOnlyRounds(entries);
989
+ const views = history.rounds;
854
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
+ }
855
1004
  return {
856
1005
  loopId,
857
1006
  committed_rounds: views.length,
1007
+ legacy_rounds: history.legacyRounds.length,
858
1008
  rounds_with_unverified_items: roundsWithUnverifiedItems,
1009
+ rounds_by_evidence_status: roundsByEvidenceStatus,
859
1010
  // The policy value the enforcement gate escalates on. The streak itself
860
1011
  // is derived in the gate — re-deriving it here would be a second
861
1012
  // implementation of the same rule, which is what this release removes.
@@ -863,53 +1014,231 @@ export class SessionManager {
863
1014
  policy_metrics: this.getPolicyMetrics(loopId),
864
1015
  };
865
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
+ }
866
1130
  /** Core cycle: extract self-eval → record feedback → check stop → compile next.
867
- * The lease + per-session queue wrap the RoundLifecycle state machine.
868
- * @param preExtractedEval Structured SelfEvaluation supplied by the caller.
869
- * An absent value is returned as evaluation_invalid without mutating state.
870
- * @param roundId v3.0.1: The roundId of the round this submission reports on
871
- * (from the last start/next/resume response). Anchors the submission so a
872
- * stale or duplicate submission is not processed against a later round.
873
- * Optional for library callers — when absent the anchor check is skipped. */
874
- /** \`submission\` is the RAW evaluation payload, not a built one.
875
1131
  *
876
- * v3.8.1: this method used to carry a partial copy of the submission
877
- * boundary (an object check plus core-field validation) while the MCP tool
878
- * handler carried the rest — one rule, three copies, across two layers.
879
- * The boundary now lives entirely in the runtime
880
- * (`RoundLifecycle.submissionBoundary`), so every caller — this manager, a
881
- * library user, a future CLI command — gets the same strictness. */
882
- /** \`submission\` is the RAW evaluation payload, not a built one.
883
- *
884
- * v3.8.1: this method used to carry a partial copy of the submission
885
- * boundary (an object check plus core-field validation) while the MCP tool
886
- * handler carried the rest — one rule, three copies, across two layers.
887
- * The whole boundary now runs HERE, before the queue and before the lease
888
- * heartbeat, so every caller of the public API gets it and a rejected
889
- * payload leaves nothing durable behind. */
890
- async advance(sessionId, output, submission, roundId) {
891
- // Judged before the session is even resolved: a malformed evaluation is a
892
- // payload defect whatever the session's state. See submission-boundary.ts.
893
- 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);
894
1157
  if (!boundary.ok) {
1158
+ const current = makeSubmissionHandle(session);
895
1159
  return {
896
- sessionId,
897
- round: this.sessions.get(sessionId)?.currentRound ?? 0,
898
- ...(roundId !== undefined ? { roundId } : {}),
1160
+ sessionId: session.sessionId,
1161
+ round: session.currentRound,
1162
+ ...(current ? { submissionHandle: current } : {}),
899
1163
  prompt: null,
900
- stopReason: boundary.submissionError.code,
901
1164
  stopDetail: boundary.stopDetail,
902
1165
  submissionError: boundary.submissionError,
903
1166
  };
904
1167
  }
905
- return this.withSessionQueue(sessionId, async () => {
906
- const session = this.sessions.get(sessionId);
907
- if (session?.status === "running" &&
908
- !this.renewSessionLease(session.loopId)) {
909
- 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));
910
1239
  }
911
1240
  try {
912
- return await this.lifecycle.advance(sessionId, output, boundary.evaluation, roundId, boundary.diagnostics, boundary.submittedFields);
1241
+ return await this.lifecycle.recompileHeldWithInstruction(current, normalizedInputs.currentUserInstruction);
913
1242
  }
914
1243
  catch (error) {
915
1244
  if (error instanceof SessionLeaseConflictError) {
@@ -919,6 +1248,330 @@ export class SessionManager {
919
1248
  }
920
1249
  });
921
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
+ }
922
1575
  /** Replay timeline for a session — creates ReplayBackend from the stored backend. */
923
1576
  replayTimeline(sessionId) {
924
1577
  const session = this.sessions.get(sessionId);
@@ -938,6 +1591,45 @@ export class SessionManager {
938
1591
  const timeline = replay.timeline(loopId);
939
1592
  return timeline.length > 0 ? timeline : null;
940
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
+ }
941
1633
  /** v3.9.1: unsupported envelope versions for a loop, reported explicitly
942
1634
  * rather than silently dropped from replay/audit. */
943
1635
  replayLegacyRounds(loopId) {