loopforge 2.7.0 → 3.0.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 +136 -182
- package/dist/canonical-event-writer.d.ts +20 -0
- package/dist/canonical-event-writer.d.ts.map +1 -0
- package/dist/canonical-event-writer.js +131 -0
- package/dist/canonical-event-writer.js.map +1 -0
- package/dist/canonical-state.d.ts +20 -16
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +211 -64
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -10
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-governance.d.ts +48 -0
- package/dist/cognitive-governance.d.ts.map +1 -0
- package/dist/cognitive-governance.js +226 -0
- package/dist/cognitive-governance.js.map +1 -0
- package/dist/compiler-subgoals.d.ts +7 -0
- package/dist/compiler-subgoals.d.ts.map +1 -0
- package/dist/compiler-subgoals.js +93 -0
- package/dist/compiler-subgoals.js.map +1 -0
- package/dist/derive-id.d.ts +12 -0
- package/dist/derive-id.d.ts.map +1 -0
- package/dist/derive-id.js +31 -0
- package/dist/derive-id.js.map +1 -0
- package/dist/evidence-provider.d.ts +26 -26
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +45 -71
- package/dist/evidence-provider.js.map +1 -1
- package/dist/generate-schema.js +90 -2
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +23 -29
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -26
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +2 -2
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +321 -315
- 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-store.d.ts +20 -58
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +232 -311
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/server.d.ts +3 -3
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +123 -19
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session-projection.d.ts +5 -0
- package/dist/mcp/session-projection.d.ts.map +1 -0
- package/dist/mcp/session-projection.js +18 -0
- package/dist/mcp/session-projection.js.map +1 -0
- package/dist/mcp/session-queue.d.ts +3 -0
- package/dist/mcp/session-queue.d.ts.map +1 -0
- package/dist/mcp/session-queue.js +18 -0
- package/dist/mcp/session-queue.js.map +1 -0
- package/dist/mcp/session-types.d.ts +72 -0
- package/dist/mcp/session-types.d.ts.map +1 -0
- package/dist/mcp/session-types.js +2 -0
- package/dist/mcp/session-types.js.map +1 -0
- package/dist/mcp/session.d.ts +65 -84
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +667 -224
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +26 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +401 -254
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +24 -0
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +174 -0
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +74 -12
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +124 -19
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +8 -4
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +533 -86
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-policy.d.ts +8 -1
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +26 -8
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +259 -173
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +17 -96
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts +11 -3
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +51 -14
- package/dist/replay.js.map +1 -1
- package/dist/round-commit-store.d.ts +12 -0
- package/dist/round-commit-store.d.ts.map +1 -0
- package/dist/round-commit-store.js +13 -0
- package/dist/round-commit-store.js.map +1 -0
- package/dist/round-coordinator.d.ts +53 -22
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +140 -44
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +18 -10
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +11 -13
- package/dist/round-driver.js.map +1 -1
- package/dist/round-gate.d.ts +31 -0
- package/dist/round-gate.d.ts.map +1 -0
- package/dist/round-gate.js +259 -0
- package/dist/round-gate.js.map +1 -0
- package/dist/round-recovery.d.ts +17 -0
- package/dist/round-recovery.d.ts.map +1 -0
- package/dist/round-recovery.js +117 -0
- package/dist/round-recovery.js.map +1 -0
- package/dist/round-report.d.ts +16 -0
- package/dist/round-report.d.ts.map +1 -0
- package/dist/round-report.js +401 -0
- package/dist/round-report.js.map +1 -0
- package/dist/round-transaction.d.ts +23 -12
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +104 -91
- package/dist/round-transaction.js.map +1 -1
- package/dist/runtime-services.d.ts +59 -0
- package/dist/runtime-services.d.ts.map +1 -0
- package/dist/{engine.js → runtime-services.js} +157 -275
- package/dist/runtime-services.js.map +1 -0
- package/dist/storage.d.ts +3 -9
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +6 -17
- package/dist/storage.js.map +1 -1
- package/dist/token-utils.d.ts +12 -8
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +56 -10
- package/dist/token-utils.js.map +1 -1
- package/dist/vault-format.d.ts +4 -0
- package/dist/vault-format.d.ts.map +1 -0
- package/dist/vault-format.js +4 -0
- package/dist/vault-format.js.map +1 -0
- package/dist/vault-types.d.ts +24 -0
- package/dist/vault-types.d.ts.map +1 -0
- package/dist/vault-types.js +2 -0
- package/dist/vault-types.js.map +1 -0
- package/loop_policy.json +10 -12
- package/loopforge-policy.json +340 -0
- package/package.json +13 -7
- package/skills/perception/SKILL.md +200 -90
- 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/enforcement-gate.d.ts +0 -50
- package/dist/enforcement-gate.d.ts.map +0 -1
- package/dist/enforcement-gate.js +0 -501
- package/dist/enforcement-gate.js.map +0 -1
- package/dist/engine.d.ts +0 -59
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js.map +0 -1
- package/dist/interop.d.ts +0 -35
- package/dist/interop.d.ts.map +0 -1
- package/dist/interop.js +0 -34
- package/dist/interop.js.map +0 -1
- package/dist/run.d.ts +0 -51
- package/dist/run.d.ts.map +0 -1
- package/dist/run.js +0 -200
- package/dist/run.js.map +0 -1
- package/dist/self-eval.d.ts +0 -23
- package/dist/self-eval.d.ts.map +0 -1
- package/dist/self-eval.js +0 -151
- package/dist/self-eval.js.map +0 -1
- package/dist/verification-gate.d.ts +0 -52
- package/dist/verification-gate.d.ts.map +0 -1
- package/dist/verification-gate.js +0 -527
- package/dist/verification-gate.js.map +0 -1
package/dist/mcp/session.js
CHANGED
|
@@ -2,78 +2,36 @@
|
|
|
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: validate report → commit round event → check stop → compile next.
|
|
6
6
|
*/
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { RuntimeServices } from "../runtime-services.js";
|
|
9
9
|
import { checkLoopHealth } from "../loop-compiler.js";
|
|
10
10
|
import { getPolicy } from "../policy.js";
|
|
11
|
-
import { Mode, makeLoopCompileRequest
|
|
11
|
+
import { Mode, makeLoopCompileRequest } from "../protocol.js";
|
|
12
12
|
import { ReplayBackend } from "../replay.js";
|
|
13
|
-
import { FileLoopStore
|
|
13
|
+
import { FileLoopStore } from "../loop-store.js";
|
|
14
14
|
import { EvidenceCollector } from "../evidence-provider.js";
|
|
15
15
|
import { parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
|
|
16
16
|
import { RoundDriver } from "../round-driver.js";
|
|
17
17
|
import { logEvent } from "../observability.js";
|
|
18
|
-
import { policyMetrics } from "../policy-metrics.js";
|
|
18
|
+
import { derivePolicyMetrics, mergePolicyMetrics, policyMetrics, } from "../policy-metrics.js";
|
|
19
19
|
import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
|
|
20
|
+
import { auditEntries, deriveStructuredGate } from "../cognitive-governance.js";
|
|
21
|
+
import { isRecord } from "../token-utils.js";
|
|
22
|
+
import { parseRoundReport, deriveCompilerProjection } from "../round-report.js";
|
|
23
|
+
import { collectSkippedRoundFiles, findSafeRestorePoint, findBacktrackTargetGitHead, } from "../round-recovery.js";
|
|
24
|
+
import { CanonicalEventWriter } from "../canonical-event-writer.js";
|
|
25
|
+
import { buildLoopRequest } from "./session-projection.js";
|
|
26
|
+
import { withSessionQueue } from "./session-queue.js";
|
|
20
27
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
21
|
-
function buildLoopRequest(session, lastEval, verificationFlags) {
|
|
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
|
-
});
|
|
68
|
-
}
|
|
69
|
-
return req;
|
|
70
|
-
}
|
|
71
28
|
// ── SessionManager ─────────────────────────────────────────────────────────
|
|
72
29
|
export class SessionManager {
|
|
73
30
|
sessions = new Map();
|
|
74
31
|
/** Serializes state transitions for each session. */
|
|
75
32
|
sessionQueues = new Map();
|
|
76
33
|
backend;
|
|
34
|
+
eventWriter;
|
|
77
35
|
sessionStore;
|
|
78
36
|
ownerId = `${process.pid}:${randomUUID()}`;
|
|
79
37
|
leaseMs;
|
|
@@ -82,16 +40,13 @@ export class SessionManager {
|
|
|
82
40
|
/** Explicit context provider; never auto-discovered. */
|
|
83
41
|
contextProvider;
|
|
84
42
|
terminalSinks = new Set();
|
|
43
|
+
/** Vault-replayed policy metrics, keyed by loop. The signature covers every
|
|
44
|
+
* entry's task_id/timestamp/sequence so both appends and idempotent
|
|
45
|
+
* replacements (same task_id) invalidate the cache. */
|
|
46
|
+
derivedMetricsCache = new Map();
|
|
85
47
|
constructor(storeOrBackend, sessionStore) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
? isLoopStore(storeOrBackend)
|
|
89
|
-
? storeOrBackend
|
|
90
|
-
: new VaultBackendLoopStore(storeOrBackend)
|
|
91
|
-
: new FileLoopStore(getPolicy().backend.root_dir);
|
|
92
|
-
this.backend = storeOrBackend && !isLoopStore(storeOrBackend)
|
|
93
|
-
? storeOrBackend
|
|
94
|
-
: new LoopStoreBackend(store);
|
|
48
|
+
const store = storeOrBackend ?? new FileLoopStore(getPolicy().backend.root_dir);
|
|
49
|
+
this.backend = store;
|
|
95
50
|
this.sessionStore = sessionStore ?? new VaultSessionStateStore(store);
|
|
96
51
|
const mcpPolicy = getPolicy().mcp;
|
|
97
52
|
this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
|
|
@@ -101,6 +56,11 @@ export class SessionManager {
|
|
|
101
56
|
this.leaseTimer.unref?.();
|
|
102
57
|
}
|
|
103
58
|
}
|
|
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
|
+
}
|
|
104
64
|
/** Stable process-local owner token used for cross-process session leases. */
|
|
105
65
|
getOwnerId() {
|
|
106
66
|
return this.ownerId;
|
|
@@ -153,41 +113,51 @@ export class SessionManager {
|
|
|
153
113
|
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.`,
|
|
154
114
|
};
|
|
155
115
|
}
|
|
156
|
-
async withSessionQueue(sessionId, work) {
|
|
157
|
-
const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
158
|
-
let release;
|
|
159
|
-
const gate = new Promise((resolve) => {
|
|
160
|
-
release = resolve;
|
|
161
|
-
});
|
|
162
|
-
const tail = previous.then(() => gate);
|
|
163
|
-
this.sessionQueues.set(sessionId, tail);
|
|
164
|
-
await previous;
|
|
165
|
-
try {
|
|
166
|
-
return await work();
|
|
167
|
-
}
|
|
168
|
-
finally {
|
|
169
|
-
release();
|
|
170
|
-
if (this.sessionQueues.get(sessionId) === tail) {
|
|
171
|
-
this.sessionQueues.delete(sessionId);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
116
|
async create(input) {
|
|
117
|
+
const policyMaxRounds = getPolicy().engine.max_rounds;
|
|
118
|
+
if (input.maxRounds !== undefined &&
|
|
119
|
+
(!Number.isInteger(input.maxRounds) || input.maxRounds < 1 || input.maxRounds > policyMaxRounds)) {
|
|
120
|
+
return {
|
|
121
|
+
sessionId: "",
|
|
122
|
+
round: 0,
|
|
123
|
+
prompt: null,
|
|
124
|
+
stopReason: "invalid_input",
|
|
125
|
+
stopDetail: `maxRounds must be an integer between 1 and ${policyMaxRounds}.`,
|
|
126
|
+
error: { code: "invalid_max_rounds", message: `maxRounds must be an integer between 1 and ${policyMaxRounds}.` },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
176
129
|
const sessionId = randomUUID();
|
|
177
130
|
const loopId = input.loopId ?? randomUUID();
|
|
178
|
-
const engine = new
|
|
131
|
+
const engine = new RuntimeServices(this.backend);
|
|
179
132
|
const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
|
|
180
133
|
// Populate extra fields for the first round
|
|
181
134
|
const request = buildLoopRequest({
|
|
182
|
-
sessionId, loopId, task: input.task, engine, currentRound: 1,
|
|
135
|
+
sessionId, loopId, task: input.task, runtime: engine, currentRound: 1,
|
|
183
136
|
maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
|
|
184
137
|
consecutiveRejections: 0,
|
|
185
|
-
|
|
138
|
+
escalatedRejectionChecks: [],
|
|
139
|
+
driftClarificationStreak: 0,
|
|
140
|
+
backtrackSkippedFiles: [],
|
|
186
141
|
evidenceBaseline: [],
|
|
187
142
|
});
|
|
188
143
|
request.domain = input.domain ?? "";
|
|
189
144
|
request.plan_source = input.planSource ?? null;
|
|
190
145
|
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
|
+
}
|
|
191
161
|
// Explicit context is requested by the MCP embedding; no auto-discovery.
|
|
192
162
|
if (this.contextProvider) {
|
|
193
163
|
try {
|
|
@@ -212,21 +182,36 @@ export class SessionManager {
|
|
|
212
182
|
}
|
|
213
183
|
}
|
|
214
184
|
const prepared = await new RoundDriver(engine, this.backend).prepare(request, loopId, 1);
|
|
185
|
+
if (!prepared) {
|
|
186
|
+
return {
|
|
187
|
+
sessionId,
|
|
188
|
+
round: 1,
|
|
189
|
+
prompt: null,
|
|
190
|
+
stopReason: "prompt_compile_failed",
|
|
191
|
+
stopDetail: "Prompt compilation failed; no session state was persisted.",
|
|
192
|
+
error: { code: "prompt_compile_failed", message: "Prompt compilation failed while starting.", retryable: true },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
215
195
|
const initialPrompt = prepared?.prompt ?? null;
|
|
216
196
|
const initialLevel = prepared?.level ?? "l2";
|
|
217
197
|
const evidenceBaseline = prepared?.evidenceBaseline ?? [];
|
|
218
198
|
const session = {
|
|
219
|
-
sessionId, loopId, task: input.task, engine,
|
|
199
|
+
sessionId, loopId, task: input.task, runtime: engine,
|
|
220
200
|
currentRound: 1, maxRounds, successTrajectory: [],
|
|
221
201
|
status: "running", createdAt: Date.now(),
|
|
222
202
|
// v1.13: Enforcement gate state
|
|
223
203
|
consecutiveRejections: 0,
|
|
224
|
-
|
|
204
|
+
escalatedRejectionChecks: [],
|
|
205
|
+
driftClarificationStreak: 0,
|
|
206
|
+
backtrackSkippedFiles: [],
|
|
225
207
|
evidenceBaseline,
|
|
226
208
|
roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
|
|
227
209
|
currentPrompt: initialPrompt,
|
|
228
210
|
currentLevel: initialLevel,
|
|
229
211
|
currentWarnings: prepared?.warnings ?? [],
|
|
212
|
+
currentCanonicalState: isRecord(prepared?.canonicalState)
|
|
213
|
+
? prepared.canonicalState
|
|
214
|
+
: undefined,
|
|
230
215
|
};
|
|
231
216
|
this.sessions.set(sessionId, session);
|
|
232
217
|
policyMetrics.recordStrategy(loopId, initialLevel);
|
|
@@ -271,14 +256,158 @@ export class SessionManager {
|
|
|
271
256
|
: 0,
|
|
272
257
|
};
|
|
273
258
|
}
|
|
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
|
+
}
|
|
274
398
|
delete(sessionId) {
|
|
275
399
|
const session = this.sessions.get(sessionId);
|
|
276
400
|
if (!session)
|
|
277
401
|
return false;
|
|
278
402
|
session.status = "stopped";
|
|
403
|
+
session.stopReason = "cancelled";
|
|
279
404
|
this.save(session);
|
|
405
|
+
// Durable terminal event after the session_state write, before the
|
|
406
|
+
// in-memory session is dropped (A4).
|
|
407
|
+
this.persistTerminalEvent(session, "cancelled");
|
|
280
408
|
void this.notifyTerminal(session, "cancelled");
|
|
281
409
|
this.sessions.delete(sessionId);
|
|
410
|
+
this.sessionQueues.delete(sessionId);
|
|
282
411
|
logEvent("session_end", {
|
|
283
412
|
sessionId,
|
|
284
413
|
loopId: session.loopId,
|
|
@@ -319,31 +448,22 @@ export class SessionManager {
|
|
|
319
448
|
warnings: (session.currentWarnings ?? []),
|
|
320
449
|
};
|
|
321
450
|
}
|
|
322
|
-
/** Reconcile the crash window where
|
|
451
|
+
/** Reconcile the crash window where a round event committed but session_state
|
|
323
452
|
* still points at the old prompt. Returns null when no commit is pending. */
|
|
324
|
-
reconcileCommittedRound(session) {
|
|
453
|
+
async reconcileCommittedRound(session) {
|
|
325
454
|
if (!session.roundSnapshot)
|
|
326
455
|
return null;
|
|
327
|
-
const recovered = new RoundDriver(session.
|
|
456
|
+
const recovered = new RoundDriver(session.runtime, this.backend).recover(session.roundSnapshot);
|
|
328
457
|
if (!recovered)
|
|
329
458
|
return null;
|
|
330
459
|
const pr = recovered.result;
|
|
331
460
|
session.roundSnapshot = recovered.snapshot;
|
|
332
|
-
// Replay the committed counter
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
session.lastRejectionCheck = pr.rejectionCheck;
|
|
339
|
-
}
|
|
340
|
-
else {
|
|
341
|
-
session.consecutiveRejections = pr.newConsecutiveRejections;
|
|
342
|
-
if (pr.action !== "reject")
|
|
343
|
-
session.lastRejectionCheck = "";
|
|
344
|
-
}
|
|
345
|
-
if (pr.newLastSelfEval)
|
|
346
|
-
session.lastSelfEval = pr.newLastSelfEval;
|
|
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;
|
|
347
467
|
if (pr.shouldPushSuccessTrajectory &&
|
|
348
468
|
session.successTrajectory.length < session.currentRound) {
|
|
349
469
|
session.successTrajectory.push(pr.roundSuccess);
|
|
@@ -372,20 +492,36 @@ export class SessionManager {
|
|
|
372
492
|
roundSuccess: pr.roundSuccess,
|
|
373
493
|
};
|
|
374
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
|
+
}
|
|
375
512
|
if (pr.action !== "continue")
|
|
376
513
|
return null;
|
|
377
514
|
session.currentRound++;
|
|
378
|
-
const request = buildLoopRequest(session,
|
|
379
|
-
const prepared = new RoundDriver(session.
|
|
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);
|
|
380
517
|
if (!prepared) {
|
|
381
|
-
session.status = "stalled";
|
|
382
|
-
this.save(session);
|
|
383
518
|
return {
|
|
384
519
|
sessionId: session.sessionId,
|
|
385
520
|
round: session.currentRound,
|
|
386
521
|
prompt: null,
|
|
387
|
-
stopReason: "
|
|
388
|
-
stopDetail: "
|
|
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 },
|
|
389
525
|
};
|
|
390
526
|
}
|
|
391
527
|
const prompt = prepared.prompt;
|
|
@@ -394,6 +530,9 @@ export class SessionManager {
|
|
|
394
530
|
session.currentPrompt = prompt;
|
|
395
531
|
session.currentLevel = prepared.level;
|
|
396
532
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
533
|
+
session.currentCanonicalState = isRecord(prepared.canonicalState)
|
|
534
|
+
? prepared.canonicalState
|
|
535
|
+
: undefined;
|
|
397
536
|
policyMetrics.recordStrategy(session.loopId, session.currentLevel);
|
|
398
537
|
this.save(session);
|
|
399
538
|
return {
|
|
@@ -443,7 +582,7 @@ export class SessionManager {
|
|
|
443
582
|
catch {
|
|
444
583
|
// Async evidence is best-effort; fall back to sync baseline.
|
|
445
584
|
}
|
|
446
|
-
const reconciled = this.reconcileCommittedRound(session);
|
|
585
|
+
const reconciled = await this.reconcileCommittedRound(session);
|
|
447
586
|
if (reconciled)
|
|
448
587
|
return reconciled;
|
|
449
588
|
const restored = this.restoredPromptResult(session);
|
|
@@ -465,7 +604,6 @@ export class SessionManager {
|
|
|
465
604
|
const compileRequest = {
|
|
466
605
|
task: session.task,
|
|
467
606
|
mode: Mode.LOOP_COMPILE,
|
|
468
|
-
feedback: null,
|
|
469
607
|
skill_name: null,
|
|
470
608
|
task_id: null,
|
|
471
609
|
loop_id: lcr.loop_id,
|
|
@@ -477,12 +615,16 @@ export class SessionManager {
|
|
|
477
615
|
health_check_interval: lcr.health_check_interval,
|
|
478
616
|
max_rounds: session.maxRounds,
|
|
479
617
|
};
|
|
480
|
-
const prepared = await new RoundDriver(session.
|
|
618
|
+
const prepared = await new RoundDriver(session.runtime, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
|
|
481
619
|
if (!prepared) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
};
|
|
486
628
|
}
|
|
487
629
|
const prompt = prepared.prompt;
|
|
488
630
|
const level = prepared.level;
|
|
@@ -491,6 +633,9 @@ export class SessionManager {
|
|
|
491
633
|
session.currentPrompt = prompt;
|
|
492
634
|
session.currentLevel = level;
|
|
493
635
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
636
|
+
session.currentCanonicalState = isRecord(prepared.canonicalState)
|
|
637
|
+
? prepared.canonicalState
|
|
638
|
+
: undefined;
|
|
494
639
|
policyMetrics.recordStrategy(session.loopId, level);
|
|
495
640
|
this.save(session);
|
|
496
641
|
return {
|
|
@@ -525,12 +670,19 @@ export class SessionManager {
|
|
|
525
670
|
created_at: session.createdAt,
|
|
526
671
|
// v1.13: Enforcement gate state
|
|
527
672
|
consecutive_rejections: session.consecutiveRejections,
|
|
528
|
-
|
|
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 ?? "",
|
|
529
681
|
// v1.19: durable round transaction state
|
|
530
682
|
round_snapshot: session.roundSnapshot ?? null,
|
|
531
|
-
last_self_eval: session.lastSelfEval ?? null,
|
|
532
683
|
current_prompt: session.currentPrompt ?? null,
|
|
533
684
|
current_level: session.currentLevel ?? "",
|
|
685
|
+
stop_reason: session.stopReason ?? "",
|
|
534
686
|
// v1.20: cross-process single-owner lease
|
|
535
687
|
lease_owner: leaseActive ? this.ownerId : "",
|
|
536
688
|
lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
|
|
@@ -551,47 +703,61 @@ export class SessionManager {
|
|
|
551
703
|
}
|
|
552
704
|
const loopId = entry.loop_id;
|
|
553
705
|
const currentRound = lineage.current_round ?? 1;
|
|
554
|
-
const successTrajectory = lineage.success_trajectory
|
|
706
|
+
const successTrajectory = Array.isArray(lineage.success_trajectory)
|
|
707
|
+
? lineage.success_trajectory.filter((value) => typeof value === "boolean")
|
|
708
|
+
: [];
|
|
555
709
|
const task = entry.task ?? "";
|
|
556
710
|
const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
|
|
557
711
|
const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
typeof
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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)
|
|
566
723
|
: undefined;
|
|
567
|
-
const engine = new
|
|
724
|
+
const engine = new RuntimeServices(this.backend);
|
|
568
725
|
return {
|
|
569
726
|
sessionId: typeof lineage.session_id === "string" && lineage.session_id
|
|
570
727
|
? lineage.session_id
|
|
571
728
|
: randomUUID(),
|
|
572
|
-
loopId, task, engine,
|
|
729
|
+
loopId, task, runtime: engine,
|
|
573
730
|
currentRound, maxRounds, successTrajectory,
|
|
574
731
|
status: status,
|
|
575
732
|
createdAt: lineage.created_at ?? Date.now(),
|
|
576
733
|
consecutiveRejections: lineage.consecutive_rejections ?? 0,
|
|
577
|
-
|
|
578
|
-
? lineage.
|
|
579
|
-
:
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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,
|
|
583
746
|
currentPrompt: typeof lineage.current_prompt === "string"
|
|
584
747
|
? lineage.current_prompt
|
|
585
748
|
: null,
|
|
586
749
|
currentLevel: typeof lineage.current_level === "string"
|
|
587
750
|
? lineage.current_level
|
|
588
751
|
: undefined,
|
|
752
|
+
stopReason: typeof lineage.stop_reason === "string" && lineage.stop_reason
|
|
753
|
+
? lineage.stop_reason
|
|
754
|
+
: undefined,
|
|
589
755
|
};
|
|
590
756
|
}
|
|
591
757
|
/** Resume a loop from vault state.
|
|
592
758
|
* Reconstructs the session and compiles the prompt for the next round.
|
|
593
759
|
* Returns null if no session_state entry exists for this loopId. */
|
|
594
|
-
resume(loopId) {
|
|
760
|
+
async resume(loopId) {
|
|
595
761
|
const persistedEntry = this.findSessionEntry(loopId);
|
|
596
762
|
if (!persistedEntry)
|
|
597
763
|
return null;
|
|
@@ -617,21 +783,36 @@ export class SessionManager {
|
|
|
617
783
|
};
|
|
618
784
|
}
|
|
619
785
|
this.sessions.set(session.sessionId, session);
|
|
620
|
-
const reconciled = this.reconcileCommittedRound(session);
|
|
786
|
+
const reconciled = await this.reconcileCommittedRound(session);
|
|
621
787
|
if (reconciled)
|
|
622
788
|
return reconciled;
|
|
623
789
|
const restored = this.restoredPromptResult(session);
|
|
624
790
|
if (restored)
|
|
625
791
|
return restored;
|
|
626
|
-
//
|
|
792
|
+
// A persisted session may lack a prompt after an interrupted handoff;
|
|
793
|
+
// compile the current canonical state once and persist the replacement.
|
|
627
794
|
const request = buildLoopRequest(session);
|
|
628
|
-
const prepared = new RoundDriver(session.
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
795
|
+
const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, session.currentRound);
|
|
796
|
+
if (!prepared) {
|
|
797
|
+
return {
|
|
798
|
+
sessionId: session.sessionId,
|
|
799
|
+
round: session.currentRound,
|
|
800
|
+
roundId: session.roundSnapshot?.roundId,
|
|
801
|
+
prompt: null,
|
|
802
|
+
stopReason: "prompt_compile_failed",
|
|
803
|
+
stopDetail: "Prompt compilation failed; the persisted session remains unchanged and can be retried.",
|
|
804
|
+
error: { code: "prompt_compile_failed", message: "Prompt compilation failed while resuming.", retryable: true },
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const prompt = prepared.prompt;
|
|
808
|
+
session.evidenceBaseline = prepared.evidenceBaseline;
|
|
809
|
+
session.roundSnapshot = prepared.snapshot;
|
|
632
810
|
session.currentPrompt = prompt;
|
|
633
811
|
session.currentLevel = prepared?.level ?? "l2";
|
|
634
812
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
813
|
+
session.currentCanonicalState = isRecord(prepared?.canonicalState)
|
|
814
|
+
? prepared.canonicalState
|
|
815
|
+
: undefined;
|
|
635
816
|
policyMetrics.recordStrategy(session.loopId, session.currentLevel);
|
|
636
817
|
this.save(session);
|
|
637
818
|
return {
|
|
@@ -714,6 +895,56 @@ export class SessionManager {
|
|
|
714
895
|
}
|
|
715
896
|
return result;
|
|
716
897
|
}
|
|
898
|
+
/** Stable signature over the vault event list. A changed signature means the
|
|
899
|
+
* vault has grown or an idempotent replacement landed, so the cached
|
|
900
|
+
* snapshot must be recomputed. Physical order is stable: writeEntry appends,
|
|
901
|
+
* and replacements update in place. */
|
|
902
|
+
metricsSignature(entries) {
|
|
903
|
+
const hash = createHash("sha256");
|
|
904
|
+
for (const entry of entries) {
|
|
905
|
+
hash.update(entry.task_id ?? "");
|
|
906
|
+
hash.update("\\u0000");
|
|
907
|
+
hash.update(entry.timestamp ?? "");
|
|
908
|
+
hash.update("\\u0000");
|
|
909
|
+
hash.update(String(entry.event_sequence ?? ""));
|
|
910
|
+
hash.update("\\u0000");
|
|
911
|
+
hash.update(entry.task_type ?? "");
|
|
912
|
+
}
|
|
913
|
+
return hash.digest("hex");
|
|
914
|
+
}
|
|
915
|
+
/** Policy metrics that survive process restarts: durable events are
|
|
916
|
+
* replayed through derivePolicyMetrics and merged with live in-process
|
|
917
|
+
* observations (rejections never reach the vault). */
|
|
918
|
+
getLoopPolicyMetrics(loopId) {
|
|
919
|
+
const entries = this.backend.queryLoopEvents(loopId);
|
|
920
|
+
const signature = this.metricsSignature(entries);
|
|
921
|
+
const cached = this.derivedMetricsCache.get(loopId);
|
|
922
|
+
if (cached && cached.signature === signature)
|
|
923
|
+
return cached.snapshot;
|
|
924
|
+
const snapshot = mergePolicyMetrics(derivePolicyMetrics(loopId, entries), policyMetrics.snapshot(loopId));
|
|
925
|
+
this.derivedMetricsCache.set(loopId, { signature, snapshot });
|
|
926
|
+
return snapshot;
|
|
927
|
+
}
|
|
928
|
+
/** Append a durable session_end event for a terminal transition. Call after
|
|
929
|
+
* the session_state write so a failure here only loses a metric, never the
|
|
930
|
+
* stopped status. The fixed task_id makes repeated terminals idempotent. */
|
|
931
|
+
persistTerminalEvent(session, stopReason, extra) {
|
|
932
|
+
this.appendCanonicalEvent({
|
|
933
|
+
task_id: `loop:${session.loopId}:end`,
|
|
934
|
+
task_type: "session_end",
|
|
935
|
+
loop_id: session.loopId,
|
|
936
|
+
task: session.task,
|
|
937
|
+
timestamp: new Date().toISOString(),
|
|
938
|
+
loop_lineage: {
|
|
939
|
+
round: session.currentRound,
|
|
940
|
+
session_id: session.sessionId,
|
|
941
|
+
stop_reason: stopReason,
|
|
942
|
+
success_trajectory: session.successTrajectory,
|
|
943
|
+
consecutive_rejections: session.consecutiveRejections,
|
|
944
|
+
...extra,
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
}
|
|
717
948
|
/** Get loop health for a loop (in-memory or vault).
|
|
718
949
|
* Computes goal alignment, constraint integrity, drift, strategy stability. */
|
|
719
950
|
getHealth(loopId) {
|
|
@@ -723,8 +954,8 @@ export class SessionManager {
|
|
|
723
954
|
for (const s of this.sessions.values()) {
|
|
724
955
|
if (s.loopId === loopId) {
|
|
725
956
|
task = s.task;
|
|
726
|
-
if (s.
|
|
727
|
-
goalId = s.
|
|
957
|
+
if (s.runtime.state?.task_id) {
|
|
958
|
+
goalId = s.runtime.state.task_id;
|
|
728
959
|
}
|
|
729
960
|
break;
|
|
730
961
|
}
|
|
@@ -739,7 +970,7 @@ export class SessionManager {
|
|
|
739
970
|
if (!task)
|
|
740
971
|
return null;
|
|
741
972
|
// Hydrate vault context
|
|
742
|
-
const engine = new
|
|
973
|
+
const engine = new RuntimeServices(this.backend);
|
|
743
974
|
const vaultContext = engine.hydrateLoopContext(loopId);
|
|
744
975
|
// Build a minimal request for health check
|
|
745
976
|
const request = makeLoopCompileRequest({
|
|
@@ -756,22 +987,32 @@ export class SessionManager {
|
|
|
756
987
|
drift_detected: health.drift_detected,
|
|
757
988
|
strategy_stability: health.strategy_stability,
|
|
758
989
|
task_continuity: health.task_continuity,
|
|
759
|
-
policy_metrics:
|
|
990
|
+
policy_metrics: this.getLoopPolicyMetrics(loopId),
|
|
760
991
|
};
|
|
761
992
|
}
|
|
762
|
-
/** Core cycle
|
|
763
|
-
*
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
async advance(sessionId, output, preExtractedEval) {
|
|
767
|
-
return this.withSessionQueue(sessionId, async () => {
|
|
993
|
+
/** Core cycle for a canonical structured round report. Free-form output is
|
|
994
|
+
* intentionally ignored; the runtime never guesses state from text. */
|
|
995
|
+
async advance(sessionId, report) {
|
|
996
|
+
return withSessionQueue(this.sessionQueues, sessionId, async () => {
|
|
768
997
|
const session = this.sessions.get(sessionId);
|
|
769
998
|
if (session?.status === "running" &&
|
|
770
999
|
!this.renewSessionLease(session.loopId)) {
|
|
771
1000
|
return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
|
|
772
1001
|
}
|
|
773
1002
|
try {
|
|
774
|
-
|
|
1003
|
+
const normalized = parseRoundReport(report);
|
|
1004
|
+
if (!normalized) {
|
|
1005
|
+
return {
|
|
1006
|
+
sessionId,
|
|
1007
|
+
round: session?.currentRound ?? 0,
|
|
1008
|
+
roundId: session?.roundSnapshot?.roundId,
|
|
1009
|
+
prompt: session?.currentPrompt ?? null,
|
|
1010
|
+
stopReason: "invalid_round_report",
|
|
1011
|
+
stopDetail: "The submitted report is not a valid canonical RoundReport. The current round remains unchanged and can be retried.",
|
|
1012
|
+
roundSuccess: false,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
return await this.advanceUnlocked(sessionId, normalized.summary, normalized);
|
|
775
1016
|
}
|
|
776
1017
|
catch (error) {
|
|
777
1018
|
if (error instanceof SessionLeaseConflictError) {
|
|
@@ -788,83 +1029,122 @@ export class SessionManager {
|
|
|
788
1029
|
//
|
|
789
1030
|
// Pipeline: validate → extract → execute transaction → route disposition
|
|
790
1031
|
// → advance to next round (continue) or return terminal result.
|
|
791
|
-
/**
|
|
792
|
-
* Structured param preferred → regex extraction → heuristic fallback.
|
|
793
|
-
* Returns null only when both regex and heuristic returned null. */
|
|
794
|
-
extractEvaluation(output, preExtractedEval) {
|
|
795
|
-
if (preExtractedEval) {
|
|
796
|
-
return { selfEval: preExtractedEval, extractionFailed: false };
|
|
797
|
-
}
|
|
798
|
-
const structured = extractSelfEvaluation(output);
|
|
799
|
-
if (!structured)
|
|
800
|
-
return null;
|
|
801
|
-
return { selfEval: structured, extractionFailed: false };
|
|
802
|
-
}
|
|
803
|
-
/** Execute the round transaction and apply per-rule rejection tracking.
|
|
1032
|
+
/** Execute the round transaction and apply global rejection tracking.
|
|
804
1033
|
* MUTATES: session.roundSnapshot, session.consecutiveRejections,
|
|
805
|
-
* session.
|
|
806
|
-
* session.successTrajectory */
|
|
807
|
-
async executeRoundTransaction(session,
|
|
1034
|
+
* session.escalatedRejectionChecks, session.lastRoundProjection,
|
|
1035
|
+
* session.successTrajectory, session.driftClarificationStreak */
|
|
1036
|
+
async executeRoundTransaction(session, roundReport) {
|
|
808
1037
|
const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
|
|
809
|
-
const completed = await new RoundDriver(session.
|
|
1038
|
+
const completed = await new RoundDriver(session.runtime, this.backend).complete({
|
|
810
1039
|
snapshot,
|
|
811
1040
|
loopId: session.loopId,
|
|
812
1041
|
task: session.task,
|
|
813
1042
|
maxRounds: session.maxRounds,
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
lastSelfEval: session.lastSelfEval,
|
|
1043
|
+
roundReport,
|
|
1044
|
+
previousProjection: session.lastRoundProjection,
|
|
817
1045
|
consecutiveRejections: session.consecutiveRejections,
|
|
818
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
|
+
: [],
|
|
819
1059
|
});
|
|
820
1060
|
const outcome = completed.outcome;
|
|
821
1061
|
const actualEvidence = completed.actualEvidence;
|
|
822
1062
|
session.roundSnapshot = outcome.snapshot;
|
|
823
1063
|
const pr = outcome.result;
|
|
824
1064
|
policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
session.
|
|
836
|
-
if (pr.action !== "reject")
|
|
837
|
-
session.lastRejectionCheck = "";
|
|
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;
|
|
838
1076
|
}
|
|
839
|
-
if (pr.
|
|
840
|
-
session.
|
|
1077
|
+
if (pr.newRoundProjection)
|
|
1078
|
+
session.lastRoundProjection = pr.newRoundProjection;
|
|
841
1079
|
if (pr.shouldPushSuccessTrajectory) {
|
|
842
1080
|
session.successTrajectory.push(pr.roundSuccess);
|
|
843
1081
|
}
|
|
1082
|
+
if (pr.action === "continue" || pr.action === "stop") {
|
|
1083
|
+
session.currentCanonicalState = undefined;
|
|
1084
|
+
}
|
|
844
1085
|
return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
|
|
845
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
|
+
}
|
|
846
1125
|
/** Build a rejection result: compile a retry prompt, persist, return.
|
|
847
1126
|
* MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
|
|
848
1127
|
async buildRejectionResult(sessionId, session, pr, verificationFlags) {
|
|
849
1128
|
const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
|
|
850
|
-
const preparedRetry = await new RoundDriver(session.
|
|
1129
|
+
const preparedRetry = await new RoundDriver(session.runtime, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
|
|
851
1130
|
if (!preparedRetry) {
|
|
852
|
-
session.status = "stalled";
|
|
853
|
-
session.currentPrompt = null;
|
|
854
|
-
this.save(session);
|
|
855
1131
|
return {
|
|
856
1132
|
sessionId,
|
|
857
1133
|
round: session.currentRound,
|
|
858
1134
|
roundId: session.roundSnapshot?.roundId,
|
|
859
1135
|
prompt: null,
|
|
860
|
-
stopReason: "
|
|
861
|
-
stopDetail: "
|
|
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 },
|
|
862
1139
|
};
|
|
863
1140
|
}
|
|
864
1141
|
session.roundSnapshot = preparedRetry.snapshot;
|
|
865
1142
|
session.currentPrompt = preparedRetry.prompt;
|
|
866
1143
|
session.currentLevel = preparedRetry.level;
|
|
867
1144
|
session.currentWarnings = preparedRetry.warnings ?? [];
|
|
1145
|
+
session.currentCanonicalState = isRecord(preparedRetry.canonicalState)
|
|
1146
|
+
? preparedRetry.canonicalState
|
|
1147
|
+
: undefined;
|
|
868
1148
|
this.save(session);
|
|
869
1149
|
return {
|
|
870
1150
|
sessionId,
|
|
@@ -876,12 +1156,128 @@ export class SessionManager {
|
|
|
876
1156
|
enforcementReason: pr.enforcementReason,
|
|
877
1157
|
};
|
|
878
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
|
+
}
|
|
879
1270
|
/** Build a termination result: persist stopped status, notify sinks.
|
|
880
1271
|
* MUTATES: session.status, session.currentPrompt */
|
|
881
1272
|
buildTerminationResult(sessionId, session, pr) {
|
|
882
1273
|
session.status = "stopped";
|
|
883
1274
|
session.currentPrompt = null;
|
|
884
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
|
+
});
|
|
885
1281
|
void this.notifyTerminal(session, "enforcement_terminated");
|
|
886
1282
|
logEvent("session_end", {
|
|
887
1283
|
sessionId, loopId: session.loopId,
|
|
@@ -905,6 +1301,8 @@ export class SessionManager {
|
|
|
905
1301
|
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
906
1302
|
session.currentPrompt = null;
|
|
907
1303
|
this.save(session);
|
|
1304
|
+
// Durable terminal event after the session_state write (A4).
|
|
1305
|
+
this.persistTerminalEvent(session, reason);
|
|
908
1306
|
void this.notifyTerminal(session, reason);
|
|
909
1307
|
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
|
|
910
1308
|
return {
|
|
@@ -912,7 +1310,7 @@ export class SessionManager {
|
|
|
912
1310
|
roundId: session.roundSnapshot?.roundId,
|
|
913
1311
|
prompt: null, stopReason: reason,
|
|
914
1312
|
stopDetail: pr.stopReason === "circuit_breaker"
|
|
915
|
-
?
|
|
1313
|
+
? "The last consecutive accepted rounds all failed without recovery, triggering the circuit breaker."
|
|
916
1314
|
: pr.stopReason === "max_rounds"
|
|
917
1315
|
? "The configured maximum number of rounds has been reached."
|
|
918
1316
|
: `Loop stopped: ${reason}.`,
|
|
@@ -923,26 +1321,25 @@ export class SessionManager {
|
|
|
923
1321
|
* Includes the commit fence (pause/delete race guard) and context provider.
|
|
924
1322
|
* MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
|
|
925
1323
|
* session.evidenceBaseline, session.roundSnapshot */
|
|
926
|
-
async advanceToNextRound(sessionId, session,
|
|
1324
|
+
async advanceToNextRound(sessionId, session, pr, verificationFlags, actualEvidence) {
|
|
927
1325
|
const roundSuccess = pr.roundSuccess;
|
|
928
|
-
session.currentRound
|
|
929
|
-
session.currentPrompt = null;
|
|
1326
|
+
const nextRound = session.currentRound + 1;
|
|
930
1327
|
// v1.8: Memory injection — tier-aware external context.
|
|
931
1328
|
let externalCtx = "";
|
|
932
1329
|
if (this.contextProvider) {
|
|
933
1330
|
try {
|
|
934
1331
|
externalCtx = (await this.contextProvider({
|
|
935
1332
|
loopId: session.loopId,
|
|
936
|
-
round:
|
|
1333
|
+
round: nextRound,
|
|
937
1334
|
task: session.task,
|
|
938
1335
|
domain: "",
|
|
939
|
-
|
|
1336
|
+
lastRoundView: session.lastRoundProjection,
|
|
940
1337
|
})).trim();
|
|
941
1338
|
}
|
|
942
1339
|
catch {
|
|
943
1340
|
logEvent("context_provider_error", {
|
|
944
1341
|
loopId: session.loopId,
|
|
945
|
-
round:
|
|
1342
|
+
round: nextRound,
|
|
946
1343
|
});
|
|
947
1344
|
}
|
|
948
1345
|
}
|
|
@@ -958,19 +1355,39 @@ export class SessionManager {
|
|
|
958
1355
|
roundSuccess,
|
|
959
1356
|
};
|
|
960
1357
|
}
|
|
961
|
-
const request = buildLoopRequest(session,
|
|
1358
|
+
const request = buildLoopRequest({ ...session, currentRound: nextRound }, session.lastRoundProjection, verificationFlags);
|
|
962
1359
|
if (externalCtx) {
|
|
963
1360
|
request.external_context = externalCtx;
|
|
964
1361
|
}
|
|
965
|
-
const prepared = await new RoundDriver(session.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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;
|
|
969
1383
|
session.evidenceBaseline = nextBaseline;
|
|
970
1384
|
session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
|
|
971
1385
|
session.currentPrompt = nextPrompt;
|
|
972
1386
|
session.currentLevel = nextLevel;
|
|
973
1387
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
1388
|
+
session.currentCanonicalState = isRecord(prepared?.canonicalState)
|
|
1389
|
+
? prepared.canonicalState
|
|
1390
|
+
: undefined;
|
|
974
1391
|
policyMetrics.recordStrategy(session.loopId, nextLevel);
|
|
975
1392
|
this.save(session);
|
|
976
1393
|
return {
|
|
@@ -983,7 +1400,7 @@ export class SessionManager {
|
|
|
983
1400
|
warnings: prepared?.warnings ?? [],
|
|
984
1401
|
};
|
|
985
1402
|
}
|
|
986
|
-
async advanceUnlocked(sessionId, output,
|
|
1403
|
+
async advanceUnlocked(sessionId, output, roundReport) {
|
|
987
1404
|
// ── 1. Validate session ──────────────────────────────────────────────
|
|
988
1405
|
const session = this.sessions.get(sessionId);
|
|
989
1406
|
if (!session)
|
|
@@ -991,22 +1408,33 @@ export class SessionManager {
|
|
|
991
1408
|
if (session.status !== "running") {
|
|
992
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.` };
|
|
993
1410
|
}
|
|
994
|
-
// ── 2.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
return {
|
|
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
|
+
};
|
|
1002
1427
|
}
|
|
1003
|
-
const { selfEval, extractionFailed } = extracted;
|
|
1004
1428
|
// ── 3. Execute round transaction ─────────────────────────────────────
|
|
1005
|
-
const tx = await this.executeRoundTransaction(session,
|
|
1429
|
+
const tx = await this.executeRoundTransaction(session, roundReport);
|
|
1006
1430
|
// ── 4. Route disposition ─────────────────────────────────────────────
|
|
1007
1431
|
if (tx.pr.action === "reject") {
|
|
1008
1432
|
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
|
|
1009
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
|
+
}
|
|
1010
1438
|
// Accepted rounds advance the before-snapshot baseline. Rejected rounds
|
|
1011
1439
|
// retain the original baseline so their retry remains zero-commit.
|
|
1012
1440
|
session.evidenceBaseline = tx.actualEvidence;
|
|
@@ -1017,7 +1445,7 @@ export class SessionManager {
|
|
|
1017
1445
|
return this.buildStopResult(sessionId, session, tx.pr);
|
|
1018
1446
|
}
|
|
1019
1447
|
// ── 5. Continue — compile next round ─────────────────────────────────
|
|
1020
|
-
return this.advanceToNextRound(sessionId, session,
|
|
1448
|
+
return this.advanceToNextRound(sessionId, session, tx.pr, tx.verificationFlags, tx.actualEvidence);
|
|
1021
1449
|
}
|
|
1022
1450
|
/** Write back loop knowledge to long-term memory.
|
|
1023
1451
|
* Uses shared base builder from policy.ts. Called when a loop terminates. */
|
|
@@ -1031,7 +1459,7 @@ export class SessionManager {
|
|
|
1031
1459
|
stopReason: stopReason,
|
|
1032
1460
|
roundsCompleted: session.currentRound,
|
|
1033
1461
|
successTrajectory: [...session.successTrajectory],
|
|
1034
|
-
|
|
1462
|
+
lastRoundView: session.lastRoundProjection,
|
|
1035
1463
|
};
|
|
1036
1464
|
await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
|
|
1037
1465
|
}
|
|
@@ -1041,7 +1469,22 @@ export class SessionManager {
|
|
|
1041
1469
|
if (!session)
|
|
1042
1470
|
return null;
|
|
1043
1471
|
const replay = new ReplayBackend(this.backend);
|
|
1044
|
-
return replay.
|
|
1472
|
+
return replay.eventTimeline(session.loopId);
|
|
1473
|
+
}
|
|
1474
|
+
replayTimelineByLoop(loopId, fromSequence, toSequence, limit = 50) {
|
|
1475
|
+
const replay = new ReplayBackend(this.backend);
|
|
1476
|
+
const timeline = replay.eventTimeline(loopId)
|
|
1477
|
+
.filter((entry) => {
|
|
1478
|
+
const sequence = typeof entry.event_sequence === "number" ? entry.event_sequence : null;
|
|
1479
|
+
if (sequence === null)
|
|
1480
|
+
return fromSequence === undefined && toSequence === undefined;
|
|
1481
|
+
return (fromSequence === undefined || sequence >= fromSequence) &&
|
|
1482
|
+
(toSequence === undefined || sequence <= toSequence);
|
|
1483
|
+
})
|
|
1484
|
+
.slice(0, Math.max(1, Math.min(200, limit)));
|
|
1485
|
+
const last = timeline.at(-1);
|
|
1486
|
+
const lastSequence = last && typeof last.event_sequence === "number" ? last.event_sequence : null;
|
|
1487
|
+
return { timeline, nextSequence: lastSequence === null ? null : lastSequence + 1 };
|
|
1045
1488
|
}
|
|
1046
1489
|
}
|
|
1047
1490
|
//# sourceMappingURL=session.js.map
|