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