loopforge 3.0.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -145
- package/dist/audit.d.ts +50 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +158 -0
- package/dist/audit.js.map +1 -0
- package/dist/canonical-state.d.ts +28 -20
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +82 -173
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -16
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-governance.d.ts +25 -33
- package/dist/cognitive-governance.d.ts.map +1 -1
- package/dist/cognitive-governance.js +38 -138
- package/dist/cognitive-governance.js.map +1 -1
- package/dist/enforcement-gate.d.ts +76 -0
- package/dist/enforcement-gate.d.ts.map +1 -0
- package/dist/enforcement-gate.js +1071 -0
- package/dist/enforcement-gate.js.map +1 -0
- package/dist/engine.d.ts +99 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +839 -0
- package/dist/engine.js.map +1 -0
- package/dist/evidence-claims.d.ts +50 -0
- package/dist/evidence-claims.d.ts.map +1 -0
- package/dist/evidence-claims.js +134 -0
- package/dist/evidence-claims.js.map +1 -0
- package/dist/evidence-provider.d.ts +21 -23
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +74 -34
- package/dist/evidence-provider.js.map +1 -1
- package/dist/generate-schema.js +1 -84
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +19 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -15
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +41 -3
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +632 -273
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-extras-parser.d.ts +2 -2
- package/dist/loop-extras-parser.d.ts.map +1 -1
- package/dist/loop-extras-parser.js +2 -2
- package/dist/loop-extras-parser.js.map +1 -1
- package/dist/loop-projection.d.ts +26 -0
- package/dist/loop-projection.d.ts.map +1 -0
- package/dist/loop-projection.js +142 -0
- package/dist/loop-projection.js.map +1 -0
- package/dist/loop-store.d.ts +112 -24
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +365 -218
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +216 -0
- package/dist/mcp/round-lifecycle.d.ts.map +1 -0
- package/dist/mcp/round-lifecycle.js +926 -0
- package/dist/mcp/round-lifecycle.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +28 -42
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +53 -101
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +306 -1105
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +13 -16
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +479 -413
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +12 -17
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +95 -161
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +44 -35
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +31 -115
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +37 -6
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +389 -160
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-policy.d.ts +1 -8
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +8 -26
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +357 -229
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +139 -15
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts +5 -13
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +19 -55
- package/dist/replay.js.map +1 -1
- package/dist/round-coordinator.d.ts +20 -24
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +99 -92
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +14 -13
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +26 -14
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +15 -18
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +38 -47
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +53 -0
- package/dist/self-eval.d.ts.map +1 -0
- package/dist/self-eval.js +284 -0
- package/dist/self-eval.js.map +1 -0
- package/dist/storage.d.ts +9 -4
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +18 -6
- package/dist/storage.js.map +1 -1
- package/dist/token-utils.d.ts +15 -3
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +24 -5
- package/dist/token-utils.js.map +1 -1
- package/dist/verification-gate.d.ts +108 -0
- package/dist/verification-gate.d.ts.map +1 -0
- package/dist/verification-gate.js +971 -0
- package/dist/verification-gate.js.map +1 -0
- package/loop_policy.json +44 -15
- package/package.json +5 -10
- package/skills/perception/SKILL.md +182 -199
- package/dist/canonical-event-writer.d.ts +0 -20
- package/dist/canonical-event-writer.d.ts.map +0 -1
- package/dist/canonical-event-writer.js +0 -131
- package/dist/canonical-event-writer.js.map +0 -1
- package/dist/compiler-subgoals.d.ts +0 -7
- package/dist/compiler-subgoals.d.ts.map +0 -1
- package/dist/compiler-subgoals.js +0 -93
- package/dist/compiler-subgoals.js.map +0 -1
- package/dist/derive-id.d.ts +0 -12
- package/dist/derive-id.d.ts.map +0 -1
- package/dist/derive-id.js +0 -31
- package/dist/derive-id.js.map +0 -1
- package/dist/mcp/session-projection.d.ts +0 -5
- package/dist/mcp/session-projection.d.ts.map +0 -1
- package/dist/mcp/session-projection.js +0 -18
- package/dist/mcp/session-projection.js.map +0 -1
- package/dist/mcp/session-queue.d.ts +0 -3
- package/dist/mcp/session-queue.d.ts.map +0 -1
- package/dist/mcp/session-queue.js +0 -18
- package/dist/mcp/session-queue.js.map +0 -1
- package/dist/mcp/session-types.d.ts +0 -72
- package/dist/mcp/session-types.d.ts.map +0 -1
- package/dist/mcp/session-types.js +0 -2
- package/dist/mcp/session-types.js.map +0 -1
- package/dist/round-commit-store.d.ts +0 -12
- package/dist/round-commit-store.d.ts.map +0 -1
- package/dist/round-commit-store.js +0 -13
- package/dist/round-commit-store.js.map +0 -1
- package/dist/round-gate.d.ts +0 -31
- package/dist/round-gate.d.ts.map +0 -1
- package/dist/round-gate.js +0 -259
- package/dist/round-gate.js.map +0 -1
- package/dist/round-recovery.d.ts +0 -17
- package/dist/round-recovery.d.ts.map +0 -1
- package/dist/round-recovery.js +0 -117
- package/dist/round-recovery.js.map +0 -1
- package/dist/round-report.d.ts +0 -16
- package/dist/round-report.d.ts.map +0 -1
- package/dist/round-report.js +0 -401
- package/dist/round-report.js.map +0 -1
- package/dist/runtime-services.d.ts +0 -59
- package/dist/runtime-services.d.ts.map +0 -1
- package/dist/runtime-services.js +0 -460
- package/dist/runtime-services.js.map +0 -1
- package/dist/vault-format.d.ts +0 -4
- package/dist/vault-format.d.ts.map +0 -1
- package/dist/vault-format.js +0 -4
- package/dist/vault-format.js.map +0 -1
- package/dist/vault-types.d.ts +0 -24
- package/dist/vault-types.d.ts.map +0 -1
- package/dist/vault-types.js +0 -2
- package/dist/vault-types.js.map +0 -1
- package/loopforge-policy.json +0 -340
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/** LoopForge MCP — Round lifecycle.
|
|
2
|
+
*
|
|
3
|
+
* The round state machine split out of SessionManager: crash recovery,
|
|
4
|
+
* transaction execution, disposition result building, and the advance
|
|
5
|
+
* pipeline. SessionManager owns "who may touch a session" (registry, queue,
|
|
6
|
+
* leases); this class owns "what happens to a session" (state machine,
|
|
7
|
+
* recovery, result building).
|
|
8
|
+
*
|
|
9
|
+
* Every method was moved verbatim from SessionManager. Only reference paths
|
|
10
|
+
* changed (this.sessions.* → registry.*, this.ownerId / this.leaseMs →
|
|
11
|
+
* constructor deps, this.contextProvider → getContext()).
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { LoopForgeEngine, extractSelfEvaluation } from "../engine.js";
|
|
15
|
+
import { extractSelfEvaluationWithDiagnostics, inferOutcomeFromText, } from "../self-eval.js";
|
|
16
|
+
import { deriveGate } from "../cognitive-governance.js";
|
|
17
|
+
import { Mode, makeLoopCompileRequest, makeLoopRoundResult, } from "../protocol.js";
|
|
18
|
+
import { EvidenceCollector } from "../evidence-provider.js";
|
|
19
|
+
import { makeRoundId, parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
|
|
20
|
+
import { RoundDriver } from "../round-driver.js";
|
|
21
|
+
import { getPolicy } from "../policy.js";
|
|
22
|
+
import { checkRoundSequence, queryLoopEntries } from "../loop-store.js";
|
|
23
|
+
import { logEvent } from "../observability.js";
|
|
24
|
+
import { policyMetrics } from "../policy-metrics.js";
|
|
25
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
26
|
+
export function buildLoopRequest(session, lastEval, verificationFlags) {
|
|
27
|
+
const req = {
|
|
28
|
+
task: session.task,
|
|
29
|
+
mode: Mode.LOOP_COMPILE,
|
|
30
|
+
feedback: null,
|
|
31
|
+
skill_name: null,
|
|
32
|
+
task_id: null,
|
|
33
|
+
loop_id: session.loopId,
|
|
34
|
+
round: session.currentRound,
|
|
35
|
+
max_rounds: session.maxRounds,
|
|
36
|
+
verification_flags: verificationFlags ?? [],
|
|
37
|
+
};
|
|
38
|
+
if (lastEval) {
|
|
39
|
+
req.last_round_result = makeLoopRoundResult({
|
|
40
|
+
round: session.currentRound - 1,
|
|
41
|
+
success: lastEval.success,
|
|
42
|
+
output_summary: lastEval.output_summary,
|
|
43
|
+
constraint_violations: lastEval.constraint_violations,
|
|
44
|
+
manual_fixes_needed: "",
|
|
45
|
+
// P0–P2: Forward evolution fields to next compile
|
|
46
|
+
// Merge sub-agent discovered constraints into the active set
|
|
47
|
+
discovered_constraints: [
|
|
48
|
+
...new Set([
|
|
49
|
+
...(lastEval.discovered_constraints ?? []),
|
|
50
|
+
...(lastEval.worker_results ?? []).flatMap((w) => w.discoveredConstraints ?? []).filter((c) => c.length > 0),
|
|
51
|
+
]),
|
|
52
|
+
],
|
|
53
|
+
objective_refinement: lastEval.objective_refinement ?? "",
|
|
54
|
+
emerged_subtasks: lastEval.emerged_subtasks ?? [],
|
|
55
|
+
// P4: Execution evidence
|
|
56
|
+
execution_evidence: lastEval.execution_evidence ?? undefined,
|
|
57
|
+
// P5: Self-correction
|
|
58
|
+
retracted_constraints: lastEval.retracted_constraints ?? [],
|
|
59
|
+
revised_success_criteria: lastEval.revised_success_criteria ?? [],
|
|
60
|
+
wrong_assumptions: lastEval.wrong_assumptions ?? [],
|
|
61
|
+
// Multi-agent: Forward delegation results to next compile
|
|
62
|
+
worker_results: lastEval.worker_results ?? [],
|
|
63
|
+
// v1.10: Checkpoint boundary
|
|
64
|
+
compression_checkpoint: lastEval.compression_checkpoint ?? false,
|
|
65
|
+
checkpoint_label: lastEval.checkpoint_label ?? "",
|
|
66
|
+
// v1.16: Agent's declared next action
|
|
67
|
+
next_action: lastEval.next_action,
|
|
68
|
+
// v2.2: Sub-goal lifecycle
|
|
69
|
+
completed_subtasks: lastEval.completed_subtasks ?? [],
|
|
70
|
+
blocked_subtasks: lastEval.blocked_subtasks ?? [],
|
|
71
|
+
canceled_subtasks: lastEval.canceled_subtasks ?? [],
|
|
72
|
+
// v2.8: Drift clarification
|
|
73
|
+
drift_clarification: lastEval.drift_clarification,
|
|
74
|
+
// v2.9: Model's information needs for the next prompt
|
|
75
|
+
prompt_requests: lastEval.prompt_requests,
|
|
76
|
+
// v2.12: Tri-state outcome + blocker + retroactive claims
|
|
77
|
+
outcome: lastEval.outcome,
|
|
78
|
+
blocker: lastEval.blocker,
|
|
79
|
+
retroactiveClaims: lastEval.retroactiveClaims ?? [],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return req;
|
|
83
|
+
}
|
|
84
|
+
// ── RoundLifecycle ─────────────────────────────────────────────────────────
|
|
85
|
+
export class RoundLifecycle {
|
|
86
|
+
store;
|
|
87
|
+
sessionStore;
|
|
88
|
+
registry;
|
|
89
|
+
terminalSinks;
|
|
90
|
+
ownerId;
|
|
91
|
+
leaseMs;
|
|
92
|
+
getContext;
|
|
93
|
+
constructor(deps) {
|
|
94
|
+
this.store = deps.store;
|
|
95
|
+
this.sessionStore = deps.sessionStore;
|
|
96
|
+
this.registry = deps.registry;
|
|
97
|
+
this.terminalSinks = deps.terminalSinks;
|
|
98
|
+
this.ownerId = deps.ownerId;
|
|
99
|
+
this.leaseMs = deps.leaseMs;
|
|
100
|
+
this.getContext = deps.getContext;
|
|
101
|
+
}
|
|
102
|
+
/** Persist session state to vault for cross-process recovery.
|
|
103
|
+
* The filtered vault and replacement entry are written once under the
|
|
104
|
+
* backend lock, so recovery never observes the old two-write gap. */
|
|
105
|
+
save(session) {
|
|
106
|
+
if (!this.sessionStore)
|
|
107
|
+
return;
|
|
108
|
+
const leaseActive = session.status === "running";
|
|
109
|
+
const sessionEntry = {
|
|
110
|
+
task_id: `loop:${session.loopId}:session`,
|
|
111
|
+
task_type: "session_state",
|
|
112
|
+
timestamp: new Date().toISOString(),
|
|
113
|
+
loop_id: session.loopId,
|
|
114
|
+
task: session.task,
|
|
115
|
+
loop_lineage: {
|
|
116
|
+
session_id: session.sessionId,
|
|
117
|
+
current_round: session.currentRound,
|
|
118
|
+
max_rounds: session.maxRounds,
|
|
119
|
+
success_trajectory: session.successTrajectory,
|
|
120
|
+
status: session.status,
|
|
121
|
+
created_at: session.createdAt,
|
|
122
|
+
// v1.13: Enforcement gate state
|
|
123
|
+
consecutive_rejections: session.consecutiveRejections,
|
|
124
|
+
last_rejection_check: session.lastRejectionCheck,
|
|
125
|
+
// v2.12: Clarification streak
|
|
126
|
+
drift_clarification_streak: session.driftClarificationStreak,
|
|
127
|
+
// v2.13: Backtrack skipped files for workspace restore check
|
|
128
|
+
backtrack_skipped_files: session.backtrackSkippedFiles,
|
|
129
|
+
// v2.12: Backtrack restore-point git HEAD (crash recovery)
|
|
130
|
+
backtrack_target_git_head: session.backtrackTargetGitHead ?? null,
|
|
131
|
+
// v1.19: durable round transaction state
|
|
132
|
+
round_snapshot: session.roundSnapshot ?? null,
|
|
133
|
+
last_self_eval: session.lastSelfEval ?? null,
|
|
134
|
+
current_prompt: session.currentPrompt ?? null,
|
|
135
|
+
current_level: session.currentLevel ?? "",
|
|
136
|
+
// v1.20: cross-process single-owner lease
|
|
137
|
+
lease_owner: leaseActive ? this.ownerId : "",
|
|
138
|
+
lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
this.sessionStore.save(sessionEntry, {
|
|
142
|
+
expectedLeaseOwner: this.ownerId,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Reconstruct a McpSession from a vault session_state entry.
|
|
146
|
+
* Returns null if the entry is not "running" status.
|
|
147
|
+
* Shared by resume() and autoResumeAll(). */
|
|
148
|
+
reconstructSession(entry, allowPaused = false) {
|
|
149
|
+
const lineage = (entry.loop_lineage ?? {});
|
|
150
|
+
const status = lineage.status ?? "running";
|
|
151
|
+
if (status !== "running" && !(allowPaused && status === "paused")) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const loopId = entry.loop_id;
|
|
155
|
+
// v2.14: load-time gap detection — a runtime loop whose round sequence
|
|
156
|
+
// has holes (deleted/tampered round documents) must not be resumed
|
|
157
|
+
// silently. Legacy loops without stamps are exempt; gaps and corruption
|
|
158
|
+
// surface as StorageCorruptionError. Previously checkRoundSequence was
|
|
159
|
+
// only reachable through the read-only audit.
|
|
160
|
+
checkRoundSequence(this.store, loopId);
|
|
161
|
+
const currentRound = lineage.current_round ?? 1;
|
|
162
|
+
const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
|
|
163
|
+
const task = entry.task ?? "";
|
|
164
|
+
const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
|
|
165
|
+
const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
|
|
166
|
+
const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
|
|
167
|
+
const persistedEval = lineage.last_self_eval;
|
|
168
|
+
const lastSelfEval = persistedEval !== null &&
|
|
169
|
+
typeof persistedEval === "object" &&
|
|
170
|
+
!Array.isArray(persistedEval) &&
|
|
171
|
+
typeof persistedEval.success === "boolean" &&
|
|
172
|
+
typeof persistedEval.output_summary === "string"
|
|
173
|
+
? persistedEval
|
|
174
|
+
: undefined;
|
|
175
|
+
const engine = new LoopForgeEngine(this.store);
|
|
176
|
+
return {
|
|
177
|
+
sessionId: typeof lineage.session_id === "string" && lineage.session_id
|
|
178
|
+
? lineage.session_id
|
|
179
|
+
: randomUUID(),
|
|
180
|
+
loopId, task, engine,
|
|
181
|
+
currentRound, maxRounds, successTrajectory,
|
|
182
|
+
status: status,
|
|
183
|
+
createdAt: lineage.created_at ?? Date.now(),
|
|
184
|
+
consecutiveRejections: lineage.consecutive_rejections ?? 0,
|
|
185
|
+
lastRejectionCheck: typeof lineage.last_rejection_check === "string"
|
|
186
|
+
? lineage.last_rejection_check
|
|
187
|
+
: "",
|
|
188
|
+
driftClarificationStreak: lineage.drift_clarification_streak ?? 0,
|
|
189
|
+
backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
|
|
190
|
+
backtrackTargetGitHead: typeof lineage.backtrack_target_git_head === "string" &&
|
|
191
|
+
lineage.backtrack_target_git_head.length > 0
|
|
192
|
+
? lineage.backtrack_target_git_head
|
|
193
|
+
: undefined,
|
|
194
|
+
evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
|
|
195
|
+
roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
|
|
196
|
+
lastSelfEval,
|
|
197
|
+
currentPrompt: typeof lineage.current_prompt === "string"
|
|
198
|
+
? lineage.current_prompt
|
|
199
|
+
: null,
|
|
200
|
+
currentLevel: typeof lineage.current_level === "string"
|
|
201
|
+
? lineage.current_level
|
|
202
|
+
: undefined,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Apply a prepared round to the session, persist, and build the result.
|
|
206
|
+
* Shared by reconcileCommittedRound, resume, and unpause — the three
|
|
207
|
+
* compile-then-persist tails were previously copy-pasted. */
|
|
208
|
+
persistPrepared(session, prepared, roundSuccess, warningsOverride) {
|
|
209
|
+
session.evidenceBaseline = prepared.baseline;
|
|
210
|
+
session.roundSnapshot = prepared.snapshot;
|
|
211
|
+
session.currentPrompt = prepared.prompt;
|
|
212
|
+
session.currentLevel = prepared.level;
|
|
213
|
+
session.currentWarnings = prepared.warnings;
|
|
214
|
+
session.lastCompileResponse = prepared.compileResponse ?? null;
|
|
215
|
+
policyMetrics.recordStrategy(session.loopId, prepared.level);
|
|
216
|
+
this.save(session);
|
|
217
|
+
return {
|
|
218
|
+
sessionId: session.sessionId,
|
|
219
|
+
round: session.currentRound,
|
|
220
|
+
roundId: session.roundSnapshot.roundId,
|
|
221
|
+
prompt: prepared.prompt,
|
|
222
|
+
level: prepared.level,
|
|
223
|
+
roundSuccess,
|
|
224
|
+
warnings: warningsOverride ?? session.currentWarnings ?? [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
restoredPromptResult(session) {
|
|
228
|
+
if (!session.currentPrompt)
|
|
229
|
+
return null;
|
|
230
|
+
return {
|
|
231
|
+
sessionId: session.sessionId,
|
|
232
|
+
round: session.currentRound,
|
|
233
|
+
roundId: session.roundSnapshot?.roundId,
|
|
234
|
+
prompt: session.currentPrompt,
|
|
235
|
+
level: session.currentLevel ?? "l2",
|
|
236
|
+
roundSuccess: undefined,
|
|
237
|
+
warnings: (session.currentWarnings ?? []),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** Reconcile the crash window where feedback committed but session_state
|
|
241
|
+
* still points at the old prompt. Returns null when no commit is pending. */
|
|
242
|
+
reconcileCommittedRound(session) {
|
|
243
|
+
if (!session.roundSnapshot)
|
|
244
|
+
return null;
|
|
245
|
+
const recovered = new RoundDriver(session.engine, this.store).recover(session.roundSnapshot);
|
|
246
|
+
if (!recovered)
|
|
247
|
+
return null;
|
|
248
|
+
const pr = recovered.result;
|
|
249
|
+
session.roundSnapshot = recovered.snapshot;
|
|
250
|
+
// Replay the committed counter — but with per-rule tracking.
|
|
251
|
+
if (pr.action === "reject" && pr.rejectionCheck) {
|
|
252
|
+
session.consecutiveRejections =
|
|
253
|
+
pr.rejectionCheck === session.lastRejectionCheck
|
|
254
|
+
? pr.newConsecutiveRejections
|
|
255
|
+
: 1;
|
|
256
|
+
session.lastRejectionCheck = pr.rejectionCheck;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
session.consecutiveRejections = pr.newConsecutiveRejections;
|
|
260
|
+
if (pr.action !== "reject")
|
|
261
|
+
session.lastRejectionCheck = "";
|
|
262
|
+
}
|
|
263
|
+
if (pr.newLastSelfEval)
|
|
264
|
+
session.lastSelfEval = pr.newLastSelfEval;
|
|
265
|
+
if (pr.shouldPushSuccessTrajectory &&
|
|
266
|
+
session.successTrajectory.length < session.currentRound) {
|
|
267
|
+
session.successTrajectory.push(pr.roundSuccess);
|
|
268
|
+
}
|
|
269
|
+
session.currentPrompt = null;
|
|
270
|
+
if (pr.action === "stop" || pr.action === "terminate") {
|
|
271
|
+
const reason = pr.action === "terminate"
|
|
272
|
+
? "enforcement_terminated"
|
|
273
|
+
: pr.stopReason ?? "stalled";
|
|
274
|
+
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
275
|
+
this.save(session);
|
|
276
|
+
void this.notifyTerminal(session, reason);
|
|
277
|
+
return {
|
|
278
|
+
sessionId: session.sessionId,
|
|
279
|
+
round: session.currentRound,
|
|
280
|
+
roundId: recovered.snapshot.roundId,
|
|
281
|
+
prompt: null,
|
|
282
|
+
stopReason: reason,
|
|
283
|
+
stopDetail: reason === "completed"
|
|
284
|
+
? "Task completed successfully — the agent declared should_continue=false with success=true."
|
|
285
|
+
: reason === "blocked"
|
|
286
|
+
? "Agent cannot proceed under current constraints. The task may need revised constraints or human intervention."
|
|
287
|
+
: reason === "failed"
|
|
288
|
+
? "Agent declared should_continue=false with success=false — the task was not achieved."
|
|
289
|
+
: `Loop stopped: ${reason}.`,
|
|
290
|
+
roundSuccess: pr.roundSuccess,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (pr.action === "backtrack") {
|
|
294
|
+
// Backtrack committed but prompt not delivered (crash window).
|
|
295
|
+
// Reset round counter to the restore target + 1 so the next
|
|
296
|
+
// resume / unpause compiles from the correct restored state.
|
|
297
|
+
const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
|
|
298
|
+
session.currentRound = restoreTarget + 1;
|
|
299
|
+
session.consecutiveRejections = 0;
|
|
300
|
+
session.lastRejectionCheck = "";
|
|
301
|
+
session.driftClarificationStreak = 0;
|
|
302
|
+
session.lastSelfEval = undefined;
|
|
303
|
+
session.currentPrompt = null;
|
|
304
|
+
this.save(session);
|
|
305
|
+
// Return null — caller (resume/unpause) will recompile normally
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
if (pr.action !== "continue")
|
|
309
|
+
return null;
|
|
310
|
+
// v2.14: only advance when the persisted counter has not already moved
|
|
311
|
+
// past the committed round. The pause/delete race leaves currentRound
|
|
312
|
+
// incremented (the commit fence returns before compiling, and pause()
|
|
313
|
+
// persisted the incremented value) — replaying must compile the NEXT
|
|
314
|
+
// round, not skip it by incrementing again.
|
|
315
|
+
if (session.currentRound <= recovered.snapshot.round) {
|
|
316
|
+
session.currentRound = recovered.snapshot.round + 1;
|
|
317
|
+
}
|
|
318
|
+
const request = buildLoopRequest(session, pr.newLastSelfEval, pr.verificationFlags);
|
|
319
|
+
const prepared = new RoundDriver(session.engine, this.store).prepareSync(request, session.loopId, session.currentRound);
|
|
320
|
+
if (!prepared) {
|
|
321
|
+
session.status = "stalled";
|
|
322
|
+
this.save(session);
|
|
323
|
+
return {
|
|
324
|
+
sessionId: session.sessionId,
|
|
325
|
+
round: session.currentRound,
|
|
326
|
+
prompt: null,
|
|
327
|
+
stopReason: "stalled",
|
|
328
|
+
stopDetail: "RoundDriver.prepareSync returned null — prompt compilation failed during crash recovery. The session state may be inconsistent.",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return this.persistPrepared(session, {
|
|
332
|
+
prompt: prepared.prompt,
|
|
333
|
+
level: prepared.level,
|
|
334
|
+
baseline: prepared.evidenceBaseline,
|
|
335
|
+
snapshot: prepared.snapshot,
|
|
336
|
+
warnings: prepared?.warnings ?? [],
|
|
337
|
+
}, pr.roundSuccess);
|
|
338
|
+
}
|
|
339
|
+
/** Resume tail: the session has been reconstructed and registered. Reconcile
|
|
340
|
+
* a committed-but-undelivered round, restore the held prompt, or compile
|
|
341
|
+
* the next prompt from current round state (legacy path). */
|
|
342
|
+
resume(session) {
|
|
343
|
+
const reconciled = this.reconcileCommittedRound(session);
|
|
344
|
+
if (reconciled)
|
|
345
|
+
return reconciled;
|
|
346
|
+
const restored = this.restoredPromptResult(session);
|
|
347
|
+
if (restored)
|
|
348
|
+
return restored;
|
|
349
|
+
// Legacy session without a stored prompt: compile once, then persist it.
|
|
350
|
+
const request = buildLoopRequest(session);
|
|
351
|
+
const prepared = new RoundDriver(session.engine, this.store).prepareSync(request, session.loopId, session.currentRound);
|
|
352
|
+
const prompt = prepared?.prompt ?? null;
|
|
353
|
+
const baseline = prepared?.evidenceBaseline ?? [];
|
|
354
|
+
const snapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, []);
|
|
355
|
+
return this.persistPrepared(session, {
|
|
356
|
+
prompt,
|
|
357
|
+
level: prepared?.level ?? "l2",
|
|
358
|
+
baseline,
|
|
359
|
+
snapshot,
|
|
360
|
+
warnings: prepared?.warnings ?? [],
|
|
361
|
+
}, false);
|
|
362
|
+
}
|
|
363
|
+
/** Unpause tail: the session has been reconstructed and registered with
|
|
364
|
+
* status "running". Refresh async evidence, reconcile a committed round,
|
|
365
|
+
* restore the held prompt, or compile the next prompt. */
|
|
366
|
+
async unpause(session) {
|
|
367
|
+
// Replace the sync-fallback evidence baseline with async evidence
|
|
368
|
+
// so resumed sessions don't silently drop async provider data.
|
|
369
|
+
try {
|
|
370
|
+
const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
|
|
371
|
+
loopId: session.loopId,
|
|
372
|
+
phase: "before",
|
|
373
|
+
});
|
|
374
|
+
if (asyncEvidence.length > 0) {
|
|
375
|
+
session.evidenceBaseline = asyncEvidence;
|
|
376
|
+
if (!session.roundSnapshot?.beforeEvidence?.length) {
|
|
377
|
+
session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Async evidence is best-effort; fall back to sync baseline.
|
|
383
|
+
}
|
|
384
|
+
const reconciled = this.reconcileCommittedRound(session);
|
|
385
|
+
if (reconciled)
|
|
386
|
+
return reconciled;
|
|
387
|
+
const restored = this.restoredPromptResult(session);
|
|
388
|
+
if (restored) {
|
|
389
|
+
this.save(session);
|
|
390
|
+
return restored;
|
|
391
|
+
}
|
|
392
|
+
// Compile the next prompt from the current round state
|
|
393
|
+
const lcr = makeLoopCompileRequest({
|
|
394
|
+
loop_id: session.loopId,
|
|
395
|
+
round: session.currentRound,
|
|
396
|
+
goal_id: "",
|
|
397
|
+
task: session.task,
|
|
398
|
+
domain: undefined,
|
|
399
|
+
plan_source: null,
|
|
400
|
+
constraints_from_plan: [],
|
|
401
|
+
health_check_interval: 1,
|
|
402
|
+
});
|
|
403
|
+
const compileRequest = {
|
|
404
|
+
task: session.task,
|
|
405
|
+
mode: Mode.LOOP_COMPILE,
|
|
406
|
+
feedback: null,
|
|
407
|
+
skill_name: null,
|
|
408
|
+
task_id: null,
|
|
409
|
+
loop_id: lcr.loop_id,
|
|
410
|
+
round: lcr.round,
|
|
411
|
+
goal_id: lcr.goal_id,
|
|
412
|
+
domain: lcr.domain ?? "",
|
|
413
|
+
plan_source: lcr.plan_source ?? null,
|
|
414
|
+
constraints_from_plan: lcr.constraints_from_plan ?? [],
|
|
415
|
+
health_check_interval: lcr.health_check_interval,
|
|
416
|
+
max_rounds: session.maxRounds,
|
|
417
|
+
};
|
|
418
|
+
const prepared = await new RoundDriver(session.engine, this.store).prepare(compileRequest, session.loopId, session.currentRound);
|
|
419
|
+
if (!prepared) {
|
|
420
|
+
session.status = "stopped";
|
|
421
|
+
this.save(session);
|
|
422
|
+
void this.notifyTerminal(session, "stalled");
|
|
423
|
+
return { sessionId: session.sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail: "RoundDriver.prepare returned null during unpause — the compiler could not produce a prompt." };
|
|
424
|
+
}
|
|
425
|
+
return this.persistPrepared(session, {
|
|
426
|
+
prompt: prepared.prompt,
|
|
427
|
+
level: prepared.level,
|
|
428
|
+
baseline: prepared.evidenceBaseline,
|
|
429
|
+
snapshot: prepared.snapshot,
|
|
430
|
+
warnings: prepared?.warnings ?? [],
|
|
431
|
+
}, undefined,
|
|
432
|
+
// Historical contract: unpause returns an empty warnings array even
|
|
433
|
+
// though the session carries the compiled warnings.
|
|
434
|
+
[]);
|
|
435
|
+
}
|
|
436
|
+
/** Record a gate_opened entry when an accepted round reports a high-risk
|
|
437
|
+
* blocker (USER_RISK hit). Idempotent per gate. The gate is a record
|
|
438
|
+
* layer — it never blocks the round decision flow. */
|
|
439
|
+
recordGateFromBlocked(session, blocker) {
|
|
440
|
+
if (!getPolicy().gate.enabled)
|
|
441
|
+
return;
|
|
442
|
+
const { id, gate } = deriveGate(blocker);
|
|
443
|
+
if (gate.kind !== "user")
|
|
444
|
+
return;
|
|
445
|
+
const taskId = `loop:${session.loopId}:gate:${id}`;
|
|
446
|
+
const existing = queryLoopEntries(this.store, session.loopId, { prefix: taskId })
|
|
447
|
+
.some((entry) => entry.task_id === taskId);
|
|
448
|
+
if (existing)
|
|
449
|
+
return;
|
|
450
|
+
const entry = {
|
|
451
|
+
id: randomUUID(),
|
|
452
|
+
task_id: taskId,
|
|
453
|
+
task_type: "gate_opened",
|
|
454
|
+
loop_id: session.loopId,
|
|
455
|
+
timestamp: new Date().toISOString(),
|
|
456
|
+
gate_id: id,
|
|
457
|
+
gate_action: blocker,
|
|
458
|
+
loop_lineage: {
|
|
459
|
+
round: session.currentRound,
|
|
460
|
+
gate_id: id,
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
this.store.appendEntry(entry);
|
|
464
|
+
logEvent("gate_opened", { loopId: session.loopId, gateId: id, round: session.currentRound });
|
|
465
|
+
}
|
|
466
|
+
/** Undecided user gate descriptions for a loop (recorded but unresolved). */
|
|
467
|
+
listOpenGateDescriptions(loopId) {
|
|
468
|
+
const entries = queryLoopEntries(this.store, loopId, { prefix: `loop:${loopId}:gate:` });
|
|
469
|
+
const decided = new Set(entries.filter((entry) => entry.task_type === "gate_decision")
|
|
470
|
+
.map((entry) => String(entry.gate_id ?? "")));
|
|
471
|
+
return entries
|
|
472
|
+
.filter((entry) => entry.task_type === "gate_opened" &&
|
|
473
|
+
!decided.has(String(entry.gate_id ?? "")))
|
|
474
|
+
.map((entry) => typeof entry.gate_action === "string"
|
|
475
|
+
? entry.gate_action
|
|
476
|
+
: String(entry.gate_id ?? ""));
|
|
477
|
+
}
|
|
478
|
+
/** Extract a SelfEvaluation from agent output.
|
|
479
|
+
* Structured param preferred → regex extraction → heuristic fallback.
|
|
480
|
+
* Returns null only when both regex and heuristic returned null. */
|
|
481
|
+
extractEvaluation(output, preExtractedEval) {
|
|
482
|
+
if (preExtractedEval) {
|
|
483
|
+
return { selfEval: preExtractedEval, extractionFailed: false };
|
|
484
|
+
}
|
|
485
|
+
const structured = extractSelfEvaluation(output);
|
|
486
|
+
if (!structured)
|
|
487
|
+
return null;
|
|
488
|
+
return { selfEval: structured, extractionFailed: false };
|
|
489
|
+
}
|
|
490
|
+
/** Execute the round transaction and apply per-rule rejection tracking.
|
|
491
|
+
* MUTATES: session.roundSnapshot, session.consecutiveRejections,
|
|
492
|
+
* session.lastRejectionCheck, session.lastSelfEval,
|
|
493
|
+
* session.successTrajectory, session.driftClarificationStreak */
|
|
494
|
+
async executeRoundTransaction(session, selfEval, extractionFailed) {
|
|
495
|
+
const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
|
|
496
|
+
const completed = await new RoundDriver(session.engine, this.store).complete({
|
|
497
|
+
snapshot,
|
|
498
|
+
loopId: session.loopId,
|
|
499
|
+
task: session.task,
|
|
500
|
+
maxRounds: session.maxRounds,
|
|
501
|
+
selfEval,
|
|
502
|
+
extractionSucceeded: !extractionFailed,
|
|
503
|
+
lastSelfEval: session.lastSelfEval,
|
|
504
|
+
consecutiveRejections: session.consecutiveRejections,
|
|
505
|
+
successTrajectory: session.successTrajectory,
|
|
506
|
+
driftClarificationStreak: session.driftClarificationStreak,
|
|
507
|
+
backtrackSkippedFiles: session.backtrackSkippedFiles,
|
|
508
|
+
backtrackTargetGitHead: session.backtrackTargetGitHead,
|
|
509
|
+
});
|
|
510
|
+
const outcome = completed.outcome;
|
|
511
|
+
const actualEvidence = completed.actualEvidence;
|
|
512
|
+
session.roundSnapshot = outcome.snapshot;
|
|
513
|
+
const pr = outcome.result;
|
|
514
|
+
policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
|
|
515
|
+
// v2.12: Clarification streak tracking — independent of rejection tracking.
|
|
516
|
+
// Reset on rounds without intent_drift; track substantive vs weak clarifications.
|
|
517
|
+
this.updateClarificationStreak(session, pr);
|
|
518
|
+
// Per-rule rejection tracking: same-check rejections accumulate;
|
|
519
|
+
// a different rejection reason resets the counter.
|
|
520
|
+
if (pr.action === "reject" && pr.rejectionCheck) {
|
|
521
|
+
session.consecutiveRejections =
|
|
522
|
+
pr.rejectionCheck === session.lastRejectionCheck
|
|
523
|
+
? pr.newConsecutiveRejections
|
|
524
|
+
: 1;
|
|
525
|
+
session.lastRejectionCheck = pr.rejectionCheck;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
session.consecutiveRejections = pr.newConsecutiveRejections;
|
|
529
|
+
if (pr.action !== "reject")
|
|
530
|
+
session.lastRejectionCheck = "";
|
|
531
|
+
}
|
|
532
|
+
if (pr.newLastSelfEval)
|
|
533
|
+
session.lastSelfEval = pr.newLastSelfEval;
|
|
534
|
+
if (pr.shouldPushSuccessTrajectory) {
|
|
535
|
+
session.successTrajectory.push(pr.roundSuccess);
|
|
536
|
+
}
|
|
537
|
+
return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
|
|
538
|
+
}
|
|
539
|
+
/** v2.12: Update the clarification streak based on the round result.
|
|
540
|
+
* - Substantive clarification (anchors present): keep streak — genuine pivot.
|
|
541
|
+
* - No intent_drift this round: reset streak to 0.
|
|
542
|
+
* - Weak clarification rejected by R7: streak was already consumed by
|
|
543
|
+
* enforceIntentDrift to decide reject vs terminate; staleness is handled
|
|
544
|
+
* on the enforcement side. We sync the persisted counter for crash recovery.
|
|
545
|
+
* v3.2.1: when a HIGHER-PRIORITY rule (R1–R6/R8/R9/R-EVID) rejected the
|
|
546
|
+
* round, R7 never participated — clarificationAccepted is undefined (it
|
|
547
|
+
* is only set on the continue path). Touching the streak there would
|
|
548
|
+
* pollute it with rejections unrelated to drift and terminate the loop
|
|
549
|
+
* one weak clarification early.
|
|
550
|
+
* MUTATES: session.driftClarificationStreak */
|
|
551
|
+
updateClarificationStreak(session, pr) {
|
|
552
|
+
const hasIntentDrift = pr.verificationFlags.some((f) => f.check === "intent_drift");
|
|
553
|
+
if (!hasIntentDrift) {
|
|
554
|
+
// No drift this round — reset streak
|
|
555
|
+
session.driftClarificationStreak = 0;
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
// R7 did not participate in this round's decision (a higher-priority
|
|
559
|
+
// rule rejected/accepted first) — leave the streak untouched.
|
|
560
|
+
if (pr.clarificationAccepted === undefined)
|
|
561
|
+
return;
|
|
562
|
+
// v2.12: Clarification accepted with anchors → substantive pivot.
|
|
563
|
+
// Keep streak as-is (don't increment for genuine explanations).
|
|
564
|
+
if (pr.clarificationAccepted) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// R7 rejected (weak or no clarification). The enforcement gate
|
|
568
|
+
// already used the streak to decide reject vs terminate.
|
|
569
|
+
// Increment the persisted counter so crash recovery sees it.
|
|
570
|
+
session.driftClarificationStreak += 1;
|
|
571
|
+
}
|
|
572
|
+
/** Build a rejection result: compile a retry prompt, persist, return.
|
|
573
|
+
* MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
|
|
574
|
+
async buildRejectionResult(sessionId, session, pr, verificationFlags) {
|
|
575
|
+
const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
|
|
576
|
+
const preparedRetry = await new RoundDriver(session.engine, this.store).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
|
|
577
|
+
if (!preparedRetry) {
|
|
578
|
+
session.status = "stalled";
|
|
579
|
+
session.currentPrompt = null;
|
|
580
|
+
this.save(session);
|
|
581
|
+
return {
|
|
582
|
+
sessionId,
|
|
583
|
+
round: session.currentRound,
|
|
584
|
+
roundId: session.roundSnapshot?.roundId,
|
|
585
|
+
prompt: null,
|
|
586
|
+
stopReason: "stalled",
|
|
587
|
+
stopDetail: "RoundDriver.prepareRetry returned null — the retry prompt could not be compiled.",
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
session.roundSnapshot = preparedRetry.snapshot;
|
|
591
|
+
session.currentPrompt = preparedRetry.prompt;
|
|
592
|
+
session.currentLevel = preparedRetry.level;
|
|
593
|
+
session.currentWarnings = preparedRetry.warnings ?? [];
|
|
594
|
+
session.lastCompileResponse = preparedRetry.compileResponse ?? null;
|
|
595
|
+
this.save(session);
|
|
596
|
+
return {
|
|
597
|
+
sessionId,
|
|
598
|
+
round: session.currentRound,
|
|
599
|
+
roundId: session.roundSnapshot.roundId,
|
|
600
|
+
prompt: preparedRetry.prompt,
|
|
601
|
+
level: preparedRetry.level,
|
|
602
|
+
enforcementAction: "reject",
|
|
603
|
+
enforcementReason: pr.enforcementReason,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/** v2.10: Build a backtrack result — roll back to last clean round.
|
|
607
|
+
* Resets the round counter to the restore target + 1, merges preserved
|
|
608
|
+
* discoveries, compiles from the restored state, and injects the
|
|
609
|
+
* backtrack prompt at the top.
|
|
610
|
+
* MUTATES: session.currentRound, session.roundSnapshot,
|
|
611
|
+
* session.currentPrompt, session.currentLevel,
|
|
612
|
+
* session.consecutiveRejections, session.lastSelfEval */
|
|
613
|
+
async buildBacktrackResult(sessionId, session, pr) {
|
|
614
|
+
const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
|
|
615
|
+
const newRound = restoreTarget + 1;
|
|
616
|
+
logEvent("session_backtrack", {
|
|
617
|
+
sessionId,
|
|
618
|
+
loopId: session.loopId,
|
|
619
|
+
fromRound: session.currentRound,
|
|
620
|
+
toRound: newRound,
|
|
621
|
+
triggerRule: pr.backtrackTriggerRule ?? "",
|
|
622
|
+
skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
|
|
623
|
+
});
|
|
624
|
+
// Reset session to the restored round
|
|
625
|
+
session.currentRound = newRound;
|
|
626
|
+
session.consecutiveRejections = 0;
|
|
627
|
+
session.lastRejectionCheck = "";
|
|
628
|
+
session.driftClarificationStreak = 0;
|
|
629
|
+
session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
|
|
630
|
+
// v2.12: Remember the restore point's git HEAD — the next round's
|
|
631
|
+
// verification gate confirms the workspace returned to this commit.
|
|
632
|
+
session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
|
|
633
|
+
session.lastSelfEval = undefined; // force L2 recompile from vault state
|
|
634
|
+
session.currentPrompt = null;
|
|
635
|
+
// Preserve discoveries from skipped rounds as constraints_from_plan
|
|
636
|
+
// so they survive the compilation and re-enter active constraints.
|
|
637
|
+
const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
|
|
638
|
+
// Compile the restored prompt — use a fresh request with the restored round
|
|
639
|
+
const request = {
|
|
640
|
+
task: session.task,
|
|
641
|
+
mode: Mode.LOOP_COMPILE,
|
|
642
|
+
feedback: null,
|
|
643
|
+
skill_name: null,
|
|
644
|
+
task_id: null,
|
|
645
|
+
loop_id: session.loopId,
|
|
646
|
+
round: newRound,
|
|
647
|
+
max_rounds: session.maxRounds,
|
|
648
|
+
verification_flags: pr.verificationFlags,
|
|
649
|
+
constraints_from_plan: preservedDiscoveries,
|
|
650
|
+
};
|
|
651
|
+
const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, newRound);
|
|
652
|
+
if (!prepared || !prepared.prompt) {
|
|
653
|
+
session.status = "stalled";
|
|
654
|
+
session.currentPrompt = null;
|
|
655
|
+
this.save(session);
|
|
656
|
+
return {
|
|
657
|
+
sessionId,
|
|
658
|
+
round: newRound,
|
|
659
|
+
prompt: null,
|
|
660
|
+
stopReason: "stalled",
|
|
661
|
+
stopDetail: "Backtrack failed: RoundDriver.prepare returned null — could not compile from restore point.",
|
|
662
|
+
enforcementAction: "backtrack",
|
|
663
|
+
enforcementReason: pr.enforcementReason,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// Inject backtrack prompt at the top of the compiled prompt
|
|
667
|
+
const backtrackHeader = pr.backtrackPrompt ?? "";
|
|
668
|
+
const fullPrompt = backtrackHeader
|
|
669
|
+
? backtrackHeader + "\n" + (prepared.prompt ?? "")
|
|
670
|
+
: prepared.prompt ?? "";
|
|
671
|
+
session.evidenceBaseline = prepared.evidenceBaseline;
|
|
672
|
+
session.roundSnapshot = prepared.snapshot;
|
|
673
|
+
session.currentPrompt = fullPrompt;
|
|
674
|
+
session.currentLevel = prepared.level;
|
|
675
|
+
session.currentWarnings = prepared?.warnings ?? [];
|
|
676
|
+
session.lastCompileResponse = prepared?.compileResponse ?? null;
|
|
677
|
+
this.save(session);
|
|
678
|
+
return {
|
|
679
|
+
sessionId,
|
|
680
|
+
round: newRound,
|
|
681
|
+
roundId: session.roundSnapshot?.roundId,
|
|
682
|
+
prompt: fullPrompt,
|
|
683
|
+
level: prepared.level,
|
|
684
|
+
enforcementAction: "backtrack",
|
|
685
|
+
enforcementReason: pr.enforcementReason,
|
|
686
|
+
warnings: prepared?.warnings ?? [],
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/** Build a termination result: persist stopped status, notify sinks.
|
|
690
|
+
* MUTATES: session.status, session.currentPrompt */
|
|
691
|
+
buildTerminationResult(sessionId, session, pr) {
|
|
692
|
+
session.status = "stopped";
|
|
693
|
+
session.currentPrompt = null;
|
|
694
|
+
this.save(session);
|
|
695
|
+
void this.notifyTerminal(session, "enforcement_terminated");
|
|
696
|
+
logEvent("session_end", {
|
|
697
|
+
sessionId, loopId: session.loopId,
|
|
698
|
+
stopReason: "enforcement_terminated", round: session.currentRound,
|
|
699
|
+
});
|
|
700
|
+
return {
|
|
701
|
+
sessionId,
|
|
702
|
+
round: session.currentRound,
|
|
703
|
+
roundId: session.roundSnapshot?.roundId,
|
|
704
|
+
prompt: null,
|
|
705
|
+
stopReason: "enforcement_terminated",
|
|
706
|
+
stopDetail: pr.enforcementReason ?? "The enforcement gate terminated the loop.",
|
|
707
|
+
enforcementAction: "terminate",
|
|
708
|
+
enforcementReason: pr.enforcementReason,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/** Build a stop result: persist stopped/stalled status, notify sinks.
|
|
712
|
+
* MUTATES: session.status, session.currentPrompt */
|
|
713
|
+
buildStopResult(sessionId, session, pr) {
|
|
714
|
+
const reason = pr.stopReason ?? "stalled";
|
|
715
|
+
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
716
|
+
session.currentPrompt = null;
|
|
717
|
+
this.save(session);
|
|
718
|
+
void this.notifyTerminal(session, reason);
|
|
719
|
+
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
|
|
720
|
+
return {
|
|
721
|
+
sessionId, round: session.currentRound,
|
|
722
|
+
roundId: session.roundSnapshot?.roundId,
|
|
723
|
+
prompt: null, stopReason: reason,
|
|
724
|
+
stopDetail: pr.stopReason === "circuit_breaker"
|
|
725
|
+
? `${pr.roundSuccess ? "No" : "All"} recent rounds failed, triggering the circuit breaker.`
|
|
726
|
+
: pr.stopReason === "max_rounds"
|
|
727
|
+
? "The configured maximum number of rounds has been reached."
|
|
728
|
+
: `Loop stopped: ${reason}.`,
|
|
729
|
+
roundSuccess: pr.roundSuccess,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
/** Compile the next round's prompt and advance the session.
|
|
733
|
+
* Includes the commit fence (pause/delete race guard) and context provider.
|
|
734
|
+
* MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
|
|
735
|
+
* session.evidenceBaseline, session.roundSnapshot */
|
|
736
|
+
async advanceToNextRound(sessionId, session, selfEval, pr, verificationFlags, actualEvidence) {
|
|
737
|
+
const roundSuccess = pr.roundSuccess;
|
|
738
|
+
session.currentRound++;
|
|
739
|
+
session.currentPrompt = null;
|
|
740
|
+
// v2.13: Clear backtrack skipped files after advancing past the restore round
|
|
741
|
+
session.backtrackSkippedFiles = [];
|
|
742
|
+
// v2.12: The restore-point git HEAD check only applies to the first
|
|
743
|
+
// post-backtrack round — clear it once we advance past it.
|
|
744
|
+
session.backtrackTargetGitHead = undefined;
|
|
745
|
+
// v1.8: Memory injection — tier-aware external context.
|
|
746
|
+
const contextProvider = this.getContext();
|
|
747
|
+
let externalCtx = "";
|
|
748
|
+
if (contextProvider) {
|
|
749
|
+
try {
|
|
750
|
+
externalCtx = (await contextProvider({
|
|
751
|
+
loopId: session.loopId,
|
|
752
|
+
round: session.currentRound,
|
|
753
|
+
task: session.task,
|
|
754
|
+
domain: "",
|
|
755
|
+
lastEvaluation: selfEval,
|
|
756
|
+
})).trim();
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
logEvent("context_provider_error", {
|
|
760
|
+
loopId: session.loopId,
|
|
761
|
+
round: session.currentRound,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// Commit fence: pause()/delete() may have run while the context provider
|
|
766
|
+
// was awaited. Do not compile if the session is no longer running.
|
|
767
|
+
if (this.registry.get(sessionId) !== session || session.status !== "running") {
|
|
768
|
+
return {
|
|
769
|
+
sessionId,
|
|
770
|
+
round: session.currentRound,
|
|
771
|
+
prompt: null,
|
|
772
|
+
stopReason: session.status,
|
|
773
|
+
stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the context provider was running.`,
|
|
774
|
+
roundSuccess,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const request = buildLoopRequest(session, selfEval, verificationFlags);
|
|
778
|
+
if (externalCtx) {
|
|
779
|
+
request.external_context = externalCtx;
|
|
780
|
+
}
|
|
781
|
+
const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, session.currentRound);
|
|
782
|
+
const nextPrompt = prepared?.prompt ?? null;
|
|
783
|
+
const nextLevel = prepared?.level ?? "l2";
|
|
784
|
+
const nextBaseline = prepared?.evidenceBaseline ?? actualEvidence;
|
|
785
|
+
session.evidenceBaseline = nextBaseline;
|
|
786
|
+
session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
|
|
787
|
+
session.currentPrompt = nextPrompt;
|
|
788
|
+
session.currentLevel = nextLevel;
|
|
789
|
+
session.currentWarnings = prepared?.warnings ?? [];
|
|
790
|
+
session.lastCompileResponse = prepared?.compileResponse ?? null;
|
|
791
|
+
policyMetrics.recordStrategy(session.loopId, nextLevel);
|
|
792
|
+
this.save(session);
|
|
793
|
+
return {
|
|
794
|
+
sessionId,
|
|
795
|
+
round: session.currentRound,
|
|
796
|
+
roundId: session.roundSnapshot.roundId,
|
|
797
|
+
prompt: nextPrompt,
|
|
798
|
+
level: nextLevel,
|
|
799
|
+
roundSuccess,
|
|
800
|
+
warnings: prepared?.warnings ?? [],
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
async advanceUnlocked(sessionId, output, preExtractedEval, roundId) {
|
|
804
|
+
// ── 1. Validate session ──────────────────────────────────────────────
|
|
805
|
+
const session = this.registry.get(sessionId);
|
|
806
|
+
if (!session)
|
|
807
|
+
return { sessionId, round: 0, prompt: null, stopReason: "session_not_found", stopDetail: "No session exists with this ID. It may have expired, been deleted, or never existed." };
|
|
808
|
+
if (session.status !== "running") {
|
|
809
|
+
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.` };
|
|
810
|
+
}
|
|
811
|
+
// ── 1.5 Anchor the submission to the round it reports on ─────────────
|
|
812
|
+
// v3.0.1: a submission whose roundId no longer matches the current round
|
|
813
|
+
// is stale — the round it reported on already committed (lost response,
|
|
814
|
+
// duplicate call). Never process it against the current round; return the
|
|
815
|
+
// held prompt so the agent recovers the response it missed. The reject
|
|
816
|
+
// path keeps the same roundId (prepareRejectedAttempt), so retries of a
|
|
817
|
+
// rejected round match normally.
|
|
818
|
+
const expectedRoundId = session.roundSnapshot?.roundId
|
|
819
|
+
?? makeRoundId(session.loopId, session.currentRound);
|
|
820
|
+
if (roundId !== undefined && roundId !== expectedRoundId) {
|
|
821
|
+
logEvent("stale_round_id", {
|
|
822
|
+
sessionId,
|
|
823
|
+
loopId: session.loopId,
|
|
824
|
+
submitted: roundId,
|
|
825
|
+
expected: expectedRoundId,
|
|
826
|
+
});
|
|
827
|
+
const restored = this.restoredPromptResult(session);
|
|
828
|
+
if (restored) {
|
|
829
|
+
return {
|
|
830
|
+
...restored,
|
|
831
|
+
warnings: [
|
|
832
|
+
...(restored.warnings ?? []),
|
|
833
|
+
`Submitted roundId ${roundId} does not match the current round (${expectedRoundId}) — the round it reported on has already committed. Reusing the held prompt for round ${session.currentRound}; resubmit with its roundId when the work is done.`,
|
|
834
|
+
],
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
sessionId,
|
|
839
|
+
round: session.currentRound,
|
|
840
|
+
roundId: expectedRoundId,
|
|
841
|
+
prompt: null,
|
|
842
|
+
stopReason: "stale_round_id",
|
|
843
|
+
stopDetail: `Submitted roundId ${roundId} does not match the current round ${expectedRoundId}, and no prompt is currently held. Call loopforge_status or loopforge_resume to re-anchor.`,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
// ── 2. Extract self-evaluation ───────────────────────────────────────
|
|
847
|
+
const extracted = this.extractEvaluation(output, preExtractedEval);
|
|
848
|
+
if (!extracted) {
|
|
849
|
+
session.status = "stalled";
|
|
850
|
+
this.save(session);
|
|
851
|
+
void this.notifyTerminal(session, "stalled");
|
|
852
|
+
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
|
|
853
|
+
// v2.12: Actionable diagnostics — the state machine still stalls (the
|
|
854
|
+
// runtime never guesses state from text), but the agent learns exactly
|
|
855
|
+
// what was wrong and what was recognizable.
|
|
856
|
+
let stopDetail = "Self-evaluation could not be extracted from agent output.";
|
|
857
|
+
const diag = extractSelfEvaluationWithDiagnostics(output);
|
|
858
|
+
if (diag.reason)
|
|
859
|
+
stopDetail += ` Reason: ${diag.reason}.`;
|
|
860
|
+
const inferred = inferOutcomeFromText(output);
|
|
861
|
+
if (inferred) {
|
|
862
|
+
stopDetail += ` From your output text I can see outcome=${inferred.outcome}` +
|
|
863
|
+
` (summary preview: "${inferred.summary.slice(0, 80)}..."). ` +
|
|
864
|
+
"Please resubmit a properly structured SelfEvaluation via the " +
|
|
865
|
+
"evaluation parameter of loopforge_next, or embed the " +
|
|
866
|
+
"---loopforge-eval block in your output.";
|
|
867
|
+
}
|
|
868
|
+
return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail, roundSuccess: false };
|
|
869
|
+
}
|
|
870
|
+
const { selfEval, extractionFailed } = extracted;
|
|
871
|
+
// ── 3. Execute round transaction ─────────────────────────────────────
|
|
872
|
+
const tx = await this.executeRoundTransaction(session, selfEval, extractionFailed);
|
|
873
|
+
// v2.12: Record a user gate when an accepted round reports a high-risk
|
|
874
|
+
// blocker. Record layer only — never blocks the round decision flow.
|
|
875
|
+
if (tx.pr.action !== "reject" && tx.pr.action !== "terminate" &&
|
|
876
|
+
selfEval.outcome === "blocked" && typeof selfEval.blocker === "string") {
|
|
877
|
+
this.recordGateFromBlocked(session, selfEval.blocker);
|
|
878
|
+
}
|
|
879
|
+
// ── 4. Route disposition ─────────────────────────────────────────────
|
|
880
|
+
if (tx.pr.action === "reject") {
|
|
881
|
+
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
|
|
882
|
+
}
|
|
883
|
+
// ── v2.10: Backtrack — roll back to last clean round ──────────────────
|
|
884
|
+
if (tx.pr.action === "backtrack") {
|
|
885
|
+
return this.buildBacktrackResult(sessionId, session, tx.pr);
|
|
886
|
+
}
|
|
887
|
+
// Accepted rounds advance the before-snapshot baseline. Rejected rounds
|
|
888
|
+
// retain the original baseline so their retry remains zero-commit.
|
|
889
|
+
session.evidenceBaseline = tx.actualEvidence;
|
|
890
|
+
if (tx.pr.action === "terminate") {
|
|
891
|
+
return this.buildTerminationResult(sessionId, session, tx.pr);
|
|
892
|
+
}
|
|
893
|
+
if (tx.pr.action === "stop") {
|
|
894
|
+
return this.buildStopResult(sessionId, session, tx.pr);
|
|
895
|
+
}
|
|
896
|
+
// ── 5. Continue — compile next round ─────────────────────────────────
|
|
897
|
+
return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.verificationFlags, tx.actualEvidence);
|
|
898
|
+
}
|
|
899
|
+
/** Core cycle entry used by SessionManager.advance after the queue + lease
|
|
900
|
+
* dance: validate → extract → execute transaction → route disposition
|
|
901
|
+
* → advance to next round (continue) or return terminal result.
|
|
902
|
+
* @param roundId v3.0.1: the roundId of the round this submission reports
|
|
903
|
+
* on (from the last start/next/resume response). Anchors the submission;
|
|
904
|
+
* a stale or duplicate submission returns the held prompt instead of
|
|
905
|
+
* being processed. Optional — when absent the anchor check is skipped. */
|
|
906
|
+
async advance(sessionId, output, preExtractedEval, roundId) {
|
|
907
|
+
return this.advanceUnlocked(sessionId, output, preExtractedEval, roundId);
|
|
908
|
+
}
|
|
909
|
+
/** Write back loop knowledge to long-term memory.
|
|
910
|
+
* Uses shared base builder from policy.ts. Called when a loop terminates. */
|
|
911
|
+
async notifyTerminal(session, stopReason) {
|
|
912
|
+
if (this.terminalSinks.size === 0)
|
|
913
|
+
return;
|
|
914
|
+
const event = {
|
|
915
|
+
loopId: session.loopId,
|
|
916
|
+
task: session.task,
|
|
917
|
+
success: stopReason === "completed",
|
|
918
|
+
stopReason: stopReason,
|
|
919
|
+
roundsCompleted: session.currentRound,
|
|
920
|
+
successTrajectory: [...session.successTrajectory],
|
|
921
|
+
lastEvaluation: session.lastSelfEval,
|
|
922
|
+
};
|
|
923
|
+
await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=round-lifecycle.js.map
|