loopforge 3.9.2 → 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 +152 -40
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +382 -206
- 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 +105 -141
- package/dist/committed-round.d.ts.map +1 -1
- package/dist/committed-round.js +249 -372
- 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 +32 -67
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +203 -439
- 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 +28 -887
- 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 +167 -27
- package/dist/explain.d.ts.map +1 -1
- package/dist/explain.js +373 -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 +51 -19
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +423 -507
- 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 +285 -81
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +1629 -574
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +61 -19
- 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 +963 -271
- 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 -82
- 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 +763 -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 +15 -40
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +13 -13
- 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 +28 -9
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +592 -603
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-coverage.d.ts +39 -0
- package/dist/prompt-coverage.d.ts.map +1 -0
- package/dist/prompt-coverage.js +80 -0
- package/dist/prompt-coverage.js.map +1 -0
- package/dist/prompt-freshness.d.ts +25 -0
- package/dist/prompt-freshness.d.ts.map +1 -0
- package/dist/prompt-freshness.js +88 -0
- package/dist/prompt-freshness.js.map +1 -0
- package/dist/prompt-policy.d.ts +14 -4
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +29 -10
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +875 -419
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +73 -99
- 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 +45 -91
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +254 -271
- 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 +54 -157
- 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 +856 -39
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +40 -60
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +455 -384
- 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 +43 -21
- package/dist/submission-guidance.d.ts.map +1 -1
- package/dist/submission-guidance.js +368 -262
- 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 +146 -51
- package/dist/submission-receipt.js.map +1 -1
- package/dist/token-utils.d.ts +47 -7
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +66 -15
- 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 +13 -0
- package/dist/verification-actions.d.ts.map +1 -0
- package/dist/verification-actions.js +44 -0
- package/dist/verification-actions.js.map +1 -0
- package/dist/verification-gate.d.ts +26 -46
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +218 -427
- 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 +5 -19
- package/package.json +2 -2
- 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 -370
|
@@ -10,23 +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";
|
|
38
|
+
import { SessionLeaseConflictError } from "../storage.js";
|
|
39
|
+
import { isRecord, normalizeText } from "../token-utils.js";
|
|
40
|
+
import { ProjectionCoverageError } from "../prompt-coverage.js";
|
|
41
|
+
import { CompileFailureError, LegacyHistoryError } from "../errors.js";
|
|
42
|
+
import { renderResultFacts } from "../result-facts.js";
|
|
30
43
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
31
44
|
/** M3: parse a persisted skipped-file fingerprint map (string → string).
|
|
32
45
|
* Lenient by design — v3.8.3 reuses it for the entrypoint trust baseline,
|
|
@@ -42,62 +55,165 @@ function parseFingerprintMap(value) {
|
|
|
42
55
|
}
|
|
43
56
|
return out;
|
|
44
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
|
+
}
|
|
104
|
+
/** v3.9.4: a structural defect in the start criteria argument. The MCP
|
|
105
|
+
* handler maps this to `invalid_argument`; it is never a policy defect. */
|
|
106
|
+
export class StartCriteriaError extends Error {
|
|
107
|
+
code = "invalid_argument";
|
|
108
|
+
}
|
|
109
|
+
/** v3.9.4: normalize initial criteria at the start boundary.
|
|
110
|
+
*
|
|
111
|
+
* - a non-array / non-string element is a structural defect (throws);
|
|
112
|
+
* - empty text is dropped with a warning;
|
|
113
|
+
* - duplicate normalized-exact text keeps the first spelling, with a
|
|
114
|
+
* warning (the stable id derivation would otherwise produce two entries
|
|
115
|
+
* with one `cr-*` id);
|
|
116
|
+
* - over `policy.evolution.max_start_criteria` is dropped from the tail with
|
|
117
|
+
* a warning;
|
|
118
|
+
* - an empty result is the caller's signal to keep the default criterion. */
|
|
119
|
+
export function normalizeStartCriteria(raw) {
|
|
120
|
+
if (raw === undefined)
|
|
121
|
+
return { criteria: [], warnings: [] };
|
|
122
|
+
if (!Array.isArray(raw)) {
|
|
123
|
+
throw new StartCriteriaError("criteria must be an array of non-empty strings");
|
|
124
|
+
}
|
|
125
|
+
const values = [];
|
|
126
|
+
let empty = 0;
|
|
127
|
+
for (const entry of raw) {
|
|
128
|
+
if (typeof entry !== "string") {
|
|
129
|
+
throw new StartCriteriaError("criteria entries must be strings");
|
|
130
|
+
}
|
|
131
|
+
const text = entry.trim();
|
|
132
|
+
if (!text) {
|
|
133
|
+
empty++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
values.push(text);
|
|
137
|
+
}
|
|
138
|
+
const warnings = [];
|
|
139
|
+
if (empty > 0) {
|
|
140
|
+
warnings.push(`start_criteria_empty: dropped ${empty} empty criteria`);
|
|
141
|
+
}
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
const unique = [];
|
|
144
|
+
let duplicates = 0;
|
|
145
|
+
for (const text of values) {
|
|
146
|
+
const key = normalizeText(text);
|
|
147
|
+
if (seen.has(key)) {
|
|
148
|
+
duplicates++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
seen.add(key);
|
|
152
|
+
unique.push(text);
|
|
153
|
+
}
|
|
154
|
+
if (duplicates > 0) {
|
|
155
|
+
warnings.push(`start_criteria_duplicate: dropped ${duplicates} duplicate criteria`);
|
|
156
|
+
}
|
|
157
|
+
const cap = Math.max(0, getPolicy().evolution.max_start_criteria);
|
|
158
|
+
const criteria = unique.slice(0, cap);
|
|
159
|
+
if (unique.length > criteria.length) {
|
|
160
|
+
warnings.push(`start_criteria_capped: kept ${criteria.length} of ${unique.length} ` +
|
|
161
|
+
`(max_start_criteria=${cap})`);
|
|
162
|
+
}
|
|
163
|
+
return { criteria, warnings };
|
|
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";
|
|
45
170
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
46
|
-
export function buildLoopRequest(session,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
171
|
+
export function buildLoopRequest(session,
|
|
172
|
+
/** v4.0.5: the host's explicit invocation input for THIS compile. */
|
|
173
|
+
inputs) {
|
|
174
|
+
// The request carries CURRENT-ROUND context only. Every previous-round
|
|
175
|
+
// fact is read from the Vault's committed rounds by the compiler, so live,
|
|
176
|
+
// resume, unpause and backtrack compiles cannot disagree about the same
|
|
177
|
+
// history. `attempt` is restored only while the held snapshot belongs to
|
|
178
|
+
// the current round; entering the next round resets it to 1.
|
|
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;
|
|
187
|
+
return {
|
|
53
188
|
loop_id: session.loopId,
|
|
54
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",
|
|
55
205
|
max_rounds: session.maxRounds,
|
|
56
|
-
verification_flags: verificationFlags
|
|
206
|
+
verification_flags: retry ? [...retry.verificationFlags] : [],
|
|
207
|
+
attempt: heldAttempt,
|
|
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
|
+
: {}),
|
|
57
216
|
};
|
|
58
|
-
if (lastEval) {
|
|
59
|
-
req.last_round_result = makeLoopRoundResult({
|
|
60
|
-
round: session.currentRound - 1,
|
|
61
|
-
success: lastEval.success,
|
|
62
|
-
output_summary: lastEval.output_summary,
|
|
63
|
-
constraint_violations: lastEval.constraint_violations,
|
|
64
|
-
manual_fixes_needed: "",
|
|
65
|
-
// P0–P2: Forward evolution fields to next compile.
|
|
66
|
-
// v3.9.1: worker_results stays a report; only top-level
|
|
67
|
-
// discovered_constraints promotes a finding into the active set.
|
|
68
|
-
discovered_constraints: lastEval.discovered_constraints ?? [],
|
|
69
|
-
objective_refinement: lastEval.objective_refinement ?? "",
|
|
70
|
-
emerged_subtasks: lastEval.emerged_subtasks ?? [],
|
|
71
|
-
// P4: Execution evidence
|
|
72
|
-
execution_report: lastEval.execution_report ?? undefined,
|
|
73
|
-
// P5: Self-correction
|
|
74
|
-
retracted_constraints: lastEval.retracted_constraints ?? [],
|
|
75
|
-
revised_success_criteria: lastEval.revised_success_criteria ?? [],
|
|
76
|
-
wrong_assumptions: lastEval.wrong_assumptions ?? [],
|
|
77
|
-
// Multi-agent: Forward delegation results to next compile
|
|
78
|
-
worker_results: lastEval.worker_results ?? [],
|
|
79
|
-
// v1.10: Checkpoint boundary
|
|
80
|
-
compression_checkpoint: lastEval.compression_checkpoint ?? false,
|
|
81
|
-
checkpoint_label: lastEval.checkpoint_label ?? "",
|
|
82
|
-
// v3.7.1: Sub-goal lifecycle — explicit transitions
|
|
83
|
-
subgoal_updates: lastEval.subgoal_updates ?? [],
|
|
84
|
-
// v2.9: Model's information needs for the next prompt
|
|
85
|
-
prompt_requests: lastEval.prompt_requests,
|
|
86
|
-
// v2.12: Tri-state outcome + blocker + retroactive claims
|
|
87
|
-
outcome: lastEval.outcome,
|
|
88
|
-
blocker: lastEval.blocker,
|
|
89
|
-
retroactiveClaims: lastEval.retroactiveClaims ?? [],
|
|
90
|
-
// v3.3: round_contract was carried here so it could drive the next
|
|
91
|
-
// round's Current Task. v3.4: intentionally NOT forwarded — the
|
|
92
|
-
// submission's round_contract is a PROPOSAL for the next round, and
|
|
93
|
-
// the ACTIVE contract is derived from committed rounds at compile time
|
|
94
|
-
// (`deriveRoundFacts` → `deriveActiveRoundContract`), which also keeps
|
|
95
|
-
// it correct on retry / resume / unpause / backtrack compiles that have
|
|
96
|
-
// no last_round_result. Reading this field again would reintroduce the
|
|
97
|
-
// second source of truth the derivation replaced.
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
return req;
|
|
101
217
|
}
|
|
102
218
|
// ── RoundLifecycle ─────────────────────────────────────────────────────────
|
|
103
219
|
export class RoundLifecycle {
|
|
@@ -107,40 +223,63 @@ export class RoundLifecycle {
|
|
|
107
223
|
terminalSinks;
|
|
108
224
|
ownerId;
|
|
109
225
|
leaseMs;
|
|
110
|
-
|
|
226
|
+
checkpointStoreFor;
|
|
111
227
|
constructor(deps) {
|
|
228
|
+
this.checkpointStoreFor = deps.checkpointStoreFor;
|
|
112
229
|
this.store = deps.store;
|
|
113
230
|
this.sessionStore = deps.sessionStore;
|
|
114
231
|
this.registry = deps.registry;
|
|
115
232
|
this.terminalSinks = deps.terminalSinks;
|
|
116
233
|
this.ownerId = deps.ownerId;
|
|
117
234
|
this.leaseMs = deps.leaseMs;
|
|
118
|
-
|
|
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));
|
|
119
240
|
}
|
|
120
241
|
/** v3.9.1: the submission-guidance projection for a session's held round.
|
|
121
|
-
* One history
|
|
242
|
+
* One history read and one compile window, no writes.
|
|
122
243
|
* Findings from a rejected/retried attempt are supplied by the caller. */
|
|
123
244
|
guidanceFor(session, extras = {}) {
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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;
|
|
129
251
|
return deriveSubmissionGuidance(session, {
|
|
130
|
-
rounds:
|
|
131
|
-
...
|
|
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),
|
|
132
261
|
});
|
|
133
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
|
+
}
|
|
134
273
|
/** v3.9.1: the before facts of the round being submitted, from the SAME
|
|
135
274
|
* history window the compile uses. Works on a replay too: rounds below the
|
|
136
275
|
* logical round exclude the round itself. */
|
|
137
|
-
beforeFactsFor(session) {
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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;
|
|
144
283
|
return {
|
|
145
284
|
contractId: deriveActiveRoundContract(rounds, getPolicy().evidence.commands ?? [])?.id ?? null,
|
|
146
285
|
subGoals: deriveSubGoals({
|
|
@@ -148,31 +287,19 @@ export class RoundLifecycle {
|
|
|
148
287
|
currentRound: session.currentRound,
|
|
149
288
|
rounds,
|
|
150
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,
|
|
151
293
|
};
|
|
152
294
|
}
|
|
153
295
|
/** v3.9.1: derive this submission's receipt from the transaction and the
|
|
154
296
|
* shared read model. Never writes and never invents an applied effect. */
|
|
155
297
|
buildReceipt(base, opts) {
|
|
156
|
-
const
|
|
157
|
-
const entries = [
|
|
158
|
-
...queryLoopEntries(this.store, base.snapshot.loopId, { prefix }),
|
|
159
|
-
...queryLoopEntries(this.store, base.snapshot.loopId, {
|
|
160
|
-
prefix,
|
|
161
|
-
feedbackOnly: true,
|
|
162
|
-
}),
|
|
163
|
-
];
|
|
298
|
+
const history = loadCommittedHistory(this.store, base.snapshot.loopId);
|
|
164
299
|
const committed = opts.disposition === "accepted" || opts.disposition === "stopped";
|
|
165
|
-
const rounds =
|
|
300
|
+
const rounds = historyWindow(history, committed ? base.snapshot.round + 1 : base.snapshot.round);
|
|
166
301
|
const commands = getPolicy().evidence.commands ?? [];
|
|
167
302
|
const roundDeltas = deriveRoundObservationDelta(base.snapshot.beforeEvidence, base.actualEvidence);
|
|
168
|
-
const contractView = deriveRoundContractView({
|
|
169
|
-
rounds,
|
|
170
|
-
round: base.snapshot.round,
|
|
171
|
-
report: base.selfEval.execution_report ?? null,
|
|
172
|
-
deltas: roundDeltas,
|
|
173
|
-
outcome: effectiveOutcome(base.selfEval),
|
|
174
|
-
commands,
|
|
175
|
-
});
|
|
176
303
|
const contractAfterId = opts.contractAfterId !== undefined
|
|
177
304
|
? opts.contractAfterId
|
|
178
305
|
: committed
|
|
@@ -187,6 +314,15 @@ export class RoundLifecycle {
|
|
|
187
314
|
rounds,
|
|
188
315
|
})
|
|
189
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);
|
|
190
326
|
return deriveSubmissionReceipt({
|
|
191
327
|
snapshot: base.snapshot,
|
|
192
328
|
selfEval: base.selfEval,
|
|
@@ -194,25 +330,26 @@ export class RoundLifecycle {
|
|
|
194
330
|
disposition: opts.disposition,
|
|
195
331
|
diagnostics: base.diagnostics,
|
|
196
332
|
submittedFields: base.submittedFields,
|
|
333
|
+
recoveryDirective,
|
|
334
|
+
...(opts.restoreVerified !== undefined
|
|
335
|
+
? { recoveryRestoreVerified: opts.restoreVerified }
|
|
336
|
+
: {}),
|
|
197
337
|
facts: {
|
|
198
338
|
contractBefore: base.beforeContractId,
|
|
199
339
|
contractAfter: contractAfterId,
|
|
200
340
|
subGoalsBefore: base.beforeSubGoals,
|
|
201
341
|
subGoalsAfter: afterSubGoals,
|
|
202
342
|
constraintsActiveAfter: opts.constraintsActiveAfter ?? null,
|
|
343
|
+
constraintsRetiredAfter: opts.constraintsRetiredAfter ?? null,
|
|
203
344
|
},
|
|
204
345
|
nextArtifact: opts.nextArtifact ?? null,
|
|
205
|
-
contractItems: contractView.statuses.items.map((item) => ({
|
|
206
|
-
item_id: item.itemId,
|
|
207
|
-
status: item.status,
|
|
208
|
-
})),
|
|
209
346
|
evidenceDeltas: roundDeltas,
|
|
210
347
|
});
|
|
211
348
|
}
|
|
212
349
|
/** Persist session state to vault for cross-process recovery.
|
|
213
350
|
* The filtered vault and replacement entry are written once under the
|
|
214
351
|
* backend lock, so recovery never observes the old two-write gap. */
|
|
215
|
-
save(session) {
|
|
352
|
+
save(session, options = {}) {
|
|
216
353
|
if (!this.sessionStore)
|
|
217
354
|
return;
|
|
218
355
|
const leaseActive = session.status === "running";
|
|
@@ -226,30 +363,20 @@ export class RoundLifecycle {
|
|
|
226
363
|
session_id: session.sessionId,
|
|
227
364
|
current_round: session.currentRound,
|
|
228
365
|
max_rounds: session.maxRounds,
|
|
229
|
-
success_trajectory: session.successTrajectory,
|
|
230
366
|
status: session.status,
|
|
231
367
|
created_at: session.createdAt,
|
|
232
368
|
// v1.13: Enforcement gate state
|
|
233
369
|
consecutive_rejections: session.consecutiveRejections,
|
|
234
370
|
last_rejection_check: session.lastRejectionCheck,
|
|
235
|
-
// v2.13: Backtrack skipped files for workspace restore check
|
|
236
|
-
backtrack_skipped_files: session.backtrackSkippedFiles,
|
|
237
|
-
// M3: skipped-file fingerprints at their failed rounds (restore
|
|
238
|
-
// check machine arm) — empty unless a backtrack is pending
|
|
239
|
-
backtrack_skipped_fingerprints: session.backtrackSkippedFingerprints,
|
|
240
371
|
// v3.8.3: the in-flight entrypoint trust baseline. Read back
|
|
241
372
|
// leniently (parseFingerprintMap) so a corrupt or absent value
|
|
242
373
|
// degrades to "no absolute arm" instead of costing the round.
|
|
243
374
|
entrypoint_trust: session.entrypointTrust ?? null,
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
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,
|
|
248
378
|
// v1.19: durable round transaction state
|
|
249
379
|
round_snapshot: session.roundSnapshot ?? null,
|
|
250
|
-
last_self_eval: session.lastSelfEval ?? null,
|
|
251
|
-
current_prompt: session.currentPrompt ?? null,
|
|
252
|
-
current_level: session.currentLevel ?? "",
|
|
253
380
|
// v1.20: cross-process single-owner lease
|
|
254
381
|
lease_owner: leaseActive ? this.ownerId : "",
|
|
255
382
|
lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
|
|
@@ -257,6 +384,7 @@ export class RoundLifecycle {
|
|
|
257
384
|
};
|
|
258
385
|
this.sessionStore.save(sessionEntry, {
|
|
259
386
|
expectedLeaseOwner: this.ownerId,
|
|
387
|
+
...options,
|
|
260
388
|
});
|
|
261
389
|
}
|
|
262
390
|
/** Reconstruct a McpSession from a vault session_state entry.
|
|
@@ -265,7 +393,11 @@ export class RoundLifecycle {
|
|
|
265
393
|
reconstructSession(entry, allowPaused = false) {
|
|
266
394
|
const lineage = (entry.loop_lineage ?? {});
|
|
267
395
|
const status = lineage.status ?? "running";
|
|
268
|
-
|
|
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")) {
|
|
269
401
|
return null;
|
|
270
402
|
}
|
|
271
403
|
const loopId = entry.loop_id;
|
|
@@ -276,60 +408,64 @@ export class RoundLifecycle {
|
|
|
276
408
|
// only reachable through the read-only audit.
|
|
277
409
|
checkRoundSequence(this.store, loopId);
|
|
278
410
|
const currentRound = lineage.current_round ?? 1;
|
|
279
|
-
const successTrajectory = Array.isArray(lineage.success_trajectory)
|
|
280
|
-
? lineage.success_trajectory.filter((value) => typeof value === "boolean")
|
|
281
|
-
: [];
|
|
282
411
|
const task = entry.task ?? "";
|
|
283
412
|
const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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;
|
|
298
438
|
const engine = new LoopForgeEngine(this.store);
|
|
299
439
|
return {
|
|
300
440
|
sessionId: typeof lineage.session_id === "string" && lineage.session_id
|
|
301
441
|
? lineage.session_id
|
|
302
442
|
: randomUUID(),
|
|
303
443
|
loopId, task, engine,
|
|
304
|
-
currentRound, maxRounds,
|
|
444
|
+
currentRound, maxRounds,
|
|
305
445
|
status: status,
|
|
306
446
|
createdAt: lineage.created_at ?? Date.now(),
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
: null,
|
|
330
|
-
currentLevel: typeof lineage.current_level === "string"
|
|
331
|
-
? lineage.current_level
|
|
332
|
-
: 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,
|
|
333
469
|
};
|
|
334
470
|
}
|
|
335
471
|
/** Apply a prepared round to the session, persist, and build the result.
|
|
@@ -347,6 +483,14 @@ export class RoundLifecycle {
|
|
|
347
483
|
* `newConsecutiveRejections` comes from the enforcement gate and already
|
|
348
484
|
* counts the round; it is only honoured when the check is unchanged. */
|
|
349
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
|
+
}
|
|
350
494
|
if (pr.action === "reject" && pr.rejectionCheck) {
|
|
351
495
|
session.consecutiveRejections =
|
|
352
496
|
pr.rejectionCheck === session.lastRejectionCheck
|
|
@@ -359,8 +503,6 @@ export class RoundLifecycle {
|
|
|
359
503
|
if (pr.action !== "reject")
|
|
360
504
|
session.lastRejectionCheck = "";
|
|
361
505
|
}
|
|
362
|
-
if (pr.newLastSelfEval)
|
|
363
|
-
session.lastSelfEval = pr.newLastSelfEval;
|
|
364
506
|
}
|
|
365
507
|
/** v3.8.3: capture the trusted entrypoint baseline for the round about to be
|
|
366
508
|
* prepared.
|
|
@@ -373,6 +515,9 @@ export class RoundLifecycle {
|
|
|
373
515
|
*
|
|
374
516
|
* Best-effort: an unreadable policy or an unresolvable command must leave
|
|
375
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. */
|
|
376
521
|
seedEntrypointTrust(session) {
|
|
377
522
|
try {
|
|
378
523
|
session.entrypointTrust = captureEntrypointTrust(getPolicy());
|
|
@@ -381,68 +526,212 @@ export class RoundLifecycle {
|
|
|
381
526
|
session.entrypointTrust = undefined;
|
|
382
527
|
}
|
|
383
528
|
}
|
|
384
|
-
|
|
385
|
-
|
|
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;
|
|
386
563
|
session.roundSnapshot = prepared.snapshot;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
+
}
|
|
392
599
|
policyMetrics.recordStrategy(session.loopId, prepared.level);
|
|
393
|
-
this.save(session);
|
|
394
600
|
return {
|
|
395
601
|
sessionId: session.sessionId,
|
|
396
602
|
round: session.currentRound,
|
|
397
|
-
|
|
603
|
+
...this.heldHandle(session),
|
|
604
|
+
...this.transactionFacts(session),
|
|
605
|
+
...this.contextFacts(prepared.snapshot.promptArtifact),
|
|
398
606
|
prompt: prepared.prompt,
|
|
399
607
|
level: prepared.level,
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
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 ?? [],
|
|
405
616
|
submission_guidance: this.guidanceFor(session),
|
|
406
617
|
};
|
|
407
618
|
}
|
|
408
|
-
|
|
409
|
-
|
|
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)
|
|
410
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;
|
|
411
706
|
return {
|
|
412
707
|
sessionId: session.sessionId,
|
|
413
708
|
round: session.currentRound,
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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 } : {}),
|
|
420
720
|
};
|
|
421
721
|
}
|
|
422
722
|
/** Reconcile the crash window where feedback committed but session_state
|
|
423
723
|
* still points at the old prompt. Returns null when no commit is pending.
|
|
424
724
|
* v3.7: async — the continue-after-crash branch compiles through
|
|
425
725
|
* RoundDriver.prepare (the sync prepareSync fallback was removed). */
|
|
426
|
-
async reconcileCommittedRound(session) {
|
|
726
|
+
async reconcileCommittedRound(session, inputs) {
|
|
427
727
|
if (!session.roundSnapshot)
|
|
428
728
|
return null;
|
|
429
|
-
const recovered =
|
|
729
|
+
const recovered = this.driver(session.engine, session.loopId).recover(session.roundSnapshot);
|
|
430
730
|
if (!recovered)
|
|
431
731
|
return null;
|
|
432
732
|
const pr = recovered.result;
|
|
433
733
|
session.roundSnapshot = recovered.snapshot;
|
|
434
734
|
this.applyRoundCounters(session, pr);
|
|
435
|
-
// L1 (v3.7.x): trajectory push guard must use the SAME judgement as the
|
|
436
|
-
// counter guard below (recovered.snapshot.round). Comparing against
|
|
437
|
-
// session.currentRound conflates two windows: the crash window (persisted
|
|
438
|
-
// length N-1, currentRound N — push needed) and the pause race (pause()
|
|
439
|
-
// persisted the incremented counter AND the already-pushed round N —
|
|
440
|
-
// length N, currentRound N+1 — pushing again duplicates the entry).
|
|
441
|
-
if (pr.shouldPushSuccessTrajectory &&
|
|
442
|
-
session.successTrajectory.length < recovered.snapshot.round) {
|
|
443
|
-
session.successTrajectory.push(pr.roundSuccess);
|
|
444
|
-
}
|
|
445
|
-
session.currentPrompt = null;
|
|
446
735
|
if (pr.action === "stop" || pr.action === "terminate") {
|
|
447
736
|
// v3.8.3: a replayed termination carries the gate's own reason for the
|
|
448
737
|
// same reason the live path does (see buildTerminationResult) — the
|
|
@@ -453,34 +742,38 @@ export class RoundLifecycle {
|
|
|
453
742
|
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
454
743
|
this.save(session);
|
|
455
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;
|
|
456
755
|
return {
|
|
457
756
|
sessionId: session.sessionId,
|
|
458
757
|
round: session.currentRound,
|
|
459
|
-
roundId: recovered.snapshot.roundId,
|
|
460
758
|
prompt: null,
|
|
461
759
|
stopReason: reason,
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
: `Loop stopped: ${reason}.`,
|
|
469
|
-
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 } : {}),
|
|
470
766
|
};
|
|
471
767
|
}
|
|
472
|
-
if (pr.action === "backtrack") {
|
|
473
|
-
// Backtrack committed but prompt not delivered
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
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;
|
|
478
773
|
session.consecutiveRejections = 0;
|
|
479
774
|
session.lastRejectionCheck = "";
|
|
480
|
-
session.lastSelfEval = undefined;
|
|
481
|
-
session.currentPrompt = null;
|
|
482
775
|
this.save(session);
|
|
483
|
-
// Return null — caller (resume/unpause) will recompile
|
|
776
|
+
// Return null — caller (resume/unpause) will restore/recompile.
|
|
484
777
|
return null;
|
|
485
778
|
}
|
|
486
779
|
if (pr.action !== "continue")
|
|
@@ -493,9 +786,78 @@ export class RoundLifecycle {
|
|
|
493
786
|
if (session.currentRound <= recovered.snapshot.round) {
|
|
494
787
|
session.currentRound = recovered.snapshot.round + 1;
|
|
495
788
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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) {
|
|
499
861
|
session.status = "stalled";
|
|
500
862
|
this.save(session);
|
|
501
863
|
return {
|
|
@@ -503,123 +865,313 @@ export class RoundLifecycle {
|
|
|
503
865
|
round: session.currentRound,
|
|
504
866
|
prompt: null,
|
|
505
867
|
stopReason: "stalled",
|
|
506
|
-
stopDetail: "
|
|
868
|
+
stopDetail: "The committed rollback directive is unreadable; its history must be repaired before the restore can continue.",
|
|
507
869
|
};
|
|
508
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
|
+
}
|
|
509
1013
|
return this.persistPrepared(session, {
|
|
510
1014
|
prompt: prepared.prompt,
|
|
511
1015
|
level: prepared.level,
|
|
512
1016
|
baseline: prepared.evidenceBaseline,
|
|
513
1017
|
snapshot: prepared.snapshot,
|
|
514
|
-
warnings: prepared
|
|
515
|
-
},
|
|
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));
|
|
516
1092
|
}
|
|
517
1093
|
/** Resume tail: the session has been reconstructed and registered. Reconcile
|
|
518
1094
|
* a committed-but-undelivered round, restore the held prompt, or recover a
|
|
519
1095
|
* missing prompt from current round state. v3.7: async — compilation runs
|
|
520
1096
|
* through RoundDriver.prepare like unpause (the prepareSync fallback was
|
|
521
1097
|
* removed); a failed compile now degrades to the same stalled terminal
|
|
522
|
-
* result unpause returns instead of persisting a null prompt.
|
|
523
|
-
|
|
524
|
-
|
|
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);
|
|
525
1106
|
if (reconciled)
|
|
526
1107
|
return reconciled;
|
|
527
|
-
const
|
|
1108
|
+
const retryRecovery = await this.recompileRetryAttempt(session, inputs ?? {});
|
|
1109
|
+
if (retryRecovery)
|
|
1110
|
+
return retryRecovery;
|
|
1111
|
+
const restored = await this.heldPromptWithInputs(session, inputs);
|
|
528
1112
|
if (restored)
|
|
529
1113
|
return restored;
|
|
530
1114
|
// Missing held prompt: compile (async) once, then persist it.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
prompt:
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
};
|
|
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;
|
|
544
1127
|
}
|
|
545
1128
|
return this.persistPrepared(session, {
|
|
546
1129
|
prompt: prepared.prompt,
|
|
547
1130
|
level: prepared.level,
|
|
548
1131
|
baseline: prepared.evidenceBaseline,
|
|
549
1132
|
snapshot: prepared.snapshot,
|
|
550
|
-
warnings: prepared
|
|
551
|
-
}, false);
|
|
1133
|
+
warnings: prepared.warnings ?? [],
|
|
1134
|
+
}, false, this.resumeWarnings(session, prepared.warnings), false);
|
|
552
1135
|
}
|
|
553
1136
|
/** Unpause tail: the session has been reconstructed and registered with
|
|
554
|
-
* status "running".
|
|
555
|
-
*
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
phase: "before",
|
|
563
|
-
});
|
|
564
|
-
if (asyncEvidence.length > 0) {
|
|
565
|
-
session.evidenceBaseline = asyncEvidence;
|
|
566
|
-
if (!session.roundSnapshot?.beforeEvidence?.length) {
|
|
567
|
-
session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
catch {
|
|
572
|
-
// Async evidence is best-effort; fall back to sync baseline.
|
|
573
|
-
}
|
|
574
|
-
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);
|
|
575
1145
|
if (reconciled)
|
|
576
1146
|
return reconciled;
|
|
577
|
-
const
|
|
1147
|
+
const retryRecovery = await this.recompileRetryAttempt(session, inputs ?? {});
|
|
1148
|
+
if (retryRecovery)
|
|
1149
|
+
return retryRecovery;
|
|
1150
|
+
const restored = await this.heldPromptWithInputs(session, inputs);
|
|
578
1151
|
if (restored) {
|
|
579
1152
|
this.save(session);
|
|
580
1153
|
return restored;
|
|
581
1154
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
mode: Mode.LOOP_COMPILE,
|
|
595
|
-
feedback: null,
|
|
596
|
-
skill_name: null,
|
|
597
|
-
task_id: null,
|
|
598
|
-
loop_id: lcr.loop_id,
|
|
599
|
-
round: lcr.round,
|
|
600
|
-
goal_id: lcr.goal_id,
|
|
601
|
-
domain: lcr.domain ?? "",
|
|
602
|
-
plan_source: lcr.plan_source ?? null,
|
|
603
|
-
constraints_from_plan: lcr.constraints_from_plan ?? [],
|
|
604
|
-
max_rounds: session.maxRounds,
|
|
605
|
-
};
|
|
606
|
-
const prepared = await new RoundDriver(session.engine, this.store).prepare(compileRequest, session.loopId, session.currentRound);
|
|
607
|
-
if (!prepared) {
|
|
608
|
-
session.status = "stopped";
|
|
609
|
-
this.save(session);
|
|
610
|
-
void this.notifyTerminal(session, "stalled");
|
|
611
|
-
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;
|
|
612
1167
|
}
|
|
613
1168
|
return this.persistPrepared(session, {
|
|
614
1169
|
prompt: prepared.prompt,
|
|
615
1170
|
level: prepared.level,
|
|
616
1171
|
baseline: prepared.evidenceBaseline,
|
|
617
1172
|
snapshot: prepared.snapshot,
|
|
618
|
-
warnings: prepared
|
|
619
|
-
}, undefined,
|
|
620
|
-
// Historical contract: unpause returns an empty warnings array even
|
|
621
|
-
// though the session carries the compiled warnings.
|
|
622
|
-
[]);
|
|
1173
|
+
warnings: prepared.warnings ?? [],
|
|
1174
|
+
}, undefined, this.resumeWarnings(session, prepared.warnings), false);
|
|
623
1175
|
}
|
|
624
1176
|
/** Record a gate_opened entry when an accepted round reports a high-risk
|
|
625
1177
|
* blocker (USER_RISK hit). Idempotent per gate. The gate is a record
|
|
@@ -631,8 +1183,8 @@ export class RoundLifecycle {
|
|
|
631
1183
|
if (gate.kind !== "user")
|
|
632
1184
|
return;
|
|
633
1185
|
const taskId = `loop:${session.loopId}:gate:${id}`;
|
|
634
|
-
const existing =
|
|
635
|
-
.some((entry) => entry.task_id === taskId);
|
|
1186
|
+
const existing = loadCommittedHistory(this.store, session.loopId)
|
|
1187
|
+
.entries.some((entry) => entry.task_id === taskId);
|
|
636
1188
|
if (existing)
|
|
637
1189
|
return;
|
|
638
1190
|
const entry = {
|
|
@@ -653,7 +1205,9 @@ export class RoundLifecycle {
|
|
|
653
1205
|
}
|
|
654
1206
|
/** Undecided user gate descriptions for a loop (recorded but unresolved). */
|
|
655
1207
|
listOpenGateDescriptions(loopId) {
|
|
656
|
-
const
|
|
1208
|
+
const gatePrefix = `loop:${loopId}:gate:`;
|
|
1209
|
+
const entries = loadCommittedHistory(this.store, loopId).entries
|
|
1210
|
+
.filter((entry) => String(entry.task_id ?? "").startsWith(gatePrefix));
|
|
657
1211
|
const decided = new Set(entries.filter((entry) => entry.task_type === "gate_decision")
|
|
658
1212
|
.map((entry) => String(entry.gate_id ?? "")));
|
|
659
1213
|
return entries
|
|
@@ -670,24 +1224,21 @@ export class RoundLifecycle {
|
|
|
670
1224
|
}
|
|
671
1225
|
/** Execute the round transaction and apply per-rule rejection tracking.
|
|
672
1226
|
* MUTATES: session.roundSnapshot, session.consecutiveRejections,
|
|
673
|
-
* session.lastRejectionCheck
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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({
|
|
678
1234
|
snapshot,
|
|
679
1235
|
loopId: session.loopId,
|
|
680
1236
|
task: session.task,
|
|
681
1237
|
maxRounds: session.maxRounds,
|
|
1238
|
+
...(history ? { history } : {}),
|
|
682
1239
|
selfEval,
|
|
683
|
-
lastSelfEval: session.lastSelfEval,
|
|
684
1240
|
consecutiveRejections: session.consecutiveRejections,
|
|
685
1241
|
lastRejectionCheck: session.lastRejectionCheck,
|
|
686
|
-
successTrajectory: session.successTrajectory,
|
|
687
|
-
backtrackSkippedFiles: session.backtrackSkippedFiles,
|
|
688
|
-
backtrackSkippedFingerprints: session.backtrackSkippedFingerprints,
|
|
689
|
-
backtrackTargetGitHead: session.backtrackTargetGitHead,
|
|
690
|
-
backtrackTargetDirty: session.backtrackTargetDirty,
|
|
691
1242
|
entrypointTrust: session.entrypointTrust,
|
|
692
1243
|
normalizationDiagnostics,
|
|
693
1244
|
submittedFields,
|
|
@@ -697,14 +1248,6 @@ export class RoundLifecycle {
|
|
|
697
1248
|
session.roundSnapshot = outcome.snapshot;
|
|
698
1249
|
const pr = outcome.result;
|
|
699
1250
|
this.applyRoundCounters(session, pr);
|
|
700
|
-
// L1 (v3.7.x): never push a REPLAYED outcome onto the trajectory. When a
|
|
701
|
-
// crash/exception window left the committed round undelivered, the replay
|
|
702
|
-
// re-executes the same round in memory — its success was already recorded
|
|
703
|
-
// by the original attempt (crash recovery re-pushes through
|
|
704
|
-
// reconcileCommittedRound instead, which uses the length-vs-round guard).
|
|
705
|
-
if (pr.shouldPushSuccessTrajectory && !completed.outcome.replayed) {
|
|
706
|
-
session.successTrajectory.push(pr.roundSuccess);
|
|
707
|
-
}
|
|
708
1251
|
return {
|
|
709
1252
|
pr,
|
|
710
1253
|
verificationFlags: pr.verificationFlags,
|
|
@@ -714,168 +1257,386 @@ export class RoundLifecycle {
|
|
|
714
1257
|
}
|
|
715
1258
|
/** Build a rejection result: compile a retry prompt, persist, return.
|
|
716
1259
|
* MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
|
|
717
|
-
async buildRejectionResult(sessionId, session, pr, verificationFlags, receiptBase) {
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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 {
|
|
723
1278
|
this.save(session);
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
+
}
|
|
1284
|
+
// In-flight attempt findings are authority for THIS attempt, not
|
|
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);
|
|
1293
|
+
retryRequest.verification_flags = verificationFlags;
|
|
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;
|
|
732
1307
|
}
|
|
733
1308
|
session.roundSnapshot = preparedRetry.snapshot;
|
|
734
|
-
session.currentPrompt = preparedRetry.prompt;
|
|
735
|
-
session.currentLevel = preparedRetry.level;
|
|
736
|
-
session.currentWarnings = preparedRetry.warnings ?? [];
|
|
737
|
-
session.lastCompileResponse = preparedRetry.compileResponse ?? null;
|
|
738
1309
|
this.save(session);
|
|
1310
|
+
const retryArtifact = preparedRetry.artifact ??
|
|
1311
|
+
preparedRetry.compileResponse?.prompt_artifact ?? null;
|
|
739
1312
|
return {
|
|
740
1313
|
sessionId,
|
|
741
1314
|
round: session.currentRound,
|
|
742
|
-
|
|
1315
|
+
...this.heldHandle(session),
|
|
1316
|
+
...this.transactionFacts(session),
|
|
1317
|
+
...this.contextFacts(retryArtifact),
|
|
743
1318
|
prompt: preparedRetry.prompt,
|
|
744
1319
|
level: preparedRetry.level,
|
|
1320
|
+
agentSuccessClaim: pr.agentSuccessClaim,
|
|
1321
|
+
agentOutcome: pr.agentOutcome,
|
|
745
1322
|
enforcementAction: "reject",
|
|
746
|
-
|
|
1323
|
+
evidencePosture: pr.evidencePosture,
|
|
747
1324
|
enforcementReason: pr.enforcementReason,
|
|
748
1325
|
submission_guidance: this.guidanceFor(session, {
|
|
749
1326
|
verificationFlags: pr.verificationFlags,
|
|
750
1327
|
}),
|
|
751
1328
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
752
1329
|
disposition: "rejected",
|
|
753
|
-
nextArtifact:
|
|
754
|
-
preparedRetry.compileResponse?.prompt_artifact ?? null,
|
|
1330
|
+
nextArtifact: retryArtifact,
|
|
755
1331
|
afterSubGoals: null,
|
|
756
1332
|
constraintsActiveAfter: null,
|
|
757
1333
|
contractAfterId: receiptBase.beforeContractId,
|
|
758
1334
|
}),
|
|
759
1335
|
};
|
|
760
1336
|
}
|
|
761
|
-
/**
|
|
762
|
-
*
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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", {
|
|
777
1494
|
sessionId,
|
|
778
1495
|
loopId: session.loopId,
|
|
779
1496
|
fromRound: session.currentRound,
|
|
780
1497
|
toRound: newRound,
|
|
781
|
-
|
|
782
|
-
|
|
1498
|
+
origin: directive.origin,
|
|
1499
|
+
triggerRule: directive.trigger_rule ?? "",
|
|
1500
|
+
checkpointId: directive.target.checkpoint_id,
|
|
1501
|
+
skippedDiscoveries: directive.skipped_discoveries.length,
|
|
783
1502
|
});
|
|
784
|
-
// Reset session to the restored round
|
|
785
|
-
session.currentRound = newRound;
|
|
786
1503
|
session.consecutiveRejections = 0;
|
|
787
1504
|
session.lastRejectionCheck = "";
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
// dirty sets instead of demanding a globally clean tree.
|
|
797
|
-
session.backtrackTargetDirty = pr.backtrackTargetDirty;
|
|
798
|
-
session.lastSelfEval = undefined; // force L2 recompile from vault state
|
|
799
|
-
session.currentPrompt = null;
|
|
800
|
-
// Preserve discoveries from skipped rounds as constraints_from_plan
|
|
801
|
-
// so they survive the compilation and re-enter active constraints.
|
|
802
|
-
const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
|
|
803
|
-
// Compile the restored prompt — use a fresh request with the restored round
|
|
804
|
-
const request = {
|
|
805
|
-
task: session.task,
|
|
806
|
-
mode: Mode.LOOP_COMPILE,
|
|
807
|
-
feedback: null,
|
|
808
|
-
skill_name: null,
|
|
809
|
-
task_id: null,
|
|
810
|
-
loop_id: session.loopId,
|
|
811
|
-
round: newRound,
|
|
812
|
-
max_rounds: session.maxRounds,
|
|
813
|
-
verification_flags: pr.verificationFlags,
|
|
814
|
-
constraints_from_plan: preservedDiscoveries,
|
|
815
|
-
};
|
|
816
|
-
const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, newRound);
|
|
817
|
-
if (!prepared || !prepared.prompt) {
|
|
818
|
-
session.status = "stalled";
|
|
819
|
-
session.currentPrompt = null;
|
|
820
|
-
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) {
|
|
821
1513
|
return {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
verificationStatus: pr.verificationStatus,
|
|
829
|
-
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,
|
|
830
1520
|
};
|
|
831
1521
|
}
|
|
832
|
-
|
|
833
|
-
const backtrackHeader = pr.backtrackPrompt ?? "";
|
|
834
|
-
const fullPrompt = backtrackHeader
|
|
835
|
-
? backtrackHeader + "\n" + (prepared.prompt ?? "")
|
|
836
|
-
: prepared.prompt ?? "";
|
|
837
|
-
session.evidenceBaseline = prepared.evidenceBaseline;
|
|
1522
|
+
const { restore, prepared } = outcome;
|
|
838
1523
|
session.roundSnapshot = prepared.snapshot;
|
|
839
|
-
// v3.8.3: the rollback redo is a NEW round — `prepare` collected a fresh
|
|
840
|
-
// before capture above — so its entrypoint baseline is the workspace as it
|
|
841
|
-
// stands after the rollback, which is what lets a round that stalled on a
|
|
842
|
-
// rewritten entrypoint proceed once the entrypoint is in place.
|
|
843
1524
|
this.seedEntrypointTrust(session);
|
|
844
|
-
session.currentPrompt = fullPrompt;
|
|
845
|
-
session.currentLevel = prepared.level;
|
|
846
|
-
session.currentWarnings = prepared?.warnings ?? [];
|
|
847
|
-
session.lastCompileResponse = prepared?.compileResponse ?? null;
|
|
848
1525
|
this.save(session);
|
|
1526
|
+
const nextArtifact = prepared.artifact ??
|
|
1527
|
+
prepared.compileResponse?.prompt_artifact ?? null;
|
|
849
1528
|
return {
|
|
850
1529
|
sessionId,
|
|
851
1530
|
round: newRound,
|
|
852
|
-
|
|
853
|
-
|
|
1531
|
+
...this.heldHandle(session),
|
|
1532
|
+
...this.transactionFacts(session),
|
|
1533
|
+
...this.contextFacts(nextArtifact),
|
|
1534
|
+
prompt: prepared.prompt,
|
|
854
1535
|
level: prepared.level,
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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 ?? [],
|
|
859
1557
|
submission_guidance: this.guidanceFor(session, {
|
|
860
|
-
verificationFlags:
|
|
1558
|
+
verificationFlags: recoveryFlags,
|
|
861
1559
|
recovery: {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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,
|
|
867
1567
|
},
|
|
868
1568
|
}),
|
|
869
1569
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
870
1570
|
disposition: "backtracked",
|
|
871
|
-
nextArtifact
|
|
872
|
-
prepared.compileResponse?.prompt_artifact ?? null,
|
|
1571
|
+
nextArtifact,
|
|
873
1572
|
afterSubGoals: null,
|
|
874
1573
|
constraintsActiveAfter: null,
|
|
875
1574
|
contractAfterId: receiptBase.beforeContractId,
|
|
1575
|
+
// The SAME verified-restore fact the top-level recovery reports.
|
|
1576
|
+
restoreVerified: true,
|
|
876
1577
|
}),
|
|
877
1578
|
};
|
|
878
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
|
+
}
|
|
879
1640
|
/** Build a termination result: persist stopped status, notify sinks.
|
|
880
1641
|
* MUTATES: session.status, session.currentPrompt
|
|
881
1642
|
*
|
|
@@ -889,7 +1650,6 @@ export class RoundLifecycle {
|
|
|
889
1650
|
guardedReason, receiptBase) {
|
|
890
1651
|
const reason = guardedReason ?? pr.stopReason ?? "enforcement_terminated";
|
|
891
1652
|
session.status = "stopped";
|
|
892
|
-
session.currentPrompt = null;
|
|
893
1653
|
// v3.8.3: no round is in flight once the loop stops — the in-flight trust
|
|
894
1654
|
// baseline goes with it, so a later resume cannot compare against a state
|
|
895
1655
|
// from a previous life.
|
|
@@ -903,12 +1663,19 @@ export class RoundLifecycle {
|
|
|
903
1663
|
return {
|
|
904
1664
|
sessionId,
|
|
905
1665
|
round: session.currentRound,
|
|
906
|
-
roundId: session.roundSnapshot?.roundId,
|
|
907
1666
|
prompt: null,
|
|
908
1667
|
stopReason: reason,
|
|
909
|
-
|
|
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"),
|
|
910
1677
|
enforcementAction: "terminate",
|
|
911
|
-
|
|
1678
|
+
evidencePosture: pr.evidencePosture,
|
|
912
1679
|
enforcementReason: pr.enforcementReason,
|
|
913
1680
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
914
1681
|
disposition: "terminated",
|
|
@@ -927,7 +1694,6 @@ export class RoundLifecycle {
|
|
|
927
1694
|
guardedReason, receiptBase) {
|
|
928
1695
|
const reason = guardedReason ?? pr.stopReason ?? "stalled";
|
|
929
1696
|
session.status = reason === "stalled" ? "stalled" : "stopped";
|
|
930
|
-
session.currentPrompt = null;
|
|
931
1697
|
// v3.8.3: see buildTerminationResult — the in-flight trust baseline dies
|
|
932
1698
|
// with the in-flight round.
|
|
933
1699
|
session.entrypointTrust = undefined;
|
|
@@ -936,12 +1702,18 @@ export class RoundLifecycle {
|
|
|
936
1702
|
logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
|
|
937
1703
|
return {
|
|
938
1704
|
sessionId, round: session.currentRound,
|
|
939
|
-
roundId: session.roundSnapshot?.roundId,
|
|
940
1705
|
prompt: null, stopReason: reason,
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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,
|
|
945
1717
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
946
1718
|
disposition: "stopped",
|
|
947
1719
|
nextArtifact: null,
|
|
@@ -953,8 +1725,8 @@ export class RoundLifecycle {
|
|
|
953
1725
|
* Includes the commit fence (pause/delete race guard) and context provider.
|
|
954
1726
|
* MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
|
|
955
1727
|
* session.evidenceBaseline, session.roundSnapshot */
|
|
956
|
-
async advanceToNextRound(sessionId, session, selfEval, pr,
|
|
957
|
-
const
|
|
1728
|
+
async advanceToNextRound(sessionId, session, selfEval, pr, actualEvidence, receiptBase, inputs) {
|
|
1729
|
+
const agentSuccessClaim = pr.agentSuccessClaim;
|
|
958
1730
|
// M4 (v3.7.x): replay guard. The counter may already point past the
|
|
959
1731
|
// committed round when this continue is a REPLAY — after a crash or an
|
|
960
1732
|
// in-process exception between the round's commit and this compile, the
|
|
@@ -965,53 +1737,23 @@ export class RoundLifecycle {
|
|
|
965
1737
|
if (!session.roundSnapshot || session.currentRound <= session.roundSnapshot.round) {
|
|
966
1738
|
session.currentRound++;
|
|
967
1739
|
}
|
|
968
|
-
|
|
969
|
-
// v2.13: Clear backtrack skipped files after advancing past the restore round
|
|
970
|
-
session.backtrackSkippedFiles = [];
|
|
971
|
-
session.backtrackSkippedFingerprints = {};
|
|
972
|
-
// v2.12: The restore-point git HEAD check only applies to the first
|
|
973
|
-
// post-backtrack round — clear it once we advance past it.
|
|
974
|
-
session.backtrackTargetGitHead = undefined;
|
|
975
|
-
session.backtrackTargetDirty = undefined;
|
|
976
|
-
// v1.8: Memory injection — tier-aware external context.
|
|
977
|
-
const contextProvider = this.getContext();
|
|
978
|
-
let externalCtx = "";
|
|
979
|
-
if (contextProvider) {
|
|
980
|
-
try {
|
|
981
|
-
externalCtx = (await contextProvider({
|
|
982
|
-
loopId: session.loopId,
|
|
983
|
-
round: session.currentRound,
|
|
984
|
-
task: session.task,
|
|
985
|
-
domain: "",
|
|
986
|
-
lastEvaluation: selfEval,
|
|
987
|
-
})).trim();
|
|
988
|
-
}
|
|
989
|
-
catch {
|
|
990
|
-
logEvent("context_provider_error", {
|
|
991
|
-
loopId: session.loopId,
|
|
992
|
-
round: session.currentRound,
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
// Commit fence: pause()/delete() may have run while the context provider
|
|
997
|
-
// was awaited. Do not compile if the session is no longer running.
|
|
1740
|
+
// Commit fence: pause()/delete() may run while prepare is awaited.
|
|
998
1741
|
if (this.registry.get(sessionId) !== session || session.status !== "running") {
|
|
999
1742
|
return {
|
|
1000
1743
|
sessionId,
|
|
1001
1744
|
round: session.currentRound,
|
|
1002
1745
|
prompt: null,
|
|
1003
|
-
|
|
1004
|
-
stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the
|
|
1005
|
-
|
|
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,
|
|
1006
1749
|
};
|
|
1007
1750
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
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);
|
|
1012
1754
|
let prepared;
|
|
1013
1755
|
try {
|
|
1014
|
-
prepared = await
|
|
1756
|
+
prepared = await this.driver(session.engine, session.loopId).prepare(request, session.loopId, session.currentRound, this.prepareOptions(session));
|
|
1015
1757
|
}
|
|
1016
1758
|
catch (error) {
|
|
1017
1759
|
// M4: a post-commit compile exception must not silently strand the
|
|
@@ -1024,31 +1766,38 @@ export class RoundLifecycle {
|
|
|
1024
1766
|
round: session.currentRound,
|
|
1025
1767
|
error: String(error),
|
|
1026
1768
|
});
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
return this.stalledAfterCommit(sessionId, session,
|
|
1037
|
-
|
|
1038
|
-
|
|
1769
|
+
if (error instanceof ProjectionCoverageError) {
|
|
1770
|
+
// v3.9.4: the failure is a projection code defect, not a work-quality
|
|
1771
|
+
// verdict. Keep the committed round held and surface the stable code.
|
|
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.`);
|
|
1777
|
+
}
|
|
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
|
+
};
|
|
1039
1794
|
}
|
|
1040
1795
|
const nextPrompt = prepared.prompt;
|
|
1041
1796
|
const nextLevel = prepared.level;
|
|
1042
|
-
const nextBaseline = prepared.evidenceBaseline ?? actualEvidence;
|
|
1043
|
-
session.evidenceBaseline = nextBaseline;
|
|
1044
1797
|
session.roundSnapshot = prepared.snapshot;
|
|
1045
1798
|
// v3.8.3: the round that just committed is history; the trust baseline
|
|
1046
1799
|
// moves to the round being prepared now.
|
|
1047
1800
|
this.seedEntrypointTrust(session);
|
|
1048
|
-
session.currentPrompt = nextPrompt;
|
|
1049
|
-
session.currentLevel = nextLevel;
|
|
1050
|
-
session.currentWarnings = prepared.warnings ?? [];
|
|
1051
|
-
session.lastCompileResponse = prepared.compileResponse ?? null;
|
|
1052
1801
|
policyMetrics.recordStrategy(session.loopId, nextLevel);
|
|
1053
1802
|
try {
|
|
1054
1803
|
this.save(session);
|
|
@@ -1062,35 +1811,39 @@ export class RoundLifecycle {
|
|
|
1062
1811
|
round: session.currentRound,
|
|
1063
1812
|
error: String(error),
|
|
1064
1813
|
});
|
|
1065
|
-
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)}. ` +
|
|
1066
1815
|
`Resume the loop to recover the held prompt.`);
|
|
1067
1816
|
}
|
|
1068
1817
|
const compiled = prepared.compileResponse;
|
|
1818
|
+
const nextArtifact = prepared.artifact ?? compiled?.prompt_artifact ?? null;
|
|
1069
1819
|
return {
|
|
1070
1820
|
sessionId,
|
|
1071
1821
|
round: session.currentRound,
|
|
1072
|
-
|
|
1822
|
+
...this.heldHandle(session),
|
|
1823
|
+
...this.transactionFacts(session),
|
|
1824
|
+
...this.contextFacts(nextArtifact),
|
|
1073
1825
|
prompt: nextPrompt,
|
|
1074
1826
|
level: nextLevel,
|
|
1075
|
-
|
|
1827
|
+
agentSuccessClaim,
|
|
1828
|
+
agentOutcome: pr.agentOutcome,
|
|
1076
1829
|
warnings: prepared.warnings ?? [],
|
|
1077
1830
|
submission_guidance: this.guidanceFor(session),
|
|
1078
1831
|
submission_receipt: this.buildReceipt(receiptBase, {
|
|
1079
1832
|
disposition: "accepted",
|
|
1080
|
-
nextArtifact
|
|
1833
|
+
nextArtifact,
|
|
1081
1834
|
afterSubGoals: compiled?.sub_goals ?? null,
|
|
1082
1835
|
constraintsActiveAfter: compiled?.constraints_active ?? null,
|
|
1836
|
+
constraintsRetiredAfter: compiled?.constraints_retired ?? null,
|
|
1083
1837
|
}),
|
|
1084
1838
|
};
|
|
1085
1839
|
}
|
|
1086
1840
|
/** M4: mark a session stalled after its round committed but the next
|
|
1087
|
-
* prompt could not be prepared/persisted
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
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) {
|
|
1092
1846
|
session.status = "stalled";
|
|
1093
|
-
session.currentPrompt = null;
|
|
1094
1847
|
try {
|
|
1095
1848
|
this.save(session);
|
|
1096
1849
|
}
|
|
@@ -1104,117 +1857,155 @@ export class RoundLifecycle {
|
|
|
1104
1857
|
return {
|
|
1105
1858
|
sessionId,
|
|
1106
1859
|
round: session.currentRound,
|
|
1107
|
-
roundId: session.roundSnapshot?.roundId,
|
|
1108
1860
|
prompt: null,
|
|
1109
1861
|
stopReason: "stalled",
|
|
1110
1862
|
stopDetail: detail,
|
|
1111
|
-
|
|
1863
|
+
agentSuccessClaim,
|
|
1864
|
+
...(projectionError ? { projectionError } : {}),
|
|
1112
1865
|
};
|
|
1113
1866
|
}
|
|
1114
|
-
async advanceUnlocked(sessionId, output, preExtractedEval,
|
|
1867
|
+
async advanceUnlocked(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields, inputs) {
|
|
1115
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.
|
|
1116
1873
|
const session = this.registry.get(sessionId);
|
|
1117
|
-
if (!session)
|
|
1118
|
-
return {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
+
};
|
|
1126
1887
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// is stale — the round it reported on already committed (lost response,
|
|
1130
|
-
// duplicate call). Never process it against the current round; return the
|
|
1131
|
-
// held prompt so the agent recovers the response it missed. The reject
|
|
1132
|
-
// path keeps the same roundId (prepareRejectedAttempt), so retries of a
|
|
1133
|
-
// rejected round match normally.
|
|
1134
|
-
const expectedRoundId = session.roundSnapshot?.roundId
|
|
1135
|
-
?? makeRoundId(session.loopId, session.currentRound);
|
|
1136
|
-
if (roundId !== undefined && roundId !== expectedRoundId) {
|
|
1137
|
-
logEvent("stale_round_id", {
|
|
1888
|
+
if (session.status !== "running" && session.status !== "stalled") {
|
|
1889
|
+
return {
|
|
1138
1890
|
sessionId,
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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)) {
|
|
1153
1909
|
return {
|
|
1154
1910
|
sessionId,
|
|
1155
1911
|
round: session.currentRound,
|
|
1156
|
-
roundId: expectedRoundId,
|
|
1157
1912
|
prompt: null,
|
|
1158
|
-
stopReason: "
|
|
1159
|
-
|
|
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
|
+
},
|
|
1160
1923
|
};
|
|
1161
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
|
+
}
|
|
1162
1930
|
// ── 2. Extract self-evaluation ───────────────────────────────────────
|
|
1163
1931
|
const extracted = this.extractEvaluation(output, preExtractedEval);
|
|
1164
1932
|
if (!extracted) {
|
|
1165
1933
|
return {
|
|
1166
1934
|
sessionId,
|
|
1167
1935
|
round: session.currentRound,
|
|
1168
|
-
|
|
1169
|
-
?? makeRoundId(session.loopId, session.currentRound),
|
|
1936
|
+
...this.heldHandle(session),
|
|
1170
1937
|
prompt: null,
|
|
1171
|
-
|
|
1172
|
-
|
|
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.",
|
|
1173
1949
|
};
|
|
1174
1950
|
}
|
|
1175
1951
|
const selfEval = extracted;
|
|
1176
1952
|
// ── 3. Execute round transaction ─────────────────────────────────────
|
|
1177
1953
|
// Capture the before facts from the shared compile window BEFORE the
|
|
1178
1954
|
// transaction commits (a replay sees the same window: rounds < R).
|
|
1179
|
-
const beforeFacts = this.beforeFactsFor(session);
|
|
1180
|
-
const tx = await this.executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields);
|
|
1181
|
-
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;
|
|
1182
1966
|
const receiptBase = {
|
|
1183
1967
|
snapshot: receiptSnapshot,
|
|
1184
|
-
selfEval,
|
|
1968
|
+
selfEval: receiptSelfEval,
|
|
1185
1969
|
pr: tx.pr,
|
|
1186
|
-
actualEvidence: tx.
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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 ?? []),
|
|
1190
1980
|
beforeContractId: beforeFacts.contractId,
|
|
1191
1981
|
beforeSubGoals: beforeFacts.subGoals,
|
|
1982
|
+
recoveryDirective: beforeFacts.recoveryDirective,
|
|
1192
1983
|
replayed: tx.replayed,
|
|
1193
1984
|
};
|
|
1194
1985
|
// v2.12: Record a user gate when an accepted round reports a high-risk
|
|
1195
1986
|
// blocker. Record layer only — never blocks the round decision flow.
|
|
1196
1987
|
if (tx.pr.action !== "reject" && tx.pr.action !== "terminate" &&
|
|
1197
|
-
|
|
1198
|
-
|
|
1988
|
+
declaresBlocked(receiptSelfEval) &&
|
|
1989
|
+
typeof receiptSelfEval.blocker === "string") {
|
|
1990
|
+
this.recordGateFromBlocked(session, receiptSelfEval.blocker);
|
|
1199
1991
|
}
|
|
1200
1992
|
// ── 4. Route disposition ─────────────────────────────────────────────
|
|
1201
1993
|
if (tx.pr.action === "reject") {
|
|
1202
|
-
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase);
|
|
1994
|
+
return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase, inputs);
|
|
1203
1995
|
}
|
|
1204
|
-
// ── v2.10:
|
|
1205
|
-
if (tx.pr.action === "backtrack") {
|
|
1206
|
-
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);
|
|
1207
1999
|
}
|
|
1208
2000
|
// Accepted rounds advance the before-snapshot baseline. Rejected rounds
|
|
1209
2001
|
// retain the original baseline so their retry remains zero-commit.
|
|
1210
|
-
session.evidenceBaseline = tx.actualEvidence;
|
|
1211
2002
|
// v3.8.3: completion truth — the second line, at the point the disposition
|
|
1212
2003
|
// becomes the reported stop reason. The coordinator already guards the
|
|
1213
2004
|
// reason it COMMITS (the round document carries result.stopReason), so
|
|
1214
2005
|
// this guards what the client and the terminal notification see. A
|
|
1215
2006
|
// violation can only be a code defect: it degrades to the honest reason
|
|
1216
2007
|
// and leaves an observability record instead of throwing mid-lifecycle.
|
|
1217
|
-
const finalized = finalizeStopReason(tx.pr.stopReason,
|
|
2008
|
+
const finalized = finalizeStopReason(tx.pr.stopReason, receiptSelfEval, undefined, receiptSelfEval.round_contract?.supersedes ?? null);
|
|
1218
2009
|
if (finalized.reason !== tx.pr.stopReason) {
|
|
1219
2010
|
logEvent("completion_truth_downgraded", {
|
|
1220
2011
|
loopId: session.loopId,
|
|
@@ -1230,31 +2021,295 @@ export class RoundLifecycle {
|
|
|
1230
2021
|
return this.buildStopResult(sessionId, session, tx.pr, finalized.reason, receiptBase);
|
|
1231
2022
|
}
|
|
1232
2023
|
// ── 5. Continue — compile next round ─────────────────────────────────
|
|
1233
|
-
return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.
|
|
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
|
+
};
|
|
1234
2275
|
}
|
|
1235
2276
|
/** Core cycle entry used by SessionManager.advance after the queue + lease
|
|
1236
2277
|
* dance: validate → extract → execute transaction → route disposition
|
|
1237
2278
|
* → advance to next round (continue) or return terminal result.
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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);
|
|
1244
2286
|
}
|
|
1245
2287
|
/** Write back loop knowledge to long-term memory.
|
|
1246
2288
|
* Uses shared base builder from policy.ts. Called when a loop terminates. */
|
|
1247
2289
|
async notifyTerminal(session, stopReason) {
|
|
1248
2290
|
if (this.terminalSinks.size === 0)
|
|
1249
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);
|
|
1250
2299
|
const event = {
|
|
1251
2300
|
loopId: session.loopId,
|
|
1252
2301
|
task: session.task,
|
|
1253
|
-
|
|
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,
|
|
1254
2306
|
stopReason: stopReason,
|
|
2307
|
+
// v4.0.4: keep the historical alias, but state the two facts separately.
|
|
1255
2308
|
roundsCompleted: session.currentRound,
|
|
1256
|
-
|
|
1257
|
-
|
|
2309
|
+
roundCursor: session.currentRound,
|
|
2310
|
+
committedRounds: views.length,
|
|
2311
|
+
machineBackedSuccessTrajectory,
|
|
2312
|
+
lastEvaluation: lastView?.evaluation ?? undefined,
|
|
1258
2313
|
};
|
|
1259
2314
|
await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
|
|
1260
2315
|
}
|