loopforge 2.11.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 +145 -128
- 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 +170 -61
- 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 +23 -21
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +34 -74
- 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 +22 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -16
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +1 -1
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +250 -337
- 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 -309
- 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 +83 -13
- 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 +49 -93
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +544 -245
- 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 +399 -274
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +17 -1
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +164 -1
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +34 -18
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +113 -18
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +5 -3
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +126 -77
- 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 +232 -192
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +15 -100
- 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 +25 -18
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +83 -71
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +14 -10
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +9 -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 +19 -12
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +102 -88
- 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/runtime-services.js +460 -0
- 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 +9 -8
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +50 -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 +12 -6
- package/skills/perception/SKILL.md +199 -164
- 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 -71
- package/dist/enforcement-gate.d.ts.map +0 -1
- package/dist/enforcement-gate.js +0 -744
- 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 +0 -596
- package/dist/engine.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 -181
- package/dist/self-eval.js.map +0 -1
- package/dist/verification-gate.d.ts +0 -56
- package/dist/verification-gate.d.ts.map +0 -1
- package/dist/verification-gate.js +0 -563
- package/dist/verification-gate.js.map +0 -1
package/dist/mcp/session.js
CHANGED
|
@@ -2,82 +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
|
-
// 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
|
-
}
|
|
75
28
|
// ── SessionManager ─────────────────────────────────────────────────────────
|
|
76
29
|
export class SessionManager {
|
|
77
30
|
sessions = new Map();
|
|
78
31
|
/** Serializes state transitions for each session. */
|
|
79
32
|
sessionQueues = new Map();
|
|
80
33
|
backend;
|
|
34
|
+
eventWriter;
|
|
81
35
|
sessionStore;
|
|
82
36
|
ownerId = `${process.pid}:${randomUUID()}`;
|
|
83
37
|
leaseMs;
|
|
@@ -86,16 +40,13 @@ export class SessionManager {
|
|
|
86
40
|
/** Explicit context provider; never auto-discovered. */
|
|
87
41
|
contextProvider;
|
|
88
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();
|
|
89
47
|
constructor(storeOrBackend, sessionStore) {
|
|
90
|
-
const
|
|
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);
|
|
48
|
+
const store = storeOrBackend ?? new FileLoopStore(getPolicy().backend.root_dir);
|
|
49
|
+
this.backend = store;
|
|
99
50
|
this.sessionStore = sessionStore ?? new VaultSessionStateStore(store);
|
|
100
51
|
const mcpPolicy = getPolicy().mcp;
|
|
101
52
|
this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
|
|
@@ -105,6 +56,11 @@ export class SessionManager {
|
|
|
105
56
|
this.leaseTimer.unref?.();
|
|
106
57
|
}
|
|
107
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
|
+
}
|
|
108
64
|
/** Stable process-local owner token used for cross-process session leases. */
|
|
109
65
|
getOwnerId() {
|
|
110
66
|
return this.ownerId;
|
|
@@ -157,36 +113,29 @@ export class SessionManager {
|
|
|
157
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.`,
|
|
158
114
|
};
|
|
159
115
|
}
|
|
160
|
-
async withSessionQueue(sessionId, work) {
|
|
161
|
-
const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
162
|
-
let release;
|
|
163
|
-
const gate = new Promise((resolve) => {
|
|
164
|
-
release = resolve;
|
|
165
|
-
});
|
|
166
|
-
const tail = previous.then(() => gate);
|
|
167
|
-
this.sessionQueues.set(sessionId, tail);
|
|
168
|
-
await previous;
|
|
169
|
-
try {
|
|
170
|
-
return await work();
|
|
171
|
-
}
|
|
172
|
-
finally {
|
|
173
|
-
release();
|
|
174
|
-
if (this.sessionQueues.get(sessionId) === tail) {
|
|
175
|
-
this.sessionQueues.delete(sessionId);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
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
|
+
}
|
|
180
129
|
const sessionId = randomUUID();
|
|
181
130
|
const loopId = input.loopId ?? randomUUID();
|
|
182
|
-
const engine = new
|
|
131
|
+
const engine = new RuntimeServices(this.backend);
|
|
183
132
|
const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
|
|
184
133
|
// Populate extra fields for the first round
|
|
185
134
|
const request = buildLoopRequest({
|
|
186
|
-
sessionId, loopId, task: input.task, engine, currentRound: 1,
|
|
135
|
+
sessionId, loopId, task: input.task, runtime: engine, currentRound: 1,
|
|
187
136
|
maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
|
|
188
137
|
consecutiveRejections: 0,
|
|
189
|
-
|
|
138
|
+
escalatedRejectionChecks: [],
|
|
190
139
|
driftClarificationStreak: 0,
|
|
191
140
|
backtrackSkippedFiles: [],
|
|
192
141
|
evidenceBaseline: [],
|
|
@@ -194,6 +143,21 @@ export class SessionManager {
|
|
|
194
143
|
request.domain = input.domain ?? "";
|
|
195
144
|
request.plan_source = input.planSource ?? null;
|
|
196
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
|
+
}
|
|
197
161
|
// Explicit context is requested by the MCP embedding; no auto-discovery.
|
|
198
162
|
if (this.contextProvider) {
|
|
199
163
|
try {
|
|
@@ -218,16 +182,26 @@ export class SessionManager {
|
|
|
218
182
|
}
|
|
219
183
|
}
|
|
220
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
|
+
}
|
|
221
195
|
const initialPrompt = prepared?.prompt ?? null;
|
|
222
196
|
const initialLevel = prepared?.level ?? "l2";
|
|
223
197
|
const evidenceBaseline = prepared?.evidenceBaseline ?? [];
|
|
224
198
|
const session = {
|
|
225
|
-
sessionId, loopId, task: input.task, engine,
|
|
199
|
+
sessionId, loopId, task: input.task, runtime: engine,
|
|
226
200
|
currentRound: 1, maxRounds, successTrajectory: [],
|
|
227
201
|
status: "running", createdAt: Date.now(),
|
|
228
202
|
// v1.13: Enforcement gate state
|
|
229
203
|
consecutiveRejections: 0,
|
|
230
|
-
|
|
204
|
+
escalatedRejectionChecks: [],
|
|
231
205
|
driftClarificationStreak: 0,
|
|
232
206
|
backtrackSkippedFiles: [],
|
|
233
207
|
evidenceBaseline,
|
|
@@ -235,6 +209,9 @@ export class SessionManager {
|
|
|
235
209
|
currentPrompt: initialPrompt,
|
|
236
210
|
currentLevel: initialLevel,
|
|
237
211
|
currentWarnings: prepared?.warnings ?? [],
|
|
212
|
+
currentCanonicalState: isRecord(prepared?.canonicalState)
|
|
213
|
+
? prepared.canonicalState
|
|
214
|
+
: undefined,
|
|
238
215
|
};
|
|
239
216
|
this.sessions.set(sessionId, session);
|
|
240
217
|
policyMetrics.recordStrategy(loopId, initialLevel);
|
|
@@ -279,12 +256,155 @@ export class SessionManager {
|
|
|
279
256
|
: 0,
|
|
280
257
|
};
|
|
281
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
|
+
}
|
|
282
398
|
delete(sessionId) {
|
|
283
399
|
const session = this.sessions.get(sessionId);
|
|
284
400
|
if (!session)
|
|
285
401
|
return false;
|
|
286
402
|
session.status = "stopped";
|
|
403
|
+
session.stopReason = "cancelled";
|
|
287
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");
|
|
288
408
|
void this.notifyTerminal(session, "cancelled");
|
|
289
409
|
this.sessions.delete(sessionId);
|
|
290
410
|
this.sessionQueues.delete(sessionId);
|
|
@@ -328,31 +448,22 @@ export class SessionManager {
|
|
|
328
448
|
warnings: (session.currentWarnings ?? []),
|
|
329
449
|
};
|
|
330
450
|
}
|
|
331
|
-
/** Reconcile the crash window where
|
|
451
|
+
/** Reconcile the crash window where a round event committed but session_state
|
|
332
452
|
* still points at the old prompt. Returns null when no commit is pending. */
|
|
333
|
-
reconcileCommittedRound(session) {
|
|
453
|
+
async reconcileCommittedRound(session) {
|
|
334
454
|
if (!session.roundSnapshot)
|
|
335
455
|
return null;
|
|
336
|
-
const recovered = new RoundDriver(session.
|
|
456
|
+
const recovered = new RoundDriver(session.runtime, this.backend).recover(session.roundSnapshot);
|
|
337
457
|
if (!recovered)
|
|
338
458
|
return null;
|
|
339
459
|
const pr = recovered.result;
|
|
340
460
|
session.roundSnapshot = recovered.snapshot;
|
|
341
|
-
// Replay the committed counter
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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;
|
|
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;
|
|
356
467
|
if (pr.shouldPushSuccessTrajectory &&
|
|
357
468
|
session.successTrajectory.length < session.currentRound) {
|
|
358
469
|
session.successTrajectory.push(pr.roundSuccess);
|
|
@@ -388,9 +499,11 @@ export class SessionManager {
|
|
|
388
499
|
const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
|
|
389
500
|
session.currentRound = restoreTarget + 1;
|
|
390
501
|
session.consecutiveRejections = 0;
|
|
391
|
-
session.
|
|
502
|
+
session.escalatedRejectionChecks = [];
|
|
392
503
|
session.driftClarificationStreak = 0;
|
|
393
|
-
session.
|
|
504
|
+
session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
|
|
505
|
+
session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
|
|
506
|
+
session.lastRoundProjection = undefined;
|
|
394
507
|
session.currentPrompt = null;
|
|
395
508
|
this.save(session);
|
|
396
509
|
// Return null — caller (resume/unpause) will recompile normally
|
|
@@ -399,17 +512,16 @@ export class SessionManager {
|
|
|
399
512
|
if (pr.action !== "continue")
|
|
400
513
|
return null;
|
|
401
514
|
session.currentRound++;
|
|
402
|
-
const request = buildLoopRequest(session,
|
|
403
|
-
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);
|
|
404
517
|
if (!prepared) {
|
|
405
|
-
session.status = "stalled";
|
|
406
|
-
this.save(session);
|
|
407
518
|
return {
|
|
408
519
|
sessionId: session.sessionId,
|
|
409
520
|
round: session.currentRound,
|
|
410
521
|
prompt: null,
|
|
411
|
-
stopReason: "
|
|
412
|
-
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 },
|
|
413
525
|
};
|
|
414
526
|
}
|
|
415
527
|
const prompt = prepared.prompt;
|
|
@@ -418,6 +530,9 @@ export class SessionManager {
|
|
|
418
530
|
session.currentPrompt = prompt;
|
|
419
531
|
session.currentLevel = prepared.level;
|
|
420
532
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
533
|
+
session.currentCanonicalState = isRecord(prepared.canonicalState)
|
|
534
|
+
? prepared.canonicalState
|
|
535
|
+
: undefined;
|
|
421
536
|
policyMetrics.recordStrategy(session.loopId, session.currentLevel);
|
|
422
537
|
this.save(session);
|
|
423
538
|
return {
|
|
@@ -467,7 +582,7 @@ export class SessionManager {
|
|
|
467
582
|
catch {
|
|
468
583
|
// Async evidence is best-effort; fall back to sync baseline.
|
|
469
584
|
}
|
|
470
|
-
const reconciled = this.reconcileCommittedRound(session);
|
|
585
|
+
const reconciled = await this.reconcileCommittedRound(session);
|
|
471
586
|
if (reconciled)
|
|
472
587
|
return reconciled;
|
|
473
588
|
const restored = this.restoredPromptResult(session);
|
|
@@ -489,7 +604,6 @@ export class SessionManager {
|
|
|
489
604
|
const compileRequest = {
|
|
490
605
|
task: session.task,
|
|
491
606
|
mode: Mode.LOOP_COMPILE,
|
|
492
|
-
feedback: null,
|
|
493
607
|
skill_name: null,
|
|
494
608
|
task_id: null,
|
|
495
609
|
loop_id: lcr.loop_id,
|
|
@@ -501,12 +615,16 @@ export class SessionManager {
|
|
|
501
615
|
health_check_interval: lcr.health_check_interval,
|
|
502
616
|
max_rounds: session.maxRounds,
|
|
503
617
|
};
|
|
504
|
-
const prepared = await new RoundDriver(session.
|
|
618
|
+
const prepared = await new RoundDriver(session.runtime, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
|
|
505
619
|
if (!prepared) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
+
};
|
|
510
628
|
}
|
|
511
629
|
const prompt = prepared.prompt;
|
|
512
630
|
const level = prepared.level;
|
|
@@ -515,6 +633,9 @@ export class SessionManager {
|
|
|
515
633
|
session.currentPrompt = prompt;
|
|
516
634
|
session.currentLevel = level;
|
|
517
635
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
636
|
+
session.currentCanonicalState = isRecord(prepared.canonicalState)
|
|
637
|
+
? prepared.canonicalState
|
|
638
|
+
: undefined;
|
|
518
639
|
policyMetrics.recordStrategy(session.loopId, level);
|
|
519
640
|
this.save(session);
|
|
520
641
|
return {
|
|
@@ -549,16 +670,19 @@ export class SessionManager {
|
|
|
549
670
|
created_at: session.createdAt,
|
|
550
671
|
// v1.13: Enforcement gate state
|
|
551
672
|
consecutive_rejections: session.consecutiveRejections,
|
|
552
|
-
|
|
673
|
+
// Enforcement escalation is tracked per terminal rule.
|
|
674
|
+
escalated_rejection_checks: session.escalatedRejectionChecks,
|
|
553
675
|
// v2.12: Clarification streak
|
|
554
676
|
drift_clarification_streak: session.driftClarificationStreak,
|
|
555
677
|
// v2.13: Backtrack skipped files for workspace restore check
|
|
556
678
|
backtrack_skipped_files: session.backtrackSkippedFiles,
|
|
679
|
+
// v2.13: Git HEAD of the active backtrack restore point
|
|
680
|
+
backtrack_target_git_head: session.backtrackTargetGitHead ?? "",
|
|
557
681
|
// v1.19: durable round transaction state
|
|
558
682
|
round_snapshot: session.roundSnapshot ?? null,
|
|
559
|
-
last_self_eval: session.lastSelfEval ?? null,
|
|
560
683
|
current_prompt: session.currentPrompt ?? null,
|
|
561
684
|
current_level: session.currentLevel ?? "",
|
|
685
|
+
stop_reason: session.stopReason ?? "",
|
|
562
686
|
// v1.20: cross-process single-owner lease
|
|
563
687
|
lease_owner: leaseActive ? this.ownerId : "",
|
|
564
688
|
lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
|
|
@@ -579,49 +703,61 @@ export class SessionManager {
|
|
|
579
703
|
}
|
|
580
704
|
const loopId = entry.loop_id;
|
|
581
705
|
const currentRound = lineage.current_round ?? 1;
|
|
582
|
-
const successTrajectory = lineage.success_trajectory
|
|
706
|
+
const successTrajectory = Array.isArray(lineage.success_trajectory)
|
|
707
|
+
? lineage.success_trajectory.filter((value) => typeof value === "boolean")
|
|
708
|
+
: [];
|
|
583
709
|
const task = entry.task ?? "";
|
|
584
710
|
const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
|
|
585
711
|
const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
typeof
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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)
|
|
594
723
|
: undefined;
|
|
595
|
-
const engine = new
|
|
724
|
+
const engine = new RuntimeServices(this.backend);
|
|
596
725
|
return {
|
|
597
726
|
sessionId: typeof lineage.session_id === "string" && lineage.session_id
|
|
598
727
|
? lineage.session_id
|
|
599
728
|
: randomUUID(),
|
|
600
|
-
loopId, task, engine,
|
|
729
|
+
loopId, task, runtime: engine,
|
|
601
730
|
currentRound, maxRounds, successTrajectory,
|
|
602
731
|
status: status,
|
|
603
732
|
createdAt: lineage.created_at ?? Date.now(),
|
|
604
733
|
consecutiveRejections: lineage.consecutive_rejections ?? 0,
|
|
605
|
-
|
|
606
|
-
? lineage.
|
|
607
|
-
:
|
|
734
|
+
escalatedRejectionChecks: Array.isArray(lineage.escalated_rejection_checks)
|
|
735
|
+
? lineage.escalated_rejection_checks.filter((value) => typeof value === "string")
|
|
736
|
+
: [],
|
|
608
737
|
driftClarificationStreak: lineage.drift_clarification_streak ?? 0,
|
|
609
738
|
backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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,
|
|
613
746
|
currentPrompt: typeof lineage.current_prompt === "string"
|
|
614
747
|
? lineage.current_prompt
|
|
615
748
|
: null,
|
|
616
749
|
currentLevel: typeof lineage.current_level === "string"
|
|
617
750
|
? lineage.current_level
|
|
618
751
|
: undefined,
|
|
752
|
+
stopReason: typeof lineage.stop_reason === "string" && lineage.stop_reason
|
|
753
|
+
? lineage.stop_reason
|
|
754
|
+
: undefined,
|
|
619
755
|
};
|
|
620
756
|
}
|
|
621
757
|
/** Resume a loop from vault state.
|
|
622
758
|
* Reconstructs the session and compiles the prompt for the next round.
|
|
623
759
|
* Returns null if no session_state entry exists for this loopId. */
|
|
624
|
-
resume(loopId) {
|
|
760
|
+
async resume(loopId) {
|
|
625
761
|
const persistedEntry = this.findSessionEntry(loopId);
|
|
626
762
|
if (!persistedEntry)
|
|
627
763
|
return null;
|
|
@@ -647,21 +783,36 @@ export class SessionManager {
|
|
|
647
783
|
};
|
|
648
784
|
}
|
|
649
785
|
this.sessions.set(session.sessionId, session);
|
|
650
|
-
const reconciled = this.reconcileCommittedRound(session);
|
|
786
|
+
const reconciled = await this.reconcileCommittedRound(session);
|
|
651
787
|
if (reconciled)
|
|
652
788
|
return reconciled;
|
|
653
789
|
const restored = this.restoredPromptResult(session);
|
|
654
790
|
if (restored)
|
|
655
791
|
return restored;
|
|
656
|
-
//
|
|
792
|
+
// A persisted session may lack a prompt after an interrupted handoff;
|
|
793
|
+
// compile the current canonical state once and persist the replacement.
|
|
657
794
|
const request = buildLoopRequest(session);
|
|
658
|
-
const prepared = new RoundDriver(session.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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;
|
|
662
810
|
session.currentPrompt = prompt;
|
|
663
811
|
session.currentLevel = prepared?.level ?? "l2";
|
|
664
812
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
813
|
+
session.currentCanonicalState = isRecord(prepared?.canonicalState)
|
|
814
|
+
? prepared.canonicalState
|
|
815
|
+
: undefined;
|
|
665
816
|
policyMetrics.recordStrategy(session.loopId, session.currentLevel);
|
|
666
817
|
this.save(session);
|
|
667
818
|
return {
|
|
@@ -744,6 +895,56 @@ export class SessionManager {
|
|
|
744
895
|
}
|
|
745
896
|
return result;
|
|
746
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
|
+
}
|
|
747
948
|
/** Get loop health for a loop (in-memory or vault).
|
|
748
949
|
* Computes goal alignment, constraint integrity, drift, strategy stability. */
|
|
749
950
|
getHealth(loopId) {
|
|
@@ -753,8 +954,8 @@ export class SessionManager {
|
|
|
753
954
|
for (const s of this.sessions.values()) {
|
|
754
955
|
if (s.loopId === loopId) {
|
|
755
956
|
task = s.task;
|
|
756
|
-
if (s.
|
|
757
|
-
goalId = s.
|
|
957
|
+
if (s.runtime.state?.task_id) {
|
|
958
|
+
goalId = s.runtime.state.task_id;
|
|
758
959
|
}
|
|
759
960
|
break;
|
|
760
961
|
}
|
|
@@ -769,7 +970,7 @@ export class SessionManager {
|
|
|
769
970
|
if (!task)
|
|
770
971
|
return null;
|
|
771
972
|
// Hydrate vault context
|
|
772
|
-
const engine = new
|
|
973
|
+
const engine = new RuntimeServices(this.backend);
|
|
773
974
|
const vaultContext = engine.hydrateLoopContext(loopId);
|
|
774
975
|
// Build a minimal request for health check
|
|
775
976
|
const request = makeLoopCompileRequest({
|
|
@@ -786,22 +987,32 @@ export class SessionManager {
|
|
|
786
987
|
drift_detected: health.drift_detected,
|
|
787
988
|
strategy_stability: health.strategy_stability,
|
|
788
989
|
task_continuity: health.task_continuity,
|
|
789
|
-
policy_metrics:
|
|
990
|
+
policy_metrics: this.getLoopPolicyMetrics(loopId),
|
|
790
991
|
};
|
|
791
992
|
}
|
|
792
|
-
/** Core cycle
|
|
793
|
-
*
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
async advance(sessionId, output, preExtractedEval) {
|
|
797
|
-
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 () => {
|
|
798
997
|
const session = this.sessions.get(sessionId);
|
|
799
998
|
if (session?.status === "running" &&
|
|
800
999
|
!this.renewSessionLease(session.loopId)) {
|
|
801
1000
|
return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
|
|
802
1001
|
}
|
|
803
1002
|
try {
|
|
804
|
-
|
|
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);
|
|
805
1016
|
}
|
|
806
1017
|
catch (error) {
|
|
807
1018
|
if (error instanceof SessionLeaseConflictError) {
|
|
@@ -818,36 +1029,33 @@ export class SessionManager {
|
|
|
818
1029
|
//
|
|
819
1030
|
// Pipeline: validate → extract → execute transaction → route disposition
|
|
820
1031
|
// → advance to next round (continue) or return terminal result.
|
|
821
|
-
/**
|
|
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.
|
|
1032
|
+
/** Execute the round transaction and apply global rejection tracking.
|
|
834
1033
|
* MUTATES: session.roundSnapshot, session.consecutiveRejections,
|
|
835
|
-
* session.
|
|
1034
|
+
* session.escalatedRejectionChecks, session.lastRoundProjection,
|
|
836
1035
|
* session.successTrajectory, session.driftClarificationStreak */
|
|
837
|
-
async executeRoundTransaction(session,
|
|
1036
|
+
async executeRoundTransaction(session, roundReport) {
|
|
838
1037
|
const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
|
|
839
|
-
const completed = await new RoundDriver(session.
|
|
1038
|
+
const completed = await new RoundDriver(session.runtime, this.backend).complete({
|
|
840
1039
|
snapshot,
|
|
841
1040
|
loopId: session.loopId,
|
|
842
1041
|
task: session.task,
|
|
843
1042
|
maxRounds: session.maxRounds,
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
lastSelfEval: session.lastSelfEval,
|
|
1043
|
+
roundReport,
|
|
1044
|
+
previousProjection: session.lastRoundProjection,
|
|
847
1045
|
consecutiveRejections: session.consecutiveRejections,
|
|
848
1046
|
successTrajectory: session.successTrajectory,
|
|
849
1047
|
driftClarificationStreak: session.driftClarificationStreak,
|
|
1048
|
+
escalatedRejectionChecks: session.escalatedRejectionChecks,
|
|
850
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
|
+
: [],
|
|
851
1059
|
});
|
|
852
1060
|
const outcome = completed.outcome;
|
|
853
1061
|
const actualEvidence = completed.actualEvidence;
|
|
@@ -857,25 +1065,23 @@ export class SessionManager {
|
|
|
857
1065
|
// v2.12: Clarification streak tracking — independent of rejection tracking.
|
|
858
1066
|
// Reset on rounds without intent_drift; track substantive vs weak clarifications.
|
|
859
1067
|
this.updateClarificationStreak(session, pr);
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
session.
|
|
868
|
-
}
|
|
869
|
-
else {
|
|
870
|
-
session.consecutiveRejections = pr.newConsecutiveRejections;
|
|
871
|
-
if (pr.action !== "reject")
|
|
872
|
-
session.lastRejectionCheck = "";
|
|
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;
|
|
873
1076
|
}
|
|
874
|
-
if (pr.
|
|
875
|
-
session.
|
|
1077
|
+
if (pr.newRoundProjection)
|
|
1078
|
+
session.lastRoundProjection = pr.newRoundProjection;
|
|
876
1079
|
if (pr.shouldPushSuccessTrajectory) {
|
|
877
1080
|
session.successTrajectory.push(pr.roundSuccess);
|
|
878
1081
|
}
|
|
1082
|
+
if (pr.action === "continue" || pr.action === "stop") {
|
|
1083
|
+
session.currentCanonicalState = undefined;
|
|
1084
|
+
}
|
|
879
1085
|
return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
|
|
880
1086
|
}
|
|
881
1087
|
/** v2.12: Update the clarification streak based on the round result.
|
|
@@ -892,9 +1098,9 @@ export class SessionManager {
|
|
|
892
1098
|
session.driftClarificationStreak = 0;
|
|
893
1099
|
return;
|
|
894
1100
|
}
|
|
895
|
-
//
|
|
896
|
-
// Keep streak as-is (don't increment for genuine explanations).
|
|
1101
|
+
// A runtime-verified clarification closes the drift episode.
|
|
897
1102
|
if (pr.clarificationAccepted) {
|
|
1103
|
+
session.driftClarificationStreak = 0;
|
|
898
1104
|
return;
|
|
899
1105
|
}
|
|
900
1106
|
// R7 rejected (weak or no clarification). The enforcement gate
|
|
@@ -902,28 +1108,43 @@ export class SessionManager {
|
|
|
902
1108
|
// Increment the persisted counter so crash recovery sees it.
|
|
903
1109
|
session.driftClarificationStreak += 1;
|
|
904
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
|
+
}
|
|
905
1125
|
/** Build a rejection result: compile a retry prompt, persist, return.
|
|
906
1126
|
* MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
|
|
907
1127
|
async buildRejectionResult(sessionId, session, pr, verificationFlags) {
|
|
908
1128
|
const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
|
|
909
|
-
const preparedRetry = await new RoundDriver(session.
|
|
1129
|
+
const preparedRetry = await new RoundDriver(session.runtime, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
|
|
910
1130
|
if (!preparedRetry) {
|
|
911
|
-
session.status = "stalled";
|
|
912
|
-
session.currentPrompt = null;
|
|
913
|
-
this.save(session);
|
|
914
1131
|
return {
|
|
915
1132
|
sessionId,
|
|
916
1133
|
round: session.currentRound,
|
|
917
1134
|
roundId: session.roundSnapshot?.roundId,
|
|
918
1135
|
prompt: null,
|
|
919
|
-
stopReason: "
|
|
920
|
-
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 },
|
|
921
1139
|
};
|
|
922
1140
|
}
|
|
923
1141
|
session.roundSnapshot = preparedRetry.snapshot;
|
|
924
1142
|
session.currentPrompt = preparedRetry.prompt;
|
|
925
1143
|
session.currentLevel = preparedRetry.level;
|
|
926
1144
|
session.currentWarnings = preparedRetry.warnings ?? [];
|
|
1145
|
+
session.currentCanonicalState = isRecord(preparedRetry.canonicalState)
|
|
1146
|
+
? preparedRetry.canonicalState
|
|
1147
|
+
: undefined;
|
|
927
1148
|
this.save(session);
|
|
928
1149
|
return {
|
|
929
1150
|
sessionId,
|
|
@@ -941,14 +1162,15 @@ export class SessionManager {
|
|
|
941
1162
|
* backtrack prompt at the top.
|
|
942
1163
|
* MUTATES: session.currentRound, session.roundSnapshot,
|
|
943
1164
|
* session.currentPrompt, session.currentLevel,
|
|
944
|
-
* session.consecutiveRejections, session.
|
|
1165
|
+
* session.consecutiveRejections, session.lastRoundProjection */
|
|
945
1166
|
async buildBacktrackResult(sessionId, session, pr) {
|
|
946
|
-
const
|
|
1167
|
+
const skippedUntilRound = session.currentRound;
|
|
1168
|
+
const restoreTarget = pr.backtrackTarget ?? (skippedUntilRound - 1);
|
|
947
1169
|
const newRound = restoreTarget + 1;
|
|
948
1170
|
logEvent("session_backtrack", {
|
|
949
1171
|
sessionId,
|
|
950
1172
|
loopId: session.loopId,
|
|
951
|
-
fromRound:
|
|
1173
|
+
fromRound: skippedUntilRound,
|
|
952
1174
|
toRound: newRound,
|
|
953
1175
|
triggerRule: pr.backtrackTriggerRule ?? "",
|
|
954
1176
|
skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
|
|
@@ -956,11 +1178,28 @@ export class SessionManager {
|
|
|
956
1178
|
// Reset session to the restored round
|
|
957
1179
|
session.currentRound = newRound;
|
|
958
1180
|
session.consecutiveRejections = 0;
|
|
959
|
-
session.
|
|
1181
|
+
session.escalatedRejectionChecks = [];
|
|
960
1182
|
session.driftClarificationStreak = 0;
|
|
961
1183
|
session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
|
|
962
|
-
session.
|
|
1184
|
+
session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
|
|
1185
|
+
session.lastRoundProjection = undefined; // force L2 recompile from vault state
|
|
963
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
|
+
});
|
|
964
1203
|
// Preserve discoveries from skipped rounds as constraints_from_plan
|
|
965
1204
|
// so they survive the compilation and re-enter active constraints.
|
|
966
1205
|
const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
|
|
@@ -968,7 +1207,6 @@ export class SessionManager {
|
|
|
968
1207
|
const request = {
|
|
969
1208
|
task: session.task,
|
|
970
1209
|
mode: Mode.LOOP_COMPILE,
|
|
971
|
-
feedback: null,
|
|
972
1210
|
skill_name: null,
|
|
973
1211
|
task_id: null,
|
|
974
1212
|
loop_id: session.loopId,
|
|
@@ -977,19 +1215,24 @@ export class SessionManager {
|
|
|
977
1215
|
verification_flags: pr.verificationFlags,
|
|
978
1216
|
constraints_from_plan: preservedDiscoveries,
|
|
979
1217
|
};
|
|
980
|
-
const prepared = await new RoundDriver(session.
|
|
1218
|
+
const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, newRound);
|
|
981
1219
|
if (!prepared || !prepared.prompt) {
|
|
982
|
-
session.status = "stalled";
|
|
983
|
-
session.currentPrompt = null;
|
|
984
|
-
this.save(session);
|
|
985
1220
|
return {
|
|
986
1221
|
sessionId,
|
|
987
1222
|
round: newRound,
|
|
988
1223
|
prompt: null,
|
|
989
|
-
stopReason: "
|
|
990
|
-
stopDetail: "Backtrack
|
|
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 },
|
|
991
1227
|
enforcementAction: "backtrack",
|
|
992
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
|
+
],
|
|
993
1236
|
};
|
|
994
1237
|
}
|
|
995
1238
|
// Inject backtrack prompt at the top of the compiled prompt
|
|
@@ -1002,6 +1245,9 @@ export class SessionManager {
|
|
|
1002
1245
|
session.currentPrompt = fullPrompt;
|
|
1003
1246
|
session.currentLevel = prepared.level;
|
|
1004
1247
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
1248
|
+
session.currentCanonicalState = isRecord(prepared.canonicalState)
|
|
1249
|
+
? prepared.canonicalState
|
|
1250
|
+
: undefined;
|
|
1005
1251
|
this.save(session);
|
|
1006
1252
|
return {
|
|
1007
1253
|
sessionId,
|
|
@@ -1012,6 +1258,13 @@ export class SessionManager {
|
|
|
1012
1258
|
enforcementAction: "backtrack",
|
|
1013
1259
|
enforcementReason: pr.enforcementReason,
|
|
1014
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
|
+
],
|
|
1015
1268
|
};
|
|
1016
1269
|
}
|
|
1017
1270
|
/** Build a termination result: persist stopped status, notify sinks.
|
|
@@ -1020,6 +1273,11 @@ export class SessionManager {
|
|
|
1020
1273
|
session.status = "stopped";
|
|
1021
1274
|
session.currentPrompt = null;
|
|
1022
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
|
+
});
|
|
1023
1281
|
void this.notifyTerminal(session, "enforcement_terminated");
|
|
1024
1282
|
logEvent("session_end", {
|
|
1025
1283
|
sessionId, loopId: session.loopId,
|
|
@@ -1043,6 +1301,8 @@ export class SessionManager {
|
|
|
1043
1301
|
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
1044
1302
|
session.currentPrompt = null;
|
|
1045
1303
|
this.save(session);
|
|
1304
|
+
// Durable terminal event after the session_state write (A4).
|
|
1305
|
+
this.persistTerminalEvent(session, reason);
|
|
1046
1306
|
void this.notifyTerminal(session, reason);
|
|
1047
1307
|
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
|
|
1048
1308
|
return {
|
|
@@ -1050,7 +1310,7 @@ export class SessionManager {
|
|
|
1050
1310
|
roundId: session.roundSnapshot?.roundId,
|
|
1051
1311
|
prompt: null, stopReason: reason,
|
|
1052
1312
|
stopDetail: pr.stopReason === "circuit_breaker"
|
|
1053
|
-
?
|
|
1313
|
+
? "The last consecutive accepted rounds all failed without recovery, triggering the circuit breaker."
|
|
1054
1314
|
: pr.stopReason === "max_rounds"
|
|
1055
1315
|
? "The configured maximum number of rounds has been reached."
|
|
1056
1316
|
: `Loop stopped: ${reason}.`,
|
|
@@ -1061,28 +1321,25 @@ export class SessionManager {
|
|
|
1061
1321
|
* Includes the commit fence (pause/delete race guard) and context provider.
|
|
1062
1322
|
* MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
|
|
1063
1323
|
* session.evidenceBaseline, session.roundSnapshot */
|
|
1064
|
-
async advanceToNextRound(sessionId, session,
|
|
1324
|
+
async advanceToNextRound(sessionId, session, pr, verificationFlags, actualEvidence) {
|
|
1065
1325
|
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 = [];
|
|
1326
|
+
const nextRound = session.currentRound + 1;
|
|
1070
1327
|
// v1.8: Memory injection — tier-aware external context.
|
|
1071
1328
|
let externalCtx = "";
|
|
1072
1329
|
if (this.contextProvider) {
|
|
1073
1330
|
try {
|
|
1074
1331
|
externalCtx = (await this.contextProvider({
|
|
1075
1332
|
loopId: session.loopId,
|
|
1076
|
-
round:
|
|
1333
|
+
round: nextRound,
|
|
1077
1334
|
task: session.task,
|
|
1078
1335
|
domain: "",
|
|
1079
|
-
|
|
1336
|
+
lastRoundView: session.lastRoundProjection,
|
|
1080
1337
|
})).trim();
|
|
1081
1338
|
}
|
|
1082
1339
|
catch {
|
|
1083
1340
|
logEvent("context_provider_error", {
|
|
1084
1341
|
loopId: session.loopId,
|
|
1085
|
-
round:
|
|
1342
|
+
round: nextRound,
|
|
1086
1343
|
});
|
|
1087
1344
|
}
|
|
1088
1345
|
}
|
|
@@ -1098,19 +1355,39 @@ export class SessionManager {
|
|
|
1098
1355
|
roundSuccess,
|
|
1099
1356
|
};
|
|
1100
1357
|
}
|
|
1101
|
-
const request = buildLoopRequest(session,
|
|
1358
|
+
const request = buildLoopRequest({ ...session, currentRound: nextRound }, session.lastRoundProjection, verificationFlags);
|
|
1102
1359
|
if (externalCtx) {
|
|
1103
1360
|
request.external_context = externalCtx;
|
|
1104
1361
|
}
|
|
1105
|
-
const prepared = await new RoundDriver(session.
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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;
|
|
1109
1383
|
session.evidenceBaseline = nextBaseline;
|
|
1110
1384
|
session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
|
|
1111
1385
|
session.currentPrompt = nextPrompt;
|
|
1112
1386
|
session.currentLevel = nextLevel;
|
|
1113
1387
|
session.currentWarnings = prepared?.warnings ?? [];
|
|
1388
|
+
session.currentCanonicalState = isRecord(prepared?.canonicalState)
|
|
1389
|
+
? prepared.canonicalState
|
|
1390
|
+
: undefined;
|
|
1114
1391
|
policyMetrics.recordStrategy(session.loopId, nextLevel);
|
|
1115
1392
|
this.save(session);
|
|
1116
1393
|
return {
|
|
@@ -1123,7 +1400,7 @@ export class SessionManager {
|
|
|
1123
1400
|
warnings: prepared?.warnings ?? [],
|
|
1124
1401
|
};
|
|
1125
1402
|
}
|
|
1126
|
-
async advanceUnlocked(sessionId, output,
|
|
1403
|
+
async advanceUnlocked(sessionId, output, roundReport) {
|
|
1127
1404
|
// ── 1. Validate session ──────────────────────────────────────────────
|
|
1128
1405
|
const session = this.sessions.get(sessionId);
|
|
1129
1406
|
if (!session)
|
|
@@ -1131,18 +1408,25 @@ export class SessionManager {
|
|
|
1131
1408
|
if (session.status !== "running") {
|
|
1132
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.` };
|
|
1133
1410
|
}
|
|
1134
|
-
// ── 2.
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
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
|
+
};
|
|
1142
1427
|
}
|
|
1143
|
-
const { selfEval, extractionFailed } = extracted;
|
|
1144
1428
|
// ── 3. Execute round transaction ─────────────────────────────────────
|
|
1145
|
-
const tx = await this.executeRoundTransaction(session,
|
|
1429
|
+
const tx = await this.executeRoundTransaction(session, roundReport);
|
|
1146
1430
|
// ── 4. Route disposition ─────────────────────────────────────────────
|
|
1147
1431
|
if (tx.pr.action === "reject") {
|
|
1148
1432
|
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
|
|
@@ -1161,7 +1445,7 @@ export class SessionManager {
|
|
|
1161
1445
|
return this.buildStopResult(sessionId, session, tx.pr);
|
|
1162
1446
|
}
|
|
1163
1447
|
// ── 5. Continue — compile next round ─────────────────────────────────
|
|
1164
|
-
return this.advanceToNextRound(sessionId, session,
|
|
1448
|
+
return this.advanceToNextRound(sessionId, session, tx.pr, tx.verificationFlags, tx.actualEvidence);
|
|
1165
1449
|
}
|
|
1166
1450
|
/** Write back loop knowledge to long-term memory.
|
|
1167
1451
|
* Uses shared base builder from policy.ts. Called when a loop terminates. */
|
|
@@ -1175,7 +1459,7 @@ export class SessionManager {
|
|
|
1175
1459
|
stopReason: stopReason,
|
|
1176
1460
|
roundsCompleted: session.currentRound,
|
|
1177
1461
|
successTrajectory: [...session.successTrajectory],
|
|
1178
|
-
|
|
1462
|
+
lastRoundView: session.lastRoundProjection,
|
|
1179
1463
|
};
|
|
1180
1464
|
await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
|
|
1181
1465
|
}
|
|
@@ -1185,7 +1469,22 @@ export class SessionManager {
|
|
|
1185
1469
|
if (!session)
|
|
1186
1470
|
return null;
|
|
1187
1471
|
const replay = new ReplayBackend(this.backend);
|
|
1188
|
-
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 };
|
|
1189
1488
|
}
|
|
1190
1489
|
}
|
|
1191
1490
|
//# sourceMappingURL=session.js.map
|