loopforge 2.11.0 → 3.2.1
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 +4 -4
- package/dist/audit.d.ts +50 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +158 -0
- package/dist/audit.js.map +1 -0
- package/dist/canonical-state.d.ts +21 -9
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +34 -16
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -6
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-governance.d.ts +40 -0
- package/dist/cognitive-governance.d.ts.map +1 -0
- package/dist/cognitive-governance.js +126 -0
- package/dist/cognitive-governance.js.map +1 -0
- package/dist/enforcement-gate.d.ts +8 -3
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +419 -92
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts +47 -7
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +303 -60
- package/dist/engine.js.map +1 -1
- package/dist/evidence-claims.d.ts +50 -0
- package/dist/evidence-claims.d.ts.map +1 -0
- package/dist/evidence-claims.js +134 -0
- package/dist/evidence-claims.js.map +1 -0
- package/dist/generate-schema.js +6 -1
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +41 -3
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +310 -38
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-projection.d.ts +26 -0
- package/dist/loop-projection.d.ts.map +1 -0
- package/dist/loop-projection.js +142 -0
- package/dist/loop-projection.js.map +1 -0
- package/dist/loop-store.d.ts +91 -41
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +243 -173
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +216 -0
- package/dist/mcp/round-lifecycle.d.ts.map +1 -0
- package/dist/mcp/round-lifecycle.js +926 -0
- package/dist/mcp/round-lifecycle.js.map +1 -0
- package/dist/mcp/server.d.ts +3 -2
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +65 -9
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +47 -139
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +284 -784
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +23 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +270 -79
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +11 -0
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +118 -21
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +26 -1
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +13 -2
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +34 -1
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +320 -42
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/protocol.d.ts +169 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +39 -0
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts +5 -5
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +9 -8
- package/dist/replay.js.map +1 -1
- package/dist/round-coordinator.d.ts +6 -3
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +38 -19
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +9 -4
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +15 -7
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +7 -3
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +93 -88
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +31 -1
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +115 -12
- package/dist/self-eval.js.map +1 -1
- package/dist/storage.d.ts +3 -4
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +5 -4
- package/dist/storage.js.map +1 -1
- package/dist/token-utils.d.ts +20 -7
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +68 -9
- package/dist/token-utils.js.map +1 -1
- package/dist/verification-gate.d.ts +54 -2
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +433 -25
- package/dist/verification-gate.js.map +1 -1
- package/loop_policy.json +35 -8
- package/package.json +3 -2
- package/skills/perception/SKILL.md +24 -6
- package/dist/backends/interface.d.ts +0 -39
- package/dist/backends/interface.d.ts.map +0 -1
- package/dist/backends/interface.js +0 -6
- package/dist/backends/interface.js.map +0 -1
package/dist/mcp/session.js
CHANGED
|
@@ -3,103 +3,64 @@
|
|
|
3
3
|
* Each McpSession = one complete multi-round loop.
|
|
4
4
|
* SessionManager holds Map<sessionId, McpSession> and drives
|
|
5
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
15
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { LoopForgeEngine
|
|
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 { listVerifiedClaims } from "../evidence-claims.js";
|
|
21
|
+
import { buildAudit } from "../audit.js";
|
|
9
22
|
import { checkLoopHealth } from "../loop-compiler.js";
|
|
10
|
-
import { getPolicy } from "../policy.js";
|
|
11
|
-
import {
|
|
23
|
+
import { getPolicy, validateLoopId } from "../policy.js";
|
|
24
|
+
import { makeLoopCompileRequest } from "../protocol.js";
|
|
12
25
|
import { ReplayBackend } from "../replay.js";
|
|
13
|
-
import { FileLoopStore,
|
|
14
|
-
import {
|
|
15
|
-
import { parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
|
|
26
|
+
import { FileLoopStore, queryLoopEntries } from "../loop-store.js";
|
|
27
|
+
import { makeRoundId, prepareRoundTransaction } from "../round-transaction.js";
|
|
16
28
|
import { RoundDriver } from "../round-driver.js";
|
|
17
29
|
import { logEvent } from "../observability.js";
|
|
18
|
-
import { policyMetrics } from "../policy-metrics.js";
|
|
30
|
+
import { policyMetrics, derivePolicyMetrics, mergePolicyMetrics, } from "../policy-metrics.js";
|
|
19
31
|
import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const req = {
|
|
23
|
-
task: session.task,
|
|
24
|
-
mode: Mode.LOOP_COMPILE,
|
|
25
|
-
feedback: null,
|
|
26
|
-
skill_name: null,
|
|
27
|
-
task_id: null,
|
|
28
|
-
loop_id: session.loopId,
|
|
29
|
-
round: session.currentRound,
|
|
30
|
-
max_rounds: session.maxRounds,
|
|
31
|
-
verification_flags: verificationFlags ?? [],
|
|
32
|
-
};
|
|
33
|
-
if (lastEval) {
|
|
34
|
-
req.last_round_result = makeLoopRoundResult({
|
|
35
|
-
round: session.currentRound - 1,
|
|
36
|
-
success: lastEval.success,
|
|
37
|
-
output_summary: lastEval.output_summary,
|
|
38
|
-
constraint_violations: lastEval.constraint_violations,
|
|
39
|
-
manual_fixes_needed: "",
|
|
40
|
-
// P0–P2: Forward evolution fields to next compile
|
|
41
|
-
// Merge sub-agent discovered constraints into the active set
|
|
42
|
-
discovered_constraints: [
|
|
43
|
-
...new Set([
|
|
44
|
-
...(lastEval.discovered_constraints ?? []),
|
|
45
|
-
...(lastEval.worker_results ?? []).flatMap((w) => w.discoveredConstraints ?? []).filter((c) => c.length > 0),
|
|
46
|
-
]),
|
|
47
|
-
],
|
|
48
|
-
objective_refinement: lastEval.objective_refinement ?? "",
|
|
49
|
-
emerged_subtasks: lastEval.emerged_subtasks ?? [],
|
|
50
|
-
// P4: Execution evidence
|
|
51
|
-
execution_evidence: lastEval.execution_evidence ?? undefined,
|
|
52
|
-
// P5: Self-correction
|
|
53
|
-
retracted_constraints: lastEval.retracted_constraints ?? [],
|
|
54
|
-
revised_success_criteria: lastEval.revised_success_criteria ?? [],
|
|
55
|
-
wrong_assumptions: lastEval.wrong_assumptions ?? [],
|
|
56
|
-
// Multi-agent: Forward delegation results to next compile
|
|
57
|
-
worker_results: lastEval.worker_results ?? [],
|
|
58
|
-
// v1.10: Checkpoint boundary
|
|
59
|
-
compression_checkpoint: lastEval.compression_checkpoint ?? false,
|
|
60
|
-
checkpoint_label: lastEval.checkpoint_label ?? "",
|
|
61
|
-
// v1.16: Agent's declared next action
|
|
62
|
-
next_action: lastEval.next_action,
|
|
63
|
-
// v2.2: Sub-goal lifecycle
|
|
64
|
-
completed_subtasks: lastEval.completed_subtasks ?? [],
|
|
65
|
-
blocked_subtasks: lastEval.blocked_subtasks ?? [],
|
|
66
|
-
canceled_subtasks: lastEval.canceled_subtasks ?? [],
|
|
67
|
-
// v2.8: Drift clarification
|
|
68
|
-
drift_clarification: lastEval.drift_clarification,
|
|
69
|
-
// v2.9: Model's information needs for the next prompt
|
|
70
|
-
prompt_requests: lastEval.prompt_requests,
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
return req;
|
|
74
|
-
}
|
|
32
|
+
import { StorageCorruptionError } from "../loop-store.js";
|
|
33
|
+
import { RoundLifecycle, buildLoopRequest } from "./round-lifecycle.js";
|
|
75
34
|
// ── SessionManager ─────────────────────────────────────────────────────────
|
|
76
35
|
export class SessionManager {
|
|
77
36
|
sessions = new Map();
|
|
78
37
|
/** Serializes state transitions for each session. */
|
|
79
38
|
sessionQueues = new Map();
|
|
80
|
-
|
|
39
|
+
loopStore;
|
|
81
40
|
sessionStore;
|
|
82
41
|
ownerId = `${process.pid}:${randomUUID()}`;
|
|
83
42
|
leaseMs;
|
|
84
43
|
leaseRenewIntervalMs;
|
|
85
44
|
leaseTimer = null;
|
|
45
|
+
lifecycle;
|
|
86
46
|
/** Explicit context provider; never auto-discovered. */
|
|
87
47
|
contextProvider;
|
|
88
48
|
terminalSinks = new Set();
|
|
89
|
-
constructor(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
? isLoopStore(storeOrBackend)
|
|
93
|
-
? storeOrBackend
|
|
94
|
-
: new VaultBackendLoopStore(storeOrBackend)
|
|
95
|
-
: new FileLoopStore(getPolicy().backend.root_dir);
|
|
96
|
-
this.backend = storeOrBackend && !isLoopStore(storeOrBackend)
|
|
97
|
-
? storeOrBackend
|
|
98
|
-
: new LoopStoreBackend(store);
|
|
99
|
-
this.sessionStore = sessionStore ?? new VaultSessionStateStore(store);
|
|
49
|
+
constructor(store, sessionStore) {
|
|
50
|
+
this.loopStore = store ?? new FileLoopStore(getPolicy().backend.root_dir);
|
|
51
|
+
this.sessionStore = sessionStore ?? new VaultSessionStateStore(this.loopStore);
|
|
100
52
|
const mcpPolicy = getPolicy().mcp;
|
|
101
53
|
this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
|
|
102
54
|
this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
|
|
55
|
+
this.lifecycle = new RoundLifecycle({
|
|
56
|
+
store: this.loopStore,
|
|
57
|
+
sessionStore: this.sessionStore,
|
|
58
|
+
registry: this,
|
|
59
|
+
terminalSinks: this.terminalSinks,
|
|
60
|
+
ownerId: this.ownerId,
|
|
61
|
+
leaseMs: this.leaseMs,
|
|
62
|
+
getContext: () => this.contextProvider,
|
|
63
|
+
});
|
|
103
64
|
if (this.sessionStore?.renewLease) {
|
|
104
65
|
this.leaseTimer = setInterval(() => this.renewOwnedLeases(), this.leaseRenewIntervalMs);
|
|
105
66
|
this.leaseTimer.unref?.();
|
|
@@ -122,6 +83,15 @@ export class SessionManager {
|
|
|
122
83
|
this.sessionStore?.releaseLease?.(session.loopId, this.ownerId);
|
|
123
84
|
}
|
|
124
85
|
}
|
|
86
|
+
// ── SessionRegistry (view for RoundLifecycle) ────────────────────────────
|
|
87
|
+
values() {
|
|
88
|
+
return this.sessions.values();
|
|
89
|
+
}
|
|
90
|
+
/** Register (or replace) a session in the in-memory registry. */
|
|
91
|
+
upsert(session) {
|
|
92
|
+
this.sessions.set(session.sessionId, session);
|
|
93
|
+
}
|
|
94
|
+
// ── Lease helpers ────────────────────────────────────────────────────────
|
|
125
95
|
findSessionEntry(loopId) {
|
|
126
96
|
return this.sessionStore?.load(loopId);
|
|
127
97
|
}
|
|
@@ -176,10 +146,41 @@ export class SessionManager {
|
|
|
176
146
|
}
|
|
177
147
|
}
|
|
178
148
|
}
|
|
149
|
+
// ── Lifecycle entry points ───────────────────────────────────────────────
|
|
179
150
|
async create(input) {
|
|
180
151
|
const sessionId = randomUUID();
|
|
181
152
|
const loopId = input.loopId ?? randomUUID();
|
|
182
|
-
|
|
153
|
+
// v2.14: same-process duplicate guard. Cross-process duplicates are
|
|
154
|
+
// fenced by the lease (SessionLeaseConflictError on save); within one
|
|
155
|
+
// process the lease owner is identical, so a second create for the
|
|
156
|
+
// same loopId would silently overwrite the first session's persisted
|
|
157
|
+
// state and leave two in-memory sessions pointing at one loop.
|
|
158
|
+
const existing = [...this.sessions.values()].find((s) => s.loopId === loopId);
|
|
159
|
+
if (existing) {
|
|
160
|
+
return {
|
|
161
|
+
sessionId: "",
|
|
162
|
+
round: existing.currentRound,
|
|
163
|
+
prompt: null,
|
|
164
|
+
stopReason: `loop_already_running:${loopId}`,
|
|
165
|
+
stopDetail: `A session for loop "${loopId}" already exists in this process (session ${existing.sessionId}). Inspect or resume it instead of starting a duplicate.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// v3.2.1: pre-flight the cross-process lease BEFORE compiling — the
|
|
169
|
+
// compile persists round-1 lineage and the state file into the loop's
|
|
170
|
+
// vault, which would corrupt another process's live loop (round-1
|
|
171
|
+
// lineage replaced, metadata overwritten). Mirrors the save-time check
|
|
172
|
+
// (VaultSessionStateStore.save: a non-empty lease_owner that is not ours
|
|
173
|
+
// conflicts), so observable behavior is unchanged — only the side
|
|
174
|
+
// effects are avoided. The save below remains the atomic final gate.
|
|
175
|
+
const persistedSession = this.sessionStore?.load(loopId);
|
|
176
|
+
const persistedLineage = persistedSession?.loop_lineage;
|
|
177
|
+
const persistedOwner = persistedLineage && typeof persistedLineage === "object" && !Array.isArray(persistedLineage)
|
|
178
|
+
? persistedLineage.lease_owner
|
|
179
|
+
: "";
|
|
180
|
+
if (typeof persistedOwner === "string" && persistedOwner && persistedOwner !== this.ownerId) {
|
|
181
|
+
throw new SessionLeaseConflictError(loopId);
|
|
182
|
+
}
|
|
183
|
+
const engine = new LoopForgeEngine(this.loopStore);
|
|
183
184
|
const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
|
|
184
185
|
// Populate extra fields for the first round
|
|
185
186
|
const request = buildLoopRequest({
|
|
@@ -217,7 +218,7 @@ export class SessionManager {
|
|
|
217
218
|
logEvent("context_provider_error", { loopId, round: 1 });
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
|
-
const prepared = await new RoundDriver(engine, this.
|
|
221
|
+
const prepared = await new RoundDriver(engine, this.loopStore).prepare(request, loopId, 1);
|
|
221
222
|
const initialPrompt = prepared?.prompt ?? null;
|
|
222
223
|
const initialLevel = prepared?.level ?? "l2";
|
|
223
224
|
const evidenceBaseline = prepared?.evidenceBaseline ?? [];
|
|
@@ -235,11 +236,23 @@ export class SessionManager {
|
|
|
235
236
|
currentPrompt: initialPrompt,
|
|
236
237
|
currentLevel: initialLevel,
|
|
237
238
|
currentWarnings: prepared?.warnings ?? [],
|
|
239
|
+
lastCompileResponse: prepared?.compileResponse ?? null,
|
|
238
240
|
};
|
|
239
241
|
this.sessions.set(sessionId, session);
|
|
240
242
|
policyMetrics.recordStrategy(loopId, initialLevel);
|
|
241
|
-
// Persist to vault for cross-process recovery
|
|
242
|
-
|
|
243
|
+
// Persist to vault for cross-process recovery. The save is the atomic
|
|
244
|
+
// lease gate — a TOCTOU window between the pre-flight check above and
|
|
245
|
+
// this write can still lose the lease race to another process.
|
|
246
|
+
try {
|
|
247
|
+
this.lifecycle.save(session);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
// v3.2.1: roll back the in-memory registration — a registered-but-
|
|
251
|
+
// unsaved session would make every later create for this loopId
|
|
252
|
+
// return loop_already_running until this process restarts.
|
|
253
|
+
this.sessions.delete(sessionId);
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
243
256
|
logEvent("session_start", {
|
|
244
257
|
sessionId,
|
|
245
258
|
loopId,
|
|
@@ -284,8 +297,8 @@ export class SessionManager {
|
|
|
284
297
|
if (!session)
|
|
285
298
|
return false;
|
|
286
299
|
session.status = "stopped";
|
|
287
|
-
this.save(session);
|
|
288
|
-
void this.notifyTerminal(session, "cancelled");
|
|
300
|
+
this.lifecycle.save(session);
|
|
301
|
+
void this.lifecycle.notifyTerminal(session, "cancelled");
|
|
289
302
|
this.sessions.delete(sessionId);
|
|
290
303
|
this.sessionQueues.delete(sessionId);
|
|
291
304
|
logEvent("session_end", {
|
|
@@ -307,7 +320,7 @@ export class SessionManager {
|
|
|
307
320
|
return { sessionId, round: session.currentRound, status: session.status };
|
|
308
321
|
}
|
|
309
322
|
session.status = "paused";
|
|
310
|
-
this.save(session);
|
|
323
|
+
this.lifecycle.save(session);
|
|
311
324
|
logEvent("session_paused", {
|
|
312
325
|
sessionId,
|
|
313
326
|
loopId: session.loopId,
|
|
@@ -315,308 +328,10 @@ export class SessionManager {
|
|
|
315
328
|
});
|
|
316
329
|
return { sessionId, round: session.currentRound, status: "paused" };
|
|
317
330
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
return null;
|
|
321
|
-
return {
|
|
322
|
-
sessionId: session.sessionId,
|
|
323
|
-
round: session.currentRound,
|
|
324
|
-
roundId: session.roundSnapshot?.roundId,
|
|
325
|
-
prompt: session.currentPrompt,
|
|
326
|
-
level: session.currentLevel ?? "l2",
|
|
327
|
-
roundSuccess: undefined,
|
|
328
|
-
warnings: (session.currentWarnings ?? []),
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
/** Reconcile the crash window where feedback committed but session_state
|
|
332
|
-
* still points at the old prompt. Returns null when no commit is pending. */
|
|
333
|
-
reconcileCommittedRound(session) {
|
|
334
|
-
if (!session.roundSnapshot)
|
|
335
|
-
return null;
|
|
336
|
-
const recovered = new RoundDriver(session.engine, this.backend).recover(session.roundSnapshot);
|
|
337
|
-
if (!recovered)
|
|
338
|
-
return null;
|
|
339
|
-
const pr = recovered.result;
|
|
340
|
-
session.roundSnapshot = recovered.snapshot;
|
|
341
|
-
// Replay the committed counter — but with per-rule tracking.
|
|
342
|
-
if (pr.action === "reject" && pr.rejectionCheck) {
|
|
343
|
-
session.consecutiveRejections =
|
|
344
|
-
pr.rejectionCheck === session.lastRejectionCheck
|
|
345
|
-
? pr.newConsecutiveRejections
|
|
346
|
-
: 1;
|
|
347
|
-
session.lastRejectionCheck = pr.rejectionCheck;
|
|
348
|
-
}
|
|
349
|
-
else {
|
|
350
|
-
session.consecutiveRejections = pr.newConsecutiveRejections;
|
|
351
|
-
if (pr.action !== "reject")
|
|
352
|
-
session.lastRejectionCheck = "";
|
|
353
|
-
}
|
|
354
|
-
if (pr.newLastSelfEval)
|
|
355
|
-
session.lastSelfEval = pr.newLastSelfEval;
|
|
356
|
-
if (pr.shouldPushSuccessTrajectory &&
|
|
357
|
-
session.successTrajectory.length < session.currentRound) {
|
|
358
|
-
session.successTrajectory.push(pr.roundSuccess);
|
|
359
|
-
}
|
|
360
|
-
session.currentPrompt = null;
|
|
361
|
-
if (pr.action === "stop" || pr.action === "terminate") {
|
|
362
|
-
const reason = pr.action === "terminate"
|
|
363
|
-
? "enforcement_terminated"
|
|
364
|
-
: pr.stopReason ?? "stalled";
|
|
365
|
-
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
366
|
-
this.save(session);
|
|
367
|
-
void this.notifyTerminal(session, reason);
|
|
368
|
-
return {
|
|
369
|
-
sessionId: session.sessionId,
|
|
370
|
-
round: session.currentRound,
|
|
371
|
-
roundId: recovered.snapshot.roundId,
|
|
372
|
-
prompt: null,
|
|
373
|
-
stopReason: reason,
|
|
374
|
-
stopDetail: reason === "completed"
|
|
375
|
-
? "Task completed successfully — the agent declared should_continue=false with success=true."
|
|
376
|
-
: reason === "blocked"
|
|
377
|
-
? "Agent cannot proceed under current constraints. The task may need revised constraints or human intervention."
|
|
378
|
-
: reason === "failed"
|
|
379
|
-
? "Agent declared should_continue=false with success=false — the task was not achieved."
|
|
380
|
-
: `Loop stopped: ${reason}.`,
|
|
381
|
-
roundSuccess: pr.roundSuccess,
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
if (pr.action === "backtrack") {
|
|
385
|
-
// Backtrack committed but prompt not delivered (crash window).
|
|
386
|
-
// Reset round counter to the restore target + 1 so the next
|
|
387
|
-
// resume / unpause compiles from the correct restored state.
|
|
388
|
-
const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
|
|
389
|
-
session.currentRound = restoreTarget + 1;
|
|
390
|
-
session.consecutiveRejections = 0;
|
|
391
|
-
session.lastRejectionCheck = "";
|
|
392
|
-
session.driftClarificationStreak = 0;
|
|
393
|
-
session.lastSelfEval = undefined;
|
|
394
|
-
session.currentPrompt = null;
|
|
395
|
-
this.save(session);
|
|
396
|
-
// Return null — caller (resume/unpause) will recompile normally
|
|
397
|
-
return null;
|
|
398
|
-
}
|
|
399
|
-
if (pr.action !== "continue")
|
|
400
|
-
return null;
|
|
401
|
-
session.currentRound++;
|
|
402
|
-
const request = buildLoopRequest(session, pr.newLastSelfEval, pr.verificationFlags);
|
|
403
|
-
const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
|
|
404
|
-
if (!prepared) {
|
|
405
|
-
session.status = "stalled";
|
|
406
|
-
this.save(session);
|
|
407
|
-
return {
|
|
408
|
-
sessionId: session.sessionId,
|
|
409
|
-
round: session.currentRound,
|
|
410
|
-
prompt: null,
|
|
411
|
-
stopReason: "stalled",
|
|
412
|
-
stopDetail: "RoundDriver.prepareSync returned null — prompt compilation failed during crash recovery. The session state may be inconsistent.",
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
const prompt = prepared.prompt;
|
|
416
|
-
session.evidenceBaseline = prepared.evidenceBaseline;
|
|
417
|
-
session.roundSnapshot = prepared.snapshot;
|
|
418
|
-
session.currentPrompt = prompt;
|
|
419
|
-
session.currentLevel = prepared.level;
|
|
420
|
-
session.currentWarnings = prepared?.warnings ?? [];
|
|
421
|
-
policyMetrics.recordStrategy(session.loopId, session.currentLevel);
|
|
422
|
-
this.save(session);
|
|
423
|
-
return {
|
|
424
|
-
sessionId: session.sessionId,
|
|
425
|
-
round: session.currentRound,
|
|
426
|
-
roundId: session.roundSnapshot.roundId,
|
|
427
|
-
prompt,
|
|
428
|
-
level: session.currentLevel,
|
|
429
|
-
roundSuccess: pr.roundSuccess,
|
|
430
|
-
warnings: (session.currentWarnings ?? []),
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
/** v1.18: Resume a paused session. Reconstructs from vault state and
|
|
434
|
-
* compiles the next prompt. Returns null if no paused session exists
|
|
435
|
-
* for this loopId. */
|
|
436
|
-
async unpause(loopId) {
|
|
437
|
-
const persistedEntry = this.findSessionEntry(loopId);
|
|
438
|
-
if (!persistedEntry)
|
|
439
|
-
return null;
|
|
440
|
-
const lineage = (persistedEntry.loop_lineage ?? {});
|
|
441
|
-
const status = lineage.status ?? "running";
|
|
442
|
-
if (status !== "paused")
|
|
443
|
-
return null;
|
|
444
|
-
const sessionEntry = this.claimSessionEntry(loopId);
|
|
445
|
-
if (!sessionEntry)
|
|
446
|
-
return this.leaseConflictResult(loopId, persistedEntry);
|
|
447
|
-
const session = this.reconstructSession(sessionEntry, true);
|
|
448
|
-
if (!session)
|
|
449
|
-
return null;
|
|
450
|
-
// Set to running so advance() works
|
|
451
|
-
session.status = "running";
|
|
452
|
-
this.sessions.set(session.sessionId, session);
|
|
453
|
-
// Replace the sync-fallback evidence baseline with async evidence
|
|
454
|
-
// so resumed sessions don't silently drop async provider data.
|
|
455
|
-
try {
|
|
456
|
-
const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
|
|
457
|
-
loopId: session.loopId,
|
|
458
|
-
phase: "before",
|
|
459
|
-
});
|
|
460
|
-
if (asyncEvidence.length > 0) {
|
|
461
|
-
session.evidenceBaseline = asyncEvidence;
|
|
462
|
-
if (!session.roundSnapshot?.beforeEvidence?.length) {
|
|
463
|
-
session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
catch {
|
|
468
|
-
// Async evidence is best-effort; fall back to sync baseline.
|
|
469
|
-
}
|
|
470
|
-
const reconciled = this.reconcileCommittedRound(session);
|
|
471
|
-
if (reconciled)
|
|
472
|
-
return reconciled;
|
|
473
|
-
const restored = this.restoredPromptResult(session);
|
|
474
|
-
if (restored) {
|
|
475
|
-
this.save(session);
|
|
476
|
-
return restored;
|
|
477
|
-
}
|
|
478
|
-
// Compile the next prompt from the current round state
|
|
479
|
-
const lcr = makeLoopCompileRequest({
|
|
480
|
-
loop_id: session.loopId,
|
|
481
|
-
round: session.currentRound,
|
|
482
|
-
goal_id: "",
|
|
483
|
-
task: session.task,
|
|
484
|
-
domain: undefined,
|
|
485
|
-
plan_source: null,
|
|
486
|
-
constraints_from_plan: [],
|
|
487
|
-
health_check_interval: 1,
|
|
488
|
-
});
|
|
489
|
-
const compileRequest = {
|
|
490
|
-
task: session.task,
|
|
491
|
-
mode: Mode.LOOP_COMPILE,
|
|
492
|
-
feedback: null,
|
|
493
|
-
skill_name: null,
|
|
494
|
-
task_id: null,
|
|
495
|
-
loop_id: lcr.loop_id,
|
|
496
|
-
round: lcr.round,
|
|
497
|
-
goal_id: lcr.goal_id,
|
|
498
|
-
domain: lcr.domain ?? "",
|
|
499
|
-
plan_source: lcr.plan_source ?? null,
|
|
500
|
-
constraints_from_plan: lcr.constraints_from_plan ?? [],
|
|
501
|
-
health_check_interval: lcr.health_check_interval,
|
|
502
|
-
max_rounds: session.maxRounds,
|
|
503
|
-
};
|
|
504
|
-
const prepared = await new RoundDriver(session.engine, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
|
|
505
|
-
if (!prepared) {
|
|
506
|
-
session.status = "stopped";
|
|
507
|
-
this.save(session);
|
|
508
|
-
void this.notifyTerminal(session, "stalled");
|
|
509
|
-
return { sessionId: session.sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail: "RoundDriver.prepare returned null during unpause — the compiler could not produce a prompt." };
|
|
510
|
-
}
|
|
511
|
-
const prompt = prepared.prompt;
|
|
512
|
-
const level = prepared.level;
|
|
513
|
-
session.evidenceBaseline = prepared.evidenceBaseline;
|
|
514
|
-
session.roundSnapshot = prepared.snapshot;
|
|
515
|
-
session.currentPrompt = prompt;
|
|
516
|
-
session.currentLevel = level;
|
|
517
|
-
session.currentWarnings = prepared?.warnings ?? [];
|
|
518
|
-
policyMetrics.recordStrategy(session.loopId, level);
|
|
519
|
-
this.save(session);
|
|
520
|
-
return {
|
|
521
|
-
sessionId: session.sessionId,
|
|
522
|
-
round: session.currentRound,
|
|
523
|
-
roundId: session.roundSnapshot.roundId,
|
|
524
|
-
prompt,
|
|
525
|
-
level,
|
|
526
|
-
roundSuccess: undefined,
|
|
527
|
-
warnings: [],
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
/** Persist session state to vault for cross-process recovery.
|
|
531
|
-
* The filtered vault and replacement entry are written once under the
|
|
532
|
-
* backend lock, so recovery never observes the old two-write gap. */
|
|
331
|
+
/** Persist session state to vault for cross-process recovery. Delegates to
|
|
332
|
+
* the round lifecycle, which owns the durable session document shape. */
|
|
533
333
|
save(session) {
|
|
534
|
-
|
|
535
|
-
return;
|
|
536
|
-
const leaseActive = session.status === "running";
|
|
537
|
-
const sessionEntry = {
|
|
538
|
-
task_id: `loop:${session.loopId}:session`,
|
|
539
|
-
task_type: "session_state",
|
|
540
|
-
timestamp: new Date().toISOString(),
|
|
541
|
-
loop_id: session.loopId,
|
|
542
|
-
task: session.task,
|
|
543
|
-
loop_lineage: {
|
|
544
|
-
session_id: session.sessionId,
|
|
545
|
-
current_round: session.currentRound,
|
|
546
|
-
max_rounds: session.maxRounds,
|
|
547
|
-
success_trajectory: session.successTrajectory,
|
|
548
|
-
status: session.status,
|
|
549
|
-
created_at: session.createdAt,
|
|
550
|
-
// v1.13: Enforcement gate state
|
|
551
|
-
consecutive_rejections: session.consecutiveRejections,
|
|
552
|
-
last_rejection_check: session.lastRejectionCheck,
|
|
553
|
-
// v2.12: Clarification streak
|
|
554
|
-
drift_clarification_streak: session.driftClarificationStreak,
|
|
555
|
-
// v2.13: Backtrack skipped files for workspace restore check
|
|
556
|
-
backtrack_skipped_files: session.backtrackSkippedFiles,
|
|
557
|
-
// v1.19: durable round transaction state
|
|
558
|
-
round_snapshot: session.roundSnapshot ?? null,
|
|
559
|
-
last_self_eval: session.lastSelfEval ?? null,
|
|
560
|
-
current_prompt: session.currentPrompt ?? null,
|
|
561
|
-
current_level: session.currentLevel ?? "",
|
|
562
|
-
// v1.20: cross-process single-owner lease
|
|
563
|
-
lease_owner: leaseActive ? this.ownerId : "",
|
|
564
|
-
lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
|
|
565
|
-
},
|
|
566
|
-
};
|
|
567
|
-
this.sessionStore.save(sessionEntry, {
|
|
568
|
-
expectedLeaseOwner: this.ownerId,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
/** Reconstruct a McpSession from a vault session_state entry.
|
|
572
|
-
* Returns null if the entry is not "running" status.
|
|
573
|
-
* Shared by resume() and autoResumeAll(). */
|
|
574
|
-
reconstructSession(entry, allowPaused = false) {
|
|
575
|
-
const lineage = (entry.loop_lineage ?? {});
|
|
576
|
-
const status = lineage.status ?? "running";
|
|
577
|
-
if (status !== "running" && !(allowPaused && status === "paused")) {
|
|
578
|
-
return null;
|
|
579
|
-
}
|
|
580
|
-
const loopId = entry.loop_id;
|
|
581
|
-
const currentRound = lineage.current_round ?? 1;
|
|
582
|
-
const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
|
|
583
|
-
const task = entry.task ?? "";
|
|
584
|
-
const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
|
|
585
|
-
const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
|
|
586
|
-
const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
|
|
587
|
-
const persistedEval = lineage.last_self_eval;
|
|
588
|
-
const lastSelfEval = persistedEval !== null &&
|
|
589
|
-
typeof persistedEval === "object" &&
|
|
590
|
-
!Array.isArray(persistedEval) &&
|
|
591
|
-
typeof persistedEval.success === "boolean" &&
|
|
592
|
-
typeof persistedEval.output_summary === "string"
|
|
593
|
-
? persistedEval
|
|
594
|
-
: undefined;
|
|
595
|
-
const engine = new LoopForgeEngine(this.backend);
|
|
596
|
-
return {
|
|
597
|
-
sessionId: typeof lineage.session_id === "string" && lineage.session_id
|
|
598
|
-
? lineage.session_id
|
|
599
|
-
: randomUUID(),
|
|
600
|
-
loopId, task, engine,
|
|
601
|
-
currentRound, maxRounds, successTrajectory,
|
|
602
|
-
status: status,
|
|
603
|
-
createdAt: lineage.created_at ?? Date.now(),
|
|
604
|
-
consecutiveRejections: lineage.consecutive_rejections ?? 0,
|
|
605
|
-
lastRejectionCheck: typeof lineage.last_rejection_check === "string"
|
|
606
|
-
? lineage.last_rejection_check
|
|
607
|
-
: "",
|
|
608
|
-
driftClarificationStreak: lineage.drift_clarification_streak ?? 0,
|
|
609
|
-
backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
|
|
610
|
-
evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
|
|
611
|
-
roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
|
|
612
|
-
lastSelfEval,
|
|
613
|
-
currentPrompt: typeof lineage.current_prompt === "string"
|
|
614
|
-
? lineage.current_prompt
|
|
615
|
-
: null,
|
|
616
|
-
currentLevel: typeof lineage.current_level === "string"
|
|
617
|
-
? lineage.current_level
|
|
618
|
-
: undefined,
|
|
619
|
-
};
|
|
334
|
+
this.lifecycle.save(session);
|
|
620
335
|
}
|
|
621
336
|
/** Resume a loop from vault state.
|
|
622
337
|
* Reconstructs the session and compiles the prompt for the next round.
|
|
@@ -632,7 +347,7 @@ export class SessionManager {
|
|
|
632
347
|
: persistedEntry;
|
|
633
348
|
if (!sessionEntry)
|
|
634
349
|
return this.leaseConflictResult(loopId, persistedEntry);
|
|
635
|
-
const session = this.reconstructSession(sessionEntry);
|
|
350
|
+
const session = this.lifecycle.reconstructSession(sessionEntry);
|
|
636
351
|
if (!session) {
|
|
637
352
|
// was not "running" — return stopped/stalled status
|
|
638
353
|
const lineage = (sessionEntry.loop_lineage ?? {});
|
|
@@ -647,32 +362,29 @@ export class SessionManager {
|
|
|
647
362
|
};
|
|
648
363
|
}
|
|
649
364
|
this.sessions.set(session.sessionId, session);
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
this.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
roundSuccess: false,
|
|
674
|
-
warnings: (session.currentWarnings ?? []),
|
|
675
|
-
};
|
|
365
|
+
return this.lifecycle.resume(session);
|
|
366
|
+
}
|
|
367
|
+
/** v1.18: Resume a paused session. Reconstructs from vault state and
|
|
368
|
+
* compiles the next prompt. Returns null if no paused session exists
|
|
369
|
+
* for this loopId. */
|
|
370
|
+
async unpause(loopId) {
|
|
371
|
+
const persistedEntry = this.findSessionEntry(loopId);
|
|
372
|
+
if (!persistedEntry)
|
|
373
|
+
return null;
|
|
374
|
+
const lineage = (persistedEntry.loop_lineage ?? {});
|
|
375
|
+
const status = lineage.status ?? "running";
|
|
376
|
+
if (status !== "paused")
|
|
377
|
+
return null;
|
|
378
|
+
const sessionEntry = this.claimSessionEntry(loopId);
|
|
379
|
+
if (!sessionEntry)
|
|
380
|
+
return this.leaseConflictResult(loopId, persistedEntry);
|
|
381
|
+
const session = this.lifecycle.reconstructSession(sessionEntry, true);
|
|
382
|
+
if (!session)
|
|
383
|
+
return null;
|
|
384
|
+
// Set to running so advance() works
|
|
385
|
+
session.status = "running";
|
|
386
|
+
this.sessions.set(session.sessionId, session);
|
|
387
|
+
return this.lifecycle.unpause(session);
|
|
676
388
|
}
|
|
677
389
|
/** Auto-resume all "running" sessions from vault on server startup.
|
|
678
390
|
* Scans vault for session_state entries, reconstructs each as an in-memory
|
|
@@ -701,7 +413,20 @@ export class SessionManager {
|
|
|
701
413
|
const claimedEntry = this.claimSessionEntry(lid);
|
|
702
414
|
if (!claimedEntry)
|
|
703
415
|
continue;
|
|
704
|
-
|
|
416
|
+
// v2.14: a bulk startup scan must not die on one corrupt loop —
|
|
417
|
+
// recoverable gaps are recorded and skipped; corrupted storage
|
|
418
|
+
// (invalid JSON/format/sequence) still surfaces to the caller.
|
|
419
|
+
let session = null;
|
|
420
|
+
try {
|
|
421
|
+
session = this.lifecycle.reconstructSession(claimedEntry, true);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (error instanceof StorageCorruptionError && error.recoverable) {
|
|
425
|
+
logEvent("session_skip_gap", { loopId: lid, error: error.message });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
705
430
|
if (session) {
|
|
706
431
|
this.sessions.set(session.sessionId, session);
|
|
707
432
|
activeLoopIds.add(lid);
|
|
@@ -744,6 +469,144 @@ export class SessionManager {
|
|
|
744
469
|
}
|
|
745
470
|
return result;
|
|
746
471
|
}
|
|
472
|
+
// ── v2.12: User/Agent gates ────────────────────────────────────────────
|
|
473
|
+
/** Classify a flat gate text. Read-only — no vault writes. */
|
|
474
|
+
checkGate(gateText) {
|
|
475
|
+
const { id, gate } = deriveGate(gateText);
|
|
476
|
+
return {
|
|
477
|
+
id,
|
|
478
|
+
kind: gate.kind,
|
|
479
|
+
...(gate.kind === "user"
|
|
480
|
+
? {
|
|
481
|
+
question: gate.question,
|
|
482
|
+
blockedScope: gate.blockedScope,
|
|
483
|
+
allowedWork: gate.allowedWork,
|
|
484
|
+
}
|
|
485
|
+
: {
|
|
486
|
+
problem: gate.problem,
|
|
487
|
+
requiredEvidence: gate.requiredEvidence,
|
|
488
|
+
suggestedResolution: gate.suggestedResolution,
|
|
489
|
+
}),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/** Record a user decision for a recorded gate. The gateId embeds the
|
|
493
|
+
* canonicalized action hash — if the action changed, the match fails and
|
|
494
|
+
* the old approval expires automatically. */
|
|
495
|
+
resolveGate(sessionId, gateId, approved, note) {
|
|
496
|
+
const session = this.sessions.get(sessionId);
|
|
497
|
+
if (!session)
|
|
498
|
+
return { error: `session not found: ${sessionId}` };
|
|
499
|
+
const prefix = `loop:${session.loopId}:gate:`;
|
|
500
|
+
const opened = queryLoopEntries(this.loopStore, session.loopId, { prefix })
|
|
501
|
+
.find((entry) => entry.task_type === "gate_opened" && entry.gate_id === gateId);
|
|
502
|
+
if (!opened) {
|
|
503
|
+
return { error: `no gate_opened record for gate ${gateId} — the gate may have expired or was never recorded` };
|
|
504
|
+
}
|
|
505
|
+
const actionText = typeof opened.gate_action === "string" ? opened.gate_action : "";
|
|
506
|
+
const { id, gate, actionHash } = deriveGate(actionText);
|
|
507
|
+
if (id !== gateId) {
|
|
508
|
+
return { error: "gateId does not match the recorded action — the action changed, old approvals expire" };
|
|
509
|
+
}
|
|
510
|
+
if (gate.kind !== "user") {
|
|
511
|
+
return { error: "agent gates are resolved by submitting the required evidence, not by user approval" };
|
|
512
|
+
}
|
|
513
|
+
const decision = makeGateDecision({
|
|
514
|
+
gateId,
|
|
515
|
+
kind: "user",
|
|
516
|
+
approved: approved === true,
|
|
517
|
+
scope: gate.blockedScope ?? [],
|
|
518
|
+
note: typeof note === "string" ? note : "",
|
|
519
|
+
decidedAt: new Date().toISOString(),
|
|
520
|
+
actionHash: actionHash ?? "",
|
|
521
|
+
});
|
|
522
|
+
const entry = {
|
|
523
|
+
id: randomUUID(),
|
|
524
|
+
task_id: `loop:${session.loopId}:gate:${gateId}:decision`,
|
|
525
|
+
task_type: "gate_decision",
|
|
526
|
+
loop_id: session.loopId,
|
|
527
|
+
timestamp: new Date().toISOString(),
|
|
528
|
+
gate_id: gateId,
|
|
529
|
+
approved: decision.approved,
|
|
530
|
+
decision_note: decision.note,
|
|
531
|
+
gate_decision: decision,
|
|
532
|
+
loop_lineage: {
|
|
533
|
+
round: session.currentRound,
|
|
534
|
+
gate_id: gateId,
|
|
535
|
+
action_hash: actionHash,
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
this.loopStore.appendEntry(entry);
|
|
539
|
+
return { sessionId, loopId: session.loopId, gateId, approved: decision.approved };
|
|
540
|
+
}
|
|
541
|
+
// ── v2.12: Typed projection + audit ────────────────────────────────────
|
|
542
|
+
/** Typed cognitive state projection for an active session. Derived on
|
|
543
|
+
* demand — zero persistence. Null when nothing meaningful exists yet. */
|
|
544
|
+
getProjection(sessionId) {
|
|
545
|
+
const session = this.sessions.get(sessionId);
|
|
546
|
+
if (!session)
|
|
547
|
+
return null;
|
|
548
|
+
const entries = queryLoopEntries(this.loopStore, session.loopId, { prefix: `loop:${session.loopId}:` });
|
|
549
|
+
let compileResponse = null;
|
|
550
|
+
// v3.0.1: the round boundary already compiled this round — derive the
|
|
551
|
+
// projection from that response instead of recompiling the whole vault.
|
|
552
|
+
// The cache is invalidated by every compile path (it is rebuilt with the
|
|
553
|
+
// new round's response); the artifact's deterministic roundId guards
|
|
554
|
+
// against stale reuse.
|
|
555
|
+
const cached = session.lastCompileResponse;
|
|
556
|
+
const expectedRoundId = session.roundSnapshot?.roundId
|
|
557
|
+
?? makeRoundId(session.loopId, session.currentRound);
|
|
558
|
+
if (cached?.prompt_artifact && cached.prompt_artifact.roundId === expectedRoundId) {
|
|
559
|
+
compileResponse = cached;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
try {
|
|
563
|
+
const request = {
|
|
564
|
+
task: session.task,
|
|
565
|
+
mode: Mode.LOOP_COMPILE,
|
|
566
|
+
feedback: null,
|
|
567
|
+
skill_name: null,
|
|
568
|
+
task_id: null,
|
|
569
|
+
loop_id: session.loopId,
|
|
570
|
+
round: session.currentRound,
|
|
571
|
+
max_rounds: session.maxRounds,
|
|
572
|
+
verification_flags: [],
|
|
573
|
+
};
|
|
574
|
+
const compiled = session.engine.invokeLoopCompile(request, undefined, { persistLineage: false });
|
|
575
|
+
compileResponse = compiled.response ?? null;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
compileResponse = null; // projection degrades gracefully
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const openGates = this.lifecycle.listOpenGateDescriptions(session.loopId);
|
|
582
|
+
const projection = buildLoopProjection({
|
|
583
|
+
loopId: session.loopId,
|
|
584
|
+
currentRound: session.currentRound,
|
|
585
|
+
compileResponse,
|
|
586
|
+
vaultEntries: entries,
|
|
587
|
+
verifiedClaims: listVerifiedClaims(entries, session.loopId),
|
|
588
|
+
openGates,
|
|
589
|
+
});
|
|
590
|
+
return projection ? { ...projection } : null;
|
|
591
|
+
}
|
|
592
|
+
/** Read-only end-of-loop audit (verification view). Never writes. */
|
|
593
|
+
getAudit(loopId) {
|
|
594
|
+
validateLoopId(loopId);
|
|
595
|
+
const entries = queryLoopEntries(this.loopStore, loopId, { prefix: `loop:${loopId}:` });
|
|
596
|
+
const audit = buildAudit(loopId, entries, this.loopStore);
|
|
597
|
+
return { ...audit };
|
|
598
|
+
}
|
|
599
|
+
/** v2.12: Policy metrics that survive restarts — vault-derived round
|
|
600
|
+
* statistics (A4 port) folded with this process's live observations.
|
|
601
|
+
* Non-durable fields (evidence, vault errors) come from live only. */
|
|
602
|
+
getPolicyMetrics(loopId) {
|
|
603
|
+
const entries = queryLoopEntries(this.loopStore, loopId, { prefix: `loop:${loopId}:` });
|
|
604
|
+
const derived = derivePolicyMetrics(loopId, entries);
|
|
605
|
+
const live = policyMetrics.snapshot(loopId);
|
|
606
|
+
if (derived.committedRounds === 0)
|
|
607
|
+
return live;
|
|
608
|
+
return mergePolicyMetrics(derived, live);
|
|
609
|
+
}
|
|
747
610
|
/** Get loop health for a loop (in-memory or vault).
|
|
748
611
|
* Computes goal alignment, constraint integrity, drift, strategy stability. */
|
|
749
612
|
getHealth(loopId) {
|
|
@@ -769,7 +632,7 @@ export class SessionManager {
|
|
|
769
632
|
if (!task)
|
|
770
633
|
return null;
|
|
771
634
|
// Hydrate vault context
|
|
772
|
-
const engine = new LoopForgeEngine(this.
|
|
635
|
+
const engine = new LoopForgeEngine(this.loopStore);
|
|
773
636
|
const vaultContext = engine.hydrateLoopContext(loopId);
|
|
774
637
|
// Build a minimal request for health check
|
|
775
638
|
const request = makeLoopCompileRequest({
|
|
@@ -786,14 +649,19 @@ export class SessionManager {
|
|
|
786
649
|
drift_detected: health.drift_detected,
|
|
787
650
|
strategy_stability: health.strategy_stability,
|
|
788
651
|
task_continuity: health.task_continuity,
|
|
789
|
-
policy_metrics:
|
|
652
|
+
policy_metrics: this.getPolicyMetrics(loopId),
|
|
790
653
|
};
|
|
791
654
|
}
|
|
792
655
|
/** Core cycle: extract self-eval → record feedback → check stop → compile next.
|
|
656
|
+
* The lease + per-session queue wrap the RoundLifecycle state machine.
|
|
793
657
|
* @param preExtractedEval Optional pre-built SelfEvaluation from MCP tool parameter.
|
|
794
658
|
* When provided (MCP path with evaluation parameter), skips regex extraction.
|
|
795
|
-
* When undefined (runtime/CLI path), falls back to regex extraction from output.
|
|
796
|
-
|
|
659
|
+
* When undefined (runtime/CLI path), falls back to regex extraction from output.
|
|
660
|
+
* @param roundId v3.0.1: The roundId of the round this submission reports on
|
|
661
|
+
* (from the last start/next/resume response). Anchors the submission so a
|
|
662
|
+
* stale or duplicate submission is not processed against a later round.
|
|
663
|
+
* Optional for library callers — when absent the anchor check is skipped. */
|
|
664
|
+
async advance(sessionId, output, preExtractedEval, roundId) {
|
|
797
665
|
return this.withSessionQueue(sessionId, async () => {
|
|
798
666
|
const session = this.sessions.get(sessionId);
|
|
799
667
|
if (session?.status === "running" &&
|
|
@@ -801,7 +669,7 @@ export class SessionManager {
|
|
|
801
669
|
return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
|
|
802
670
|
}
|
|
803
671
|
try {
|
|
804
|
-
return await this.
|
|
672
|
+
return await this.lifecycle.advance(sessionId, output, preExtractedEval, roundId);
|
|
805
673
|
}
|
|
806
674
|
catch (error) {
|
|
807
675
|
if (error instanceof SessionLeaseConflictError) {
|
|
@@ -811,380 +679,12 @@ export class SessionManager {
|
|
|
811
679
|
}
|
|
812
680
|
});
|
|
813
681
|
}
|
|
814
|
-
// ── advanceUnlocked pipeline ───────────────────────────────────────────
|
|
815
|
-
//
|
|
816
|
-
// The method is split into focused private helpers so each phase of the
|
|
817
|
-
// round-boundary decision loop is independently readable and testable.
|
|
818
|
-
//
|
|
819
|
-
// Pipeline: validate → extract → execute transaction → route disposition
|
|
820
|
-
// → advance to next round (continue) or return terminal result.
|
|
821
|
-
/** Extract a SelfEvaluation from agent output.
|
|
822
|
-
* Structured param preferred → regex extraction → heuristic fallback.
|
|
823
|
-
* Returns null only when both regex and heuristic returned null. */
|
|
824
|
-
extractEvaluation(output, preExtractedEval) {
|
|
825
|
-
if (preExtractedEval) {
|
|
826
|
-
return { selfEval: preExtractedEval, extractionFailed: false };
|
|
827
|
-
}
|
|
828
|
-
const structured = extractSelfEvaluation(output);
|
|
829
|
-
if (!structured)
|
|
830
|
-
return null;
|
|
831
|
-
return { selfEval: structured, extractionFailed: false };
|
|
832
|
-
}
|
|
833
|
-
/** Execute the round transaction and apply per-rule rejection tracking.
|
|
834
|
-
* MUTATES: session.roundSnapshot, session.consecutiveRejections,
|
|
835
|
-
* session.lastRejectionCheck, session.lastSelfEval,
|
|
836
|
-
* session.successTrajectory, session.driftClarificationStreak */
|
|
837
|
-
async executeRoundTransaction(session, selfEval, extractionFailed) {
|
|
838
|
-
const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
|
|
839
|
-
const completed = await new RoundDriver(session.engine, this.backend).complete({
|
|
840
|
-
snapshot,
|
|
841
|
-
loopId: session.loopId,
|
|
842
|
-
task: session.task,
|
|
843
|
-
maxRounds: session.maxRounds,
|
|
844
|
-
selfEval,
|
|
845
|
-
extractionSucceeded: !extractionFailed,
|
|
846
|
-
lastSelfEval: session.lastSelfEval,
|
|
847
|
-
consecutiveRejections: session.consecutiveRejections,
|
|
848
|
-
successTrajectory: session.successTrajectory,
|
|
849
|
-
driftClarificationStreak: session.driftClarificationStreak,
|
|
850
|
-
backtrackSkippedFiles: session.backtrackSkippedFiles,
|
|
851
|
-
});
|
|
852
|
-
const outcome = completed.outcome;
|
|
853
|
-
const actualEvidence = completed.actualEvidence;
|
|
854
|
-
session.roundSnapshot = outcome.snapshot;
|
|
855
|
-
const pr = outcome.result;
|
|
856
|
-
policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
|
|
857
|
-
// v2.12: Clarification streak tracking — independent of rejection tracking.
|
|
858
|
-
// Reset on rounds without intent_drift; track substantive vs weak clarifications.
|
|
859
|
-
this.updateClarificationStreak(session, pr);
|
|
860
|
-
// Per-rule rejection tracking: same-check rejections accumulate;
|
|
861
|
-
// a different rejection reason resets the counter.
|
|
862
|
-
if (pr.action === "reject" && pr.rejectionCheck) {
|
|
863
|
-
session.consecutiveRejections =
|
|
864
|
-
pr.rejectionCheck === session.lastRejectionCheck
|
|
865
|
-
? pr.newConsecutiveRejections
|
|
866
|
-
: 1;
|
|
867
|
-
session.lastRejectionCheck = pr.rejectionCheck;
|
|
868
|
-
}
|
|
869
|
-
else {
|
|
870
|
-
session.consecutiveRejections = pr.newConsecutiveRejections;
|
|
871
|
-
if (pr.action !== "reject")
|
|
872
|
-
session.lastRejectionCheck = "";
|
|
873
|
-
}
|
|
874
|
-
if (pr.newLastSelfEval)
|
|
875
|
-
session.lastSelfEval = pr.newLastSelfEval;
|
|
876
|
-
if (pr.shouldPushSuccessTrajectory) {
|
|
877
|
-
session.successTrajectory.push(pr.roundSuccess);
|
|
878
|
-
}
|
|
879
|
-
return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
|
|
880
|
-
}
|
|
881
|
-
/** v2.12: Update the clarification streak based on the round result.
|
|
882
|
-
* - Substantive clarification (anchors present): keep streak — genuine pivot.
|
|
883
|
-
* - No intent_drift this round: reset streak to 0.
|
|
884
|
-
* - Weak clarification rejected by R7: streak was already consumed by
|
|
885
|
-
* enforceIntentDrift to decide reject vs terminate; staleness is handled
|
|
886
|
-
* on the enforcement side. We sync the persisted counter for crash recovery.
|
|
887
|
-
* MUTATES: session.driftClarificationStreak */
|
|
888
|
-
updateClarificationStreak(session, pr) {
|
|
889
|
-
const hasIntentDrift = pr.verificationFlags.some((f) => f.check === "intent_drift");
|
|
890
|
-
if (!hasIntentDrift) {
|
|
891
|
-
// No drift this round — reset streak
|
|
892
|
-
session.driftClarificationStreak = 0;
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
// v2.12: Clarification accepted with anchors → substantive pivot.
|
|
896
|
-
// Keep streak as-is (don't increment for genuine explanations).
|
|
897
|
-
if (pr.clarificationAccepted) {
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
// R7 rejected (weak or no clarification). The enforcement gate
|
|
901
|
-
// already used the streak to decide reject vs terminate.
|
|
902
|
-
// Increment the persisted counter so crash recovery sees it.
|
|
903
|
-
session.driftClarificationStreak += 1;
|
|
904
|
-
}
|
|
905
|
-
/** Build a rejection result: compile a retry prompt, persist, return.
|
|
906
|
-
* MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
|
|
907
|
-
async buildRejectionResult(sessionId, session, pr, verificationFlags) {
|
|
908
|
-
const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
|
|
909
|
-
const preparedRetry = await new RoundDriver(session.engine, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
|
|
910
|
-
if (!preparedRetry) {
|
|
911
|
-
session.status = "stalled";
|
|
912
|
-
session.currentPrompt = null;
|
|
913
|
-
this.save(session);
|
|
914
|
-
return {
|
|
915
|
-
sessionId,
|
|
916
|
-
round: session.currentRound,
|
|
917
|
-
roundId: session.roundSnapshot?.roundId,
|
|
918
|
-
prompt: null,
|
|
919
|
-
stopReason: "stalled",
|
|
920
|
-
stopDetail: "RoundDriver.prepareRetry returned null — the retry prompt could not be compiled.",
|
|
921
|
-
};
|
|
922
|
-
}
|
|
923
|
-
session.roundSnapshot = preparedRetry.snapshot;
|
|
924
|
-
session.currentPrompt = preparedRetry.prompt;
|
|
925
|
-
session.currentLevel = preparedRetry.level;
|
|
926
|
-
session.currentWarnings = preparedRetry.warnings ?? [];
|
|
927
|
-
this.save(session);
|
|
928
|
-
return {
|
|
929
|
-
sessionId,
|
|
930
|
-
round: session.currentRound,
|
|
931
|
-
roundId: session.roundSnapshot.roundId,
|
|
932
|
-
prompt: preparedRetry.prompt,
|
|
933
|
-
level: preparedRetry.level,
|
|
934
|
-
enforcementAction: "reject",
|
|
935
|
-
enforcementReason: pr.enforcementReason,
|
|
936
|
-
};
|
|
937
|
-
}
|
|
938
|
-
/** v2.10: Build a backtrack result — roll back to last clean round.
|
|
939
|
-
* Resets the round counter to the restore target + 1, merges preserved
|
|
940
|
-
* discoveries, compiles from the restored state, and injects the
|
|
941
|
-
* backtrack prompt at the top.
|
|
942
|
-
* MUTATES: session.currentRound, session.roundSnapshot,
|
|
943
|
-
* session.currentPrompt, session.currentLevel,
|
|
944
|
-
* session.consecutiveRejections, session.lastSelfEval */
|
|
945
|
-
async buildBacktrackResult(sessionId, session, pr) {
|
|
946
|
-
const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
|
|
947
|
-
const newRound = restoreTarget + 1;
|
|
948
|
-
logEvent("session_backtrack", {
|
|
949
|
-
sessionId,
|
|
950
|
-
loopId: session.loopId,
|
|
951
|
-
fromRound: session.currentRound,
|
|
952
|
-
toRound: newRound,
|
|
953
|
-
triggerRule: pr.backtrackTriggerRule ?? "",
|
|
954
|
-
skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
|
|
955
|
-
});
|
|
956
|
-
// Reset session to the restored round
|
|
957
|
-
session.currentRound = newRound;
|
|
958
|
-
session.consecutiveRejections = 0;
|
|
959
|
-
session.lastRejectionCheck = "";
|
|
960
|
-
session.driftClarificationStreak = 0;
|
|
961
|
-
session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
|
|
962
|
-
session.lastSelfEval = undefined; // force L2 recompile from vault state
|
|
963
|
-
session.currentPrompt = null;
|
|
964
|
-
// Preserve discoveries from skipped rounds as constraints_from_plan
|
|
965
|
-
// so they survive the compilation and re-enter active constraints.
|
|
966
|
-
const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
|
|
967
|
-
// Compile the restored prompt — use a fresh request with the restored round
|
|
968
|
-
const request = {
|
|
969
|
-
task: session.task,
|
|
970
|
-
mode: Mode.LOOP_COMPILE,
|
|
971
|
-
feedback: null,
|
|
972
|
-
skill_name: null,
|
|
973
|
-
task_id: null,
|
|
974
|
-
loop_id: session.loopId,
|
|
975
|
-
round: newRound,
|
|
976
|
-
max_rounds: session.maxRounds,
|
|
977
|
-
verification_flags: pr.verificationFlags,
|
|
978
|
-
constraints_from_plan: preservedDiscoveries,
|
|
979
|
-
};
|
|
980
|
-
const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, newRound);
|
|
981
|
-
if (!prepared || !prepared.prompt) {
|
|
982
|
-
session.status = "stalled";
|
|
983
|
-
session.currentPrompt = null;
|
|
984
|
-
this.save(session);
|
|
985
|
-
return {
|
|
986
|
-
sessionId,
|
|
987
|
-
round: newRound,
|
|
988
|
-
prompt: null,
|
|
989
|
-
stopReason: "stalled",
|
|
990
|
-
stopDetail: "Backtrack failed: RoundDriver.prepare returned null — could not compile from restore point.",
|
|
991
|
-
enforcementAction: "backtrack",
|
|
992
|
-
enforcementReason: pr.enforcementReason,
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
// Inject backtrack prompt at the top of the compiled prompt
|
|
996
|
-
const backtrackHeader = pr.backtrackPrompt ?? "";
|
|
997
|
-
const fullPrompt = backtrackHeader
|
|
998
|
-
? backtrackHeader + "\n" + (prepared.prompt ?? "")
|
|
999
|
-
: prepared.prompt ?? "";
|
|
1000
|
-
session.evidenceBaseline = prepared.evidenceBaseline;
|
|
1001
|
-
session.roundSnapshot = prepared.snapshot;
|
|
1002
|
-
session.currentPrompt = fullPrompt;
|
|
1003
|
-
session.currentLevel = prepared.level;
|
|
1004
|
-
session.currentWarnings = prepared?.warnings ?? [];
|
|
1005
|
-
this.save(session);
|
|
1006
|
-
return {
|
|
1007
|
-
sessionId,
|
|
1008
|
-
round: newRound,
|
|
1009
|
-
roundId: session.roundSnapshot?.roundId,
|
|
1010
|
-
prompt: fullPrompt,
|
|
1011
|
-
level: prepared.level,
|
|
1012
|
-
enforcementAction: "backtrack",
|
|
1013
|
-
enforcementReason: pr.enforcementReason,
|
|
1014
|
-
warnings: prepared?.warnings ?? [],
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
/** Build a termination result: persist stopped status, notify sinks.
|
|
1018
|
-
* MUTATES: session.status, session.currentPrompt */
|
|
1019
|
-
buildTerminationResult(sessionId, session, pr) {
|
|
1020
|
-
session.status = "stopped";
|
|
1021
|
-
session.currentPrompt = null;
|
|
1022
|
-
this.save(session);
|
|
1023
|
-
void this.notifyTerminal(session, "enforcement_terminated");
|
|
1024
|
-
logEvent("session_end", {
|
|
1025
|
-
sessionId, loopId: session.loopId,
|
|
1026
|
-
stopReason: "enforcement_terminated", round: session.currentRound,
|
|
1027
|
-
});
|
|
1028
|
-
return {
|
|
1029
|
-
sessionId,
|
|
1030
|
-
round: session.currentRound,
|
|
1031
|
-
roundId: session.roundSnapshot?.roundId,
|
|
1032
|
-
prompt: null,
|
|
1033
|
-
stopReason: "enforcement_terminated",
|
|
1034
|
-
stopDetail: pr.enforcementReason ?? "The enforcement gate terminated the loop.",
|
|
1035
|
-
enforcementAction: "terminate",
|
|
1036
|
-
enforcementReason: pr.enforcementReason,
|
|
1037
|
-
};
|
|
1038
|
-
}
|
|
1039
|
-
/** Build a stop result: persist stopped/stalled status, notify sinks.
|
|
1040
|
-
* MUTATES: session.status, session.currentPrompt */
|
|
1041
|
-
buildStopResult(sessionId, session, pr) {
|
|
1042
|
-
const reason = pr.stopReason ?? "stalled";
|
|
1043
|
-
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
1044
|
-
session.currentPrompt = null;
|
|
1045
|
-
this.save(session);
|
|
1046
|
-
void this.notifyTerminal(session, reason);
|
|
1047
|
-
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
|
|
1048
|
-
return {
|
|
1049
|
-
sessionId, round: session.currentRound,
|
|
1050
|
-
roundId: session.roundSnapshot?.roundId,
|
|
1051
|
-
prompt: null, stopReason: reason,
|
|
1052
|
-
stopDetail: pr.stopReason === "circuit_breaker"
|
|
1053
|
-
? `${pr.roundSuccess ? "No" : "All"} recent rounds failed, triggering the circuit breaker.`
|
|
1054
|
-
: pr.stopReason === "max_rounds"
|
|
1055
|
-
? "The configured maximum number of rounds has been reached."
|
|
1056
|
-
: `Loop stopped: ${reason}.`,
|
|
1057
|
-
roundSuccess: pr.roundSuccess,
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
/** Compile the next round's prompt and advance the session.
|
|
1061
|
-
* Includes the commit fence (pause/delete race guard) and context provider.
|
|
1062
|
-
* MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
|
|
1063
|
-
* session.evidenceBaseline, session.roundSnapshot */
|
|
1064
|
-
async advanceToNextRound(sessionId, session, selfEval, pr, verificationFlags, actualEvidence) {
|
|
1065
|
-
const roundSuccess = pr.roundSuccess;
|
|
1066
|
-
session.currentRound++;
|
|
1067
|
-
session.currentPrompt = null;
|
|
1068
|
-
// v2.13: Clear backtrack skipped files after advancing past the restore round
|
|
1069
|
-
session.backtrackSkippedFiles = [];
|
|
1070
|
-
// v1.8: Memory injection — tier-aware external context.
|
|
1071
|
-
let externalCtx = "";
|
|
1072
|
-
if (this.contextProvider) {
|
|
1073
|
-
try {
|
|
1074
|
-
externalCtx = (await this.contextProvider({
|
|
1075
|
-
loopId: session.loopId,
|
|
1076
|
-
round: session.currentRound,
|
|
1077
|
-
task: session.task,
|
|
1078
|
-
domain: "",
|
|
1079
|
-
lastEvaluation: selfEval,
|
|
1080
|
-
})).trim();
|
|
1081
|
-
}
|
|
1082
|
-
catch {
|
|
1083
|
-
logEvent("context_provider_error", {
|
|
1084
|
-
loopId: session.loopId,
|
|
1085
|
-
round: session.currentRound,
|
|
1086
|
-
});
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
// Commit fence: pause()/delete() may have run while the context provider
|
|
1090
|
-
// was awaited. Do not compile if the session is no longer running.
|
|
1091
|
-
if (this.sessions.get(sessionId) !== session || session.status !== "running") {
|
|
1092
|
-
return {
|
|
1093
|
-
sessionId,
|
|
1094
|
-
round: session.currentRound,
|
|
1095
|
-
prompt: null,
|
|
1096
|
-
stopReason: session.status,
|
|
1097
|
-
stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the context provider was running.`,
|
|
1098
|
-
roundSuccess,
|
|
1099
|
-
};
|
|
1100
|
-
}
|
|
1101
|
-
const request = buildLoopRequest(session, selfEval, verificationFlags);
|
|
1102
|
-
if (externalCtx) {
|
|
1103
|
-
request.external_context = externalCtx;
|
|
1104
|
-
}
|
|
1105
|
-
const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, session.currentRound);
|
|
1106
|
-
const nextPrompt = prepared?.prompt ?? null;
|
|
1107
|
-
const nextLevel = prepared?.level ?? "l2";
|
|
1108
|
-
const nextBaseline = prepared?.evidenceBaseline ?? actualEvidence;
|
|
1109
|
-
session.evidenceBaseline = nextBaseline;
|
|
1110
|
-
session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
|
|
1111
|
-
session.currentPrompt = nextPrompt;
|
|
1112
|
-
session.currentLevel = nextLevel;
|
|
1113
|
-
session.currentWarnings = prepared?.warnings ?? [];
|
|
1114
|
-
policyMetrics.recordStrategy(session.loopId, nextLevel);
|
|
1115
|
-
this.save(session);
|
|
1116
|
-
return {
|
|
1117
|
-
sessionId,
|
|
1118
|
-
round: session.currentRound,
|
|
1119
|
-
roundId: session.roundSnapshot.roundId,
|
|
1120
|
-
prompt: nextPrompt,
|
|
1121
|
-
level: nextLevel,
|
|
1122
|
-
roundSuccess,
|
|
1123
|
-
warnings: prepared?.warnings ?? [],
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
async advanceUnlocked(sessionId, output, preExtractedEval) {
|
|
1127
|
-
// ── 1. Validate session ──────────────────────────────────────────────
|
|
1128
|
-
const session = this.sessions.get(sessionId);
|
|
1129
|
-
if (!session)
|
|
1130
|
-
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." };
|
|
1131
|
-
if (session.status !== "running") {
|
|
1132
|
-
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.` };
|
|
1133
|
-
}
|
|
1134
|
-
// ── 2. Extract self-evaluation ───────────────────────────────────────
|
|
1135
|
-
const extracted = this.extractEvaluation(output, preExtractedEval);
|
|
1136
|
-
if (!extracted) {
|
|
1137
|
-
session.status = "stalled";
|
|
1138
|
-
this.save(session);
|
|
1139
|
-
void this.notifyTerminal(session, "stalled");
|
|
1140
|
-
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
|
|
1141
|
-
return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail: "Self-evaluation could not be extracted from agent output. Both structured regex and heuristic fallback returned null.", roundSuccess: false };
|
|
1142
|
-
}
|
|
1143
|
-
const { selfEval, extractionFailed } = extracted;
|
|
1144
|
-
// ── 3. Execute round transaction ─────────────────────────────────────
|
|
1145
|
-
const tx = await this.executeRoundTransaction(session, selfEval, extractionFailed);
|
|
1146
|
-
// ── 4. Route disposition ─────────────────────────────────────────────
|
|
1147
|
-
if (tx.pr.action === "reject") {
|
|
1148
|
-
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
|
|
1149
|
-
}
|
|
1150
|
-
// ── v2.10: Backtrack — roll back to last clean round ──────────────────
|
|
1151
|
-
if (tx.pr.action === "backtrack") {
|
|
1152
|
-
return this.buildBacktrackResult(sessionId, session, tx.pr);
|
|
1153
|
-
}
|
|
1154
|
-
// Accepted rounds advance the before-snapshot baseline. Rejected rounds
|
|
1155
|
-
// retain the original baseline so their retry remains zero-commit.
|
|
1156
|
-
session.evidenceBaseline = tx.actualEvidence;
|
|
1157
|
-
if (tx.pr.action === "terminate") {
|
|
1158
|
-
return this.buildTerminationResult(sessionId, session, tx.pr);
|
|
1159
|
-
}
|
|
1160
|
-
if (tx.pr.action === "stop") {
|
|
1161
|
-
return this.buildStopResult(sessionId, session, tx.pr);
|
|
1162
|
-
}
|
|
1163
|
-
// ── 5. Continue — compile next round ─────────────────────────────────
|
|
1164
|
-
return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.verificationFlags, tx.actualEvidence);
|
|
1165
|
-
}
|
|
1166
|
-
/** Write back loop knowledge to long-term memory.
|
|
1167
|
-
* Uses shared base builder from policy.ts. Called when a loop terminates. */
|
|
1168
|
-
async notifyTerminal(session, stopReason) {
|
|
1169
|
-
if (this.terminalSinks.size === 0)
|
|
1170
|
-
return;
|
|
1171
|
-
const event = {
|
|
1172
|
-
loopId: session.loopId,
|
|
1173
|
-
task: session.task,
|
|
1174
|
-
success: stopReason === "completed",
|
|
1175
|
-
stopReason: stopReason,
|
|
1176
|
-
roundsCompleted: session.currentRound,
|
|
1177
|
-
successTrajectory: [...session.successTrajectory],
|
|
1178
|
-
lastEvaluation: session.lastSelfEval,
|
|
1179
|
-
};
|
|
1180
|
-
await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
|
|
1181
|
-
}
|
|
1182
682
|
/** Replay timeline for a session — creates ReplayBackend from the stored backend. */
|
|
1183
683
|
replayTimeline(sessionId) {
|
|
1184
684
|
const session = this.sessions.get(sessionId);
|
|
1185
685
|
if (!session)
|
|
1186
686
|
return null;
|
|
1187
|
-
const replay = new ReplayBackend(this.
|
|
687
|
+
const replay = new ReplayBackend(this.loopStore);
|
|
1188
688
|
return replay.timeline(session.loopId);
|
|
1189
689
|
}
|
|
1190
690
|
}
|