loopforge 3.9.4 → 4.0.7
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 +236 -112
- package/dist/audit.d.ts +73 -15
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +131 -61
- package/dist/audit.js.map +1 -1
- package/dist/canonical-state.d.ts +114 -40
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +276 -182
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +41 -12
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-governance.d.ts +0 -2
- package/dist/cognitive-governance.d.ts.map +1 -1
- package/dist/cognitive-governance.js +0 -4
- package/dist/cognitive-governance.js.map +1 -1
- package/dist/committed-round.d.ts +97 -156
- package/dist/committed-round.d.ts.map +1 -1
- package/dist/committed-round.js +223 -419
- package/dist/committed-round.js.map +1 -1
- package/dist/constraint-lifecycle.d.ts +73 -0
- package/dist/constraint-lifecycle.d.ts.map +1 -0
- package/dist/constraint-lifecycle.js +174 -0
- package/dist/constraint-lifecycle.js.map +1 -0
- package/dist/context-boundary.d.ts +83 -0
- package/dist/context-boundary.d.ts.map +1 -0
- package/dist/context-boundary.js +101 -0
- package/dist/context-boundary.js.map +1 -0
- package/dist/contract-items.d.ts +12 -8
- package/dist/contract-items.d.ts.map +1 -1
- package/dist/contract-items.js +31 -26
- package/dist/contract-items.js.map +1 -1
- package/dist/contract-requirement.d.ts +47 -0
- package/dist/contract-requirement.d.ts.map +1 -0
- package/dist/contract-requirement.js +51 -0
- package/dist/contract-requirement.js.map +1 -0
- package/dist/criterion-status.d.ts +35 -0
- package/dist/criterion-status.d.ts.map +1 -0
- package/dist/criterion-status.js +89 -0
- package/dist/criterion-status.js.map +1 -0
- package/dist/enforcement-gate.d.ts +26 -38
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +196 -319
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts +9 -103
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +27 -897
- package/dist/engine.js.map +1 -1
- package/dist/errors.d.ts +41 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +69 -0
- package/dist/errors.js.map +1 -0
- package/dist/evidence-claims.d.ts +33 -31
- package/dist/evidence-claims.d.ts.map +1 -1
- package/dist/evidence-claims.js +58 -72
- package/dist/evidence-claims.js.map +1 -1
- package/dist/evidence-posture-view.d.ts +19 -0
- package/dist/evidence-posture-view.d.ts.map +1 -0
- package/dist/evidence-posture-view.js +116 -0
- package/dist/evidence-posture-view.js.map +1 -0
- package/dist/evidence-posture.d.ts +73 -0
- package/dist/evidence-posture.d.ts.map +1 -0
- package/dist/evidence-posture.js +281 -0
- package/dist/evidence-posture.js.map +1 -0
- package/dist/evidence-provider.d.ts +55 -37
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +182 -332
- package/dist/evidence-provider.js.map +1 -1
- package/dist/explain.d.ts +166 -27
- package/dist/explain.d.ts.map +1 -1
- package/dist/explain.js +372 -58
- package/dist/explain.js.map +1 -1
- package/dist/field-accounting.d.ts.map +1 -1
- package/dist/field-accounting.js +3 -22
- package/dist/field-accounting.js.map +1 -1
- package/dist/generate-schema.js +13 -8
- package/dist/generate-schema.js.map +1 -1
- package/dist/git-boundary.d.ts +128 -0
- package/dist/git-boundary.d.ts.map +1 -0
- package/dist/git-boundary.js +625 -0
- package/dist/git-boundary.js.map +1 -0
- package/dist/git-facts.d.ts +22 -0
- package/dist/git-facts.d.ts.map +1 -0
- package/dist/git-facts.js +29 -0
- package/dist/git-facts.js.map +1 -0
- package/dist/git-motion.d.ts +37 -0
- package/dist/git-motion.d.ts.map +1 -0
- package/dist/git-motion.js +59 -0
- package/dist/git-motion.js.map +1 -0
- package/dist/history.d.ts +44 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +77 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +16 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +80 -14
- package/dist/init.js.map +1 -1
- package/dist/loop-compiler.d.ts +39 -23
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +368 -565
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-store.d.ts +6 -5
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +153 -35
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +255 -81
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +1552 -539
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +42 -18
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +81 -39
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +956 -282
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/submission-boundary.d.ts +23 -31
- package/dist/mcp/submission-boundary.d.ts.map +1 -1
- package/dist/mcp/submission-boundary.js +242 -92
- package/dist/mcp/submission-boundary.js.map +1 -1
- package/dist/mcp/submission-handle.d.ts +66 -0
- package/dist/mcp/submission-handle.d.ts.map +1 -0
- package/dist/mcp/submission-handle.js +173 -0
- package/dist/mcp/submission-handle.js.map +1 -0
- package/dist/mcp/tools.d.ts +17 -7
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +707 -244
- package/dist/mcp/tools.js.map +1 -1
- package/dist/open-question.d.ts +7 -0
- package/dist/open-question.d.ts.map +1 -0
- package/dist/open-question.js +14 -0
- package/dist/open-question.js.map +1 -0
- package/dist/policy-metrics.d.ts +31 -21
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +117 -64
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +11 -39
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +12 -12
- package/dist/policy.js.map +1 -1
- package/dist/progress.d.ts +45 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +136 -0
- package/dist/progress.js.map +1 -0
- package/dist/prompt-assembler.d.ts +20 -6
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +475 -479
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-coverage.d.ts +0 -6
- package/dist/prompt-coverage.d.ts.map +1 -1
- package/dist/prompt-coverage.js +4 -8
- package/dist/prompt-coverage.js.map +1 -1
- package/dist/prompt-freshness.d.ts +6 -17
- package/dist/prompt-freshness.d.ts.map +1 -1
- package/dist/prompt-freshness.js +27 -22
- package/dist/prompt-freshness.js.map +1 -1
- package/dist/prompt-policy.d.ts +14 -7
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +21 -5
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +843 -424
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +73 -102
- package/dist/protocol.js.map +1 -1
- package/dist/recovery-guidance.d.ts +13 -0
- package/dist/recovery-guidance.d.ts.map +1 -0
- package/dist/recovery-guidance.js +15 -0
- package/dist/recovery-guidance.js.map +1 -0
- package/dist/recovery.d.ts +68 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +234 -0
- package/dist/recovery.js.map +1 -0
- package/dist/replay.d.ts +42 -8
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +311 -84
- package/dist/replay.js.map +1 -1
- package/dist/result-facts.d.ts +45 -0
- package/dist/result-facts.d.ts.map +1 -0
- package/dist/result-facts.js +86 -0
- package/dist/result-facts.js.map +1 -0
- package/dist/round-contract.d.ts +38 -8
- package/dist/round-contract.d.ts.map +1 -1
- package/dist/round-contract.js +71 -31
- package/dist/round-contract.js.map +1 -1
- package/dist/round-coordinator.d.ts +41 -89
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +250 -267
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +56 -16
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +225 -39
- package/dist/round-driver.js.map +1 -1
- package/dist/round-facts.d.ts +33 -52
- package/dist/round-facts.d.ts.map +1 -1
- package/dist/round-facts.js +51 -160
- package/dist/round-facts.js.map +1 -1
- package/dist/round-guidance.d.ts +56 -0
- package/dist/round-guidance.d.ts.map +1 -0
- package/dist/round-guidance.js +161 -0
- package/dist/round-guidance.js.map +1 -0
- package/dist/round-outcome.d.ts +21 -0
- package/dist/round-outcome.d.ts.map +1 -0
- package/dist/round-outcome.js +31 -0
- package/dist/round-outcome.js.map +1 -0
- package/dist/round-transaction.d.ts +168 -21
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +837 -38
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +37 -61
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +413 -392
- package/dist/self-eval.js.map +1 -1
- package/dist/storage.d.ts +16 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +54 -4
- package/dist/storage.js.map +1 -1
- package/dist/subgoal-state.d.ts +2 -3
- package/dist/subgoal-state.d.ts.map +1 -1
- package/dist/subgoal-state.js +2 -3
- package/dist/subgoal-state.js.map +1 -1
- package/dist/submission-guidance.d.ts +29 -21
- package/dist/submission-guidance.d.ts.map +1 -1
- package/dist/submission-guidance.js +308 -260
- package/dist/submission-guidance.js.map +1 -1
- package/dist/submission-receipt.d.ts +16 -8
- package/dist/submission-receipt.d.ts.map +1 -1
- package/dist/submission-receipt.js +145 -51
- package/dist/submission-receipt.js.map +1 -1
- package/dist/token-utils.d.ts +28 -7
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +27 -11
- package/dist/token-utils.js.map +1 -1
- package/dist/transaction-identity.d.ts +9 -0
- package/dist/transaction-identity.d.ts.map +1 -0
- package/dist/transaction-identity.js +69 -0
- package/dist/transaction-identity.js.map +1 -0
- package/dist/verification-actions.d.ts.map +1 -1
- package/dist/verification-actions.js +10 -11
- package/dist/verification-actions.js.map +1 -1
- package/dist/verification-gate.d.ts +23 -47
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +202 -423
- package/dist/verification-gate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/working-memory.d.ts +187 -0
- package/dist/working-memory.d.ts.map +1 -0
- package/dist/working-memory.js +767 -0
- package/dist/working-memory.js.map +1 -0
- package/dist/workspace-checkpoint.d.ts +88 -0
- package/dist/workspace-checkpoint.d.ts.map +1 -0
- package/dist/workspace-checkpoint.js +326 -0
- package/dist/workspace-checkpoint.js.map +1 -0
- package/dist/workspace-restore.d.ts +68 -0
- package/dist/workspace-restore.d.ts.map +1 -0
- package/dist/workspace-restore.js +604 -0
- package/dist/workspace-restore.js.map +1 -0
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +0 -17
- package/dist/workspace.js.map +1 -1
- package/loop_policy.json +4 -18
- package/package.json +68 -68
- package/skills/forge/SKILL.md +212 -0
- package/skills/forge/references/evidence-and-contracts.md +204 -0
- package/skills/forge/references/recovery-and-views.md +188 -0
- package/dist/cognitive-facts.d.ts +0 -30
- package/dist/cognitive-facts.d.ts.map +0 -1
- package/dist/cognitive-facts.js +0 -113
- package/dist/cognitive-facts.js.map +0 -1
- package/dist/loop-extras-parser.d.ts +0 -81
- package/dist/loop-extras-parser.d.ts.map +0 -1
- package/dist/loop-extras-parser.js +0 -213
- package/dist/loop-extras-parser.js.map +0 -1
- package/dist/loop-projection.d.ts +0 -6
- package/dist/loop-projection.d.ts.map +0 -1
- package/dist/loop-projection.js +0 -28
- package/dist/loop-projection.js.map +0 -1
- package/skills/perception/SKILL.md +0 -389
|
@@ -10,25 +10,36 @@
|
|
|
10
10
|
* session ownership, queues, and leases remain in SessionManager.
|
|
11
11
|
*/
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { relative, resolve, sep } from "node:path";
|
|
13
14
|
import { LoopForgeEngine } from "../engine.js";
|
|
14
15
|
import { deriveGate } from "../cognitive-governance.js";
|
|
15
|
-
import {
|
|
16
|
-
import { captureEntrypointTrust
|
|
17
|
-
import { deriveRoundObservationDelta,
|
|
18
|
-
import { deriveActiveRoundContract
|
|
16
|
+
import { makeLoopObjective, } from "../protocol.js";
|
|
17
|
+
import { captureEntrypointTrust } from "../evidence-provider.js";
|
|
18
|
+
import { deriveRoundObservationDelta, deriveTransactionId, parseRoundTransactionSnapshot, rootBranchId, } from "../round-transaction.js";
|
|
19
|
+
import { deriveActiveRoundContract } from "../round-contract.js";
|
|
19
20
|
import { deriveSubGoals } from "../subgoal-state.js";
|
|
20
|
-
import { effectiveOutcome } from "../
|
|
21
|
-
import { deriveSubmissionReceipt } from "../submission-receipt.js";
|
|
21
|
+
import { declaresBlocked, effectiveOutcome, effectiveSuccess } from "../round-outcome.js";
|
|
22
|
+
import { deriveCommittedSubmissionView, deriveSubmissionReceipt, } from "../submission-receipt.js";
|
|
23
|
+
import { rollbackEvidencePosture } from "../evidence-posture.js";
|
|
22
24
|
import { RoundDriver } from "../round-driver.js";
|
|
25
|
+
import { deriveRecoveryDirective, findCheckpointRef, validateCheckpointManifest, } from "../recovery.js";
|
|
26
|
+
import { executeRestore, preflightRestore, RestoreFailure, } from "../workspace-restore.js";
|
|
27
|
+
import { resolveStateDirectory } from "../policy.js";
|
|
23
28
|
import { finalizeStopReason } from "../round-coordinator.js";
|
|
24
29
|
import { getPolicy } from "../policy.js";
|
|
25
|
-
import { checkRoundSequence
|
|
26
|
-
import {
|
|
30
|
+
import { checkRoundSequence } from "../loop-store.js";
|
|
31
|
+
import { branchRounds } from "../committed-round.js";
|
|
32
|
+
import { historyWindow, loadCommittedHistory, } from "../history.js";
|
|
27
33
|
import { deriveSubmissionGuidance } from "../submission-guidance.js";
|
|
34
|
+
import { latestObjective } from "../loop-compiler.js";
|
|
35
|
+
import { heldPromptSnapshot, makeSubmissionHandle, } from "./submission-handle.js";
|
|
28
36
|
import { logEvent } from "../observability.js";
|
|
29
37
|
import { policyMetrics } from "../policy-metrics.js";
|
|
30
|
-
import {
|
|
38
|
+
import { SessionLeaseConflictError } from "../storage.js";
|
|
39
|
+
import { isRecord, normalizeText } from "../token-utils.js";
|
|
31
40
|
import { ProjectionCoverageError } from "../prompt-coverage.js";
|
|
41
|
+
import { CompileFailureError, LegacyHistoryError } from "../errors.js";
|
|
42
|
+
import { renderResultFacts } from "../result-facts.js";
|
|
32
43
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
33
44
|
/** M3: parse a persisted skipped-file fingerprint map (string → string).
|
|
34
45
|
* Lenient by design — v3.8.3 reuses it for the entrypoint trust baseline,
|
|
@@ -44,6 +55,52 @@ function parseFingerprintMap(value) {
|
|
|
44
55
|
}
|
|
45
56
|
return out;
|
|
46
57
|
}
|
|
58
|
+
/** Parse the session's operator-acceptance cursor leniently. A corrupt copy
|
|
59
|
+
* degrades to the committed root transaction; it never invents criteria. */
|
|
60
|
+
function parseOperatorAcceptance(value) {
|
|
61
|
+
if (!isRecord(value))
|
|
62
|
+
return undefined;
|
|
63
|
+
if (typeof value.task !== "string" ||
|
|
64
|
+
!Array.isArray(value.success_criteria) ||
|
|
65
|
+
!value.success_criteria.every((item) => typeof item === "string") ||
|
|
66
|
+
!Array.isArray(value.hard_constraints) ||
|
|
67
|
+
!value.hard_constraints.every((item) => typeof item === "string")) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/** v4.0.5: a structural defect in invocation input. The MCP handlers map this
|
|
73
|
+
* to `invalid_argument`; it is never a policy or round defect. */
|
|
74
|
+
export class InvocationInputError extends Error {
|
|
75
|
+
code = "invalid_argument";
|
|
76
|
+
}
|
|
77
|
+
/** v4.0.5: normalize the invocation instruction at the transport boundary.
|
|
78
|
+
* `undefined`/absent stays absent ("not provided"); non-string or
|
|
79
|
+
* empty/whitespace is a structural defect. The trimmed text is stored
|
|
80
|
+
* verbatim — no case folding, no internal-whitespace folding. */
|
|
81
|
+
export function normalizeCurrentUserInstruction(raw) {
|
|
82
|
+
if (raw === undefined || raw === null)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (typeof raw !== "string") {
|
|
85
|
+
throw new InvocationInputError("currentUserInstruction must be a non-empty string");
|
|
86
|
+
}
|
|
87
|
+
const text = raw.trim();
|
|
88
|
+
if (text.length === 0) {
|
|
89
|
+
throw new InvocationInputError("currentUserInstruction must be a non-empty string");
|
|
90
|
+
}
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
/** v4.0.5: normalize a whole transport input bundle before any state is read
|
|
94
|
+
* or mutated. */
|
|
95
|
+
export function normalizeInvocationInputs(inputs) {
|
|
96
|
+
if (!inputs)
|
|
97
|
+
return undefined;
|
|
98
|
+
const instruction = normalizeCurrentUserInstruction(inputs.currentUserInstruction);
|
|
99
|
+
if (instruction === undefined && inputs.currentUserInstruction === undefined) {
|
|
100
|
+
return inputs;
|
|
101
|
+
}
|
|
102
|
+
return { ...inputs, currentUserInstruction: instruction };
|
|
103
|
+
}
|
|
47
104
|
/** v3.9.4: a structural defect in the start criteria argument. The MCP
|
|
48
105
|
* handler maps this to `invalid_argument`; it is never a policy defect. */
|
|
49
106
|
export class StartCriteriaError extends Error {
|
|
@@ -105,27 +162,57 @@ export function normalizeStartCriteria(raw) {
|
|
|
105
162
|
}
|
|
106
163
|
return { criteria, warnings };
|
|
107
164
|
}
|
|
165
|
+
/** v4.0.4: the terminal reason a manual `loopforge_stop` reports to the
|
|
166
|
+
* terminal sink and in its response. It is never persisted as a committed
|
|
167
|
+
* round fact, so status/resume after a restart still report "no committed
|
|
168
|
+
* terminal reason". One constant keeps the response and the sink aligned. */
|
|
169
|
+
export const MANUAL_STOP_REASON = "cancelled";
|
|
108
170
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
109
|
-
export function buildLoopRequest(session
|
|
171
|
+
export function buildLoopRequest(session,
|
|
172
|
+
/** v4.0.5: the host's explicit invocation input for THIS compile. */
|
|
173
|
+
inputs) {
|
|
110
174
|
// The request carries CURRENT-ROUND context only. Every previous-round
|
|
111
175
|
// fact is read from the Vault's committed rounds by the compiler, so live,
|
|
112
176
|
// resume, unpause and backtrack compiles cannot disagree about the same
|
|
113
177
|
// history. `attempt` is restored only while the held snapshot belongs to
|
|
114
178
|
// the current round; entering the next round resets it to 1.
|
|
115
|
-
const
|
|
116
|
-
? session.roundSnapshot
|
|
117
|
-
:
|
|
179
|
+
const held = session.roundSnapshot?.round === session.currentRound
|
|
180
|
+
? session.roundSnapshot
|
|
181
|
+
: undefined;
|
|
182
|
+
// v4.0.3: a held retry carries its attempt/notice/findings/posture even
|
|
183
|
+
// after a process restart or a failed retry compile.
|
|
184
|
+
const retry = held?.retryContext;
|
|
185
|
+
const heldAttempt = retry?.attempt ?? held?.attempt ?? 1;
|
|
186
|
+
const acceptance = session.operatorAcceptance ?? session.roundSnapshot?.operatorAcceptance;
|
|
118
187
|
return {
|
|
119
|
-
task: session.task,
|
|
120
|
-
mode: Mode.LOOP_COMPILE,
|
|
121
|
-
feedback: null,
|
|
122
|
-
skill_name: null,
|
|
123
|
-
task_id: null,
|
|
124
188
|
loop_id: session.loopId,
|
|
125
189
|
round: session.currentRound,
|
|
190
|
+
task: session.task,
|
|
191
|
+
// Round 1 has no committed acceptance to read from history; the session
|
|
192
|
+
// cursor is the bootstrap. Later rounds read the committed copy.
|
|
193
|
+
loop_objective: session.currentRound === 1 && acceptance
|
|
194
|
+
? makeLoopObjective({
|
|
195
|
+
objective: acceptance.task,
|
|
196
|
+
success_criteria: [...acceptance.success_criteria],
|
|
197
|
+
hard_constraints: [...acceptance.hard_constraints],
|
|
198
|
+
created_at_round: 1,
|
|
199
|
+
loop_id: session.loopId,
|
|
200
|
+
})
|
|
201
|
+
: null,
|
|
202
|
+
plan_source: null,
|
|
203
|
+
constraints_from_plan: [],
|
|
204
|
+
force_level: "auto",
|
|
126
205
|
max_rounds: session.maxRounds,
|
|
206
|
+
verification_flags: retry ? [...retry.verificationFlags] : [],
|
|
127
207
|
attempt: heldAttempt,
|
|
128
208
|
consecutive_rejections: session.consecutiveRejections,
|
|
209
|
+
rejection_notice: retry?.rejectionNotice ?? "",
|
|
210
|
+
...(retry ? { retry_evidence_posture: retry.evidencePosture } : {}),
|
|
211
|
+
// v4.0.5: invocation input only. It never replaces `task`; the compiler
|
|
212
|
+
// and assembler project it into the current prompt/state/artifact.
|
|
213
|
+
...(inputs?.currentUserInstruction !== undefined
|
|
214
|
+
? { current_user_instruction: inputs.currentUserInstruction }
|
|
215
|
+
: {}),
|
|
129
216
|
};
|
|
130
217
|
}
|
|
131
218
|
// ── RoundLifecycle ─────────────────────────────────────────────────────────
|
|
@@ -136,40 +223,63 @@ export class RoundLifecycle {
|
|
|
136
223
|
terminalSinks;
|
|
137
224
|
ownerId;
|
|
138
225
|
leaseMs;
|
|
139
|
-
|
|
226
|
+
checkpointStoreFor;
|
|
140
227
|
constructor(deps) {
|
|
228
|
+
this.checkpointStoreFor = deps.checkpointStoreFor;
|
|
141
229
|
this.store = deps.store;
|
|
142
230
|
this.sessionStore = deps.sessionStore;
|
|
143
231
|
this.registry = deps.registry;
|
|
144
232
|
this.terminalSinks = deps.terminalSinks;
|
|
145
233
|
this.ownerId = deps.ownerId;
|
|
146
234
|
this.leaseMs = deps.leaseMs;
|
|
147
|
-
|
|
235
|
+
}
|
|
236
|
+
/** v3.9.7: the one RoundDriver construction site. Every prepare must carry
|
|
237
|
+
* the per-loop checkpoint store, or no round can produce a before-image. */
|
|
238
|
+
driver(engine, loopId) {
|
|
239
|
+
return new RoundDriver(engine, this.store, this.checkpointStoreFor?.(loopId));
|
|
148
240
|
}
|
|
149
241
|
/** v3.9.1: the submission-guidance projection for a session's held round.
|
|
150
|
-
* One history
|
|
242
|
+
* One history read and one compile window, no writes.
|
|
151
243
|
* Findings from a rejected/retried attempt are supplied by the caller. */
|
|
152
244
|
guidanceFor(session, extras = {}) {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
245
|
+
const history = loadCommittedHistory(this.store, session.loopId);
|
|
246
|
+
const criteria = this.objectiveCriteriaFor(session, history.entries);
|
|
247
|
+
const { verificationFlags: explicitFlags, ...restExtras } = extras;
|
|
248
|
+
// v4.0.3: a held retry projects its findings even after resume/stall.
|
|
249
|
+
const verificationFlags = explicitFlags ??
|
|
250
|
+
session.roundSnapshot?.retryContext?.verificationFlags;
|
|
158
251
|
return deriveSubmissionGuidance(session, {
|
|
159
|
-
rounds:
|
|
160
|
-
...
|
|
252
|
+
rounds: historyWindow(history, session.currentRound),
|
|
253
|
+
...(criteria ? { objective: { success_criteria: criteria } } : {}),
|
|
254
|
+
...restExtras,
|
|
255
|
+
...(verificationFlags ? { verificationFlags } : {}),
|
|
256
|
+
// A pending directive is a current fact. Unless the caller supplied a
|
|
257
|
+
// more specific projection, expose it so status/resume agree.
|
|
258
|
+
recovery: extras.recovery !== undefined
|
|
259
|
+
? extras.recovery
|
|
260
|
+
: this.pendingRecoveryGuidance(session),
|
|
161
261
|
});
|
|
162
262
|
}
|
|
263
|
+
/** The operator criteria the held prompt's reference space was built from.
|
|
264
|
+
* The held acceptance wins; committed objective lineage is the fallback for
|
|
265
|
+
* a session whose snapshot predates the acceptance field. Null when neither
|
|
266
|
+
* is observable — callers fail open. */
|
|
267
|
+
objectiveCriteriaFor(session, entries) {
|
|
268
|
+
const acceptance = session.operatorAcceptance ?? session.roundSnapshot?.operatorAcceptance;
|
|
269
|
+
if (acceptance)
|
|
270
|
+
return acceptance.success_criteria;
|
|
271
|
+
return latestObjective(session.loopId, { results: entries })?.success_criteria ?? null;
|
|
272
|
+
}
|
|
163
273
|
/** v3.9.1: the before facts of the round being submitted, from the SAME
|
|
164
274
|
* history window the compile uses. Works on a replay too: rounds below the
|
|
165
275
|
* logical round exclude the round itself. */
|
|
166
|
-
beforeFactsFor(session) {
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
276
|
+
beforeFactsFor(session, loaded) {
|
|
277
|
+
const history = loaded ?? loadCommittedHistory(this.store, session.loopId);
|
|
278
|
+
const rounds = historyWindow(history, session.currentRound);
|
|
279
|
+
const pending = history.pendingDirective?.recovery?.target.redo_round ===
|
|
280
|
+
session.currentRound
|
|
281
|
+
? history.pendingDirective
|
|
282
|
+
: null;
|
|
173
283
|
return {
|
|
174
284
|
contractId: deriveActiveRoundContract(rounds, getPolicy().evidence.commands ?? [])?.id ?? null,
|
|
175
285
|
subGoals: deriveSubGoals({
|
|
@@ -177,31 +287,19 @@ export class RoundLifecycle {
|
|
|
177
287
|
currentRound: session.currentRound,
|
|
178
288
|
rounds,
|
|
179
289
|
}),
|
|
290
|
+
// Captured before the transaction commits. A redo commit consumes the
|
|
291
|
+
// pending directive, so reading it afterwards would lose the fact.
|
|
292
|
+
recoveryDirective: pending?.recovery ?? null,
|
|
180
293
|
};
|
|
181
294
|
}
|
|
182
295
|
/** v3.9.1: derive this submission's receipt from the transaction and the
|
|
183
296
|
* shared read model. Never writes and never invents an applied effect. */
|
|
184
297
|
buildReceipt(base, opts) {
|
|
185
|
-
const
|
|
186
|
-
const entries = [
|
|
187
|
-
...queryLoopEntries(this.store, base.snapshot.loopId, { prefix }),
|
|
188
|
-
...queryLoopEntries(this.store, base.snapshot.loopId, {
|
|
189
|
-
prefix,
|
|
190
|
-
feedbackOnly: true,
|
|
191
|
-
}),
|
|
192
|
-
];
|
|
298
|
+
const history = loadCommittedHistory(this.store, base.snapshot.loopId);
|
|
193
299
|
const committed = opts.disposition === "accepted" || opts.disposition === "stopped";
|
|
194
|
-
const rounds =
|
|
300
|
+
const rounds = historyWindow(history, committed ? base.snapshot.round + 1 : base.snapshot.round);
|
|
195
301
|
const commands = getPolicy().evidence.commands ?? [];
|
|
196
302
|
const roundDeltas = deriveRoundObservationDelta(base.snapshot.beforeEvidence, base.actualEvidence);
|
|
197
|
-
const contractView = deriveRoundContractView({
|
|
198
|
-
rounds,
|
|
199
|
-
round: base.snapshot.round,
|
|
200
|
-
report: base.selfEval.execution_report ?? null,
|
|
201
|
-
deltas: roundDeltas,
|
|
202
|
-
outcome: effectiveOutcome(base.selfEval),
|
|
203
|
-
commands,
|
|
204
|
-
});
|
|
205
303
|
const contractAfterId = opts.contractAfterId !== undefined
|
|
206
304
|
? opts.contractAfterId
|
|
207
305
|
: committed
|
|
@@ -216,6 +314,15 @@ export class RoundLifecycle {
|
|
|
216
314
|
rounds,
|
|
217
315
|
})
|
|
218
316
|
: base.beforeSubGoals;
|
|
317
|
+
// The pre-commit capture is authoritative. The committed-directive
|
|
318
|
+
// fallback covers a replayed transaction whose pending directive was
|
|
319
|
+
// consumed by a commit this process never observed.
|
|
320
|
+
const recoveryDirective = base.recoveryDirective ??
|
|
321
|
+
(base.snapshot.kind === "rollback"
|
|
322
|
+
? null
|
|
323
|
+
: [...history.directives].reverse().find((item) => item.recovery !== null && item.recovery !== undefined &&
|
|
324
|
+
item.recovery.target.redo_round === base.snapshot.round &&
|
|
325
|
+
item.recovery.transaction_id === base.snapshot.branchId)?.recovery ?? null);
|
|
219
326
|
return deriveSubmissionReceipt({
|
|
220
327
|
snapshot: base.snapshot,
|
|
221
328
|
selfEval: base.selfEval,
|
|
@@ -223,25 +330,26 @@ export class RoundLifecycle {
|
|
|
223
330
|
disposition: opts.disposition,
|
|
224
331
|
diagnostics: base.diagnostics,
|
|
225
332
|
submittedFields: base.submittedFields,
|
|
333
|
+
recoveryDirective,
|
|
334
|
+
...(opts.restoreVerified !== undefined
|
|
335
|
+
? { recoveryRestoreVerified: opts.restoreVerified }
|
|
336
|
+
: {}),
|
|
226
337
|
facts: {
|
|
227
338
|
contractBefore: base.beforeContractId,
|
|
228
339
|
contractAfter: contractAfterId,
|
|
229
340
|
subGoalsBefore: base.beforeSubGoals,
|
|
230
341
|
subGoalsAfter: afterSubGoals,
|
|
231
342
|
constraintsActiveAfter: opts.constraintsActiveAfter ?? null,
|
|
343
|
+
constraintsRetiredAfter: opts.constraintsRetiredAfter ?? null,
|
|
232
344
|
},
|
|
233
345
|
nextArtifact: opts.nextArtifact ?? null,
|
|
234
|
-
contractItems: contractView.statuses.items.map((item) => ({
|
|
235
|
-
item_id: item.itemId,
|
|
236
|
-
status: item.status,
|
|
237
|
-
})),
|
|
238
346
|
evidenceDeltas: roundDeltas,
|
|
239
347
|
});
|
|
240
348
|
}
|
|
241
349
|
/** Persist session state to vault for cross-process recovery.
|
|
242
350
|
* The filtered vault and replacement entry are written once under the
|
|
243
351
|
* backend lock, so recovery never observes the old two-write gap. */
|
|
244
|
-
save(session) {
|
|
352
|
+
save(session, options = {}) {
|
|
245
353
|
if (!this.sessionStore)
|
|
246
354
|
return;
|
|
247
355
|
const leaseActive = session.status === "running";
|
|
@@ -255,30 +363,20 @@ export class RoundLifecycle {
|
|
|
255
363
|
session_id: session.sessionId,
|
|
256
364
|
current_round: session.currentRound,
|
|
257
365
|
max_rounds: session.maxRounds,
|
|
258
|
-
success_trajectory: session.successTrajectory,
|
|
259
366
|
status: session.status,
|
|
260
367
|
created_at: session.createdAt,
|
|
261
368
|
// v1.13: Enforcement gate state
|
|
262
369
|
consecutive_rejections: session.consecutiveRejections,
|
|
263
370
|
last_rejection_check: session.lastRejectionCheck,
|
|
264
|
-
// v2.13: Backtrack skipped files for workspace restore check
|
|
265
|
-
backtrack_skipped_files: session.backtrackSkippedFiles,
|
|
266
|
-
// M3: skipped-file fingerprints at their failed rounds (restore
|
|
267
|
-
// check machine arm) — empty unless a backtrack is pending
|
|
268
|
-
backtrack_skipped_fingerprints: session.backtrackSkippedFingerprints,
|
|
269
371
|
// v3.8.3: the in-flight entrypoint trust baseline. Read back
|
|
270
372
|
// leniently (parseFingerprintMap) so a corrupt or absent value
|
|
271
373
|
// degrades to "no absolute arm" instead of costing the round.
|
|
272
374
|
entrypoint_trust: session.entrypointTrust ?? null,
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
backtrack_target_dirty: session.backtrackTargetDirty ?? null,
|
|
375
|
+
// v3.9.7: the operator-owned immutable acceptance cursor. The root
|
|
376
|
+
// committed round-1 transaction remains the authority.
|
|
377
|
+
operator_acceptance: session.operatorAcceptance ?? null,
|
|
277
378
|
// v1.19: durable round transaction state
|
|
278
379
|
round_snapshot: session.roundSnapshot ?? null,
|
|
279
|
-
last_self_eval: session.lastSelfEval ?? null,
|
|
280
|
-
current_prompt: session.currentPrompt ?? null,
|
|
281
|
-
current_level: session.currentLevel ?? "",
|
|
282
380
|
// v1.20: cross-process single-owner lease
|
|
283
381
|
lease_owner: leaseActive ? this.ownerId : "",
|
|
284
382
|
lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
|
|
@@ -286,6 +384,7 @@ export class RoundLifecycle {
|
|
|
286
384
|
};
|
|
287
385
|
this.sessionStore.save(sessionEntry, {
|
|
288
386
|
expectedLeaseOwner: this.ownerId,
|
|
387
|
+
...options,
|
|
289
388
|
});
|
|
290
389
|
}
|
|
291
390
|
/** Reconstruct a McpSession from a vault session_state entry.
|
|
@@ -294,7 +393,11 @@ export class RoundLifecycle {
|
|
|
294
393
|
reconstructSession(entry, allowPaused = false) {
|
|
295
394
|
const lineage = (entry.loop_lineage ?? {});
|
|
296
395
|
const status = lineage.status ?? "running";
|
|
297
|
-
|
|
396
|
+
// v3.9.7: a persisted `stalled` recovery state MUST be resumable. It is
|
|
397
|
+
// how a committed directive with an unfinished restore survives a
|
|
398
|
+
// process restart; refusing it would strand the loop forever.
|
|
399
|
+
if (status !== "running" && status !== "stalled" &&
|
|
400
|
+
!(allowPaused && status === "paused")) {
|
|
298
401
|
return null;
|
|
299
402
|
}
|
|
300
403
|
const loopId = entry.loop_id;
|
|
@@ -305,60 +408,64 @@ export class RoundLifecycle {
|
|
|
305
408
|
// only reachable through the read-only audit.
|
|
306
409
|
checkRoundSequence(this.store, loopId);
|
|
307
410
|
const currentRound = lineage.current_round ?? 1;
|
|
308
|
-
const successTrajectory = Array.isArray(lineage.success_trajectory)
|
|
309
|
-
? lineage.success_trajectory.filter((value) => typeof value === "boolean")
|
|
310
|
-
: [];
|
|
311
411
|
const task = entry.task ?? "";
|
|
312
412
|
const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
413
|
+
const rawSnapshot = lineage.round_snapshot;
|
|
414
|
+
const parsedSnapshot = parseRoundTransactionSnapshot(rawSnapshot);
|
|
415
|
+
// v4.0.4: a held snapshot written by an unsupported schema must fail
|
|
416
|
+
// closed. Silently recompiling would discard the held attempt (and, for a
|
|
417
|
+
// 4.0.3 session, rewrite history this runtime cannot read).
|
|
418
|
+
if (rawSnapshot !== undefined && rawSnapshot !== null && !parsedSnapshot) {
|
|
419
|
+
throw new LegacyHistoryError(loopId, [currentRound]);
|
|
420
|
+
}
|
|
421
|
+
const history = loadCommittedHistory(this.store, loopId);
|
|
422
|
+
// Legacy rounds are NOT history. Recompiling over them would overwrite
|
|
423
|
+
// data this runtime cannot read; refuse and leave the loop read-only.
|
|
424
|
+
if (history.legacyRounds.length > 0) {
|
|
425
|
+
throw new LegacyHistoryError(loopId, history.legacyRounds.map((round) => round.round));
|
|
426
|
+
}
|
|
427
|
+
const branch = history.branch;
|
|
428
|
+
// The session cursor is the primary copy; the held snapshot and the
|
|
429
|
+
// visible root transaction are fallbacks. Losing the operator surface
|
|
430
|
+
// during a restore/restart would be unrecoverable.
|
|
431
|
+
const operatorAcceptance = parseOperatorAcceptance(lineage.operator_acceptance) ??
|
|
432
|
+
parsedSnapshot?.operatorAcceptance ??
|
|
433
|
+
[...branch.rounds, ...branch.directives].find((view) => view.operatorAcceptance !== null)?.operatorAcceptance ??
|
|
434
|
+
undefined;
|
|
435
|
+
// No synthesized evaluation snapshot: a held prompt is exactly the parsed
|
|
436
|
+
// session snapshot. Resume/unpause recompile from committed history.
|
|
437
|
+
const roundSnapshot = parsedSnapshot ?? undefined;
|
|
327
438
|
const engine = new LoopForgeEngine(this.store);
|
|
328
439
|
return {
|
|
329
440
|
sessionId: typeof lineage.session_id === "string" && lineage.session_id
|
|
330
441
|
? lineage.session_id
|
|
331
442
|
: randomUUID(),
|
|
332
443
|
loopId, task, engine,
|
|
333
|
-
currentRound, maxRounds,
|
|
444
|
+
currentRound, maxRounds,
|
|
334
445
|
status: status,
|
|
335
446
|
createdAt: lineage.created_at ?? Date.now(),
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
: null,
|
|
359
|
-
currentLevel: typeof lineage.current_level === "string"
|
|
360
|
-
? lineage.current_level
|
|
361
|
-
: undefined,
|
|
447
|
+
// v3.9.9: a retired check's persisted counter is not a current problem.
|
|
448
|
+
...(() => {
|
|
449
|
+
const check = typeof lineage.last_rejection_check === "string"
|
|
450
|
+
? lineage.last_rejection_check
|
|
451
|
+
: "";
|
|
452
|
+
if (check === "round_scope_drift") {
|
|
453
|
+
return { consecutiveRejections: 0, lastRejectionCheck: "" };
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
consecutiveRejections: lineage.consecutive_rejections ?? 0,
|
|
457
|
+
lastRejectionCheck: check,
|
|
458
|
+
};
|
|
459
|
+
})(),
|
|
460
|
+
// v4.0.3: an absent baseline stays absent. `{}` must not masquerade as
|
|
461
|
+
// a trusted baseline (there is nothing to compare, and re-capturing it
|
|
462
|
+
// from a modified workspace would launder the change).
|
|
463
|
+
entrypointTrust: (() => {
|
|
464
|
+
const trust = parseFingerprintMap(lineage.entrypoint_trust);
|
|
465
|
+
return Object.keys(trust).length > 0 ? trust : undefined;
|
|
466
|
+
})(),
|
|
467
|
+
...(operatorAcceptance ? { operatorAcceptance } : {}),
|
|
468
|
+
roundSnapshot,
|
|
362
469
|
};
|
|
363
470
|
}
|
|
364
471
|
/** Apply a prepared round to the session, persist, and build the result.
|
|
@@ -376,6 +483,14 @@ export class RoundLifecycle {
|
|
|
376
483
|
* `newConsecutiveRejections` comes from the enforcement gate and already
|
|
377
484
|
* counts the round; it is only honoured when the check is unchanged. */
|
|
378
485
|
applyRoundCounters(session, pr) {
|
|
486
|
+
// v3.9.9: scope drift is no longer an enforcement row. A session restored
|
|
487
|
+
// from an older runtime may still carry that check's rejection streak;
|
|
488
|
+
// draining it here keeps the deleted check from reaching the
|
|
489
|
+
// max_rejections catch-all as unresolved persistence.
|
|
490
|
+
if (session.lastRejectionCheck === "round_scope_drift") {
|
|
491
|
+
session.lastRejectionCheck = "";
|
|
492
|
+
session.consecutiveRejections = 0;
|
|
493
|
+
}
|
|
379
494
|
if (pr.action === "reject" && pr.rejectionCheck) {
|
|
380
495
|
session.consecutiveRejections =
|
|
381
496
|
pr.rejectionCheck === session.lastRejectionCheck
|
|
@@ -388,8 +503,6 @@ export class RoundLifecycle {
|
|
|
388
503
|
if (pr.action !== "reject")
|
|
389
504
|
session.lastRejectionCheck = "";
|
|
390
505
|
}
|
|
391
|
-
if (pr.newLastSelfEval)
|
|
392
|
-
session.lastSelfEval = pr.newLastSelfEval;
|
|
393
506
|
}
|
|
394
507
|
/** v3.8.3: capture the trusted entrypoint baseline for the round about to be
|
|
395
508
|
* prepared.
|
|
@@ -402,6 +515,9 @@ export class RoundLifecycle {
|
|
|
402
515
|
*
|
|
403
516
|
* Best-effort: an unreadable policy or an unresolvable command must leave
|
|
404
517
|
* the round without the absolute arm, not fail the preparation. */
|
|
518
|
+
/** v4.0.3: seed the round-start entrypoint baseline. Public so the start
|
|
519
|
+
* path can seed BEFORE the first prompt is delivered; a missing baseline
|
|
520
|
+
* must never be re-derived from an already-modified workspace. */
|
|
405
521
|
seedEntrypointTrust(session) {
|
|
406
522
|
try {
|
|
407
523
|
session.entrypointTrust = captureEntrypointTrust(getPolicy());
|
|
@@ -410,68 +526,212 @@ export class RoundLifecycle {
|
|
|
410
526
|
session.entrypointTrust = undefined;
|
|
411
527
|
}
|
|
412
528
|
}
|
|
413
|
-
|
|
414
|
-
|
|
529
|
+
/** v4.0.3: warnings for a resumed round whose trusted baseline is absent.
|
|
530
|
+
* The workspace may already have been modified; re-capturing it would
|
|
531
|
+
* launder the change, so the round runs with the Git-delta arm only. */
|
|
532
|
+
resumeWarnings(session, warnings) {
|
|
533
|
+
const out = [...(warnings ?? [])];
|
|
534
|
+
if (!session.entrypointTrust) {
|
|
535
|
+
out.push("entrypoint_trust_missing: no trusted round-start baseline is held; " +
|
|
536
|
+
"only the Git-delta entrypoint arm runs for this resumed round.");
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
/** v3.9.7: operator acceptance and branch identity travel with the session
|
|
541
|
+
* cursor (not only the held snapshot). Every prepare carries them forward;
|
|
542
|
+
* a recovery that clears the held snapshot must not reset the branch to
|
|
543
|
+
* root or lose the operator's acceptance surface. */
|
|
544
|
+
prepareOptions(session, branchId) {
|
|
545
|
+
const acceptance = session.operatorAcceptance ?? session.roundSnapshot?.operatorAcceptance;
|
|
546
|
+
const effectiveBranchId = branchId ?? session.roundSnapshot?.branchId;
|
|
547
|
+
return {
|
|
548
|
+
...(effectiveBranchId ? { branchId: effectiveBranchId } : {}),
|
|
549
|
+
...(acceptance ? { operatorAcceptance: acceptance } : {}),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
persistPrepared(session, prepared, agentSuccessClaim, warningsOverride,
|
|
553
|
+
/** v4.0.3: a resumed round must NOT re-capture its baseline from a
|
|
554
|
+
* possibly modified workspace. */
|
|
555
|
+
seedTrust = true) {
|
|
556
|
+
// v4.0.5: the shared compile → save → result tail is ATOMIC. If the save
|
|
557
|
+
// fails, the in-memory session must not keep the new snapshot/trust: a
|
|
558
|
+
// caller would otherwise receive a handle for a prompt that was never
|
|
559
|
+
// persisted. The old held snapshot and handle stay valid, and the session
|
|
560
|
+
// becomes resumable.
|
|
561
|
+
const previousSnapshot = session.roundSnapshot;
|
|
562
|
+
const previousTrust = session.entrypointTrust;
|
|
415
563
|
session.roundSnapshot = prepared.snapshot;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
564
|
+
if (seedTrust)
|
|
565
|
+
this.seedEntrypointTrust(session);
|
|
566
|
+
try {
|
|
567
|
+
this.save(session);
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
session.roundSnapshot = previousSnapshot;
|
|
571
|
+
session.entrypointTrust = previousTrust;
|
|
572
|
+
if (error instanceof SessionLeaseConflictError)
|
|
573
|
+
throw error;
|
|
574
|
+
logEvent("session_save_failed", {
|
|
575
|
+
loopId: session.loopId,
|
|
576
|
+
round: session.currentRound,
|
|
577
|
+
error: String(error),
|
|
578
|
+
});
|
|
579
|
+
session.status = "stalled";
|
|
580
|
+
try {
|
|
581
|
+
this.save(session);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// The store is still unavailable; the in-memory held facts remain the
|
|
585
|
+
// best available state and the next resume retries the save.
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
sessionId: session.sessionId,
|
|
589
|
+
round: session.currentRound,
|
|
590
|
+
...this.heldHandle(session),
|
|
591
|
+
...this.transactionFacts(session),
|
|
592
|
+
prompt: null,
|
|
593
|
+
stopReason: "stalled",
|
|
594
|
+
stopDetail: "The prompt was compiled but the session could not be saved: " +
|
|
595
|
+
`${String(error).slice(0, 300)}. The previous held prompt and ` +
|
|
596
|
+
"handle are unchanged; call loopforge_resume to retry.",
|
|
597
|
+
};
|
|
598
|
+
}
|
|
421
599
|
policyMetrics.recordStrategy(session.loopId, prepared.level);
|
|
422
|
-
this.save(session);
|
|
423
600
|
return {
|
|
424
601
|
sessionId: session.sessionId,
|
|
425
602
|
round: session.currentRound,
|
|
426
|
-
|
|
603
|
+
...this.heldHandle(session),
|
|
604
|
+
...this.transactionFacts(session),
|
|
605
|
+
...this.contextFacts(prepared.snapshot.promptArtifact),
|
|
427
606
|
prompt: prepared.prompt,
|
|
428
607
|
level: prepared.level,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
608
|
+
agentSuccessClaim,
|
|
609
|
+
agentOutcome: session.roundSnapshot?.result?.agentOutcome ?? null,
|
|
610
|
+
// v4.0.3: the posture of the round that just committed — read from the
|
|
611
|
+
// transaction's committed result, never recomputed here.
|
|
612
|
+
evidencePosture: session.roundSnapshot?.result?.evidencePosture,
|
|
613
|
+
warnings: warningsOverride ??
|
|
614
|
+
session.roundSnapshot?.promptArtifact?.warnings ??
|
|
615
|
+
prepared.warnings ?? [],
|
|
434
616
|
submission_guidance: this.guidanceFor(session),
|
|
435
617
|
};
|
|
436
618
|
}
|
|
437
|
-
|
|
438
|
-
|
|
619
|
+
/** v4.0.2: the context boundary carried by a prompt-bearing result. It is
|
|
620
|
+
* a PROJECTION of the artifact that produced the prompt — never a second
|
|
621
|
+
* derivation from live session state. */
|
|
622
|
+
contextFacts(artifact) {
|
|
623
|
+
if (!artifact)
|
|
624
|
+
return {};
|
|
625
|
+
return {
|
|
626
|
+
contextMode: artifact.contextMode,
|
|
627
|
+
contextReason: artifact.contextReason,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/** v3.9.7: execution metadata attached to every held/prepared prompt. These
|
|
631
|
+
* are transport facts, never prompt governance content. v4.0.0: the anchor
|
|
632
|
+
* itself is the separate `submissionHandle`; this bundle owns only the
|
|
633
|
+
* checkpoint facts. */
|
|
634
|
+
transactionFacts(session) {
|
|
635
|
+
const snapshot = session.roundSnapshot;
|
|
636
|
+
if (!snapshot)
|
|
637
|
+
return {};
|
|
638
|
+
const checkpoints = [
|
|
639
|
+
...(snapshot.beforeCheckpoint ? [snapshot.beforeCheckpoint] : []),
|
|
640
|
+
...(snapshot.checkpoints ?? []),
|
|
641
|
+
];
|
|
642
|
+
return {
|
|
643
|
+
...(checkpoints.length > 0 ? { checkpoints } : {}),
|
|
644
|
+
...(snapshot.beforeCheckpointError
|
|
645
|
+
? {
|
|
646
|
+
beforeCheckpointError: {
|
|
647
|
+
code: snapshot.beforeCheckpointError.code,
|
|
648
|
+
detail: snapshot.beforeCheckpointError.detail,
|
|
649
|
+
},
|
|
650
|
+
}
|
|
651
|
+
: {}),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/** v4.0.0: the transport handle for the current held snapshot, or absent. */
|
|
655
|
+
heldHandle(session) {
|
|
656
|
+
const handle = makeSubmissionHandle(session);
|
|
657
|
+
return handle ? { submissionHandle: handle } : {};
|
|
658
|
+
}
|
|
659
|
+
/** v3.9.7: guidance facts for a redo whose restore is already verified.
|
|
660
|
+
* They are derived from the still-pending committed directive, so a
|
|
661
|
+
* restart returns the same surface as the original recovery response. */
|
|
662
|
+
pendingRecoveryGuidance(session) {
|
|
663
|
+
const pending = loadCommittedHistory(this.store, session.loopId).pendingDirective;
|
|
664
|
+
const directive = pending?.recovery;
|
|
665
|
+
if (!directive)
|
|
666
|
+
return null;
|
|
667
|
+
return {
|
|
668
|
+
pending: true,
|
|
669
|
+
redo_round: directive.target.redo_round,
|
|
670
|
+
checkpoint_id: directive.target.checkpoint_id,
|
|
671
|
+
restore_verified: session.roundSnapshot?.restore?.directiveTransactionId ===
|
|
672
|
+
directive.transaction_id,
|
|
673
|
+
origin: directive.origin,
|
|
674
|
+
reason: directive.reason,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
/** v4.0.0: return the exact stored prompt plus the CURRENT handle. This is
|
|
678
|
+
* the ONE path that re-delivers a prompt without compiling. It fails closed
|
|
679
|
+
* unless the full held predicate holds (phase, round, attempt, rendered
|
|
680
|
+
* prompt), so a committed/terminated/retrying snapshot can never masquerade
|
|
681
|
+
* as held. The warning is appended without mutating the artifact. Public so
|
|
682
|
+
* SessionManager can use it on its pre-queue stale-handle path. */
|
|
683
|
+
heldPromptResult(session, warning) {
|
|
684
|
+
const snapshot = heldPromptSnapshot(session);
|
|
685
|
+
const artifact = snapshot?.promptArtifact;
|
|
686
|
+
if (!snapshot || !artifact)
|
|
687
|
+
return null;
|
|
688
|
+
const handle = makeSubmissionHandle(session);
|
|
689
|
+
if (!handle)
|
|
439
690
|
return null;
|
|
691
|
+
const warnings = [...(artifact.warnings ?? [])];
|
|
692
|
+
if (warning && !warnings.includes(warning))
|
|
693
|
+
warnings.push(warning);
|
|
694
|
+
// v4.0.3: a held-prompt redelivery carries the most recent committed
|
|
695
|
+
// receipt, so the response it missed is recoverable from history.
|
|
696
|
+
const history = loadCommittedHistory(this.store, session.loopId);
|
|
697
|
+
const latestRound = history.rounds[history.rounds.length - 1];
|
|
698
|
+
const pending = history.pendingDirective;
|
|
699
|
+
const latestRecord = pending &&
|
|
700
|
+
(!latestRound || pending.round >= latestRound.round)
|
|
701
|
+
? pending
|
|
702
|
+
: latestRound;
|
|
703
|
+
const receipt = latestRecord
|
|
704
|
+
? deriveCommittedSubmissionView(latestRecord, history.rounds, history.directives)
|
|
705
|
+
: undefined;
|
|
440
706
|
return {
|
|
441
707
|
sessionId: session.sessionId,
|
|
442
708
|
round: session.currentRound,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
709
|
+
submissionHandle: handle,
|
|
710
|
+
...this.transactionFacts(session),
|
|
711
|
+
...this.contextFacts(artifact),
|
|
712
|
+
prompt: artifact.renderedPrompt,
|
|
713
|
+
level: artifact.level,
|
|
714
|
+
agentSuccessClaim: undefined,
|
|
715
|
+
warnings,
|
|
716
|
+
submission_guidance: this.guidanceFor(session, {
|
|
717
|
+
recovery: this.pendingRecoveryGuidance(session),
|
|
718
|
+
}),
|
|
719
|
+
...(receipt ? { submission_receipt: receipt } : {}),
|
|
449
720
|
};
|
|
450
721
|
}
|
|
451
722
|
/** Reconcile the crash window where feedback committed but session_state
|
|
452
723
|
* still points at the old prompt. Returns null when no commit is pending.
|
|
453
724
|
* v3.7: async — the continue-after-crash branch compiles through
|
|
454
725
|
* RoundDriver.prepare (the sync prepareSync fallback was removed). */
|
|
455
|
-
async reconcileCommittedRound(session) {
|
|
726
|
+
async reconcileCommittedRound(session, inputs) {
|
|
456
727
|
if (!session.roundSnapshot)
|
|
457
728
|
return null;
|
|
458
|
-
const recovered =
|
|
729
|
+
const recovered = this.driver(session.engine, session.loopId).recover(session.roundSnapshot);
|
|
459
730
|
if (!recovered)
|
|
460
731
|
return null;
|
|
461
732
|
const pr = recovered.result;
|
|
462
733
|
session.roundSnapshot = recovered.snapshot;
|
|
463
734
|
this.applyRoundCounters(session, pr);
|
|
464
|
-
// L1 (v3.7.x): trajectory push guard must use the SAME judgement as the
|
|
465
|
-
// counter guard below (recovered.snapshot.round). Comparing against
|
|
466
|
-
// session.currentRound conflates two windows: the crash window (persisted
|
|
467
|
-
// length N-1, currentRound N — push needed) and the pause race (pause()
|
|
468
|
-
// persisted the incremented counter AND the already-pushed round N —
|
|
469
|
-
// length N, currentRound N+1 — pushing again duplicates the entry).
|
|
470
|
-
if (pr.shouldPushSuccessTrajectory &&
|
|
471
|
-
session.successTrajectory.length < recovered.snapshot.round) {
|
|
472
|
-
session.successTrajectory.push(pr.roundSuccess);
|
|
473
|
-
}
|
|
474
|
-
session.currentPrompt = null;
|
|
475
735
|
if (pr.action === "stop" || pr.action === "terminate") {
|
|
476
736
|
// v3.8.3: a replayed termination carries the gate's own reason for the
|
|
477
737
|
// same reason the live path does (see buildTerminationResult) — the
|
|
@@ -482,34 +742,38 @@ export class RoundLifecycle {
|
|
|
482
742
|
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
483
743
|
this.save(session);
|
|
484
744
|
void this.notifyTerminal(session, reason);
|
|
745
|
+
// v4.0.4: the crash-recovery stop renders the SAME three fact blocks as
|
|
746
|
+
// the live path. A committed stop also carries the equivalent receipt;
|
|
747
|
+
// a termination never fabricates one.
|
|
748
|
+
const history = loadCommittedHistory(this.store, session.loopId);
|
|
749
|
+
const committedView = pr.action === "stop"
|
|
750
|
+
? history.rounds.find((view) => view.round === recovered.snapshot.round) ?? null
|
|
751
|
+
: null;
|
|
752
|
+
const receipt = committedView
|
|
753
|
+
? deriveCommittedSubmissionView(committedView, history.rounds, history.directives)
|
|
754
|
+
: undefined;
|
|
485
755
|
return {
|
|
486
756
|
sessionId: session.sessionId,
|
|
487
757
|
round: session.currentRound,
|
|
488
|
-
roundId: recovered.snapshot.roundId,
|
|
489
758
|
prompt: null,
|
|
490
759
|
stopReason: reason,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
: `Loop stopped: ${reason}.`,
|
|
498
|
-
roundSuccess: pr.roundSuccess,
|
|
760
|
+
sessionStatus: session.status,
|
|
761
|
+
stopDetail: renderResultFacts(this.terminalFacts(pr, reason, recovered.snapshot.evaluation ?? null, pr.action === "stop"), "terminal").join("\n"),
|
|
762
|
+
agentSuccessClaim: pr.agentSuccessClaim,
|
|
763
|
+
agentOutcome: pr.agentOutcome,
|
|
764
|
+
agentShouldContinue: recovered.snapshot.evaluation?.should_continue,
|
|
765
|
+
...(receipt ? { submission_receipt: receipt } : {}),
|
|
499
766
|
};
|
|
500
767
|
}
|
|
501
|
-
if (pr.action === "backtrack") {
|
|
502
|
-
// Backtrack committed but prompt not delivered
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
session.currentRound = restoreTarget + 1;
|
|
768
|
+
if (pr.action === "backtrack" || pr.action === "recovery") {
|
|
769
|
+
// Backtrack/directive committed but the redo prompt was not delivered
|
|
770
|
+
// (crash window). The pending-directive-first path in resume/unpause
|
|
771
|
+
// performs the restore; here we only align the cursor.
|
|
772
|
+
session.currentRound = pr.recovery?.target.redo_round ?? session.currentRound;
|
|
507
773
|
session.consecutiveRejections = 0;
|
|
508
774
|
session.lastRejectionCheck = "";
|
|
509
|
-
session.lastSelfEval = undefined;
|
|
510
|
-
session.currentPrompt = null;
|
|
511
775
|
this.save(session);
|
|
512
|
-
// Return null — caller (resume/unpause) will recompile
|
|
776
|
+
// Return null — caller (resume/unpause) will restore/recompile.
|
|
513
777
|
return null;
|
|
514
778
|
}
|
|
515
779
|
if (pr.action !== "continue")
|
|
@@ -522,9 +786,78 @@ export class RoundLifecycle {
|
|
|
522
786
|
if (session.currentRound <= recovered.snapshot.round) {
|
|
523
787
|
session.currentRound = recovered.snapshot.round + 1;
|
|
524
788
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
789
|
+
// v4.0.5: a host instruction explicitly supplied with THIS resume call
|
|
790
|
+
// carries into the next round's compile. It is never inherited from an
|
|
791
|
+
// older artifact here.
|
|
792
|
+
const request = buildLoopRequest(session, inputs);
|
|
793
|
+
let prepared;
|
|
794
|
+
try {
|
|
795
|
+
prepared = await this.driver(session.engine, session.loopId).prepare(request, session.loopId, session.currentRound, this.prepareOptions(session));
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
if (error instanceof ProjectionCoverageError) {
|
|
799
|
+
return this.stalledAfterCommit(session.sessionId, session, pr.agentSuccessClaim, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
|
|
800
|
+
}
|
|
801
|
+
if (error instanceof CompileFailureError) {
|
|
802
|
+
return this.stalledAfterCommit(session.sessionId, session, pr.agentSuccessClaim, `The committed round is held, but the next prompt could not be compiled: ${error.message}. Call loopforge_resume to retry.`);
|
|
803
|
+
}
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
return this.persistPrepared(session, {
|
|
807
|
+
prompt: prepared.prompt,
|
|
808
|
+
level: prepared.level,
|
|
809
|
+
baseline: prepared.evidenceBaseline,
|
|
810
|
+
snapshot: prepared.snapshot,
|
|
811
|
+
warnings: prepared.warnings ?? [],
|
|
812
|
+
}, pr.agentSuccessClaim);
|
|
813
|
+
}
|
|
814
|
+
/** v3.9.7: a committed directive with no verified redo snapshot must be
|
|
815
|
+
* completed BEFORE any ordinary compile/held-prompt path. The restore and
|
|
816
|
+
* redo prepare are idempotent; a verified held redo prompt is returned
|
|
817
|
+
* without re-checking the workspace (the Agent may already have worked). */
|
|
818
|
+
async completePendingRecovery(session, inputs) {
|
|
819
|
+
const pending = loadCommittedHistory(this.store, session.loopId).pendingDirective;
|
|
820
|
+
if (!pending?.recovery)
|
|
821
|
+
return null;
|
|
822
|
+
const directive = pending.recovery;
|
|
823
|
+
const verified = session.roundSnapshot?.restore?.directiveTransactionId ===
|
|
824
|
+
directive.transaction_id &&
|
|
825
|
+
session.roundSnapshot.round === directive.target.redo_round;
|
|
826
|
+
if (verified) {
|
|
827
|
+
// v4.0.5: the verified redo prompt goes through the SAME held-prompt
|
|
828
|
+
// entry as every other resume/unpause path, so a new instruction is
|
|
829
|
+
// never silently dropped on this branch.
|
|
830
|
+
return this.heldPromptWithInputs(session, inputs);
|
|
831
|
+
}
|
|
832
|
+
session.currentRound = directive.target.redo_round;
|
|
833
|
+
session.roundSnapshot = undefined;
|
|
834
|
+
session.status = "running";
|
|
835
|
+
const pr = {
|
|
836
|
+
action: directive.origin === "agent" ? "recovery" : "backtrack",
|
|
837
|
+
origin: directive.origin,
|
|
838
|
+
reason: directive.reason,
|
|
839
|
+
recovery: directive,
|
|
840
|
+
// v4.0.3: replay the directive's compact trigger findings instead of
|
|
841
|
+
// losing them with the process that produced the backtrack.
|
|
842
|
+
verificationFlags: [...(directive.trigger_findings ?? [])],
|
|
843
|
+
agentSuccessClaim: false,
|
|
844
|
+
agentOutcome: null,
|
|
845
|
+
gateContradicted: false,
|
|
846
|
+
evidencePosture: rollbackEvidencePosture(directive.target.redo_round),
|
|
847
|
+
newConsecutiveRejections: 0,
|
|
848
|
+
shouldPushSuccessTrajectory: false,
|
|
849
|
+
};
|
|
850
|
+
// The receipt base is the committed directive's own snapshot, not a
|
|
851
|
+
// freshly synthesized empty transaction. It is readable history (the
|
|
852
|
+
// legacy guard already ran) and carries the operator acceptance the
|
|
853
|
+
// recovery preserved.
|
|
854
|
+
const source = pending.sourceEntry;
|
|
855
|
+
const lineage = isRecord(source.loop_lineage) ? source.loop_lineage : {};
|
|
856
|
+
const transaction = isRecord(lineage.round_transaction)
|
|
857
|
+
? lineage.round_transaction
|
|
858
|
+
: {};
|
|
859
|
+
const snapshot = parseRoundTransactionSnapshot(transaction.snapshot);
|
|
860
|
+
if (!snapshot) {
|
|
528
861
|
session.status = "stalled";
|
|
529
862
|
this.save(session);
|
|
530
863
|
return {
|
|
@@ -532,129 +865,313 @@ export class RoundLifecycle {
|
|
|
532
865
|
round: session.currentRound,
|
|
533
866
|
prompt: null,
|
|
534
867
|
stopReason: "stalled",
|
|
535
|
-
stopDetail: "
|
|
868
|
+
stopDetail: "The committed rollback directive is unreadable; its history must be repaired before the restore can continue.",
|
|
536
869
|
};
|
|
537
870
|
}
|
|
871
|
+
const beforeFacts = this.beforeFactsFor(session);
|
|
872
|
+
return this.buildBacktrackResult(session.sessionId, session, pr, {
|
|
873
|
+
snapshot,
|
|
874
|
+
selfEval: {
|
|
875
|
+
outcome: "failed",
|
|
876
|
+
output_summary: "",
|
|
877
|
+
constraint_violations: [],
|
|
878
|
+
should_continue: true,
|
|
879
|
+
},
|
|
880
|
+
pr,
|
|
881
|
+
actualEvidence: [],
|
|
882
|
+
diagnostics: [],
|
|
883
|
+
submittedFields: [],
|
|
884
|
+
beforeContractId: beforeFacts.contractId,
|
|
885
|
+
beforeSubGoals: beforeFacts.subGoals,
|
|
886
|
+
recoveryDirective: directive,
|
|
887
|
+
replayed: true,
|
|
888
|
+
}, inputs);
|
|
889
|
+
}
|
|
890
|
+
/** v4.0.5: the zero-side-effect refusal for a cold handoff whose held retry
|
|
891
|
+
* facts are missing or inconsistent. Nothing is saved, compiled or
|
|
892
|
+
* advanced; the held snapshot and handle are unchanged. */
|
|
893
|
+
invocationRecoveryUnavailable(session, detail) {
|
|
894
|
+
return {
|
|
895
|
+
sessionId: session.sessionId,
|
|
896
|
+
round: session.currentRound,
|
|
897
|
+
...this.heldHandle(session),
|
|
898
|
+
...this.transactionFacts(session),
|
|
899
|
+
prompt: null,
|
|
900
|
+
submissionError: {
|
|
901
|
+
code: "invalid_argument",
|
|
902
|
+
details: { reason_code: "invocation_recovery_unavailable", detail },
|
|
903
|
+
},
|
|
904
|
+
stopDetail: detail,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/** v4.0.5: map a compile failure onto the existing stalled/coverage result.
|
|
908
|
+
* Returns null for an unexpected error so the caller can rethrow it. */
|
|
909
|
+
compileStalledResult(session, error, detail) {
|
|
910
|
+
if (error instanceof ProjectionCoverageError) {
|
|
911
|
+
return this.stalledAfterCommit(session.sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
|
|
912
|
+
}
|
|
913
|
+
if (error instanceof CompileFailureError) {
|
|
914
|
+
return this.stalledAfterCommit(session.sessionId, session, false, `${detail}: ${error.message}. The previous prompt and handle are ` +
|
|
915
|
+
"unchanged; call loopforge_resume to retry.");
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
/** v4.0.5: ONE held-prompt entry for resume/unpause/recovery. With no new
|
|
920
|
+
* instruction it re-delivers the stored prompt and handle unchanged; with a
|
|
921
|
+
* new instruction it recompiles the SAME round/attempt and preserves the
|
|
922
|
+
* held context boundary (including an existing invocation recovery). */
|
|
923
|
+
async heldPromptWithInputs(session, inputs) {
|
|
924
|
+
const held = heldPromptSnapshot(session);
|
|
925
|
+
if (!held?.promptArtifact)
|
|
926
|
+
return null;
|
|
927
|
+
const instruction = inputs?.currentUserInstruction;
|
|
928
|
+
if (instruction === undefined ||
|
|
929
|
+
instruction === held.promptArtifact.currentUserInstruction) {
|
|
930
|
+
return this.heldPromptResult(session);
|
|
931
|
+
}
|
|
932
|
+
return this.recompileHeldPrompt(session, held, {
|
|
933
|
+
currentUserInstruction: instruction,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
/** v4.0.5: the public `loopforge_next` instruction-recompile entry. It only
|
|
937
|
+
* recompiles the held prompt for a new invocation instruction: no
|
|
938
|
+
* submission, no round/attempt change, no evidence collection. The handle
|
|
939
|
+
* was validated by SessionManager before its queue and lease heartbeat. */
|
|
940
|
+
async recompileHeldWithInstruction(session, instruction) {
|
|
941
|
+
const held = heldPromptSnapshot(session);
|
|
942
|
+
if (!held?.promptArtifact) {
|
|
943
|
+
return {
|
|
944
|
+
sessionId: session.sessionId,
|
|
945
|
+
round: session.currentRound,
|
|
946
|
+
prompt: null,
|
|
947
|
+
submissionError: {
|
|
948
|
+
code: "invalid_argument",
|
|
949
|
+
details: {
|
|
950
|
+
reason_code: "no_held_prompt",
|
|
951
|
+
detail: "No held prompt exists to recompile.",
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
if (instruction === held.promptArtifact.currentUserInstruction) {
|
|
957
|
+
// Idempotent: the exact same instruction returns the same prompt and
|
|
958
|
+
// handle without compiling again.
|
|
959
|
+
return this.heldPromptResult(session) ?? {
|
|
960
|
+
sessionId: session.sessionId,
|
|
961
|
+
round: session.currentRound,
|
|
962
|
+
prompt: null,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
return this.recompileHeldPrompt(session, held, {
|
|
966
|
+
currentUserInstruction: instruction,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
/** v4.0.5: compile a held prompt again without collecting evidence, writing
|
|
970
|
+
* the Vault, changing attempt/round or touching retry counters. */
|
|
971
|
+
async recompileHeldPrompt(session, held, inputs) {
|
|
972
|
+
const request = buildLoopRequest(session, inputs);
|
|
973
|
+
if (inputs.invocationRecovery)
|
|
974
|
+
request.invocation_recovery = true;
|
|
975
|
+
let prepared;
|
|
976
|
+
try {
|
|
977
|
+
prepared = await this.driver(session.engine, session.loopId)
|
|
978
|
+
.recompileHeld(request, held);
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
const stalled = this.compileStalledResult(session, error, "The held prompt could not be recompiled");
|
|
982
|
+
if (stalled)
|
|
983
|
+
return stalled;
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
return this.persistPrepared(session, {
|
|
987
|
+
prompt: prepared.prompt,
|
|
988
|
+
level: prepared.level,
|
|
989
|
+
baseline: prepared.evidenceBaseline,
|
|
990
|
+
snapshot: prepared.snapshot,
|
|
991
|
+
warnings: prepared.warnings ?? [],
|
|
992
|
+
}, undefined, this.resumeWarnings(session, prepared.warnings), false);
|
|
993
|
+
}
|
|
994
|
+
/** v4.0.5: compile the retry prompt for a rejected attempt that has no held
|
|
995
|
+
* prompt (its retry compile failed before). This is the one path that
|
|
996
|
+
* creates the next attempt for such a session. */
|
|
997
|
+
async prepareRetryWithInputs(session, rejected, opts) {
|
|
998
|
+
const retryContext = rejected.retryContext;
|
|
999
|
+
if (!retryContext) {
|
|
1000
|
+
throw new Error("prepareRetryWithInputs requires a retry context");
|
|
1001
|
+
}
|
|
1002
|
+
const request = buildLoopRequest(session, opts);
|
|
1003
|
+
let prepared;
|
|
1004
|
+
try {
|
|
1005
|
+
prepared = await this.driver(session.engine, session.loopId).prepareRetry(request, rejected, retryContext.rejectionNotice, session.consecutiveRejections, opts.invocationRecovery ? { invocationRecovery: true } : {});
|
|
1006
|
+
}
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
const stalled = this.compileStalledResult(session, error, "The retry prompt could not be compiled");
|
|
1009
|
+
if (stalled)
|
|
1010
|
+
return stalled;
|
|
1011
|
+
throw error;
|
|
1012
|
+
}
|
|
538
1013
|
return this.persistPrepared(session, {
|
|
539
1014
|
prompt: prepared.prompt,
|
|
540
1015
|
level: prepared.level,
|
|
541
1016
|
baseline: prepared.evidenceBaseline,
|
|
542
1017
|
snapshot: prepared.snapshot,
|
|
543
|
-
warnings: prepared
|
|
544
|
-
},
|
|
1018
|
+
warnings: prepared.warnings ?? [],
|
|
1019
|
+
}, undefined, this.resumeWarnings(session, prepared.warnings), false);
|
|
1020
|
+
}
|
|
1021
|
+
/** v4.0.5: recompile the SAME retry attempt when the host declared the
|
|
1022
|
+
* original invocation lost, or when a submitted retry prompt never existed
|
|
1023
|
+
* (rejected snapshot). Returns null when this session has no retry material
|
|
1024
|
+
* or when the held prompt is already a clean context (idempotent no-op). */
|
|
1025
|
+
async recompileRetryAttempt(session, inputs) {
|
|
1026
|
+
const invocationLost = inputs.invocationLost === true;
|
|
1027
|
+
if (!invocationLost && inputs.currentUserInstruction === undefined) {
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
const held = session.roundSnapshot?.round === session.currentRound
|
|
1031
|
+
? session.roundSnapshot
|
|
1032
|
+
: undefined;
|
|
1033
|
+
// A rejected attempt whose retry prompt never compiled: there is nothing
|
|
1034
|
+
// to re-deliver, so the retry prompt is compiled for real. Instruction
|
|
1035
|
+
// inheritance is scoped to this same logical round.
|
|
1036
|
+
if (held?.phase === "rejected" && held.retryContext) {
|
|
1037
|
+
const instruction = inputs.currentUserInstruction ??
|
|
1038
|
+
held.promptArtifact?.currentUserInstruction;
|
|
1039
|
+
return this.prepareRetryWithInputs(session, held, {
|
|
1040
|
+
...(instruction !== undefined
|
|
1041
|
+
? { currentUserInstruction: instruction }
|
|
1042
|
+
: {}),
|
|
1043
|
+
invocationRecovery: invocationLost && session.consecutiveRejections < 2,
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
// Only an in-context retry has an invocation to recover. A held clean
|
|
1047
|
+
// prompt (first/next round, escalation, recovery redo) is already the
|
|
1048
|
+
// cold-context prompt, so `invocationLost` is an idempotent no-op.
|
|
1049
|
+
if (held?.phase !== "prompted" || !held.promptArtifact)
|
|
1050
|
+
return null;
|
|
1051
|
+
if (held.promptArtifact.contextMode !== "same_round_retry")
|
|
1052
|
+
return null;
|
|
1053
|
+
// A plain instruction recompile is handled by the unified held-prompt
|
|
1054
|
+
// entry; only the lost invocation rebuilds the context boundary.
|
|
1055
|
+
if (!invocationLost)
|
|
1056
|
+
return null;
|
|
1057
|
+
if (!held.retryContext) {
|
|
1058
|
+
return this.invocationRecoveryUnavailable(session, "The held in-context retry has no retry context, so the same attempt " +
|
|
1059
|
+
"cannot be recompiled in cold context.");
|
|
1060
|
+
}
|
|
1061
|
+
if (held.retryContext.attempt !== held.attempt ||
|
|
1062
|
+
held.retryContext.rejectedAttempt + 1 !== held.attempt) {
|
|
1063
|
+
return this.invocationRecoveryUnavailable(session, "The held retry context does not match the held attempt, so the same " +
|
|
1064
|
+
"attempt cannot be recompiled in cold context.");
|
|
1065
|
+
}
|
|
1066
|
+
if (session.consecutiveRejections >= 2) {
|
|
1067
|
+
return this.invocationRecoveryUnavailable(session, "The retry has reached the rejection escalation threshold; it is " +
|
|
1068
|
+
"already clean context and is not rewritten as an invocation recovery.");
|
|
1069
|
+
}
|
|
1070
|
+
const instruction = inputs.currentUserInstruction ??
|
|
1071
|
+
held.promptArtifact.currentUserInstruction;
|
|
1072
|
+
return this.recompileHeldPrompt(session, held, {
|
|
1073
|
+
...(instruction !== undefined ? { currentUserInstruction: instruction } : {}),
|
|
1074
|
+
invocationRecovery: true,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
/** A rejected round still owns its original before-image. Recover its next
|
|
1078
|
+
* attempt through the same retry preparation used by a live rejection;
|
|
1079
|
+
* ordinary prepare would recapture evidence and reset snapshot.attempt.
|
|
1080
|
+
* v4.0.5: the instruction travels with the compile and is inherited from
|
|
1081
|
+
* the rejected prompt within the same logical round. */
|
|
1082
|
+
prepareMissingPrompt(session, inputs) {
|
|
1083
|
+
const driver = this.driver(session.engine, session.loopId);
|
|
1084
|
+
const rejected = session.roundSnapshot;
|
|
1085
|
+
if (rejected?.phase === "rejected" && rejected.retryContext) {
|
|
1086
|
+
const instruction = inputs?.currentUserInstruction ??
|
|
1087
|
+
rejected.promptArtifact?.currentUserInstruction;
|
|
1088
|
+
const request = buildLoopRequest(session, instruction !== undefined ? { currentUserInstruction: instruction } : undefined);
|
|
1089
|
+
return driver.prepareRetry(request, rejected, rejected.retryContext.rejectionNotice, session.consecutiveRejections);
|
|
1090
|
+
}
|
|
1091
|
+
return driver.prepare(buildLoopRequest(session, inputs), session.loopId, session.currentRound, this.prepareOptions(session));
|
|
545
1092
|
}
|
|
546
1093
|
/** Resume tail: the session has been reconstructed and registered. Reconcile
|
|
547
1094
|
* a committed-but-undelivered round, restore the held prompt, or recover a
|
|
548
1095
|
* missing prompt from current round state. v3.7: async — compilation runs
|
|
549
1096
|
* through RoundDriver.prepare like unpause (the prepareSync fallback was
|
|
550
1097
|
* removed); a failed compile now degrades to the same stalled terminal
|
|
551
|
-
* result unpause returns instead of persisting a null prompt.
|
|
552
|
-
|
|
553
|
-
|
|
1098
|
+
* result unpause returns instead of persisting a null prompt.
|
|
1099
|
+
* v4.0.5: an `invocationLost` retry is recompiled as a cold-context prompt
|
|
1100
|
+
* for the SAME logical round/attempt, before the ordinary held-prompt path. */
|
|
1101
|
+
async resume(session, inputs) {
|
|
1102
|
+
const pendingRecovery = await this.completePendingRecovery(session, inputs);
|
|
1103
|
+
if (pendingRecovery)
|
|
1104
|
+
return pendingRecovery;
|
|
1105
|
+
const reconciled = await this.reconcileCommittedRound(session, inputs);
|
|
554
1106
|
if (reconciled)
|
|
555
1107
|
return reconciled;
|
|
556
|
-
const
|
|
1108
|
+
const retryRecovery = await this.recompileRetryAttempt(session, inputs ?? {});
|
|
1109
|
+
if (retryRecovery)
|
|
1110
|
+
return retryRecovery;
|
|
1111
|
+
const restored = await this.heldPromptWithInputs(session, inputs);
|
|
557
1112
|
if (restored)
|
|
558
1113
|
return restored;
|
|
559
1114
|
// Missing held prompt: compile (async) once, then persist it.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
prompt:
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
};
|
|
1115
|
+
let prepared;
|
|
1116
|
+
try {
|
|
1117
|
+
prepared = await this.prepareMissingPrompt(session, inputs);
|
|
1118
|
+
}
|
|
1119
|
+
catch (error) {
|
|
1120
|
+
if (error instanceof ProjectionCoverageError) {
|
|
1121
|
+
return this.stalledAfterCommit(session.sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
|
|
1122
|
+
}
|
|
1123
|
+
if (error instanceof CompileFailureError) {
|
|
1124
|
+
return this.stalledAfterCommit(session.sessionId, session, false, `The compiler could not produce a prompt during resume: ${error.message}. Call loopforge_resume to retry.`);
|
|
1125
|
+
}
|
|
1126
|
+
throw error;
|
|
573
1127
|
}
|
|
574
1128
|
return this.persistPrepared(session, {
|
|
575
1129
|
prompt: prepared.prompt,
|
|
576
1130
|
level: prepared.level,
|
|
577
1131
|
baseline: prepared.evidenceBaseline,
|
|
578
1132
|
snapshot: prepared.snapshot,
|
|
579
|
-
warnings: prepared
|
|
580
|
-
}, false);
|
|
1133
|
+
warnings: prepared.warnings ?? [],
|
|
1134
|
+
}, false, this.resumeWarnings(session, prepared.warnings), false);
|
|
581
1135
|
}
|
|
582
1136
|
/** Unpause tail: the session has been reconstructed and registered with
|
|
583
|
-
* status "running".
|
|
584
|
-
*
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
phase: "before",
|
|
592
|
-
});
|
|
593
|
-
if (asyncEvidence.length > 0) {
|
|
594
|
-
session.evidenceBaseline = asyncEvidence;
|
|
595
|
-
if (!session.roundSnapshot?.beforeEvidence?.length) {
|
|
596
|
-
session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
catch {
|
|
601
|
-
// Async evidence is best-effort; fall back to sync baseline.
|
|
602
|
-
}
|
|
603
|
-
const reconciled = await this.reconcileCommittedRound(session);
|
|
1137
|
+
* status "running". Reconcile a committed round, restore the held prompt,
|
|
1138
|
+
* or compile the next prompt from the committed window. v4.0.5: the same
|
|
1139
|
+
* invocation inputs and cold-handoff recompile apply here. */
|
|
1140
|
+
async unpause(session, inputs) {
|
|
1141
|
+
const pendingRecovery = await this.completePendingRecovery(session, inputs);
|
|
1142
|
+
if (pendingRecovery)
|
|
1143
|
+
return pendingRecovery;
|
|
1144
|
+
const reconciled = await this.reconcileCommittedRound(session, inputs);
|
|
604
1145
|
if (reconciled)
|
|
605
1146
|
return reconciled;
|
|
606
|
-
const
|
|
1147
|
+
const retryRecovery = await this.recompileRetryAttempt(session, inputs ?? {});
|
|
1148
|
+
if (retryRecovery)
|
|
1149
|
+
return retryRecovery;
|
|
1150
|
+
const restored = await this.heldPromptWithInputs(session, inputs);
|
|
607
1151
|
if (restored) {
|
|
608
1152
|
this.save(session);
|
|
609
1153
|
return restored;
|
|
610
1154
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
mode: Mode.LOOP_COMPILE,
|
|
624
|
-
feedback: null,
|
|
625
|
-
skill_name: null,
|
|
626
|
-
task_id: null,
|
|
627
|
-
loop_id: lcr.loop_id,
|
|
628
|
-
round: lcr.round,
|
|
629
|
-
goal_id: lcr.goal_id,
|
|
630
|
-
domain: lcr.domain ?? "",
|
|
631
|
-
plan_source: lcr.plan_source ?? null,
|
|
632
|
-
constraints_from_plan: lcr.constraints_from_plan ?? [],
|
|
633
|
-
max_rounds: session.maxRounds,
|
|
634
|
-
// Preserve an in-flight retry's attempt; a next-round compile resets
|
|
635
|
-
// it because the held snapshot belongs to the previous round.
|
|
636
|
-
attempt: session.roundSnapshot?.round === session.currentRound
|
|
637
|
-
? session.roundSnapshot.attempt
|
|
638
|
-
: 1,
|
|
639
|
-
consecutive_rejections: session.consecutiveRejections,
|
|
640
|
-
};
|
|
641
|
-
const prepared = await new RoundDriver(session.engine, this.store).prepare(compileRequest, session.loopId, session.currentRound);
|
|
642
|
-
if (!prepared) {
|
|
643
|
-
session.status = "stopped";
|
|
644
|
-
this.save(session);
|
|
645
|
-
void this.notifyTerminal(session, "stalled");
|
|
646
|
-
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." };
|
|
1155
|
+
let prepared;
|
|
1156
|
+
try {
|
|
1157
|
+
prepared = await this.prepareMissingPrompt(session, inputs);
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
if (error instanceof ProjectionCoverageError) {
|
|
1161
|
+
return this.stalledAfterCommit(session.sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
|
|
1162
|
+
}
|
|
1163
|
+
if (error instanceof CompileFailureError) {
|
|
1164
|
+
return this.stalledAfterCommit(session.sessionId, session, false, `The compiler could not produce a prompt during unpause: ${error.message}. Call loopforge_resume to retry.`);
|
|
1165
|
+
}
|
|
1166
|
+
throw error;
|
|
647
1167
|
}
|
|
648
1168
|
return this.persistPrepared(session, {
|
|
649
1169
|
prompt: prepared.prompt,
|
|
650
1170
|
level: prepared.level,
|
|
651
1171
|
baseline: prepared.evidenceBaseline,
|
|
652
1172
|
snapshot: prepared.snapshot,
|
|
653
|
-
warnings: prepared
|
|
654
|
-
}, undefined,
|
|
655
|
-
// Historical contract: unpause returns an empty warnings array even
|
|
656
|
-
// though the session carries the compiled warnings.
|
|
657
|
-
[]);
|
|
1173
|
+
warnings: prepared.warnings ?? [],
|
|
1174
|
+
}, undefined, this.resumeWarnings(session, prepared.warnings), false);
|
|
658
1175
|
}
|
|
659
1176
|
/** Record a gate_opened entry when an accepted round reports a high-risk
|
|
660
1177
|
* blocker (USER_RISK hit). Idempotent per gate. The gate is a record
|
|
@@ -666,8 +1183,8 @@ export class RoundLifecycle {
|
|
|
666
1183
|
if (gate.kind !== "user")
|
|
667
1184
|
return;
|
|
668
1185
|
const taskId = `loop:${session.loopId}:gate:${id}`;
|
|
669
|
-
const existing =
|
|
670
|
-
.some((entry) => entry.task_id === taskId);
|
|
1186
|
+
const existing = loadCommittedHistory(this.store, session.loopId)
|
|
1187
|
+
.entries.some((entry) => entry.task_id === taskId);
|
|
671
1188
|
if (existing)
|
|
672
1189
|
return;
|
|
673
1190
|
const entry = {
|
|
@@ -688,7 +1205,9 @@ export class RoundLifecycle {
|
|
|
688
1205
|
}
|
|
689
1206
|
/** Undecided user gate descriptions for a loop (recorded but unresolved). */
|
|
690
1207
|
listOpenGateDescriptions(loopId) {
|
|
691
|
-
const
|
|
1208
|
+
const gatePrefix = `loop:${loopId}:gate:`;
|
|
1209
|
+
const entries = loadCommittedHistory(this.store, loopId).entries
|
|
1210
|
+
.filter((entry) => String(entry.task_id ?? "").startsWith(gatePrefix));
|
|
692
1211
|
const decided = new Set(entries.filter((entry) => entry.task_type === "gate_decision")
|
|
693
1212
|
.map((entry) => String(entry.gate_id ?? "")));
|
|
694
1213
|
return entries
|
|
@@ -705,24 +1224,21 @@ export class RoundLifecycle {
|
|
|
705
1224
|
}
|
|
706
1225
|
/** Execute the round transaction and apply per-rule rejection tracking.
|
|
707
1226
|
* MUTATES: session.roundSnapshot, session.consecutiveRejections,
|
|
708
|
-
* session.lastRejectionCheck
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1227
|
+
* session.lastRejectionCheck */
|
|
1228
|
+
async executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields, history) {
|
|
1229
|
+
const snapshot = session.roundSnapshot;
|
|
1230
|
+
if (!snapshot) {
|
|
1231
|
+
throw new Error("No held round transaction — the submission boundary must reject this attempt.");
|
|
1232
|
+
}
|
|
1233
|
+
const completed = await this.driver(session.engine, session.loopId).complete({
|
|
713
1234
|
snapshot,
|
|
714
1235
|
loopId: session.loopId,
|
|
715
1236
|
task: session.task,
|
|
716
1237
|
maxRounds: session.maxRounds,
|
|
1238
|
+
...(history ? { history } : {}),
|
|
717
1239
|
selfEval,
|
|
718
|
-
lastSelfEval: session.lastSelfEval,
|
|
719
1240
|
consecutiveRejections: session.consecutiveRejections,
|
|
720
1241
|
lastRejectionCheck: session.lastRejectionCheck,
|
|
721
|
-
successTrajectory: session.successTrajectory,
|
|
722
|
-
backtrackSkippedFiles: session.backtrackSkippedFiles,
|
|
723
|
-
backtrackSkippedFingerprints: session.backtrackSkippedFingerprints,
|
|
724
|
-
backtrackTargetGitHead: session.backtrackTargetGitHead,
|
|
725
|
-
backtrackTargetDirty: session.backtrackTargetDirty,
|
|
726
1242
|
entrypointTrust: session.entrypointTrust,
|
|
727
1243
|
normalizationDiagnostics,
|
|
728
1244
|
submittedFields,
|
|
@@ -732,14 +1248,6 @@ export class RoundLifecycle {
|
|
|
732
1248
|
session.roundSnapshot = outcome.snapshot;
|
|
733
1249
|
const pr = outcome.result;
|
|
734
1250
|
this.applyRoundCounters(session, pr);
|
|
735
|
-
// L1 (v3.7.x): never push a REPLAYED outcome onto the trajectory. When a
|
|
736
|
-
// crash/exception window left the committed round undelivered, the replay
|
|
737
|
-
// re-executes the same round in memory — its success was already recorded
|
|
738
|
-
// by the original attempt (crash recovery re-pushes through
|
|
739
|
-
// reconcileCommittedRound instead, which uses the length-vs-round guard).
|
|
740
|
-
if (pr.shouldPushSuccessTrajectory && !completed.outcome.replayed) {
|
|
741
|
-
session.successTrajectory.push(pr.roundSuccess);
|
|
742
|
-
}
|
|
743
1251
|
return {
|
|
744
1252
|
pr,
|
|
745
1253
|
verificationFlags: pr.verificationFlags,
|
|
@@ -749,169 +1257,386 @@ export class RoundLifecycle {
|
|
|
749
1257
|
}
|
|
750
1258
|
/** Build a rejection result: compile a retry prompt, persist, return.
|
|
751
1259
|
* MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
|
|
752
|
-
async buildRejectionResult(sessionId, session, pr, verificationFlags, receiptBase) {
|
|
1260
|
+
async buildRejectionResult(sessionId, session, pr, verificationFlags, receiptBase, inputs) {
|
|
1261
|
+
const rejectedSnapshot = session.roundSnapshot;
|
|
1262
|
+
if (!rejectedSnapshot) {
|
|
1263
|
+
throw new Error("A rejected attempt must keep its held transaction snapshot.");
|
|
1264
|
+
}
|
|
1265
|
+
// v4.0.3: persist the retry facts BEFORE compiling the retry prompt. A
|
|
1266
|
+
// compile failure (or a process restart) must not lose the attempt,
|
|
1267
|
+
// notice, findings or transient posture.
|
|
1268
|
+
const retryContext = {
|
|
1269
|
+
attempt: rejectedSnapshot.attempt + 1,
|
|
1270
|
+
rejectedAttempt: rejectedSnapshot.attempt,
|
|
1271
|
+
rejectionNotice: pr.rejectionPrompt ?? "",
|
|
1272
|
+
verificationFlags: [...verificationFlags],
|
|
1273
|
+
evidencePosture: pr.evidencePosture,
|
|
1274
|
+
rejectedAt: Date.now(),
|
|
1275
|
+
};
|
|
1276
|
+
session.roundSnapshot = { ...rejectedSnapshot, retryContext };
|
|
1277
|
+
try {
|
|
1278
|
+
this.save(session);
|
|
1279
|
+
}
|
|
1280
|
+
catch (error) {
|
|
1281
|
+
return this.stalledAfterCommit(sessionId, session, false, `The rejected attempt could not be persisted: ${String(error).slice(0, 300)}. ` +
|
|
1282
|
+
"Call loopforge_resume to retry; the rejection facts are still held in memory.");
|
|
1283
|
+
}
|
|
753
1284
|
// In-flight attempt findings are authority for THIS attempt, not
|
|
754
|
-
// committed history:
|
|
755
|
-
|
|
1285
|
+
// committed history: buildLoopRequest now projects them from retryContext.
|
|
1286
|
+
// v4.0.5: a same-round retry inherits the rejected prompt's instruction
|
|
1287
|
+
// unless the host supplied a new one for this submission.
|
|
1288
|
+
const inheritedInstruction = inputs?.currentUserInstruction ??
|
|
1289
|
+
rejectedSnapshot.promptArtifact?.currentUserInstruction;
|
|
1290
|
+
const retryRequest = buildLoopRequest(session, inheritedInstruction !== undefined
|
|
1291
|
+
? { currentUserInstruction: inheritedInstruction }
|
|
1292
|
+
: undefined);
|
|
756
1293
|
retryRequest.verification_flags = verificationFlags;
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
session.
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1294
|
+
retryRequest.rejection_notice = pr.rejectionPrompt ?? "";
|
|
1295
|
+
let preparedRetry;
|
|
1296
|
+
try {
|
|
1297
|
+
preparedRetry = await this.driver(session.engine, session.loopId).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
if (error instanceof ProjectionCoverageError) {
|
|
1301
|
+
return this.stalledAfterCommit(sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
|
|
1302
|
+
}
|
|
1303
|
+
if (error instanceof CompileFailureError) {
|
|
1304
|
+
return this.stalledAfterCommit(sessionId, session, false, `The retry prompt could not be compiled: ${error.message}. Call loopforge_resume to retry.`);
|
|
1305
|
+
}
|
|
1306
|
+
throw error;
|
|
770
1307
|
}
|
|
771
1308
|
session.roundSnapshot = preparedRetry.snapshot;
|
|
772
|
-
session.currentPrompt = preparedRetry.prompt;
|
|
773
|
-
session.currentLevel = preparedRetry.level;
|
|
774
|
-
session.currentWarnings = preparedRetry.warnings ?? [];
|
|
775
|
-
session.lastCompileResponse = preparedRetry.compileResponse ?? null;
|
|
776
1309
|
this.save(session);
|
|
1310
|
+
const retryArtifact = preparedRetry.artifact ??
|
|
1311
|
+
preparedRetry.compileResponse?.prompt_artifact ?? null;
|
|
777
1312
|
return {
|
|
778
1313
|
sessionId,
|
|
779
1314
|
round: session.currentRound,
|
|
780
|
-
|
|
1315
|
+
...this.heldHandle(session),
|
|
1316
|
+
...this.transactionFacts(session),
|
|
1317
|
+
...this.contextFacts(retryArtifact),
|
|
781
1318
|
prompt: preparedRetry.prompt,
|
|
782
1319
|
level: preparedRetry.level,
|
|
1320
|
+
agentSuccessClaim: pr.agentSuccessClaim,
|
|
1321
|
+
agentOutcome: pr.agentOutcome,
|
|
783
1322
|
enforcementAction: "reject",
|
|
784
|
-
|
|
1323
|
+
evidencePosture: pr.evidencePosture,
|
|
785
1324
|
enforcementReason: pr.enforcementReason,
|
|
786
1325
|
submission_guidance: this.guidanceFor(session, {
|
|
787
1326
|
verificationFlags: pr.verificationFlags,
|
|
788
1327
|
}),
|
|
789
1328
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
790
1329
|
disposition: "rejected",
|
|
791
|
-
nextArtifact:
|
|
792
|
-
preparedRetry.compileResponse?.prompt_artifact ?? null,
|
|
1330
|
+
nextArtifact: retryArtifact,
|
|
793
1331
|
afterSubGoals: null,
|
|
794
1332
|
constraintsActiveAfter: null,
|
|
795
1333
|
contractAfterId: receiptBase.beforeContractId,
|
|
796
1334
|
}),
|
|
797
1335
|
};
|
|
798
1336
|
}
|
|
799
|
-
/**
|
|
800
|
-
*
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1337
|
+
/** v3.9.7: the workspace/state-dir facts shared by restore preflight and
|
|
1338
|
+
* execution. The state directory is never part of the restored boundary. */
|
|
1339
|
+
restoreContext() {
|
|
1340
|
+
const workspace = process.cwd();
|
|
1341
|
+
let stateDir;
|
|
1342
|
+
try {
|
|
1343
|
+
stateDir = resolveStateDirectory(workspace, getPolicy().backend.root_dir);
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
throw new RestoreFailure("state_directory_invalid", `The runtime state directory could not be resolved: ${String(error)}`);
|
|
1347
|
+
}
|
|
1348
|
+
const exclude = (path) => {
|
|
1349
|
+
const absolute = resolve(workspace, path);
|
|
1350
|
+
const rel = relative(stateDir, absolute);
|
|
1351
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== "..");
|
|
1352
|
+
};
|
|
1353
|
+
return { workspace, stateDir, exclude };
|
|
1354
|
+
}
|
|
1355
|
+
/** Execute the committed directive's restore, verify it, then compile the
|
|
1356
|
+
* redo from the SAME baseline. The directive is never rewritten. */
|
|
1357
|
+
async runRestoreAndPrepare(session, directive, extra = {}) {
|
|
1358
|
+
const store = this.checkpointStoreFor?.(session.loopId);
|
|
1359
|
+
if (!store) {
|
|
1360
|
+
return {
|
|
1361
|
+
ok: false,
|
|
1362
|
+
result: this.restoreFailedResult(session, directive, "store_unavailable", "no workspace checkpoint store is attached to this runtime"),
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
const manifest = store.readManifest(directive.target.checkpoint_id);
|
|
1366
|
+
const ref = {
|
|
1367
|
+
checkpointId: directive.target.checkpoint_id,
|
|
1368
|
+
contentHash: directive.target.content_hash,
|
|
1369
|
+
role: directive.target.role,
|
|
1370
|
+
round: directive.target.round,
|
|
1371
|
+
roundId: directive.target.round_id,
|
|
1372
|
+
transactionId: directive.target.transaction_id,
|
|
1373
|
+
branchId: session.roundSnapshot?.branchId ?? rootBranchId(session.loopId),
|
|
1374
|
+
redoRound: directive.target.redo_round,
|
|
1375
|
+
createdAt: Date.now(),
|
|
1376
|
+
};
|
|
1377
|
+
const validated = validateCheckpointManifest(ref, manifest);
|
|
1378
|
+
if (!validated.ok) {
|
|
1379
|
+
return {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
result: this.restoreFailedResult(session, directive, validated.reason, validated.detail),
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
const redoRound = directive.target.redo_round;
|
|
1385
|
+
// In-memory only: the session stays running and keeps the lease through
|
|
1386
|
+
// the restore. The first persisted state is either the verified redo or an
|
|
1387
|
+
// explicit stalled session.
|
|
1388
|
+
session.currentRound = redoRound;
|
|
1389
|
+
session.roundSnapshot = undefined;
|
|
1390
|
+
session.status = "running";
|
|
1391
|
+
let restore;
|
|
1392
|
+
try {
|
|
1393
|
+
const context = this.restoreContext();
|
|
1394
|
+
await preflightRestore({
|
|
1395
|
+
manifest: validated.manifest,
|
|
1396
|
+
workspace: context.workspace,
|
|
1397
|
+
store,
|
|
1398
|
+
stateDir: context.stateDir,
|
|
1399
|
+
exclude: context.exclude,
|
|
1400
|
+
});
|
|
1401
|
+
restore = await executeRestore({
|
|
1402
|
+
manifest: validated.manifest,
|
|
1403
|
+
workspace: context.workspace,
|
|
1404
|
+
store,
|
|
1405
|
+
directiveTransactionId: directive.transaction_id,
|
|
1406
|
+
stateDir: context.stateDir,
|
|
1407
|
+
exclude: context.exclude,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
catch (error) {
|
|
1411
|
+
return {
|
|
1412
|
+
ok: false,
|
|
1413
|
+
result: this.restoreFailedResult(session, directive, error instanceof RestoreFailure ? error.reasonCode : "restore_failed", String(error instanceof Error ? error.message : error)),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
const request = buildLoopRequest(session, extra.inputs);
|
|
1417
|
+
if (extra.verificationFlags) {
|
|
1418
|
+
request.verification_flags = [...extra.verificationFlags];
|
|
1419
|
+
}
|
|
1420
|
+
let prepared;
|
|
1421
|
+
try {
|
|
1422
|
+
prepared = await new RoundDriver(session.engine, this.store, this.checkpointStoreFor?.(session.loopId)).prepare(request, session.loopId, redoRound, {
|
|
1423
|
+
branchId: directive.transaction_id,
|
|
1424
|
+
...(session.operatorAcceptance
|
|
1425
|
+
? { operatorAcceptance: session.operatorAcceptance }
|
|
1426
|
+
: {}),
|
|
1427
|
+
baseline: {
|
|
1428
|
+
observations: restore.baseline,
|
|
1429
|
+
...(restore.baselineBoundary
|
|
1430
|
+
? { boundary: restore.baselineBoundary }
|
|
1431
|
+
: {}),
|
|
1432
|
+
},
|
|
1433
|
+
restore: {
|
|
1434
|
+
directiveTransactionId: directive.transaction_id,
|
|
1435
|
+
redoRound,
|
|
1436
|
+
verifiedAt: restore.verifiedAt,
|
|
1437
|
+
},
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
catch (error) {
|
|
1441
|
+
return {
|
|
1442
|
+
ok: false,
|
|
1443
|
+
result: this.restoreFailedResult(session, directive, "prepare_failed", String(error instanceof Error ? error.message : error)),
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
return { ok: true, restore, prepared };
|
|
1447
|
+
}
|
|
1448
|
+
/** Failed restore: the directive remains pending, no prompt exists, and the
|
|
1449
|
+
* session is resumable. */
|
|
1450
|
+
restoreFailedResult(session, directive, reasonCode, detail) {
|
|
1451
|
+
const redoRound = directive.target.redo_round;
|
|
1452
|
+
session.currentRound = redoRound;
|
|
1453
|
+
session.roundSnapshot = undefined;
|
|
1454
|
+
session.status = "stalled";
|
|
1455
|
+
this.save(session);
|
|
1456
|
+
return {
|
|
1457
|
+
sessionId: session.sessionId,
|
|
1458
|
+
round: redoRound,
|
|
1459
|
+
prompt: null,
|
|
1460
|
+
stopReason: "stalled",
|
|
1461
|
+
stopDetail: `Workspace restore failed (${reasonCode}). The rollback directive remains pending; ` +
|
|
1462
|
+
"call loopforge_resume to retry the same restore.",
|
|
1463
|
+
submissionError: {
|
|
1464
|
+
code: "restore_failed",
|
|
1465
|
+
details: {
|
|
1466
|
+
loopId: session.loopId,
|
|
1467
|
+
directive_transaction_id: directive.transaction_id,
|
|
1468
|
+
redo_round: redoRound,
|
|
1469
|
+
reason_code: reasonCode,
|
|
1470
|
+
detail,
|
|
1471
|
+
},
|
|
1472
|
+
},
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
/** v2.10: Build a backtrack result. The directive is already committed;
|
|
1476
|
+
* LoopForge executes and verifies the restore, then compiles the redo from
|
|
1477
|
+
* the verified baseline. A failed restore leaves the directive pending and
|
|
1478
|
+
* the session stalled — no redo prompt is ever issued unverified. */
|
|
1479
|
+
async buildBacktrackResult(sessionId, session, pr, receiptBase, inputs) {
|
|
1480
|
+
const directive = pr.recovery;
|
|
1481
|
+
if (!directive) {
|
|
1482
|
+
session.status = "stalled";
|
|
1483
|
+
this.save(session);
|
|
1484
|
+
return {
|
|
1485
|
+
sessionId,
|
|
1486
|
+
round: session.currentRound,
|
|
1487
|
+
prompt: null,
|
|
1488
|
+
stopReason: "stalled",
|
|
1489
|
+
stopDetail: "A backtrack was committed without a rollback directive.",
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
const newRound = directive.target.redo_round;
|
|
1493
|
+
logEvent("session_recovery", {
|
|
815
1494
|
sessionId,
|
|
816
1495
|
loopId: session.loopId,
|
|
817
1496
|
fromRound: session.currentRound,
|
|
818
1497
|
toRound: newRound,
|
|
819
|
-
|
|
820
|
-
|
|
1498
|
+
origin: directive.origin,
|
|
1499
|
+
triggerRule: directive.trigger_rule ?? "",
|
|
1500
|
+
checkpointId: directive.target.checkpoint_id,
|
|
1501
|
+
skippedDiscoveries: directive.skipped_discoveries.length,
|
|
821
1502
|
});
|
|
822
|
-
// Reset session to the restored round
|
|
823
|
-
session.currentRound = newRound;
|
|
824
1503
|
session.consecutiveRejections = 0;
|
|
825
1504
|
session.lastRejectionCheck = "";
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
// dirty sets instead of demanding a globally clean tree.
|
|
835
|
-
session.backtrackTargetDirty = pr.backtrackTargetDirty;
|
|
836
|
-
session.lastSelfEval = undefined; // force L2 recompile from vault state
|
|
837
|
-
session.currentPrompt = null;
|
|
838
|
-
// Preserve discoveries from skipped rounds as constraints_from_plan
|
|
839
|
-
// so they survive the compilation and re-enter active constraints.
|
|
840
|
-
const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
|
|
841
|
-
// Compile the restored prompt — use a fresh request with the restored round
|
|
842
|
-
const request = {
|
|
843
|
-
task: session.task,
|
|
844
|
-
mode: Mode.LOOP_COMPILE,
|
|
845
|
-
feedback: null,
|
|
846
|
-
skill_name: null,
|
|
847
|
-
task_id: null,
|
|
848
|
-
loop_id: session.loopId,
|
|
849
|
-
round: newRound,
|
|
850
|
-
max_rounds: session.maxRounds,
|
|
851
|
-
verification_flags: pr.verificationFlags,
|
|
852
|
-
constraints_from_plan: preservedDiscoveries,
|
|
853
|
-
};
|
|
854
|
-
const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, newRound);
|
|
855
|
-
if (!prepared || !prepared.prompt) {
|
|
856
|
-
session.status = "stalled";
|
|
857
|
-
session.currentPrompt = null;
|
|
858
|
-
this.save(session);
|
|
1505
|
+
const recoveryFlags = pr.verificationFlags.length > 0
|
|
1506
|
+
? pr.verificationFlags
|
|
1507
|
+
: (directive.trigger_findings ?? []);
|
|
1508
|
+
const outcome = await this.runRestoreAndPrepare(session, directive, {
|
|
1509
|
+
verificationFlags: recoveryFlags,
|
|
1510
|
+
...(inputs ? { inputs } : {}),
|
|
1511
|
+
});
|
|
1512
|
+
if (!outcome.ok) {
|
|
859
1513
|
return {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
verificationStatus: pr.verificationStatus,
|
|
867
|
-
enforcementReason: pr.enforcementReason,
|
|
1514
|
+
...outcome.result,
|
|
1515
|
+
agentSuccessClaim: pr.agentSuccessClaim,
|
|
1516
|
+
agentOutcome: pr.agentOutcome,
|
|
1517
|
+
enforcementAction: directive.origin === "agent" ? "recovery" : "backtrack",
|
|
1518
|
+
evidencePosture: pr.evidencePosture,
|
|
1519
|
+
enforcementReason: pr.reason ?? pr.enforcementReason,
|
|
868
1520
|
};
|
|
869
1521
|
}
|
|
870
|
-
|
|
871
|
-
// artifact. Never rewrite the prompt after compilation: the delivered
|
|
872
|
-
// prompt must equal `prompt_artifact.renderedPrompt` and its hash.
|
|
873
|
-
session.evidenceBaseline = prepared.evidenceBaseline;
|
|
1522
|
+
const { restore, prepared } = outcome;
|
|
874
1523
|
session.roundSnapshot = prepared.snapshot;
|
|
875
|
-
// v3.8.3: the rollback redo is a NEW round — `prepare` collected a fresh
|
|
876
|
-
// before capture above — so its entrypoint baseline is the workspace as it
|
|
877
|
-
// stands after the rollback, which is what lets a round that stalled on a
|
|
878
|
-
// rewritten entrypoint proceed once the entrypoint is in place.
|
|
879
1524
|
this.seedEntrypointTrust(session);
|
|
880
|
-
session.currentPrompt = prepared.prompt;
|
|
881
|
-
session.currentLevel = prepared.level;
|
|
882
|
-
session.currentWarnings = prepared?.warnings ?? [];
|
|
883
|
-
session.lastCompileResponse = prepared?.compileResponse ?? null;
|
|
884
1525
|
this.save(session);
|
|
1526
|
+
const nextArtifact = prepared.artifact ??
|
|
1527
|
+
prepared.compileResponse?.prompt_artifact ?? null;
|
|
885
1528
|
return {
|
|
886
1529
|
sessionId,
|
|
887
1530
|
round: newRound,
|
|
888
|
-
|
|
1531
|
+
...this.heldHandle(session),
|
|
1532
|
+
...this.transactionFacts(session),
|
|
1533
|
+
...this.contextFacts(nextArtifact),
|
|
889
1534
|
prompt: prepared.prompt,
|
|
890
1535
|
level: prepared.level,
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1536
|
+
agentSuccessClaim: pr.agentSuccessClaim,
|
|
1537
|
+
agentOutcome: pr.agentOutcome,
|
|
1538
|
+
enforcementAction: directive.origin === "agent" ? "recovery" : "backtrack",
|
|
1539
|
+
evidencePosture: pr.evidencePosture,
|
|
1540
|
+
enforcementReason: pr.reason ?? pr.enforcementReason,
|
|
1541
|
+
recovery: {
|
|
1542
|
+
origin: directive.origin,
|
|
1543
|
+
reason: directive.reason,
|
|
1544
|
+
target_checkpoint_id: directive.target.checkpoint_id,
|
|
1545
|
+
redo_round: newRound,
|
|
1546
|
+
restore: {
|
|
1547
|
+
status: "verified",
|
|
1548
|
+
...(restore.head ? { head: restore.head } : {}),
|
|
1549
|
+
...(restore.previousHead ? { previous_head: restore.previousHead } : {}),
|
|
1550
|
+
...(restore.safetyRef ? { safety_ref: restore.safetyRef } : {}),
|
|
1551
|
+
files_written: restore.filesWritten,
|
|
1552
|
+
files_deleted: restore.filesDeleted,
|
|
1553
|
+
verified_at: restore.verifiedAt,
|
|
1554
|
+
},
|
|
1555
|
+
},
|
|
1556
|
+
warnings: prepared.warnings ?? [],
|
|
895
1557
|
submission_guidance: this.guidanceFor(session, {
|
|
896
|
-
verificationFlags:
|
|
1558
|
+
verificationFlags: recoveryFlags,
|
|
897
1559
|
recovery: {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1560
|
+
pending: true,
|
|
1561
|
+
redo_round: newRound,
|
|
1562
|
+
checkpoint_id: directive.target.checkpoint_id,
|
|
1563
|
+
restore_verified: session.roundSnapshot.restore?.directiveTransactionId ===
|
|
1564
|
+
directive.transaction_id,
|
|
1565
|
+
origin: directive.origin,
|
|
1566
|
+
reason: directive.reason,
|
|
903
1567
|
},
|
|
904
1568
|
}),
|
|
905
1569
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
906
1570
|
disposition: "backtracked",
|
|
907
|
-
nextArtifact
|
|
908
|
-
prepared.compileResponse?.prompt_artifact ?? null,
|
|
1571
|
+
nextArtifact,
|
|
909
1572
|
afterSubGoals: null,
|
|
910
1573
|
constraintsActiveAfter: null,
|
|
911
1574
|
contractAfterId: receiptBase.beforeContractId,
|
|
1575
|
+
// The SAME verified-restore fact the top-level recovery reports.
|
|
1576
|
+
restoreVerified: true,
|
|
912
1577
|
}),
|
|
913
1578
|
};
|
|
914
1579
|
}
|
|
1580
|
+
/** v4.0.4: the ONE terminal stop-detail formatter. Live stop, termination,
|
|
1581
|
+
* crash-recovered stop and resume-of-stopped read the same three fact
|
|
1582
|
+
* blocks; a committed fact is described identically on every path. */
|
|
1583
|
+
terminalFacts(pr, reason, selfEval, committed) {
|
|
1584
|
+
return {
|
|
1585
|
+
agent: selfEval
|
|
1586
|
+
? {
|
|
1587
|
+
successClaim: effectiveSuccess(selfEval),
|
|
1588
|
+
outcome: effectiveOutcome(selfEval),
|
|
1589
|
+
shouldContinue: selfEval.should_continue,
|
|
1590
|
+
blocker: selfEval.blocker?.trim() || null,
|
|
1591
|
+
}
|
|
1592
|
+
: null,
|
|
1593
|
+
loopforge: {
|
|
1594
|
+
action: pr.action,
|
|
1595
|
+
loopStopReason: reason,
|
|
1596
|
+
enforcementReason: pr.enforcementReason ?? null,
|
|
1597
|
+
},
|
|
1598
|
+
machine: {
|
|
1599
|
+
posture: pr.evidencePosture ?? null,
|
|
1600
|
+
committed,
|
|
1601
|
+
},
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
/** v4.0.4: the terminal result of an already-stopped loop, built from
|
|
1605
|
+
* committed history. This is the ONE read path for `resume` of a stopped
|
|
1606
|
+
* loop: it reports the committed `StopReason` and renders the same three
|
|
1607
|
+
* fact blocks as a live stop. A loop with no committed stop round reports
|
|
1608
|
+
* the session status and says so instead of inventing a reason. */
|
|
1609
|
+
stoppedLoopResult(loopId, currentRound) {
|
|
1610
|
+
const history = loadCommittedHistory(this.store, loopId);
|
|
1611
|
+
if (history.legacyRounds.length > 0) {
|
|
1612
|
+
throw new LegacyHistoryError(loopId, history.legacyRounds.map((round) => round.round));
|
|
1613
|
+
}
|
|
1614
|
+
const terminal = [...history.rounds].reverse().find((view) => view.result?.stopReason !== undefined &&
|
|
1615
|
+
view.result?.stopReason !== null);
|
|
1616
|
+
const reason = terminal?.result?.stopReason;
|
|
1617
|
+
if (!terminal || !reason) {
|
|
1618
|
+
return {
|
|
1619
|
+
sessionId: "",
|
|
1620
|
+
round: currentRound,
|
|
1621
|
+
prompt: null,
|
|
1622
|
+
sessionStatus: "stopped",
|
|
1623
|
+
stopDetail: "Loop is stopped; there is no committed terminal reason for this " +
|
|
1624
|
+
"loop (it may have been cancelled before a stop round committed).",
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
return {
|
|
1628
|
+
sessionId: "",
|
|
1629
|
+
round: currentRound,
|
|
1630
|
+
prompt: null,
|
|
1631
|
+
stopReason: reason,
|
|
1632
|
+
sessionStatus: "stopped",
|
|
1633
|
+
stopDetail: renderResultFacts(this.terminalFacts(terminal.result, reason, terminal.evaluation ?? null, true), "terminal").join("\n"),
|
|
1634
|
+
agentSuccessClaim: terminal.agentSuccessClaim,
|
|
1635
|
+
agentOutcome: terminal.agentOutcome,
|
|
1636
|
+
agentShouldContinue: terminal.evaluation?.should_continue,
|
|
1637
|
+
submission_receipt: deriveCommittedSubmissionView(terminal, history.rounds, history.directives),
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
915
1640
|
/** Build a termination result: persist stopped status, notify sinks.
|
|
916
1641
|
* MUTATES: session.status, session.currentPrompt
|
|
917
1642
|
*
|
|
@@ -925,7 +1650,6 @@ export class RoundLifecycle {
|
|
|
925
1650
|
guardedReason, receiptBase) {
|
|
926
1651
|
const reason = guardedReason ?? pr.stopReason ?? "enforcement_terminated";
|
|
927
1652
|
session.status = "stopped";
|
|
928
|
-
session.currentPrompt = null;
|
|
929
1653
|
// v3.8.3: no round is in flight once the loop stops — the in-flight trust
|
|
930
1654
|
// baseline goes with it, so a later resume cannot compare against a state
|
|
931
1655
|
// from a previous life.
|
|
@@ -939,12 +1663,19 @@ export class RoundLifecycle {
|
|
|
939
1663
|
return {
|
|
940
1664
|
sessionId,
|
|
941
1665
|
round: session.currentRound,
|
|
942
|
-
roundId: session.roundSnapshot?.roundId,
|
|
943
1666
|
prompt: null,
|
|
944
1667
|
stopReason: reason,
|
|
945
|
-
|
|
1668
|
+
sessionStatus: "stopped",
|
|
1669
|
+
agentSuccessClaim: pr.agentSuccessClaim,
|
|
1670
|
+
agentOutcome: pr.agentOutcome,
|
|
1671
|
+
agentShouldContinue: receiptBase.selfEval.should_continue,
|
|
1672
|
+
// v4.0.6: the Agent's outcome/blocker are the report; LoopForge's
|
|
1673
|
+
// terminal reason is `stopReason`.
|
|
1674
|
+
// v4.0.4: a terminated attempt never committed, so its posture is
|
|
1675
|
+
// explicitly transient; no committed receipt is fabricated.
|
|
1676
|
+
stopDetail: renderResultFacts(this.terminalFacts(pr, reason, receiptBase.selfEval, false), "terminal").join("\n"),
|
|
946
1677
|
enforcementAction: "terminate",
|
|
947
|
-
|
|
1678
|
+
evidencePosture: pr.evidencePosture,
|
|
948
1679
|
enforcementReason: pr.enforcementReason,
|
|
949
1680
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
950
1681
|
disposition: "terminated",
|
|
@@ -963,7 +1694,6 @@ export class RoundLifecycle {
|
|
|
963
1694
|
guardedReason, receiptBase) {
|
|
964
1695
|
const reason = guardedReason ?? pr.stopReason ?? "stalled";
|
|
965
1696
|
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
966
|
-
session.currentPrompt = null;
|
|
967
1697
|
// v3.8.3: see buildTerminationResult — the in-flight trust baseline dies
|
|
968
1698
|
// with the in-flight round.
|
|
969
1699
|
session.entrypointTrust = undefined;
|
|
@@ -972,12 +1702,18 @@ export class RoundLifecycle {
|
|
|
972
1702
|
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
|
|
973
1703
|
return {
|
|
974
1704
|
sessionId, round: session.currentRound,
|
|
975
|
-
roundId: session.roundSnapshot?.roundId,
|
|
976
1705
|
prompt: null, stopReason: reason,
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1706
|
+
sessionStatus: session.status,
|
|
1707
|
+
agentSuccessClaim: pr.agentSuccessClaim,
|
|
1708
|
+
agentOutcome: pr.agentOutcome,
|
|
1709
|
+
agentShouldContinue: receiptBase.selfEval.should_continue,
|
|
1710
|
+
// v4.0.6: the Agent's outcome/blocker are the report; LoopForge's
|
|
1711
|
+
// terminal reason is `stopReason`.
|
|
1712
|
+
// v4.0.4: `completed` no longer implies machine success; the three
|
|
1713
|
+
// fact blocks state the Agent report, the LoopForge decision and the
|
|
1714
|
+
// persisted posture separately.
|
|
1715
|
+
stopDetail: renderResultFacts(this.terminalFacts(pr, reason, receiptBase.selfEval, true), "terminal").join("\n"),
|
|
1716
|
+
evidencePosture: pr.evidencePosture,
|
|
981
1717
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
982
1718
|
disposition: "stopped",
|
|
983
1719
|
nextArtifact: null,
|
|
@@ -989,8 +1725,8 @@ export class RoundLifecycle {
|
|
|
989
1725
|
* Includes the commit fence (pause/delete race guard) and context provider.
|
|
990
1726
|
* MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
|
|
991
1727
|
* session.evidenceBaseline, session.roundSnapshot */
|
|
992
|
-
async advanceToNextRound(sessionId, session, selfEval, pr, actualEvidence, receiptBase) {
|
|
993
|
-
const
|
|
1728
|
+
async advanceToNextRound(sessionId, session, selfEval, pr, actualEvidence, receiptBase, inputs) {
|
|
1729
|
+
const agentSuccessClaim = pr.agentSuccessClaim;
|
|
994
1730
|
// M4 (v3.7.x): replay guard. The counter may already point past the
|
|
995
1731
|
// committed round when this continue is a REPLAY — after a crash or an
|
|
996
1732
|
// in-process exception between the round's commit and this compile, the
|
|
@@ -1001,53 +1737,23 @@ export class RoundLifecycle {
|
|
|
1001
1737
|
if (!session.roundSnapshot || session.currentRound <= session.roundSnapshot.round) {
|
|
1002
1738
|
session.currentRound++;
|
|
1003
1739
|
}
|
|
1004
|
-
|
|
1005
|
-
// v2.13: Clear backtrack skipped files after advancing past the restore round
|
|
1006
|
-
session.backtrackSkippedFiles = [];
|
|
1007
|
-
session.backtrackSkippedFingerprints = {};
|
|
1008
|
-
// v2.12: The restore-point git HEAD check only applies to the first
|
|
1009
|
-
// post-backtrack round — clear it once we advance past it.
|
|
1010
|
-
session.backtrackTargetGitHead = undefined;
|
|
1011
|
-
session.backtrackTargetDirty = undefined;
|
|
1012
|
-
// v1.8: Memory injection — tier-aware external context.
|
|
1013
|
-
const contextProvider = this.getContext();
|
|
1014
|
-
let externalCtx = "";
|
|
1015
|
-
if (contextProvider) {
|
|
1016
|
-
try {
|
|
1017
|
-
externalCtx = (await contextProvider({
|
|
1018
|
-
loopId: session.loopId,
|
|
1019
|
-
round: session.currentRound,
|
|
1020
|
-
task: session.task,
|
|
1021
|
-
domain: "",
|
|
1022
|
-
lastEvaluation: selfEval,
|
|
1023
|
-
})).trim();
|
|
1024
|
-
}
|
|
1025
|
-
catch {
|
|
1026
|
-
logEvent("context_provider_error", {
|
|
1027
|
-
loopId: session.loopId,
|
|
1028
|
-
round: session.currentRound,
|
|
1029
|
-
});
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
// Commit fence: pause()/delete() may have run while the context provider
|
|
1033
|
-
// was awaited. Do not compile if the session is no longer running.
|
|
1740
|
+
// Commit fence: pause()/delete() may run while prepare is awaited.
|
|
1034
1741
|
if (this.registry.get(sessionId) !== session || session.status !== "running") {
|
|
1035
1742
|
return {
|
|
1036
1743
|
sessionId,
|
|
1037
1744
|
round: session.currentRound,
|
|
1038
1745
|
prompt: null,
|
|
1039
|
-
|
|
1040
|
-
stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the
|
|
1041
|
-
|
|
1746
|
+
sessionStatus: session.status,
|
|
1747
|
+
stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the next prompt was being prepared.`,
|
|
1748
|
+
agentSuccessClaim,
|
|
1042
1749
|
};
|
|
1043
1750
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
}
|
|
1751
|
+
// v4.0.5: a new round never inherits an earlier artifact's instruction;
|
|
1752
|
+
// only the explicit host input for this call travels.
|
|
1753
|
+
const request = buildLoopRequest(session, inputs);
|
|
1048
1754
|
let prepared;
|
|
1049
1755
|
try {
|
|
1050
|
-
prepared = await
|
|
1756
|
+
prepared = await this.driver(session.engine, session.loopId).prepare(request, session.loopId, session.currentRound, this.prepareOptions(session));
|
|
1051
1757
|
}
|
|
1052
1758
|
catch (error) {
|
|
1053
1759
|
// M4: a post-commit compile exception must not silently strand the
|
|
@@ -1063,33 +1769,35 @@ export class RoundLifecycle {
|
|
|
1063
1769
|
if (error instanceof ProjectionCoverageError) {
|
|
1064
1770
|
// v3.9.4: the failure is a projection code defect, not a work-quality
|
|
1065
1771
|
// verdict. Keep the committed round held and surface the stable code.
|
|
1066
|
-
return this.stalledAfterCommit(sessionId, session,
|
|
1772
|
+
return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
|
|
1773
|
+
}
|
|
1774
|
+
if (error instanceof CompileFailureError) {
|
|
1775
|
+
return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, `The round committed but the next prompt could not be compiled: ${error.message}. ` +
|
|
1776
|
+
`Call loopforge_resume to reconcile the commit and retry.`);
|
|
1067
1777
|
}
|
|
1068
|
-
return this.stalledAfterCommit(sessionId, session,
|
|
1069
|
-
`The session is stalled with the committed round still held —
|
|
1070
|
-
`
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1778
|
+
return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, `RoundDriver.prepare threw after the round committed: ${String(error).slice(0, 300)}. ` +
|
|
1779
|
+
`The session is stalled with the committed round still held — call ` +
|
|
1780
|
+
`loopforge_resume to reconcile it and compile the next prompt.`);
|
|
1781
|
+
}
|
|
1782
|
+
// Commit fence after the async prepare: pause()/delete() may have run
|
|
1783
|
+
// while the compile/evidence collection was in flight. Do not install a
|
|
1784
|
+
// prompt into a session that is no longer running.
|
|
1785
|
+
if (this.registry.get(sessionId) !== session || session.status !== "running") {
|
|
1786
|
+
return {
|
|
1787
|
+
sessionId,
|
|
1788
|
+
round: session.currentRound,
|
|
1789
|
+
prompt: null,
|
|
1790
|
+
sessionStatus: session.status,
|
|
1791
|
+
stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the next prompt was being prepared.`,
|
|
1792
|
+
agentSuccessClaim,
|
|
1793
|
+
};
|
|
1080
1794
|
}
|
|
1081
1795
|
const nextPrompt = prepared.prompt;
|
|
1082
1796
|
const nextLevel = prepared.level;
|
|
1083
|
-
const nextBaseline = prepared.evidenceBaseline ?? actualEvidence;
|
|
1084
|
-
session.evidenceBaseline = nextBaseline;
|
|
1085
1797
|
session.roundSnapshot = prepared.snapshot;
|
|
1086
1798
|
// v3.8.3: the round that just committed is history; the trust baseline
|
|
1087
1799
|
// moves to the round being prepared now.
|
|
1088
1800
|
this.seedEntrypointTrust(session);
|
|
1089
|
-
session.currentPrompt = nextPrompt;
|
|
1090
|
-
session.currentLevel = nextLevel;
|
|
1091
|
-
session.currentWarnings = prepared.warnings ?? [];
|
|
1092
|
-
session.lastCompileResponse = prepared.compileResponse ?? null;
|
|
1093
1801
|
policyMetrics.recordStrategy(session.loopId, nextLevel);
|
|
1094
1802
|
try {
|
|
1095
1803
|
this.save(session);
|
|
@@ -1103,35 +1811,39 @@ export class RoundLifecycle {
|
|
|
1103
1811
|
round: session.currentRound,
|
|
1104
1812
|
error: String(error),
|
|
1105
1813
|
});
|
|
1106
|
-
return this.stalledAfterCommit(sessionId, session,
|
|
1814
|
+
return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, `The round committed but the session state could not be persisted: ${String(error).slice(0, 300)}. ` +
|
|
1107
1815
|
`Resume the loop to recover the held prompt.`);
|
|
1108
1816
|
}
|
|
1109
1817
|
const compiled = prepared.compileResponse;
|
|
1818
|
+
const nextArtifact = prepared.artifact ?? compiled?.prompt_artifact ?? null;
|
|
1110
1819
|
return {
|
|
1111
1820
|
sessionId,
|
|
1112
1821
|
round: session.currentRound,
|
|
1113
|
-
|
|
1822
|
+
...this.heldHandle(session),
|
|
1823
|
+
...this.transactionFacts(session),
|
|
1824
|
+
...this.contextFacts(nextArtifact),
|
|
1114
1825
|
prompt: nextPrompt,
|
|
1115
1826
|
level: nextLevel,
|
|
1116
|
-
|
|
1827
|
+
agentSuccessClaim,
|
|
1828
|
+
agentOutcome: pr.agentOutcome,
|
|
1117
1829
|
warnings: prepared.warnings ?? [],
|
|
1118
1830
|
submission_guidance: this.guidanceFor(session),
|
|
1119
1831
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
1120
1832
|
disposition: "accepted",
|
|
1121
|
-
nextArtifact
|
|
1833
|
+
nextArtifact,
|
|
1122
1834
|
afterSubGoals: compiled?.sub_goals ?? null,
|
|
1123
1835
|
constraintsActiveAfter: compiled?.constraints_active ?? null,
|
|
1836
|
+
constraintsRetiredAfter: compiled?.constraints_retired ?? null,
|
|
1124
1837
|
}),
|
|
1125
1838
|
};
|
|
1126
1839
|
}
|
|
1127
1840
|
/** M4: mark a session stalled after its round committed but the next
|
|
1128
|
-
* prompt could not be prepared/persisted
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
stalledAfterCommit(sessionId, session,
|
|
1841
|
+
* prompt could not be prepared/persisted. v4.0.0: the committed snapshot is
|
|
1842
|
+
* NOT a held prompt, so this result carries no handle; the Agent must call
|
|
1843
|
+
* `loopforge_resume`, which reconciles the commit and compiles the next
|
|
1844
|
+
* prompt. Re-submitting an old handle can never resurrect the old round. */
|
|
1845
|
+
stalledAfterCommit(sessionId, session, agentSuccessClaim, detail, projectionError) {
|
|
1133
1846
|
session.status = "stalled";
|
|
1134
|
-
session.currentPrompt = null;
|
|
1135
1847
|
try {
|
|
1136
1848
|
this.save(session);
|
|
1137
1849
|
}
|
|
@@ -1145,118 +1857,155 @@ export class RoundLifecycle {
|
|
|
1145
1857
|
return {
|
|
1146
1858
|
sessionId,
|
|
1147
1859
|
round: session.currentRound,
|
|
1148
|
-
roundId: session.roundSnapshot?.roundId,
|
|
1149
1860
|
prompt: null,
|
|
1150
1861
|
stopReason: "stalled",
|
|
1151
1862
|
stopDetail: detail,
|
|
1152
|
-
|
|
1863
|
+
agentSuccessClaim,
|
|
1153
1864
|
...(projectionError ? { projectionError } : {}),
|
|
1154
1865
|
};
|
|
1155
1866
|
}
|
|
1156
|
-
async advanceUnlocked(sessionId, output, preExtractedEval,
|
|
1867
|
+
async advanceUnlocked(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields, inputs) {
|
|
1157
1868
|
// ── 1. Validate session ──────────────────────────────────────────────
|
|
1869
|
+
// The handle identity (including the exact transaction attempt) was
|
|
1870
|
+
// already validated by SessionManager before its queue and lease
|
|
1871
|
+
// heartbeat. This lifecycle owns the round state machine, not a second
|
|
1872
|
+
// anchor rule.
|
|
1158
1873
|
const session = this.registry.get(sessionId);
|
|
1159
|
-
if (!session)
|
|
1160
|
-
return {
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1874
|
+
if (!session) {
|
|
1875
|
+
return {
|
|
1876
|
+
sessionId,
|
|
1877
|
+
round: 0,
|
|
1878
|
+
prompt: null,
|
|
1879
|
+
submissionError: {
|
|
1880
|
+
code: "session_not_found",
|
|
1881
|
+
details: {
|
|
1882
|
+
detail: "No session exists with this ID. It may have expired, been " +
|
|
1883
|
+
"deleted, or never existed.",
|
|
1884
|
+
},
|
|
1885
|
+
},
|
|
1886
|
+
};
|
|
1168
1887
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
// is stale — the round it reported on already committed (lost response,
|
|
1172
|
-
// duplicate call). Never process it against the current round; return the
|
|
1173
|
-
// held prompt so the agent recovers the response it missed. The reject
|
|
1174
|
-
// path keeps the same roundId (prepareRejectedAttempt), so retries of a
|
|
1175
|
-
// rejected round match normally.
|
|
1176
|
-
const expectedRoundId = session.roundSnapshot?.roundId
|
|
1177
|
-
?? makeRoundId(session.loopId, session.currentRound);
|
|
1178
|
-
if (roundId !== undefined && roundId !== expectedRoundId) {
|
|
1179
|
-
logEvent("stale_round_id", {
|
|
1888
|
+
if (session.status !== "running" && session.status !== "stalled") {
|
|
1889
|
+
return {
|
|
1180
1890
|
sessionId,
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1891
|
+
round: session.currentRound,
|
|
1892
|
+
prompt: null,
|
|
1893
|
+
sessionStatus: session.status,
|
|
1894
|
+
stopDetail: `Session is ${session.status}. Use loopforge_resume to restart a ` +
|
|
1895
|
+
"paused session, or loopforge_start for a new task.",
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
// v3.9.7: a pending rollback directive with no verified redo snapshot
|
|
1899
|
+
// means the workspace is (or may be) mid-restore. Ordinary work must not
|
|
1900
|
+
// be evaluated against it. Resume completes the restore first.
|
|
1901
|
+
// v4.0.1: this is the ONE pre-commit history read of the advance path; the
|
|
1902
|
+
// before facts and the evaluation coordinator share it.
|
|
1903
|
+
const history = loadCommittedHistory(this.store, session.loopId);
|
|
1904
|
+
const pending = history.pendingDirective;
|
|
1905
|
+
if (pending?.recovery &&
|
|
1906
|
+
(session.roundSnapshot?.restore?.directiveTransactionId !==
|
|
1907
|
+
pending.recovery.transaction_id ||
|
|
1908
|
+
session.roundSnapshot.round !== pending.recovery.target.redo_round)) {
|
|
1195
1909
|
return {
|
|
1196
1910
|
sessionId,
|
|
1197
1911
|
round: session.currentRound,
|
|
1198
|
-
roundId: expectedRoundId,
|
|
1199
1912
|
prompt: null,
|
|
1200
|
-
stopReason: "
|
|
1201
|
-
|
|
1913
|
+
stopReason: "stalled",
|
|
1914
|
+
submissionError: {
|
|
1915
|
+
code: "restore_pending",
|
|
1916
|
+
details: {
|
|
1917
|
+
loopId: session.loopId,
|
|
1918
|
+
directive_transaction_id: pending.recovery.transaction_id,
|
|
1919
|
+
redo_round: pending.recovery.target.redo_round,
|
|
1920
|
+
detail: "A pending restore must be completed by loopforge_resume before work can be submitted.",
|
|
1921
|
+
},
|
|
1922
|
+
},
|
|
1202
1923
|
};
|
|
1203
1924
|
}
|
|
1925
|
+
// A stalled compiler preparation is retryable against the same held
|
|
1926
|
+
// round. Format errors never set this status; they return before advance.
|
|
1927
|
+
if (session.status === "stalled") {
|
|
1928
|
+
session.status = "running";
|
|
1929
|
+
}
|
|
1204
1930
|
// ── 2. Extract self-evaluation ───────────────────────────────────────
|
|
1205
1931
|
const extracted = this.extractEvaluation(output, preExtractedEval);
|
|
1206
1932
|
if (!extracted) {
|
|
1207
1933
|
return {
|
|
1208
1934
|
sessionId,
|
|
1209
1935
|
round: session.currentRound,
|
|
1210
|
-
|
|
1211
|
-
?? makeRoundId(session.loopId, session.currentRound),
|
|
1936
|
+
...this.heldHandle(session),
|
|
1212
1937
|
prompt: null,
|
|
1213
|
-
|
|
1214
|
-
|
|
1938
|
+
submissionError: {
|
|
1939
|
+
code: "evaluation_invalid",
|
|
1940
|
+
details: {
|
|
1941
|
+
detail: "A structured evaluation object is required. Resubmit the same " +
|
|
1942
|
+
"submissionHandle with success, output_summary, " +
|
|
1943
|
+
"constraint_violations, and should_continue.",
|
|
1944
|
+
},
|
|
1945
|
+
},
|
|
1946
|
+
stopDetail: "A structured evaluation object is required. Resubmit the same " +
|
|
1947
|
+
"submissionHandle with success, output_summary, " +
|
|
1948
|
+
"constraint_violations, and should_continue.",
|
|
1215
1949
|
};
|
|
1216
1950
|
}
|
|
1217
1951
|
const selfEval = extracted;
|
|
1218
1952
|
// ── 3. Execute round transaction ─────────────────────────────────────
|
|
1219
1953
|
// Capture the before facts from the shared compile window BEFORE the
|
|
1220
1954
|
// transaction commits (a replay sees the same window: rounds < R).
|
|
1221
|
-
const beforeFacts = this.beforeFactsFor(session);
|
|
1222
|
-
const tx = await this.executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields);
|
|
1223
|
-
const receiptSnapshot = session.roundSnapshot
|
|
1955
|
+
const beforeFacts = this.beforeFactsFor(session, history);
|
|
1956
|
+
const tx = await this.executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields, history);
|
|
1957
|
+
const receiptSnapshot = session.roundSnapshot;
|
|
1958
|
+
if (!receiptSnapshot) {
|
|
1959
|
+
throw new Error("The committed round transaction disappeared before the receipt was built.");
|
|
1960
|
+
}
|
|
1961
|
+
// v4.0.3: a REPLAYED commit is reported from its committed payload, not
|
|
1962
|
+
// from the new submission that happened to carry the same identity.
|
|
1963
|
+
const receiptSelfEval = tx.replayed
|
|
1964
|
+
? receiptSnapshot.evaluation ?? selfEval
|
|
1965
|
+
: selfEval;
|
|
1224
1966
|
const receiptBase = {
|
|
1225
1967
|
snapshot: receiptSnapshot,
|
|
1226
|
-
selfEval,
|
|
1968
|
+
selfEval: receiptSelfEval,
|
|
1227
1969
|
pr: tx.pr,
|
|
1228
|
-
actualEvidence: tx.
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1970
|
+
actualEvidence: tx.replayed
|
|
1971
|
+
? (receiptSnapshot.afterEvidence ?? [])
|
|
1972
|
+
: tx.actualEvidence,
|
|
1973
|
+
diagnostics: tx.replayed
|
|
1974
|
+
? (receiptSnapshot.normalizationDiagnostics ?? [])
|
|
1975
|
+
: (receiptSnapshot.normalizationDiagnostics ??
|
|
1976
|
+
normalizationDiagnostics ?? []),
|
|
1977
|
+
submittedFields: tx.replayed
|
|
1978
|
+
? (receiptSnapshot.submittedFields ?? [])
|
|
1979
|
+
: (receiptSnapshot.submittedFields ?? submittedFields ?? []),
|
|
1232
1980
|
beforeContractId: beforeFacts.contractId,
|
|
1233
1981
|
beforeSubGoals: beforeFacts.subGoals,
|
|
1982
|
+
recoveryDirective: beforeFacts.recoveryDirective,
|
|
1234
1983
|
replayed: tx.replayed,
|
|
1235
1984
|
};
|
|
1236
1985
|
// v2.12: Record a user gate when an accepted round reports a high-risk
|
|
1237
1986
|
// blocker. Record layer only — never blocks the round decision flow.
|
|
1238
1987
|
if (tx.pr.action !== "reject" && tx.pr.action !== "terminate" &&
|
|
1239
|
-
|
|
1240
|
-
|
|
1988
|
+
declaresBlocked(receiptSelfEval) &&
|
|
1989
|
+
typeof receiptSelfEval.blocker === "string") {
|
|
1990
|
+
this.recordGateFromBlocked(session, receiptSelfEval.blocker);
|
|
1241
1991
|
}
|
|
1242
1992
|
// ── 4. Route disposition ─────────────────────────────────────────────
|
|
1243
1993
|
if (tx.pr.action === "reject") {
|
|
1244
|
-
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase);
|
|
1994
|
+
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase, inputs);
|
|
1245
1995
|
}
|
|
1246
|
-
// ── v2.10:
|
|
1247
|
-
if (tx.pr.action === "backtrack") {
|
|
1248
|
-
return this.buildBacktrackResult(sessionId, session, tx.pr, receiptBase);
|
|
1996
|
+
// ── v2.10/v3.9.6: recovery directive (machine or agent) ───────────────
|
|
1997
|
+
if (tx.pr.action === "backtrack" || tx.pr.action === "recovery") {
|
|
1998
|
+
return this.buildBacktrackResult(sessionId, session, tx.pr, receiptBase, inputs);
|
|
1249
1999
|
}
|
|
1250
2000
|
// Accepted rounds advance the before-snapshot baseline. Rejected rounds
|
|
1251
2001
|
// retain the original baseline so their retry remains zero-commit.
|
|
1252
|
-
session.evidenceBaseline = tx.actualEvidence;
|
|
1253
2002
|
// v3.8.3: completion truth — the second line, at the point the disposition
|
|
1254
2003
|
// becomes the reported stop reason. The coordinator already guards the
|
|
1255
2004
|
// reason it COMMITS (the round document carries result.stopReason), so
|
|
1256
2005
|
// this guards what the client and the terminal notification see. A
|
|
1257
2006
|
// violation can only be a code defect: it degrades to the honest reason
|
|
1258
2007
|
// and leaves an observability record instead of throwing mid-lifecycle.
|
|
1259
|
-
const finalized = finalizeStopReason(tx.pr.stopReason,
|
|
2008
|
+
const finalized = finalizeStopReason(tx.pr.stopReason, receiptSelfEval, undefined, receiptSelfEval.round_contract?.supersedes ?? null);
|
|
1260
2009
|
if (finalized.reason !== tx.pr.stopReason) {
|
|
1261
2010
|
logEvent("completion_truth_downgraded", {
|
|
1262
2011
|
loopId: session.loopId,
|
|
@@ -1272,31 +2021,295 @@ export class RoundLifecycle {
|
|
|
1272
2021
|
return this.buildStopResult(sessionId, session, tx.pr, finalized.reason, receiptBase);
|
|
1273
2022
|
}
|
|
1274
2023
|
// ── 5. Continue — compile next round ─────────────────────────────────
|
|
1275
|
-
return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.actualEvidence, receiptBase);
|
|
2024
|
+
return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.actualEvidence, receiptBase, inputs);
|
|
2025
|
+
}
|
|
2026
|
+
/** v3.9.6: Agent-initiated recovery. The Agent selects the target; this
|
|
2027
|
+
* method only validates it against committed facts, records the rollback
|
|
2028
|
+
* directive and compiles the redo. It never runs ordinary verification or
|
|
2029
|
+
* enforcement on the abandoned work. */
|
|
2030
|
+
async recover(sessionId, recovery, inputs) {
|
|
2031
|
+
const session = this.registry.get(sessionId);
|
|
2032
|
+
if (!session) {
|
|
2033
|
+
return {
|
|
2034
|
+
sessionId,
|
|
2035
|
+
round: 0,
|
|
2036
|
+
prompt: null,
|
|
2037
|
+
submissionError: {
|
|
2038
|
+
code: "session_not_found",
|
|
2039
|
+
details: { detail: "No session exists with this ID." },
|
|
2040
|
+
},
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
if (session.status !== "running" && session.status !== "stalled") {
|
|
2044
|
+
return {
|
|
2045
|
+
sessionId,
|
|
2046
|
+
round: session.currentRound,
|
|
2047
|
+
prompt: null,
|
|
2048
|
+
sessionStatus: session.status,
|
|
2049
|
+
stopDetail: `Session is ${session.status}.`,
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
if (!getPolicy().engine.agent_recovery_enabled) {
|
|
2053
|
+
return {
|
|
2054
|
+
sessionId,
|
|
2055
|
+
round: session.currentRound,
|
|
2056
|
+
prompt: null,
|
|
2057
|
+
submissionError: {
|
|
2058
|
+
code: "recovery_disabled",
|
|
2059
|
+
details: { policy: "engine.agent_recovery_enabled" },
|
|
2060
|
+
},
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
// The handle identity (including the exact transaction attempt) was
|
|
2064
|
+
// validated by SessionManager before its queue and lease heartbeat.
|
|
2065
|
+
const held = session.roundSnapshot;
|
|
2066
|
+
// Pending directive first: one directive is the record, so a repeated
|
|
2067
|
+
// request never reads a new target and never commits a second directive.
|
|
2068
|
+
const pending = await this.completePendingRecovery(session, inputs);
|
|
2069
|
+
if (pending)
|
|
2070
|
+
return pending;
|
|
2071
|
+
if (held?.kind !== "evaluation" || held.round !== session.currentRound) {
|
|
2072
|
+
return {
|
|
2073
|
+
sessionId,
|
|
2074
|
+
round: session.currentRound,
|
|
2075
|
+
prompt: null,
|
|
2076
|
+
submissionError: {
|
|
2077
|
+
code: "recovery_invalid",
|
|
2078
|
+
details: {
|
|
2079
|
+
reason_code: "no_held_evaluation",
|
|
2080
|
+
detail: "recovery requires a held evaluation prompt for the current round",
|
|
2081
|
+
},
|
|
2082
|
+
},
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
const entries = loadCommittedHistory(this.store, session.loopId).entries;
|
|
2086
|
+
const found = findCheckpointRef({
|
|
2087
|
+
entries,
|
|
2088
|
+
held,
|
|
2089
|
+
checkpointId: recovery.target_checkpoint_id,
|
|
2090
|
+
});
|
|
2091
|
+
if (!found.ok) {
|
|
2092
|
+
return {
|
|
2093
|
+
sessionId,
|
|
2094
|
+
round: session.currentRound,
|
|
2095
|
+
...this.heldHandle(session),
|
|
2096
|
+
...this.transactionFacts(session),
|
|
2097
|
+
prompt: null,
|
|
2098
|
+
submissionError: {
|
|
2099
|
+
code: found.code,
|
|
2100
|
+
details: {
|
|
2101
|
+
target_checkpoint_id: recovery.target_checkpoint_id,
|
|
2102
|
+
reason_code: found.reason,
|
|
2103
|
+
detail: found.detail,
|
|
2104
|
+
current_round: session.currentRound,
|
|
2105
|
+
},
|
|
2106
|
+
},
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
const ref = found.ref;
|
|
2110
|
+
const store = this.checkpointStoreFor?.(session.loopId);
|
|
2111
|
+
if (!store) {
|
|
2112
|
+
return {
|
|
2113
|
+
sessionId,
|
|
2114
|
+
round: session.currentRound,
|
|
2115
|
+
...this.heldHandle(session),
|
|
2116
|
+
...this.transactionFacts(session),
|
|
2117
|
+
prompt: null,
|
|
2118
|
+
submissionError: {
|
|
2119
|
+
code: "recovery_unavailable",
|
|
2120
|
+
details: {
|
|
2121
|
+
target_checkpoint_id: recovery.target_checkpoint_id,
|
|
2122
|
+
reason_code: "store_unavailable",
|
|
2123
|
+
detail: "no workspace checkpoint store is attached to this runtime",
|
|
2124
|
+
},
|
|
2125
|
+
},
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
const manifest = store.readManifest(ref.checkpointId);
|
|
2129
|
+
const validated = validateCheckpointManifest(ref, manifest);
|
|
2130
|
+
if (!validated.ok) {
|
|
2131
|
+
return {
|
|
2132
|
+
sessionId,
|
|
2133
|
+
round: session.currentRound,
|
|
2134
|
+
...this.heldHandle(session),
|
|
2135
|
+
...this.transactionFacts(session),
|
|
2136
|
+
prompt: null,
|
|
2137
|
+
submissionError: {
|
|
2138
|
+
code: validated.code,
|
|
2139
|
+
details: {
|
|
2140
|
+
target_checkpoint_id: recovery.target_checkpoint_id,
|
|
2141
|
+
reason_code: validated.reason,
|
|
2142
|
+
detail: validated.detail,
|
|
2143
|
+
},
|
|
2144
|
+
},
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
// Deep preflight BEFORE the directive: an invalid target must leave no
|
|
2148
|
+
// directive and no session cursor change.
|
|
2149
|
+
try {
|
|
2150
|
+
const context = this.restoreContext();
|
|
2151
|
+
await preflightRestore({
|
|
2152
|
+
manifest: validated.manifest,
|
|
2153
|
+
workspace: context.workspace,
|
|
2154
|
+
store,
|
|
2155
|
+
...(context.stateDir ? { stateDir: context.stateDir } : {}),
|
|
2156
|
+
exclude: context.exclude,
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
catch (error) {
|
|
2160
|
+
return {
|
|
2161
|
+
sessionId,
|
|
2162
|
+
round: session.currentRound,
|
|
2163
|
+
...this.heldHandle(session),
|
|
2164
|
+
...this.transactionFacts(session),
|
|
2165
|
+
prompt: null,
|
|
2166
|
+
submissionError: {
|
|
2167
|
+
code: "recovery_unavailable",
|
|
2168
|
+
details: {
|
|
2169
|
+
target_checkpoint_id: recovery.target_checkpoint_id,
|
|
2170
|
+
reason_code: error instanceof RestoreFailure
|
|
2171
|
+
? error.reasonCode
|
|
2172
|
+
: "preflight_failed",
|
|
2173
|
+
detail: String(error instanceof Error ? error.message : error),
|
|
2174
|
+
},
|
|
2175
|
+
},
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
const target = {
|
|
2179
|
+
checkpoint_id: ref.checkpointId,
|
|
2180
|
+
content_hash: ref.contentHash,
|
|
2181
|
+
role: ref.role,
|
|
2182
|
+
round: ref.round,
|
|
2183
|
+
round_id: ref.roundId,
|
|
2184
|
+
transaction_id: ref.transactionId,
|
|
2185
|
+
redo_round: ref.redoRound,
|
|
2186
|
+
};
|
|
2187
|
+
const abandoned = branchRounds(entries).filter((view) => view.round >= ref.redoRound);
|
|
2188
|
+
const reportedSkippedFiles = [...new Set(abandoned.flatMap((view) => (view.executionReport?.files_changed ?? [])))].sort();
|
|
2189
|
+
const directiveTransactionId = deriveTransactionId({
|
|
2190
|
+
kind: "rollback",
|
|
2191
|
+
branchId: held.branchId ?? rootBranchId(session.loopId),
|
|
2192
|
+
roundId: held.roundId,
|
|
2193
|
+
attempt: 1,
|
|
2194
|
+
promptHash: held.promptArtifact?.promptHash,
|
|
2195
|
+
recovery: {
|
|
2196
|
+
target,
|
|
2197
|
+
reason: recovery.reason,
|
|
2198
|
+
abandonedRounds: abandoned.map((view) => view.round),
|
|
2199
|
+
reportedSkippedFiles,
|
|
2200
|
+
},
|
|
2201
|
+
});
|
|
2202
|
+
const completed = await new RoundDriver(session.engine, this.store, store).completeRecovery({
|
|
2203
|
+
snapshot: held,
|
|
2204
|
+
loopId: session.loopId,
|
|
2205
|
+
buildRecovery: (actualEvidence) => deriveRecoveryDirective({
|
|
2206
|
+
origin: "agent",
|
|
2207
|
+
reason: recovery.reason,
|
|
2208
|
+
transactionId: directiveTransactionId,
|
|
2209
|
+
targetRef: ref,
|
|
2210
|
+
targetManifest: validated.manifest,
|
|
2211
|
+
abandonedRounds: abandoned,
|
|
2212
|
+
currentAfterEvidence: actualEvidence,
|
|
2213
|
+
triggerRound: session.currentRound,
|
|
2214
|
+
}),
|
|
2215
|
+
});
|
|
2216
|
+
const directive = completed.outcome.snapshot.recovery;
|
|
2217
|
+
if (!directive) {
|
|
2218
|
+
return this.restoreFailedResult(session, {
|
|
2219
|
+
origin: "agent",
|
|
2220
|
+
reason: recovery.reason,
|
|
2221
|
+
target,
|
|
2222
|
+
skipped_files: [],
|
|
2223
|
+
skipped_discoveries: [],
|
|
2224
|
+
failed_rounds: [],
|
|
2225
|
+
approaches: [],
|
|
2226
|
+
wrong_assumptions: [],
|
|
2227
|
+
transaction_id: directiveTransactionId,
|
|
2228
|
+
}, "directive_unavailable", "the committed rollback directive could not be read back");
|
|
2229
|
+
}
|
|
2230
|
+
return this.restoreAndPrepareRedo(session, directive, inputs);
|
|
2231
|
+
}
|
|
2232
|
+
/** Execute the committed directive's restore and return the redo prompt. */
|
|
2233
|
+
async restoreAndPrepareRedo(session, directive, inputs) {
|
|
2234
|
+
const outcome = await this.runRestoreAndPrepare(session, directive, {
|
|
2235
|
+
...(inputs ? { inputs } : {}),
|
|
2236
|
+
});
|
|
2237
|
+
if (!outcome.ok)
|
|
2238
|
+
return outcome.result;
|
|
2239
|
+
const { restore, prepared } = outcome;
|
|
2240
|
+
const result = this.persistPrepared(session, {
|
|
2241
|
+
prompt: prepared.prompt,
|
|
2242
|
+
level: prepared.level,
|
|
2243
|
+
baseline: prepared.evidenceBaseline,
|
|
2244
|
+
snapshot: prepared.snapshot,
|
|
2245
|
+
warnings: prepared.warnings ?? [],
|
|
2246
|
+
}, false);
|
|
2247
|
+
return {
|
|
2248
|
+
...result,
|
|
2249
|
+
submission_guidance: this.guidanceFor(session, {
|
|
2250
|
+
recovery: {
|
|
2251
|
+
pending: true,
|
|
2252
|
+
redo_round: directive.target.redo_round,
|
|
2253
|
+
checkpoint_id: directive.target.checkpoint_id,
|
|
2254
|
+
restore_verified: true,
|
|
2255
|
+
origin: directive.origin,
|
|
2256
|
+
reason: directive.reason,
|
|
2257
|
+
},
|
|
2258
|
+
}),
|
|
2259
|
+
recovery: {
|
|
2260
|
+
origin: directive.origin,
|
|
2261
|
+
reason: directive.reason,
|
|
2262
|
+
target_checkpoint_id: directive.target.checkpoint_id,
|
|
2263
|
+
redo_round: directive.target.redo_round,
|
|
2264
|
+
restore: {
|
|
2265
|
+
status: "verified",
|
|
2266
|
+
...(restore.head ? { head: restore.head } : {}),
|
|
2267
|
+
...(restore.previousHead ? { previous_head: restore.previousHead } : {}),
|
|
2268
|
+
...(restore.safetyRef ? { safety_ref: restore.safetyRef } : {}),
|
|
2269
|
+
files_written: restore.filesWritten,
|
|
2270
|
+
files_deleted: restore.filesDeleted,
|
|
2271
|
+
verified_at: restore.verifiedAt,
|
|
2272
|
+
},
|
|
2273
|
+
},
|
|
2274
|
+
};
|
|
1276
2275
|
}
|
|
1277
2276
|
/** Core cycle entry used by SessionManager.advance after the queue + lease
|
|
1278
2277
|
* dance: validate → extract → execute transaction → route disposition
|
|
1279
2278
|
* → advance to next round (continue) or return terminal result.
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
2279
|
+
* v4.0.0: the submission identity (the exact held transaction) is validated
|
|
2280
|
+
* by SessionManager before its queue and lease heartbeat. This entry point
|
|
2281
|
+
* owns the round state machine and does not re-check anchors. */
|
|
2282
|
+
async advance(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields,
|
|
2283
|
+
/** v4.0.5: invocation input for the prompt compiled after this submission. */
|
|
2284
|
+
inputs) {
|
|
2285
|
+
return this.advanceUnlocked(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields, inputs);
|
|
1286
2286
|
}
|
|
1287
2287
|
/** Write back loop knowledge to long-term memory.
|
|
1288
2288
|
* Uses shared base builder from policy.ts. Called when a loop terminates. */
|
|
1289
2289
|
async notifyTerminal(session, stopReason) {
|
|
1290
2290
|
if (this.terminalSinks.size === 0)
|
|
1291
2291
|
return;
|
|
2292
|
+
const views = loadCommittedHistory(this.store, session.loopId).rounds;
|
|
2293
|
+
const lastView = views.length > 0 ? views[views.length - 1] : undefined;
|
|
2294
|
+
// v4.0.3: the trajectory records MACHINE-BACKED success only.
|
|
2295
|
+
const machineBackedSuccessTrajectory = views
|
|
2296
|
+
.filter((view) => view.result?.shouldPushSuccessTrajectory === true &&
|
|
2297
|
+
view.result?.evidencePosture?.machineBackedSuccess === true)
|
|
2298
|
+
.map((view) => view.agentSuccessClaim);
|
|
1292
2299
|
const event = {
|
|
1293
2300
|
loopId: session.loopId,
|
|
1294
2301
|
task: session.task,
|
|
1295
|
-
|
|
2302
|
+
completed: stopReason === "completed",
|
|
2303
|
+
// v4.0.4: no committed posture is unknown, never a synthesized false.
|
|
2304
|
+
machineBackedSuccess: lastView?.evidencePosture?.machineBackedSuccess ?? null,
|
|
2305
|
+
evidencePosture: lastView?.evidencePosture ?? null,
|
|
1296
2306
|
stopReason: stopReason,
|
|
2307
|
+
// v4.0.4: keep the historical alias, but state the two facts separately.
|
|
1297
2308
|
roundsCompleted: session.currentRound,
|
|
1298
|
-
|
|
1299
|
-
|
|
2309
|
+
roundCursor: session.currentRound,
|
|
2310
|
+
committedRounds: views.length,
|
|
2311
|
+
machineBackedSuccessTrajectory,
|
|
2312
|
+
lastEvaluation: lastView?.evaluation ?? undefined,
|
|
1300
2313
|
};
|
|
1301
2314
|
await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
|
|
1302
2315
|
}
|