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
package/dist/mcp/session.js
CHANGED
|
@@ -15,28 +15,31 @@
|
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { LoopForgeEngine } from "../engine.js";
|
|
17
17
|
import { canonicalizeGateAction, deriveGate, preflightStructuredGate, } from "../cognitive-governance.js";
|
|
18
|
-
import { makeGateDecision } from "../protocol.js";
|
|
19
|
-
import {
|
|
20
|
-
import { deriveCognitiveFacts } from "../cognitive-facts.js";
|
|
21
|
-
import { NO_IN_FLIGHT_ROUND, deriveRoundFacts } from "../round-facts.js";
|
|
22
|
-
import { listVerifiedClaims } from "../evidence-claims.js";
|
|
18
|
+
import { makeGateDecision, makeLoopObjective } from "../protocol.js";
|
|
19
|
+
import { loadCommittedHistory } from "../history.js";
|
|
23
20
|
import { buildAudit } from "../audit.js";
|
|
24
21
|
import { buildExplain } from "../explain.js";
|
|
25
22
|
import { CHECK_CONTRACT_ITEMS_UNVERIFIED } from "../verification-gate.js";
|
|
26
|
-
import { readOnlyRounds } from "../committed-round.js";
|
|
27
23
|
import { deriveCommittedSubmissionView } from "../submission-receipt.js";
|
|
28
24
|
import { getPolicy, validateLoopId } from "../policy.js";
|
|
29
|
-
import { isRecord } from "../token-utils.js";
|
|
25
|
+
import { isRecord, unique } from "../token-utils.js";
|
|
30
26
|
import { ReplayBackend } from "../replay.js";
|
|
31
|
-
import { FileLoopStore
|
|
32
|
-
import {
|
|
27
|
+
import { FileLoopStore } from "../loop-store.js";
|
|
28
|
+
import { MAX_MID_CHECKPOINTS_PER_TRANSACTION, } from "../round-transaction.js";
|
|
29
|
+
import { findCheckpointRef } from "../recovery.js";
|
|
30
|
+
import { buildCheckpointRef, CheckpointError, FileWorkspaceCheckpointStore, } from "../workspace-checkpoint.js";
|
|
31
|
+
import { resolveStateDirectory } from "../policy.js";
|
|
32
|
+
import { resolve, sep } from "node:path";
|
|
33
33
|
import { RoundDriver } from "../round-driver.js";
|
|
34
34
|
import { logEvent } from "../observability.js";
|
|
35
|
+
import { CompileFailureError, LegacyHistoryError, LoopExistsError, StartFailureError, } from "../errors.js";
|
|
35
36
|
import { policyMetrics, derivePolicyMetrics, mergePolicyMetrics, } from "../policy-metrics.js";
|
|
36
37
|
import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
|
|
37
38
|
import { StorageCorruptionError } from "../loop-store.js";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
39
|
+
import { LOST_HANDLE_ROUTING } from "../recovery-guidance.js";
|
|
40
|
+
import { MANUAL_STOP_REASON, RoundLifecycle, buildLoopRequest, normalizeInvocationInputs, normalizeStartCriteria, } from "./round-lifecycle.js";
|
|
41
|
+
import { activeContractOf, validateSubmission, } from "./submission-boundary.js";
|
|
42
|
+
import { makeSubmissionHandle, parseSubmissionHandle, staleHandleWarning, validateSubmissionHandle, } from "./submission-handle.js";
|
|
40
43
|
// ── SessionManager ─────────────────────────────────────────────────────────
|
|
41
44
|
export class SessionManager {
|
|
42
45
|
sessions = new Map();
|
|
@@ -49,12 +52,23 @@ export class SessionManager {
|
|
|
49
52
|
leaseRenewIntervalMs;
|
|
50
53
|
leaseTimer = null;
|
|
51
54
|
lifecycle;
|
|
52
|
-
/** Explicit context provider; never auto-discovered. */
|
|
53
|
-
contextProvider;
|
|
54
55
|
terminalSinks = new Set();
|
|
55
|
-
|
|
56
|
+
checkpointStoreFor;
|
|
57
|
+
constructor(store, sessionStore, checkpointStoreFor) {
|
|
56
58
|
this.loopStore = store ?? new FileLoopStore(getPolicy().backend.root_dir);
|
|
57
59
|
this.sessionStore = sessionStore ?? new VaultSessionStateStore(this.loopStore);
|
|
60
|
+
const stores = new Map();
|
|
61
|
+
const fileStore = this.loopStore instanceof FileLoopStore ? this.loopStore : undefined;
|
|
62
|
+
this.checkpointStoreFor = checkpointStoreFor ?? (fileStore
|
|
63
|
+
? (loopId) => {
|
|
64
|
+
let storeForLoop = stores.get(loopId);
|
|
65
|
+
if (!storeForLoop) {
|
|
66
|
+
storeForLoop = new FileWorkspaceCheckpointStore(fileStore.loopDir(loopId), loopId);
|
|
67
|
+
stores.set(loopId, storeForLoop);
|
|
68
|
+
}
|
|
69
|
+
return storeForLoop;
|
|
70
|
+
}
|
|
71
|
+
: undefined);
|
|
58
72
|
const mcpPolicy = getPolicy().mcp;
|
|
59
73
|
this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
|
|
60
74
|
this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
|
|
@@ -65,7 +79,9 @@ export class SessionManager {
|
|
|
65
79
|
terminalSinks: this.terminalSinks,
|
|
66
80
|
ownerId: this.ownerId,
|
|
67
81
|
leaseMs: this.leaseMs,
|
|
68
|
-
|
|
82
|
+
...(this.checkpointStoreFor
|
|
83
|
+
? { checkpointStoreFor: this.checkpointStoreFor }
|
|
84
|
+
: {}),
|
|
69
85
|
});
|
|
70
86
|
if (this.sessionStore?.renewLease) {
|
|
71
87
|
this.leaseTimer = setInterval(() => this.renewOwnedLeases(), this.leaseRenewIntervalMs);
|
|
@@ -101,6 +117,15 @@ export class SessionManager {
|
|
|
101
117
|
findSessionEntry(loopId) {
|
|
102
118
|
return this.sessionStore?.load(loopId);
|
|
103
119
|
}
|
|
120
|
+
/** Whether the loop already has durable round history. A lineage-only round
|
|
121
|
+
* document from a failed start is NOT history and must remain retryable;
|
|
122
|
+
* only a committed feedback envelope counts. */
|
|
123
|
+
hasCommittedRounds(loopId) {
|
|
124
|
+
const history = loadCommittedHistory(this.loopStore, loopId);
|
|
125
|
+
return history.rounds.length > 0 ||
|
|
126
|
+
history.directives.length > 0 ||
|
|
127
|
+
history.legacyRounds.length > 0;
|
|
128
|
+
}
|
|
104
129
|
claimSessionEntry(loopId) {
|
|
105
130
|
if (this.sessionStore?.acquireLease) {
|
|
106
131
|
return this.sessionStore.acquireLease(loopId, this.ownerId, this.leaseMs);
|
|
@@ -125,12 +150,19 @@ export class SessionManager {
|
|
|
125
150
|
}
|
|
126
151
|
leaseConflictResult(loopId, entry) {
|
|
127
152
|
const lineage = (entry?.loop_lineage ?? {});
|
|
153
|
+
const owner = entry?.loop_lineage
|
|
154
|
+
? entry.loop_lineage.lease_owner ?? "unknown"
|
|
155
|
+
: "unknown";
|
|
128
156
|
return {
|
|
129
157
|
sessionId: "",
|
|
130
158
|
round: typeof lineage.current_round === "number" ? lineage.current_round : 0,
|
|
131
159
|
prompt: null,
|
|
132
|
-
|
|
133
|
-
|
|
160
|
+
stopDetail: `Another process (PID ${String(owner)}) holds the lease for loop ` +
|
|
161
|
+
`"${loopId}". Wait for the lease to expire or stop the other process.`,
|
|
162
|
+
submissionError: {
|
|
163
|
+
code: "lease_conflict",
|
|
164
|
+
details: { loopId, owner: String(owner) },
|
|
165
|
+
},
|
|
134
166
|
};
|
|
135
167
|
}
|
|
136
168
|
async withSessionQueue(sessionId, work) {
|
|
@@ -163,21 +195,12 @@ export class SessionManager {
|
|
|
163
195
|
// state and leave two in-memory sessions pointing at one loop.
|
|
164
196
|
const existing = [...this.sessions.values()].find((s) => s.loopId === loopId);
|
|
165
197
|
if (existing) {
|
|
166
|
-
|
|
167
|
-
sessionId: "",
|
|
168
|
-
round: existing.currentRound,
|
|
169
|
-
prompt: null,
|
|
170
|
-
stopReason: `loop_already_running:${loopId}`,
|
|
171
|
-
stopDetail: `A session for loop "${loopId}" already exists in this process (session ${existing.sessionId}). Inspect or resume it instead of starting a duplicate.`,
|
|
172
|
-
};
|
|
198
|
+
throw new LoopExistsError(loopId, "active");
|
|
173
199
|
}
|
|
174
|
-
// v3.2.1: pre-flight the cross-process lease BEFORE compiling
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// (VaultSessionStateStore.save: a non-empty lease_owner that is not ours
|
|
179
|
-
// conflicts), so observable behavior is unchanged — only the side
|
|
180
|
-
// effects are avoided. The save below remains the atomic final gate.
|
|
200
|
+
// v3.2.1: pre-flight the cross-process lease BEFORE compiling. A compile
|
|
201
|
+
// no longer writes round history, but it still writes the derived state
|
|
202
|
+
// file and would waste evidence work against another process's loop.
|
|
203
|
+
// The save below remains the atomic final gate.
|
|
181
204
|
const persistedSession = this.sessionStore?.load(loopId);
|
|
182
205
|
const persistedLineage = persistedSession?.loop_lineage;
|
|
183
206
|
const persistedOwner = persistedLineage && typeof persistedLineage === "object" && !Array.isArray(persistedLineage)
|
|
@@ -186,76 +209,93 @@ export class SessionManager {
|
|
|
186
209
|
if (typeof persistedOwner === "string" && persistedOwner && persistedOwner !== this.ownerId) {
|
|
187
210
|
throw new SessionLeaseConflictError(loopId);
|
|
188
211
|
}
|
|
212
|
+
// A persisted session in ANY status, or any committed round document,
|
|
213
|
+
// means this loopId already names durable history. Starting a new round 1
|
|
214
|
+
// would overwrite it. The atomic `expectAbsent` save below is the final
|
|
215
|
+
// gate; this preflight avoids compiling and collecting evidence first.
|
|
216
|
+
if (persistedSession) {
|
|
217
|
+
const persistedStatus = isRecord(persistedLineage)
|
|
218
|
+
? persistedLineage.status
|
|
219
|
+
: undefined;
|
|
220
|
+
throw new LoopExistsError(loopId, persistedStatus === "stopped" ? "stopped" : "session");
|
|
221
|
+
}
|
|
222
|
+
if (this.hasCommittedRounds(loopId)) {
|
|
223
|
+
throw new LoopExistsError(loopId, "rounds");
|
|
224
|
+
}
|
|
225
|
+
// v3.9.4: normalize initial criteria before any runtime object is created.
|
|
226
|
+
// A structural defect throws StartCriteriaError (mapped to
|
|
227
|
+
// invalid_argument); empty/duplicate/over-cap entries produce warnings.
|
|
228
|
+
const startCriteria = normalizeStartCriteria(input.criteria);
|
|
189
229
|
const engine = new LoopForgeEngine(this.loopStore);
|
|
190
230
|
const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
|
|
191
|
-
|
|
231
|
+
const operatorAcceptance = {
|
|
232
|
+
task: input.task,
|
|
233
|
+
success_criteria: startCriteria.criteria.length > 0
|
|
234
|
+
? [...startCriteria.criteria]
|
|
235
|
+
: ["Task goal achieved with verifiable evidence"],
|
|
236
|
+
hard_constraints: unique(input.constraints ?? []),
|
|
237
|
+
};
|
|
238
|
+
// Populate the typed request for the first round. The operator surface is
|
|
239
|
+
// the one acceptance object; the objective is derived from it.
|
|
192
240
|
const request = buildLoopRequest({
|
|
193
241
|
sessionId, loopId, task: input.task, engine, currentRound: 1,
|
|
194
|
-
maxRounds,
|
|
242
|
+
maxRounds, status: "running", createdAt: Date.now(),
|
|
195
243
|
consecutiveRejections: 0,
|
|
196
244
|
lastRejectionCheck: "",
|
|
197
|
-
backtrackSkippedFiles: [],
|
|
198
|
-
backtrackSkippedFingerprints: {},
|
|
199
|
-
evidenceBaseline: [],
|
|
200
245
|
});
|
|
201
|
-
request.domain = input.domain ?? "";
|
|
202
246
|
request.plan_source = input.planSource ?? null;
|
|
203
247
|
request.constraints_from_plan = input.constraints ?? [];
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
248
|
+
// v4.0.5: the start task IS the initial invocation instruction. The
|
|
249
|
+
// prompt de-duplicates it against Current Task/Objective, but the round-1
|
|
250
|
+
// artifact records it. Later invocations only receive an instruction the
|
|
251
|
+
// host supplies explicitly.
|
|
252
|
+
if (input.task.trim()) {
|
|
253
|
+
request.current_user_instruction = input.task.trim();
|
|
254
|
+
}
|
|
255
|
+
request.loop_objective = makeLoopObjective({
|
|
256
|
+
objective: operatorAcceptance.task,
|
|
257
|
+
success_criteria: [...operatorAcceptance.success_criteria],
|
|
258
|
+
hard_constraints: [...operatorAcceptance.hard_constraints],
|
|
259
|
+
created_at_round: 1,
|
|
260
|
+
loop_id: loopId,
|
|
261
|
+
});
|
|
262
|
+
let prepared;
|
|
263
|
+
try {
|
|
264
|
+
prepared = await new RoundDriver(engine, this.loopStore, this.checkpointStoreFor?.(loopId)).prepare(request, loopId, 1, { operatorAcceptance });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (error instanceof CompileFailureError) {
|
|
268
|
+
throw new StartFailureError(error.message);
|
|
225
269
|
}
|
|
270
|
+
throw error;
|
|
226
271
|
}
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
const initialLevel = prepared?.level ?? "l2";
|
|
230
|
-
const evidenceBaseline = prepared?.evidenceBaseline ?? [];
|
|
272
|
+
const initialPrompt = prepared.prompt;
|
|
273
|
+
const initialLevel = prepared.level;
|
|
231
274
|
const session = {
|
|
232
275
|
sessionId, loopId, task: input.task, engine,
|
|
233
|
-
currentRound: 1, maxRounds,
|
|
276
|
+
currentRound: 1, maxRounds,
|
|
234
277
|
status: "running", createdAt: Date.now(),
|
|
235
278
|
// v1.13: Enforcement gate state
|
|
236
279
|
consecutiveRejections: 0,
|
|
237
280
|
lastRejectionCheck: "",
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
evidenceBaseline,
|
|
241
|
-
roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
|
|
242
|
-
currentPrompt: initialPrompt,
|
|
243
|
-
currentLevel: initialLevel,
|
|
244
|
-
currentWarnings: prepared?.warnings ?? [],
|
|
245
|
-
lastCompileResponse: prepared?.compileResponse ?? null,
|
|
281
|
+
operatorAcceptance,
|
|
282
|
+
roundSnapshot: prepared.snapshot,
|
|
246
283
|
};
|
|
284
|
+
// v4.0.3: seed the round-1 trust baseline BEFORE the first prompt is
|
|
285
|
+
// delivered, so the first round already has the absolute entrypoint arm.
|
|
286
|
+
this.lifecycle.seedEntrypointTrust(session);
|
|
247
287
|
this.sessions.set(sessionId, session);
|
|
248
288
|
policyMetrics.recordStrategy(loopId, initialLevel);
|
|
249
289
|
// Persist to vault for cross-process recovery. The save is the atomic
|
|
250
|
-
// lease gate — a TOCTOU window between the pre-flight
|
|
251
|
-
// this write can still lose the
|
|
290
|
+
// lease/existence gate — a TOCTOU window between the pre-flight checks
|
|
291
|
+
// above and this write can still lose the race to another process.
|
|
252
292
|
try {
|
|
253
|
-
this.lifecycle.save(session);
|
|
293
|
+
this.lifecycle.save(session, { expectAbsent: true });
|
|
254
294
|
}
|
|
255
295
|
catch (error) {
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
296
|
+
// Roll back the in-memory registration: a registered-but-unsaved
|
|
297
|
+
// session would make every later create for this loopId fail until
|
|
298
|
+
// this process restarts.
|
|
259
299
|
this.sessions.delete(sessionId);
|
|
260
300
|
throw error;
|
|
261
301
|
}
|
|
@@ -265,14 +305,37 @@ export class SessionManager {
|
|
|
265
305
|
task: input.task.slice(0, 80),
|
|
266
306
|
maxRounds,
|
|
267
307
|
});
|
|
308
|
+
const snapshot = session.roundSnapshot;
|
|
309
|
+
const handle = makeSubmissionHandle(session);
|
|
268
310
|
return {
|
|
269
311
|
sessionId,
|
|
312
|
+
loopId,
|
|
270
313
|
round: 1,
|
|
271
|
-
|
|
314
|
+
...(handle ? { submissionHandle: handle } : {}),
|
|
315
|
+
// v4.0.2: start is a prompt-bearing result like next/resume; the
|
|
316
|
+
// boundary is projected from the same compiled artifact.
|
|
317
|
+
...(snapshot?.promptArtifact
|
|
318
|
+
? {
|
|
319
|
+
contextMode: snapshot.promptArtifact.contextMode,
|
|
320
|
+
contextReason: snapshot.promptArtifact.contextReason,
|
|
321
|
+
}
|
|
322
|
+
: {}),
|
|
323
|
+
...(snapshot?.beforeCheckpoint
|
|
324
|
+
? { checkpoints: [snapshot.beforeCheckpoint] }
|
|
325
|
+
: {}),
|
|
326
|
+
...(snapshot?.beforeCheckpointError
|
|
327
|
+
? {
|
|
328
|
+
beforeCheckpointError: {
|
|
329
|
+
code: snapshot.beforeCheckpointError.code,
|
|
330
|
+
detail: snapshot.beforeCheckpointError.detail,
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
: {}),
|
|
272
334
|
prompt: initialPrompt,
|
|
273
335
|
level: initialLevel,
|
|
274
|
-
|
|
275
|
-
|
|
336
|
+
agentSuccessClaim: false,
|
|
337
|
+
agentOutcome: null,
|
|
338
|
+
warnings: [...startCriteria.warnings, ...(prepared.warnings ?? [])],
|
|
276
339
|
submission_guidance: this.lifecycle.guidanceFor(session),
|
|
277
340
|
};
|
|
278
341
|
}
|
|
@@ -282,28 +345,55 @@ export class SessionManager {
|
|
|
282
345
|
const session = this.sessions.get(sessionId);
|
|
283
346
|
return session ? this.lifecycle.guidanceFor(session) : null;
|
|
284
347
|
}
|
|
285
|
-
/**
|
|
286
|
-
*
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
348
|
+
/** v4.0.0: the current held handle, derived on demand. A read-only
|
|
349
|
+
* projection; never persisted. */
|
|
350
|
+
getSubmissionHandle(sessionId) {
|
|
351
|
+
return makeSubmissionHandle(this.sessions.get(sessionId));
|
|
352
|
+
}
|
|
353
|
+
/** v3.9.1: the most recent committed round's receipt for a loop, rebuilt
|
|
354
|
+
* from committed history. Rejected/terminated attempts are not history;
|
|
355
|
+
* their receipt exists only on their own response. This intentionally takes
|
|
356
|
+
* the durable loopId: it never reads live-session state. */
|
|
357
|
+
getLastSubmissionReceiptByLoopId(loopId) {
|
|
358
|
+
if (!loopId)
|
|
291
359
|
return null;
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
360
|
+
const history = loadCommittedHistory(this.loopStore, loopId);
|
|
361
|
+
const views = history.rounds;
|
|
362
|
+
const rollbacks = history.directives;
|
|
363
|
+
const latestRound = views[views.length - 1];
|
|
364
|
+
const latestRollback = rollbacks[rollbacks.length - 1];
|
|
365
|
+
if (latestRollback &&
|
|
366
|
+
(!latestRound || latestRollback.round > latestRound.round)) {
|
|
367
|
+
return deriveCommittedSubmissionView(latestRollback, views, rollbacks);
|
|
368
|
+
}
|
|
369
|
+
return latestRound
|
|
370
|
+
? deriveCommittedSubmissionView(latestRound, views, rollbacks)
|
|
371
|
+
: null;
|
|
372
|
+
}
|
|
373
|
+
/** v4.0.3: the machine-backed success trajectory is a projection of
|
|
374
|
+
* committed branch rounds, not a session counter. Unbacked claims never
|
|
375
|
+
* enter it. */
|
|
376
|
+
getMachineBackedSuccessTrajectory(loopId) {
|
|
377
|
+
return loadCommittedHistory(this.loopStore, loopId).rounds
|
|
378
|
+
.filter((view) => view.result?.shouldPushSuccessTrajectory === true &&
|
|
379
|
+
view.result?.evidencePosture?.machineBackedSuccess === true)
|
|
380
|
+
.map((view) => view.agentSuccessClaim);
|
|
303
381
|
}
|
|
304
382
|
get(sessionId) {
|
|
305
383
|
return this.sessions.get(sessionId);
|
|
306
384
|
}
|
|
385
|
+
/** Resolve a live session by its durable loop identity. `create` rejects a
|
|
386
|
+
* second live session for the same loop, so the scan is unambiguous. A
|
|
387
|
+
* persisted-only loop has no live session: call `resume` to rebuild it. */
|
|
388
|
+
getByLoopId(loopId) {
|
|
389
|
+
if (!loopId)
|
|
390
|
+
return undefined;
|
|
391
|
+
for (const session of this.sessions.values()) {
|
|
392
|
+
if (session.loopId === loopId)
|
|
393
|
+
return session;
|
|
394
|
+
}
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
307
397
|
getLeaseStatus(loopId) {
|
|
308
398
|
const entry = this.findSessionEntry(loopId);
|
|
309
399
|
if (!entry)
|
|
@@ -330,13 +420,13 @@ export class SessionManager {
|
|
|
330
420
|
return false;
|
|
331
421
|
session.status = "stopped";
|
|
332
422
|
this.lifecycle.save(session);
|
|
333
|
-
void this.lifecycle.notifyTerminal(session,
|
|
423
|
+
void this.lifecycle.notifyTerminal(session, MANUAL_STOP_REASON);
|
|
334
424
|
this.sessions.delete(sessionId);
|
|
335
425
|
this.sessionQueues.delete(sessionId);
|
|
336
426
|
logEvent("session_end", {
|
|
337
427
|
sessionId,
|
|
338
428
|
loopId: session.loopId,
|
|
339
|
-
stopReason:
|
|
429
|
+
stopReason: MANUAL_STOP_REASON,
|
|
340
430
|
roundsCompleted: session.currentRound,
|
|
341
431
|
});
|
|
342
432
|
return true;
|
|
@@ -369,38 +459,67 @@ export class SessionManager {
|
|
|
369
459
|
* Reconstructs the session and compiles the prompt for the next round
|
|
370
460
|
* (async — compilation collects evidence through RoundDriver.prepare).
|
|
371
461
|
* Returns null if no session_state entry exists for this loopId. */
|
|
372
|
-
async resume(loopId) {
|
|
462
|
+
async resume(loopId, inputs) {
|
|
463
|
+
// v4.0.5: validate invocation input before any state is read or saved.
|
|
464
|
+
const normalizedInputs = normalizeInvocationInputs(inputs);
|
|
373
465
|
const persistedEntry = this.findSessionEntry(loopId);
|
|
374
466
|
if (!persistedEntry)
|
|
375
467
|
return null;
|
|
376
468
|
const persistedLineage = (persistedEntry.loop_lineage ?? {});
|
|
377
469
|
const persistedStatus = persistedLineage.status ?? "running";
|
|
378
|
-
|
|
470
|
+
// v3.9.7: stalled is a RESUMABLE recovery state. It is how a committed
|
|
471
|
+
// directive with an unfinished restore survives a restart.
|
|
472
|
+
const sessionEntry = persistedStatus === "running" || persistedStatus === "stalled"
|
|
379
473
|
? this.claimSessionEntry(loopId)
|
|
380
474
|
: persistedEntry;
|
|
381
475
|
if (!sessionEntry)
|
|
382
476
|
return this.leaseConflictResult(loopId, persistedEntry);
|
|
383
477
|
const session = this.lifecycle.reconstructSession(sessionEntry);
|
|
384
478
|
if (!session) {
|
|
385
|
-
// was not "running" —
|
|
479
|
+
// was not "running"/"stalled" — report the SESSION status and, for a
|
|
480
|
+
// stopped loop, the committed loop terminal reason.
|
|
386
481
|
const lineage = (sessionEntry.loop_lineage ?? {});
|
|
387
482
|
const currentRound = lineage.current_round ?? 1;
|
|
388
483
|
const status = lineage.status ?? "stopped";
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
484
|
+
if (status !== "stopped") {
|
|
485
|
+
return {
|
|
486
|
+
sessionId: "",
|
|
487
|
+
round: currentRound,
|
|
488
|
+
prompt: null,
|
|
489
|
+
sessionStatus: status,
|
|
490
|
+
stopDetail: `Loop is not running (session status: ${status}).`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
// v4.0.4: resume of a stopped loop reports the committed `StopReason`
|
|
494
|
+
// (completed/failed/blocked/max_rounds/...) and the SAME three fact
|
|
495
|
+
// blocks as a live stop. Never a fake "stopped", never a legacy guess.
|
|
496
|
+
return this.lifecycle.stoppedLoopResult(loopId, currentRound);
|
|
396
497
|
}
|
|
397
|
-
|
|
398
|
-
|
|
498
|
+
// v4.0.5: the queue only serializes work if it runs against the ONE live
|
|
499
|
+
// object. If this process still holds a running/stalled session for the
|
|
500
|
+
// same identity, prefer it over a document reconstructed from a possibly
|
|
501
|
+
// pre-commit save; replacing it here would let an in-flight advance and
|
|
502
|
+
// this resume operate on two different snapshots.
|
|
503
|
+
const live = this.sessions.get(session.sessionId);
|
|
504
|
+
const target = live && live.loopId === loopId &&
|
|
505
|
+
(live.status === "running" || live.status === "stalled")
|
|
506
|
+
? live
|
|
507
|
+
: session;
|
|
508
|
+
// Resume is a recovery attempt: set running so the lease is renewed while
|
|
509
|
+
// the (possibly long) restore executes. A failure saves stalled again.
|
|
510
|
+
if (target.status === "stalled")
|
|
511
|
+
target.status = "running";
|
|
512
|
+
if (target !== live)
|
|
513
|
+
this.sessions.set(target.sessionId, target);
|
|
514
|
+
// Serialize with any in-process advance holding this session's queue, so
|
|
515
|
+
// a recompile cannot race an in-flight held-handle submission.
|
|
516
|
+
return this.withSessionQueue(target.sessionId, () => this.lifecycle.resume(target, normalizedInputs));
|
|
399
517
|
}
|
|
400
518
|
/** v1.18: Resume a paused session. Reconstructs from vault state and
|
|
401
519
|
* compiles the next prompt. Returns null if no paused session exists
|
|
402
520
|
* for this loopId. */
|
|
403
|
-
async unpause(loopId) {
|
|
521
|
+
async unpause(loopId, inputs) {
|
|
522
|
+
const normalizedInputs = normalizeInvocationInputs(inputs);
|
|
404
523
|
const persistedEntry = this.findSessionEntry(loopId);
|
|
405
524
|
if (!persistedEntry)
|
|
406
525
|
return null;
|
|
@@ -414,10 +533,17 @@ export class SessionManager {
|
|
|
414
533
|
const session = this.lifecycle.reconstructSession(sessionEntry, true);
|
|
415
534
|
if (!session)
|
|
416
535
|
return null;
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
this.sessions.
|
|
420
|
-
|
|
536
|
+
// v4.0.5: keep the ONE live object when this process already holds it,
|
|
537
|
+
// for the same queue-visibility reason as resume().
|
|
538
|
+
const live = this.sessions.get(session.sessionId);
|
|
539
|
+
const target = live && live.loopId === loopId && live.status !== "stopped"
|
|
540
|
+
? live
|
|
541
|
+
: session;
|
|
542
|
+
// Set to running so advance() works.
|
|
543
|
+
target.status = "running";
|
|
544
|
+
if (target !== live)
|
|
545
|
+
this.sessions.set(target.sessionId, target);
|
|
546
|
+
return this.withSessionQueue(target.sessionId, () => this.lifecycle.unpause(target, normalizedInputs));
|
|
421
547
|
}
|
|
422
548
|
/** Auto-resume all "running" sessions from vault on server startup.
|
|
423
549
|
* Scans vault for session_state entries, reconstructs each as an in-memory
|
|
@@ -441,7 +567,7 @@ export class SessionManager {
|
|
|
441
567
|
continue;
|
|
442
568
|
const lineage = (entry.loop_lineage ?? {});
|
|
443
569
|
const status = lineage.status ?? "running";
|
|
444
|
-
if (status !== "running" && status !== "paused")
|
|
570
|
+
if (status !== "running" && status !== "paused" && status !== "stalled")
|
|
445
571
|
continue;
|
|
446
572
|
const claimedEntry = this.claimSessionEntry(lid);
|
|
447
573
|
if (!claimedEntry)
|
|
@@ -454,6 +580,11 @@ export class SessionManager {
|
|
|
454
580
|
session = this.lifecycle.reconstructSession(claimedEntry, true);
|
|
455
581
|
}
|
|
456
582
|
catch (error) {
|
|
583
|
+
if (error instanceof LegacyHistoryError) {
|
|
584
|
+
// A bulk startup scan must not rewrite history it cannot decode.
|
|
585
|
+
logEvent("session_skip_legacy", { loopId: lid, error: error.message });
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
457
588
|
if (error instanceof StorageCorruptionError && error.recoverable) {
|
|
458
589
|
logEvent("session_skip_gap", { loopId: lid, error: error.message });
|
|
459
590
|
continue;
|
|
@@ -524,6 +655,25 @@ export class SessionManager {
|
|
|
524
655
|
},
|
|
525
656
|
});
|
|
526
657
|
}
|
|
658
|
+
gateInvalidResult(session) {
|
|
659
|
+
const handle = makeSubmissionHandle(session);
|
|
660
|
+
return {
|
|
661
|
+
error: "gate_invalid",
|
|
662
|
+
errorMessage: `Session is ${session.status}; a gate check requires a running held evaluation.`,
|
|
663
|
+
sessionId: session.sessionId,
|
|
664
|
+
...(handle ? { submissionHandle: handle } : {}),
|
|
665
|
+
details: { reason_code: "session_not_running" },
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
gateLeaseConflictResult(session) {
|
|
669
|
+
const conflict = this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
|
|
670
|
+
return {
|
|
671
|
+
error: "lease_conflict",
|
|
672
|
+
errorMessage: conflict.stopDetail ?? "Another process owns this loop.",
|
|
673
|
+
sessionId: session.sessionId,
|
|
674
|
+
details: { reason_code: "lease_conflict" },
|
|
675
|
+
};
|
|
676
|
+
}
|
|
527
677
|
/** v3.7.1: structured gate preflight. The agent submits a
|
|
528
678
|
* GateActionDescriptor; the runtime classifies it (conservative:
|
|
529
679
|
* anything not provably safe is user_required with reason codes). A
|
|
@@ -531,49 +681,71 @@ export class SessionManager {
|
|
|
531
681
|
* loop + round + actionHash; agent_allowed records nothing and returns
|
|
532
682
|
* the evidence suggestion instead. No action is executed and no round
|
|
533
683
|
* advances from this call. */
|
|
534
|
-
checkGate(
|
|
535
|
-
const
|
|
536
|
-
if (!
|
|
684
|
+
async checkGate(handle, action) {
|
|
685
|
+
const parsed = parseSubmissionHandle(handle);
|
|
686
|
+
if (!parsed) {
|
|
687
|
+
const detail = "submissionHandle must be an object with exactly sessionId, roundId, " +
|
|
688
|
+
"and transactionId as non-empty strings.";
|
|
537
689
|
return {
|
|
538
|
-
error: "
|
|
539
|
-
errorMessage:
|
|
540
|
-
|
|
690
|
+
error: "submission_handle_required",
|
|
691
|
+
errorMessage: `${detail} Copy it from the latest LoopForge response. ${LOST_HANDLE_ROUTING}`,
|
|
692
|
+
details: { reason_code: "shape", detail },
|
|
541
693
|
};
|
|
542
694
|
}
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
if (expectedRoundId !== roundId) {
|
|
695
|
+
const session = this.sessions.get(parsed.sessionId);
|
|
696
|
+
if (!session) {
|
|
546
697
|
return {
|
|
547
|
-
error: "
|
|
548
|
-
errorMessage:
|
|
549
|
-
|
|
550
|
-
sessionId,
|
|
551
|
-
roundId,
|
|
698
|
+
error: "session_not_found",
|
|
699
|
+
errorMessage: `session not found: ${parsed.sessionId}`,
|
|
700
|
+
sessionId: parsed.sessionId,
|
|
552
701
|
};
|
|
553
702
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
this.recordOpenedGate(session.loopId, session.currentRound, verdict, canonicalizeGateAction(action));
|
|
703
|
+
if (session.status !== "running") {
|
|
704
|
+
return this.gateInvalidResult(session);
|
|
557
705
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
706
|
+
const check = validateSubmissionHandle(parsed, session);
|
|
707
|
+
if (!check.ok)
|
|
708
|
+
return this.checkpointMismatchResult(check, session);
|
|
709
|
+
return this.withSessionQueue(session.sessionId, async () => {
|
|
710
|
+
const current = this.sessions.get(session.sessionId);
|
|
711
|
+
if (!current)
|
|
712
|
+
return { ...this.sessionNotFoundResult(session.sessionId) };
|
|
713
|
+
if (current.status !== "running") {
|
|
714
|
+
return this.gateInvalidResult(current);
|
|
715
|
+
}
|
|
716
|
+
// The authoritative re-check is inside the queue. A gate record must
|
|
717
|
+
// never be opened for a handle that lost the held attempt while this
|
|
718
|
+
// call was waiting behind another session mutation.
|
|
719
|
+
const inside = validateSubmissionHandle(parsed, current);
|
|
720
|
+
if (!inside.ok)
|
|
721
|
+
return this.checkpointMismatchResult(inside, current);
|
|
722
|
+
if (!this.renewSessionLease(current.loopId)) {
|
|
723
|
+
return this.gateLeaseConflictResult(current);
|
|
724
|
+
}
|
|
725
|
+
const verdict = preflightStructuredGate(action);
|
|
726
|
+
if (verdict.kind === "user") {
|
|
727
|
+
this.recordOpenedGate(current.loopId, current.currentRound, verdict, canonicalizeGateAction(action));
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
submissionHandle: inside.handle,
|
|
731
|
+
gateId: verdict.gateId,
|
|
732
|
+
risk: verdict.risk,
|
|
733
|
+
decision: verdict.decision,
|
|
734
|
+
reasonCodes: verdict.reasonCodes,
|
|
735
|
+
...(verdict.kind === "user"
|
|
736
|
+
? {
|
|
737
|
+
blockedScope: verdict.blockedScope,
|
|
738
|
+
allowedBeforeApproval: verdict.allowedBeforeApproval,
|
|
739
|
+
requiredEvidence: [],
|
|
740
|
+
approvalQuestion: verdict.approvalQuestion,
|
|
741
|
+
}
|
|
742
|
+
: {
|
|
743
|
+
blockedScope: [],
|
|
744
|
+
allowedBeforeApproval: [],
|
|
745
|
+
requiredEvidence: verdict.requiredEvidence,
|
|
746
|
+
}),
|
|
747
|
+
};
|
|
748
|
+
});
|
|
577
749
|
}
|
|
578
750
|
/** Record a user decision for a recorded gate. The gateId embeds the
|
|
579
751
|
* canonicalized action hash — if the action changed, the match fails and
|
|
@@ -621,8 +793,9 @@ export class SessionManager {
|
|
|
621
793
|
};
|
|
622
794
|
}
|
|
623
795
|
try {
|
|
624
|
-
const
|
|
625
|
-
const opened =
|
|
796
|
+
const gatePrefix = `loop:${loopId}:gate:`;
|
|
797
|
+
const opened = loadCommittedHistory(this.loopStore, loopId).entries
|
|
798
|
+
.filter((entry) => String(entry.task_id ?? "").startsWith(gatePrefix))
|
|
626
799
|
.find((entry) => entry.task_type === "gate_opened" && entry.gate_id === gateId);
|
|
627
800
|
if (!opened) {
|
|
628
801
|
return {
|
|
@@ -725,79 +898,51 @@ export class SessionManager {
|
|
|
725
898
|
* the runtime's submission boundary reads the same context (the sub-goal
|
|
726
899
|
* reference space is exactly the compiled set the prompt carried), and two
|
|
727
900
|
* copies would let the projection and the boundary disagree about it. */
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
*
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
901
|
+
/** v4.0.4: read-only terminal summary of a persisted STOPPED loop.
|
|
902
|
+
*
|
|
903
|
+
* Returns null unless the persisted session status is exactly "stopped":
|
|
904
|
+
* a running/paused/stalled loop without a live session must still go
|
|
905
|
+
* through `loopforge_resume`, so "no live session" is never mistaken for
|
|
906
|
+
* "already stopped". It never claims a lease, saves a session or writes
|
|
907
|
+
* history; the committed view supplies every field. */
|
|
908
|
+
getStoppedLoopSummary(loopId) {
|
|
909
|
+
validateLoopId(loopId);
|
|
910
|
+
const entry = this.findSessionEntry(loopId);
|
|
911
|
+
if (!entry)
|
|
736
912
|
return null;
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
// v3.8: the projection reads the WHOLE committed history. The old
|
|
745
|
-
// `beforeRound: session.currentRound` bound is only correct while the loop
|
|
746
|
-
// is running, where currentRound is always one past the last committed
|
|
747
|
-
// round; a stop / terminate / max_rounds leaves currentRound ON the round
|
|
748
|
-
// it just committed, so the bound silently dropped the loop's final round
|
|
749
|
-
// and the projection disagreed with audit and explain about it.
|
|
750
|
-
// v3.8.1: one window, one decode, one derivation. The contract facts come
|
|
751
|
-
// from the same `deriveRoundFacts` the compile path calls, so the prompt
|
|
752
|
-
// and the projection cannot tell different stories about what the machine
|
|
753
|
-
// verified.
|
|
754
|
-
const rounds = readOnlyRounds(entries);
|
|
755
|
-
const projection = buildLoopProjection(deriveCognitiveFacts({
|
|
756
|
-
compileResponse,
|
|
757
|
-
rounds,
|
|
758
|
-
facts: deriveRoundFacts({
|
|
759
|
-
rounds,
|
|
760
|
-
currentRound: (rounds[rounds.length - 1]?.round ?? 0) + 1,
|
|
761
|
-
inFlight: NO_IN_FLIGHT_ROUND,
|
|
762
|
-
subGoals: compileResponse?.sub_goals ?? [],
|
|
763
|
-
commands: getPolicy().evidence.commands ?? [],
|
|
764
|
-
}),
|
|
765
|
-
verifiedClaims: listVerifiedClaims(entries, session.loopId),
|
|
766
|
-
openGates,
|
|
767
|
-
}));
|
|
768
|
-
return projection ? { ...projection } : null;
|
|
913
|
+
const lineage = (entry.loop_lineage ?? {});
|
|
914
|
+
if (lineage.status !== "stopped")
|
|
915
|
+
return null;
|
|
916
|
+
const currentRound = typeof lineage.current_round === "number"
|
|
917
|
+
? lineage.current_round
|
|
918
|
+
: 1;
|
|
919
|
+
return this.lifecycle.stoppedLoopResult(loopId, currentRound);
|
|
769
920
|
}
|
|
770
921
|
/** v3.8: Read-only per-round "why" view over committed facts. Never
|
|
771
922
|
* rebuilds history and never writes. */
|
|
772
923
|
getExplain(loopId, round) {
|
|
773
924
|
validateLoopId(loopId);
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
];
|
|
779
|
-
return buildExplain(loopId, entries, round);
|
|
925
|
+
// v4.0.4: the SAME loader reports the legacy count Explain prints; Explain
|
|
926
|
+
// never re-scans for it and never decodes a legacy round into a fake row.
|
|
927
|
+
const history = loadCommittedHistory(this.loopStore, loopId);
|
|
928
|
+
return buildExplain(loopId, history.entries, round, history.legacyRounds.length);
|
|
780
929
|
}
|
|
781
930
|
/** Read-only end-of-loop audit (verification view). Never writes. */
|
|
782
931
|
getAudit(loopId) {
|
|
783
932
|
validateLoopId(loopId);
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
// Zero committed decisions → nothing to audit. Returning null lets the
|
|
795
|
-
// tools layer report "no audit data" instead of the external auditor
|
|
796
|
-
// solemnly passing a loop that never ran (or a mistyped loopId).
|
|
797
|
-
const hasCommittedDecision = readOnlyRounds(entries).length > 0;
|
|
798
|
-
if (!hasCommittedDecision)
|
|
933
|
+
// Audit judges COMMITTED decisions. The shared history loader returns the
|
|
934
|
+
// flat entry view and the decoded branch; there is no second query pair.
|
|
935
|
+
const history = loadCommittedHistory(this.loopStore, loopId);
|
|
936
|
+
// No committed decision and no legacy envelope → nothing to audit.
|
|
937
|
+
// Returning null lets the tools layer report "no audit data" instead of
|
|
938
|
+
// the external auditor solemnly passing a loop that never ran (or a
|
|
939
|
+
// mistyped loopId). Legacy-only loops still get a report so the gap is
|
|
940
|
+
// explicit, never silently empty.
|
|
941
|
+
if (history.rounds.length === 0 && history.directives.length === 0 &&
|
|
942
|
+
history.legacyRounds.length === 0) {
|
|
799
943
|
return null;
|
|
800
|
-
|
|
944
|
+
}
|
|
945
|
+
const audit = buildAudit(loopId, history.entries, this.loopStore);
|
|
801
946
|
return { ...audit };
|
|
802
947
|
}
|
|
803
948
|
/** v3.5: The ACTIVE Round Contract governing the session's next round —
|
|
@@ -815,16 +960,10 @@ export class SessionManager {
|
|
|
815
960
|
* statistics (A4 port) folded with this process's live observations.
|
|
816
961
|
* Non-durable fields (evidence, vault errors) come from live only. */
|
|
817
962
|
getPolicyMetrics(loopId) {
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
// the live-only snapshot every time).
|
|
823
|
-
const prefix = `loop:${loopId}:`;
|
|
824
|
-
const entries = [
|
|
825
|
-
...queryLoopEntries(this.loopStore, loopId, { prefix }),
|
|
826
|
-
...queryLoopEntries(this.loopStore, loopId, { prefix, feedbackOnly: true }),
|
|
827
|
-
];
|
|
963
|
+
// Derived metrics judge committed decisions through the same history
|
|
964
|
+
// loader as every other read path. Live-only counters are folded in
|
|
965
|
+
// afterwards; they never become the source of round facts.
|
|
966
|
+
const entries = loadCommittedHistory(this.loopStore, loopId).entries;
|
|
828
967
|
const derived = derivePolicyMetrics(loopId, entries);
|
|
829
968
|
const live = policyMetrics.snapshot(loopId);
|
|
830
969
|
if (derived.committedRounds === 0)
|
|
@@ -842,20 +981,32 @@ export class SessionManager {
|
|
|
842
981
|
* `strategy_stability` was a literal `true`. What remains is counted
|
|
843
982
|
* directly from committed round flags. */
|
|
844
983
|
getHealth(loopId) {
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
// still gets a view — with zeros — exactly as it did before.
|
|
850
|
-
if (!context)
|
|
984
|
+
const history = loadCommittedHistory(this.loopStore, loopId);
|
|
985
|
+
// No stored session or round at all → unknown loop. A started-but-
|
|
986
|
+
// uncommitted loop has a session entry, so it still gets a zeroed view.
|
|
987
|
+
if (history.entries.length === 0)
|
|
851
988
|
return null;
|
|
852
|
-
const
|
|
853
|
-
const views = readOnlyRounds(entries);
|
|
989
|
+
const views = history.rounds;
|
|
854
990
|
const roundsWithUnverifiedItems = views.filter((view) => view.verificationFlags.some((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED)).length;
|
|
991
|
+
// v4.0.3: the ONE persisted posture is the source of this distribution.
|
|
992
|
+
const roundsByEvidenceStatus = {
|
|
993
|
+
verified: 0,
|
|
994
|
+
insufficient: 0,
|
|
995
|
+
contradicted: 0,
|
|
996
|
+
unknown: 0,
|
|
997
|
+
not_applicable: 0,
|
|
998
|
+
};
|
|
999
|
+
for (const view of views) {
|
|
1000
|
+
if (view.evidencePosture) {
|
|
1001
|
+
roundsByEvidenceStatus[view.evidencePosture.status]++;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
855
1004
|
return {
|
|
856
1005
|
loopId,
|
|
857
1006
|
committed_rounds: views.length,
|
|
1007
|
+
legacy_rounds: history.legacyRounds.length,
|
|
858
1008
|
rounds_with_unverified_items: roundsWithUnverifiedItems,
|
|
1009
|
+
rounds_by_evidence_status: roundsByEvidenceStatus,
|
|
859
1010
|
// The policy value the enforcement gate escalates on. The streak itself
|
|
860
1011
|
// is derived in the gate — re-deriving it here would be a second
|
|
861
1012
|
// implementation of the same rule, which is what this release removes.
|
|
@@ -863,53 +1014,231 @@ export class SessionManager {
|
|
|
863
1014
|
policy_metrics: this.getPolicyMetrics(loopId),
|
|
864
1015
|
};
|
|
865
1016
|
}
|
|
1017
|
+
/** v4.0.0: the stable result for a missing/malformed handle. */
|
|
1018
|
+
handleRequiredResult() {
|
|
1019
|
+
const detail = "submissionHandle must be an object with exactly sessionId, roundId, " +
|
|
1020
|
+
"and transactionId as non-empty strings.";
|
|
1021
|
+
return {
|
|
1022
|
+
sessionId: "",
|
|
1023
|
+
round: 0,
|
|
1024
|
+
prompt: null,
|
|
1025
|
+
stopDetail: `${detail} Copy it from the latest LoopForge response. ` +
|
|
1026
|
+
LOST_HANDLE_ROUTING,
|
|
1027
|
+
submissionError: {
|
|
1028
|
+
code: "submission_handle_required",
|
|
1029
|
+
details: { reason_code: "shape", detail },
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
sessionNotFoundResult(sessionId) {
|
|
1034
|
+
return {
|
|
1035
|
+
sessionId,
|
|
1036
|
+
round: 0,
|
|
1037
|
+
prompt: null,
|
|
1038
|
+
stopDetail: "No session exists with this ID. It may have expired, been deleted, " +
|
|
1039
|
+
"or never existed. Call loopforge_status with view=\"all\" to find the " +
|
|
1040
|
+
"loopId; if the held prompt is still in context, call loopforge_status " +
|
|
1041
|
+
"with that loopId to fetch the current handle; otherwise call " +
|
|
1042
|
+
"loopforge_resume with it.",
|
|
1043
|
+
submissionError: {
|
|
1044
|
+
code: "session_not_found",
|
|
1045
|
+
details: { sessionId },
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
sessionStateResult(session) {
|
|
1050
|
+
const handle = makeSubmissionHandle(session);
|
|
1051
|
+
return {
|
|
1052
|
+
sessionId: session.sessionId,
|
|
1053
|
+
round: session.currentRound,
|
|
1054
|
+
...(handle ? { submissionHandle: handle } : {}),
|
|
1055
|
+
prompt: null,
|
|
1056
|
+
sessionStatus: session.status,
|
|
1057
|
+
stopDetail: `Session is ${session.status}. Use loopforge_resume to restart a ` +
|
|
1058
|
+
"paused session, or loopforge_start for a new task.",
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/** v4.0.0: one stored-prompt re-delivery for next/recover. A held prompt is
|
|
1062
|
+
* returned with the current handle and a warning; without one, the stable
|
|
1063
|
+
* mismatch error carries the current handle when it exists. */
|
|
1064
|
+
handleMismatchResult(check, session) {
|
|
1065
|
+
if (check.reason !== "no_held_prompt") {
|
|
1066
|
+
const held = this.lifecycle.heldPromptResult(session, staleHandleWarning());
|
|
1067
|
+
if (held)
|
|
1068
|
+
return held;
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
sessionId: session.sessionId,
|
|
1072
|
+
round: session.currentRound,
|
|
1073
|
+
...(check.current ? { submissionHandle: check.current } : {}),
|
|
1074
|
+
prompt: null,
|
|
1075
|
+
stopDetail: check.detail,
|
|
1076
|
+
submissionError: {
|
|
1077
|
+
code: "submission_handle_mismatch",
|
|
1078
|
+
details: {
|
|
1079
|
+
reason_code: check.reason,
|
|
1080
|
+
...(check.mismatched.length > 0 ? { mismatched: [...check.mismatched] } : {}),
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
/** v4.0.0: the checkpoint variant: never re-deliver a prompt, never create a
|
|
1086
|
+
* checkpoint. The current handle rides the error for re-anchoring. */
|
|
1087
|
+
checkpointMismatchResult(check, session) {
|
|
1088
|
+
return {
|
|
1089
|
+
error: "submission_handle_mismatch",
|
|
1090
|
+
errorMessage: check.detail,
|
|
1091
|
+
sessionId: session.sessionId,
|
|
1092
|
+
...(check.current ? { submissionHandle: check.current } : {}),
|
|
1093
|
+
details: {
|
|
1094
|
+
reason_code: check.reason,
|
|
1095
|
+
...(check.mismatched.length > 0 ? { mismatched: [...check.mismatched] } : {}),
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
/** v3.9.7: a pending rollback directive whose restore is not yet verified.
|
|
1100
|
+
* Read-only; used before the queue/lease so a stale handle cannot mask the
|
|
1101
|
+
* "call resume" recovery UX. */
|
|
1102
|
+
restorePendingResult(session) {
|
|
1103
|
+
const pending = loadCommittedHistory(this.loopStore, session.loopId)
|
|
1104
|
+
.pendingDirective;
|
|
1105
|
+
if (!pending?.recovery)
|
|
1106
|
+
return null;
|
|
1107
|
+
if (session.roundSnapshot?.restore?.directiveTransactionId ===
|
|
1108
|
+
pending.recovery.transaction_id &&
|
|
1109
|
+
session.roundSnapshot?.round === pending.recovery.target.redo_round) {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
const handle = makeSubmissionHandle(session);
|
|
1113
|
+
return {
|
|
1114
|
+
sessionId: session.sessionId,
|
|
1115
|
+
round: session.currentRound,
|
|
1116
|
+
...(handle ? { submissionHandle: handle } : {}),
|
|
1117
|
+
prompt: null,
|
|
1118
|
+
stopReason: "stalled",
|
|
1119
|
+
submissionError: {
|
|
1120
|
+
code: "restore_pending",
|
|
1121
|
+
details: {
|
|
1122
|
+
loopId: session.loopId,
|
|
1123
|
+
directive_transaction_id: pending.recovery.transaction_id,
|
|
1124
|
+
redo_round: pending.recovery.target.redo_round,
|
|
1125
|
+
detail: "A pending restore must be completed by loopforge_resume before work can be submitted.",
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
866
1130
|
/** Core cycle: extract self-eval → record feedback → check stop → compile next.
|
|
867
|
-
* The lease + per-session queue wrap the RoundLifecycle state machine.
|
|
868
|
-
* @param preExtractedEval Structured SelfEvaluation supplied by the caller.
|
|
869
|
-
* An absent value is returned as evaluation_invalid without mutating state.
|
|
870
|
-
* @param roundId v3.0.1: The roundId of the round this submission reports on
|
|
871
|
-
* (from the last start/next/resume response). Anchors the submission so a
|
|
872
|
-
* stale or duplicate submission is not processed against a later round.
|
|
873
|
-
* Optional for library callers — when absent the anchor check is skipped. */
|
|
874
|
-
/** \`submission\` is the RAW evaluation payload, not a built one.
|
|
875
1131
|
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1132
|
+
* v4.0.0: the caller supplies the ONE `submissionHandle`; identity is
|
|
1133
|
+
* checked before the payload boundary and before the queue/lease heartbeat.
|
|
1134
|
+
* `submission` is the RAW evaluation payload. The whole payload boundary
|
|
1135
|
+
* still runs here, so every public caller gets the same strictness. */
|
|
1136
|
+
async advance(handle, output, submission, inputs) {
|
|
1137
|
+
const normalizedInputs = normalizeInvocationInputs(inputs);
|
|
1138
|
+
const parsed = parseSubmissionHandle(handle);
|
|
1139
|
+
if (!parsed)
|
|
1140
|
+
return this.handleRequiredResult();
|
|
1141
|
+
const session = this.sessions.get(parsed.sessionId);
|
|
1142
|
+
if (!session)
|
|
1143
|
+
return this.sessionNotFoundResult(parsed.sessionId);
|
|
1144
|
+
if (session.status !== "running" && session.status !== "stalled") {
|
|
1145
|
+
return this.sessionStateResult(session);
|
|
1146
|
+
}
|
|
1147
|
+
const pending = this.restorePendingResult(session);
|
|
1148
|
+
if (pending)
|
|
1149
|
+
return pending;
|
|
1150
|
+
const check = validateSubmissionHandle(parsed, session);
|
|
1151
|
+
if (!check.ok)
|
|
1152
|
+
return this.handleMismatchResult(check, session);
|
|
1153
|
+
// Identity is established for the attempt named by the handle. Only now
|
|
1154
|
+
// does the payload boundary judge the submission against that attempt's
|
|
1155
|
+
// reference space.
|
|
1156
|
+
const boundary = validateSubmission(this.loopStore, session, submission);
|
|
894
1157
|
if (!boundary.ok) {
|
|
1158
|
+
const current = makeSubmissionHandle(session);
|
|
895
1159
|
return {
|
|
896
|
-
sessionId,
|
|
897
|
-
round:
|
|
898
|
-
...(
|
|
1160
|
+
sessionId: session.sessionId,
|
|
1161
|
+
round: session.currentRound,
|
|
1162
|
+
...(current ? { submissionHandle: current } : {}),
|
|
899
1163
|
prompt: null,
|
|
900
|
-
stopReason: boundary.submissionError.code,
|
|
901
1164
|
stopDetail: boundary.stopDetail,
|
|
902
1165
|
submissionError: boundary.submissionError,
|
|
903
1166
|
};
|
|
904
1167
|
}
|
|
905
|
-
return this.withSessionQueue(sessionId, async () => {
|
|
906
|
-
const
|
|
907
|
-
if (
|
|
908
|
-
|
|
909
|
-
|
|
1168
|
+
return this.withSessionQueue(session.sessionId, async () => {
|
|
1169
|
+
const current = this.sessions.get(session.sessionId);
|
|
1170
|
+
if (!current)
|
|
1171
|
+
return this.sessionNotFoundResult(session.sessionId);
|
|
1172
|
+
if (current.status !== "running" && current.status !== "stalled") {
|
|
1173
|
+
return this.sessionStateResult(current);
|
|
1174
|
+
}
|
|
1175
|
+
const pendingInside = this.restorePendingResult(current);
|
|
1176
|
+
if (pendingInside)
|
|
1177
|
+
return pendingInside;
|
|
1178
|
+
// The authoritative re-check. It runs AFTER the queue is acquired but
|
|
1179
|
+
// BEFORE the lease heartbeat, so a concurrent commit cannot make this
|
|
1180
|
+
// call renew a lease for a handle it is about to reject.
|
|
1181
|
+
const inside = validateSubmissionHandle(parsed, current);
|
|
1182
|
+
if (!inside.ok)
|
|
1183
|
+
return this.handleMismatchResult(inside, current);
|
|
1184
|
+
if (current.status === "running" &&
|
|
1185
|
+
!this.renewSessionLease(current.loopId)) {
|
|
1186
|
+
return this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
return await this.lifecycle.advance(current.sessionId, output, boundary.evaluation, boundary.diagnostics, boundary.submittedFields, normalizedInputs);
|
|
1190
|
+
}
|
|
1191
|
+
catch (error) {
|
|
1192
|
+
if (error instanceof SessionLeaseConflictError) {
|
|
1193
|
+
return this.leaseConflictResult(error.loopId, this.findSessionEntry(error.loopId));
|
|
1194
|
+
}
|
|
1195
|
+
throw error;
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
/** v4.0.5: recompile the held prompt for a new host-provided instruction.
|
|
1200
|
+
* This does NOT submit an evaluation or advance the round: the exact held
|
|
1201
|
+
* handle is required, and the same round/attempt/context boundary is
|
|
1202
|
+
* preserved. The handle is re-checked inside the queue before the lease is
|
|
1203
|
+
* renewed, so a stale handle has zero side effects. */
|
|
1204
|
+
async recompileHeld(handle, instruction) {
|
|
1205
|
+
const normalizedInputs = normalizeInvocationInputs({
|
|
1206
|
+
currentUserInstruction: instruction,
|
|
1207
|
+
});
|
|
1208
|
+
const parsed = parseSubmissionHandle(handle);
|
|
1209
|
+
if (!parsed)
|
|
1210
|
+
return this.handleRequiredResult();
|
|
1211
|
+
const session = this.sessions.get(parsed.sessionId);
|
|
1212
|
+
if (!session)
|
|
1213
|
+
return this.sessionNotFoundResult(parsed.sessionId);
|
|
1214
|
+
if (session.status !== "running" && session.status !== "stalled") {
|
|
1215
|
+
return this.sessionStateResult(session);
|
|
1216
|
+
}
|
|
1217
|
+
const pending = this.restorePendingResult(session);
|
|
1218
|
+
if (pending)
|
|
1219
|
+
return pending;
|
|
1220
|
+
const check = validateSubmissionHandle(parsed, session);
|
|
1221
|
+
if (!check.ok)
|
|
1222
|
+
return this.handleMismatchResult(check, session);
|
|
1223
|
+
return this.withSessionQueue(session.sessionId, async () => {
|
|
1224
|
+
const current = this.sessions.get(session.sessionId);
|
|
1225
|
+
if (!current)
|
|
1226
|
+
return this.sessionNotFoundResult(session.sessionId);
|
|
1227
|
+
if (current.status !== "running" && current.status !== "stalled") {
|
|
1228
|
+
return this.sessionStateResult(current);
|
|
1229
|
+
}
|
|
1230
|
+
const pendingInside = this.restorePendingResult(current);
|
|
1231
|
+
if (pendingInside)
|
|
1232
|
+
return pendingInside;
|
|
1233
|
+
const inside = validateSubmissionHandle(parsed, current);
|
|
1234
|
+
if (!inside.ok)
|
|
1235
|
+
return this.handleMismatchResult(inside, current);
|
|
1236
|
+
if (current.status === "running" &&
|
|
1237
|
+
!this.renewSessionLease(current.loopId)) {
|
|
1238
|
+
return this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
|
|
910
1239
|
}
|
|
911
1240
|
try {
|
|
912
|
-
return await this.lifecycle.
|
|
1241
|
+
return await this.lifecycle.recompileHeldWithInstruction(current, normalizedInputs.currentUserInstruction);
|
|
913
1242
|
}
|
|
914
1243
|
catch (error) {
|
|
915
1244
|
if (error instanceof SessionLeaseConflictError) {
|
|
@@ -919,6 +1248,330 @@ export class SessionManager {
|
|
|
919
1248
|
}
|
|
920
1249
|
});
|
|
921
1250
|
}
|
|
1251
|
+
/** v3.9.7: submit an Agent-initiated recovery. v4.0.0: the handle is the
|
|
1252
|
+
* only anchor; shape and target visibility are checked before the
|
|
1253
|
+
* queue/lease, and the exact handle is re-checked before lease renewal. */
|
|
1254
|
+
async recover(handle, recovery, inputs) {
|
|
1255
|
+
const normalizedInputs = normalizeInvocationInputs(inputs);
|
|
1256
|
+
const parsed = parseSubmissionHandle(handle);
|
|
1257
|
+
if (!parsed)
|
|
1258
|
+
return this.handleRequiredResult();
|
|
1259
|
+
const session = this.sessions.get(parsed.sessionId);
|
|
1260
|
+
if (!session)
|
|
1261
|
+
return this.sessionNotFoundResult(parsed.sessionId);
|
|
1262
|
+
if (session.status !== "running" && session.status !== "stalled") {
|
|
1263
|
+
return this.sessionStateResult(session);
|
|
1264
|
+
}
|
|
1265
|
+
const pending = this.restorePendingResult(session);
|
|
1266
|
+
if (pending)
|
|
1267
|
+
return pending;
|
|
1268
|
+
const check = validateSubmissionHandle(parsed, session);
|
|
1269
|
+
if (!check.ok)
|
|
1270
|
+
return this.handleMismatchResult(check, session);
|
|
1271
|
+
if (!getPolicy().engine.agent_recovery_enabled) {
|
|
1272
|
+
return this.recoveryDisabledResult(check.handle, session);
|
|
1273
|
+
}
|
|
1274
|
+
const invalid = (detail) => ({
|
|
1275
|
+
sessionId: session.sessionId,
|
|
1276
|
+
round: session.currentRound,
|
|
1277
|
+
submissionHandle: check.handle,
|
|
1278
|
+
prompt: null,
|
|
1279
|
+
submissionError: {
|
|
1280
|
+
code: "recovery_invalid",
|
|
1281
|
+
details: { reason_code: "invalid_shape", detail },
|
|
1282
|
+
},
|
|
1283
|
+
});
|
|
1284
|
+
if (!isRecord(recovery))
|
|
1285
|
+
return invalid("recovery must be an object");
|
|
1286
|
+
const keys = Object.keys(recovery);
|
|
1287
|
+
if (keys.some((key) => key !== "target_checkpoint_id" && key !== "reason")) {
|
|
1288
|
+
return invalid("recovery accepts only target_checkpoint_id and reason");
|
|
1289
|
+
}
|
|
1290
|
+
if (typeof recovery.target_checkpoint_id !== "string" ||
|
|
1291
|
+
recovery.target_checkpoint_id.trim().length === 0) {
|
|
1292
|
+
return invalid("target_checkpoint_id must be a non-empty string");
|
|
1293
|
+
}
|
|
1294
|
+
if (typeof recovery.reason !== "string" ||
|
|
1295
|
+
recovery.reason.trim().length === 0 ||
|
|
1296
|
+
recovery.reason.length > 500) {
|
|
1297
|
+
return invalid("reason must be a non-empty string of at most 500 characters");
|
|
1298
|
+
}
|
|
1299
|
+
const targetCheckpointId = recovery.target_checkpoint_id;
|
|
1300
|
+
const reason = recovery.reason;
|
|
1301
|
+
// Read-only visibility check against the exact held snapshot. An invalid
|
|
1302
|
+
// target must not cause a lease renewal or a directive.
|
|
1303
|
+
const history = loadCommittedHistory(this.loopStore, session.loopId);
|
|
1304
|
+
const entries = history.entries;
|
|
1305
|
+
// A pending directive outranks target validation; the lifecycle retries
|
|
1306
|
+
// the same restore and never commits a second directive.
|
|
1307
|
+
if (!history.pendingDirective) {
|
|
1308
|
+
const resolved = findCheckpointRef({
|
|
1309
|
+
entries,
|
|
1310
|
+
held: check.snapshot,
|
|
1311
|
+
checkpointId: targetCheckpointId,
|
|
1312
|
+
});
|
|
1313
|
+
if (!resolved.ok) {
|
|
1314
|
+
return {
|
|
1315
|
+
sessionId: session.sessionId,
|
|
1316
|
+
round: session.currentRound,
|
|
1317
|
+
submissionHandle: check.handle,
|
|
1318
|
+
prompt: null,
|
|
1319
|
+
submissionError: {
|
|
1320
|
+
code: resolved.code,
|
|
1321
|
+
details: {
|
|
1322
|
+
target_checkpoint_id: targetCheckpointId,
|
|
1323
|
+
reason_code: resolved.reason,
|
|
1324
|
+
detail: resolved.detail,
|
|
1325
|
+
current_round: session.currentRound,
|
|
1326
|
+
},
|
|
1327
|
+
},
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const validatedRecovery = {
|
|
1332
|
+
target_checkpoint_id: targetCheckpointId,
|
|
1333
|
+
reason,
|
|
1334
|
+
};
|
|
1335
|
+
return this.withSessionQueue(session.sessionId, async () => {
|
|
1336
|
+
const current = this.sessions.get(session.sessionId);
|
|
1337
|
+
if (!current)
|
|
1338
|
+
return this.sessionNotFoundResult(session.sessionId);
|
|
1339
|
+
if (current.status !== "running" && current.status !== "stalled") {
|
|
1340
|
+
return this.sessionStateResult(current);
|
|
1341
|
+
}
|
|
1342
|
+
const pendingInside = this.restorePendingResult(current);
|
|
1343
|
+
if (pendingInside)
|
|
1344
|
+
return pendingInside;
|
|
1345
|
+
const inside = validateSubmissionHandle(parsed, current);
|
|
1346
|
+
if (!inside.ok)
|
|
1347
|
+
return this.handleMismatchResult(inside, current);
|
|
1348
|
+
if (!getPolicy().engine.agent_recovery_enabled) {
|
|
1349
|
+
return this.recoveryDisabledResult(inside.handle, current);
|
|
1350
|
+
}
|
|
1351
|
+
if (current.status === "running" &&
|
|
1352
|
+
!this.renewSessionLease(current.loopId)) {
|
|
1353
|
+
return this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
|
|
1354
|
+
}
|
|
1355
|
+
try {
|
|
1356
|
+
return await this.lifecycle.recover(current.sessionId, validatedRecovery, normalizedInputs);
|
|
1357
|
+
}
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
if (error instanceof SessionLeaseConflictError) {
|
|
1360
|
+
return this.leaseConflictResult(error.loopId, this.findSessionEntry(error.loopId));
|
|
1361
|
+
}
|
|
1362
|
+
throw error;
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
recoveryDisabledResult(handle, session) {
|
|
1367
|
+
return {
|
|
1368
|
+
sessionId: session.sessionId,
|
|
1369
|
+
round: session.currentRound,
|
|
1370
|
+
submissionHandle: handle,
|
|
1371
|
+
prompt: null,
|
|
1372
|
+
submissionError: {
|
|
1373
|
+
code: "recovery_disabled",
|
|
1374
|
+
details: { policy: "engine.agent_recovery_enabled" },
|
|
1375
|
+
},
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
/** v3.9.7: Agent-created mid-round savepoint. v4.0.0: handle identity is
|
|
1379
|
+
* checked before the queue and again before lease renewal; capture/save
|
|
1380
|
+
* happen only after both. A checkpoint never changes the handle. */
|
|
1381
|
+
async checkpoint(handle) {
|
|
1382
|
+
const parsed = parseSubmissionHandle(handle);
|
|
1383
|
+
if (!parsed) {
|
|
1384
|
+
const detail = "submissionHandle must be an object with exactly sessionId, roundId, " +
|
|
1385
|
+
"and transactionId as non-empty strings.";
|
|
1386
|
+
return {
|
|
1387
|
+
error: "submission_handle_required",
|
|
1388
|
+
errorMessage: `${detail} Copy it from the latest LoopForge response. ${LOST_HANDLE_ROUTING}`,
|
|
1389
|
+
details: { reason_code: "shape", detail },
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
const session = this.sessions.get(parsed.sessionId);
|
|
1393
|
+
if (!session) {
|
|
1394
|
+
return {
|
|
1395
|
+
error: "session_not_found",
|
|
1396
|
+
errorMessage: "No session exists with this ID.",
|
|
1397
|
+
sessionId: parsed.sessionId,
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
if (session.status !== "running") {
|
|
1401
|
+
const current = makeSubmissionHandle(session);
|
|
1402
|
+
return {
|
|
1403
|
+
error: "checkpoint_invalid",
|
|
1404
|
+
errorMessage: `Session is ${session.status}; a checkpoint requires a running held evaluation.`,
|
|
1405
|
+
sessionId: session.sessionId,
|
|
1406
|
+
...(current ? { submissionHandle: current } : {}),
|
|
1407
|
+
details: { reason_code: "session_not_running" },
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
const check = validateSubmissionHandle(parsed, session);
|
|
1411
|
+
if (!check.ok)
|
|
1412
|
+
return this.checkpointMismatchResult(check, session);
|
|
1413
|
+
return this.withSessionQueue(session.sessionId, async () => {
|
|
1414
|
+
const current = this.sessions.get(session.sessionId);
|
|
1415
|
+
if (!current) {
|
|
1416
|
+
return {
|
|
1417
|
+
error: "session_not_found",
|
|
1418
|
+
errorMessage: "No session exists with this ID.",
|
|
1419
|
+
sessionId: session.sessionId,
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
if (current.status !== "running") {
|
|
1423
|
+
const currentHandle = makeSubmissionHandle(current);
|
|
1424
|
+
return {
|
|
1425
|
+
error: "checkpoint_invalid",
|
|
1426
|
+
errorMessage: `Session is ${current.status}; a checkpoint requires a running held evaluation.`,
|
|
1427
|
+
sessionId: current.sessionId,
|
|
1428
|
+
...(currentHandle ? { submissionHandle: currentHandle } : {}),
|
|
1429
|
+
details: { reason_code: "session_not_running" },
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
// Authoritative re-check: after the queue is acquired, before the lease
|
|
1433
|
+
// heartbeat. A checkpoint must never capture or save for a stale handle.
|
|
1434
|
+
const inside = validateSubmissionHandle(parsed, current);
|
|
1435
|
+
if (!inside.ok)
|
|
1436
|
+
return this.checkpointMismatchResult(inside, current);
|
|
1437
|
+
const pending = this.restorePendingResult(current);
|
|
1438
|
+
if (pending) {
|
|
1439
|
+
return {
|
|
1440
|
+
error: "restore_pending",
|
|
1441
|
+
errorMessage: "A pending restore must be completed by loopforge_resume before creating a checkpoint.",
|
|
1442
|
+
sessionId: current.sessionId,
|
|
1443
|
+
submissionHandle: inside.handle,
|
|
1444
|
+
details: { reason_code: "restore_pending" },
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
if (!this.renewSessionLease(current.loopId)) {
|
|
1448
|
+
const conflict = this.leaseConflictResult(current.loopId, this.findSessionEntry(current.loopId));
|
|
1449
|
+
return {
|
|
1450
|
+
error: "lease_conflict",
|
|
1451
|
+
errorMessage: conflict.stopDetail ?? "Another process owns this loop.",
|
|
1452
|
+
sessionId: current.sessionId,
|
|
1453
|
+
details: { reason_code: "lease_conflict" },
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
const held = inside.snapshot;
|
|
1457
|
+
const store = this.checkpointStoreFor?.(current.loopId);
|
|
1458
|
+
if (!store) {
|
|
1459
|
+
return {
|
|
1460
|
+
error: "checkpoint_unavailable",
|
|
1461
|
+
errorMessage: "No workspace checkpoint store is configured for this loop.",
|
|
1462
|
+
sessionId: current.sessionId,
|
|
1463
|
+
submissionHandle: inside.handle,
|
|
1464
|
+
details: { reason_code: "store_unavailable" },
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
let stateDir;
|
|
1468
|
+
try {
|
|
1469
|
+
stateDir = resolveStateDirectory(process.cwd(), getPolicy().backend.root_dir);
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
return {
|
|
1473
|
+
error: "checkpoint_unavailable",
|
|
1474
|
+
errorMessage: `The runtime state directory could not be resolved: ${String(error)}`,
|
|
1475
|
+
sessionId: current.sessionId,
|
|
1476
|
+
submissionHandle: inside.handle,
|
|
1477
|
+
details: { reason_code: "state_directory_invalid" },
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
const exclude = (path) => {
|
|
1481
|
+
const abs = resolve(path);
|
|
1482
|
+
const prefixAbs = stateDir.endsWith(sep) ? stateDir : `${stateDir}${sep}`;
|
|
1483
|
+
return abs === stateDir || abs.startsWith(prefixAbs);
|
|
1484
|
+
};
|
|
1485
|
+
let manifest;
|
|
1486
|
+
try {
|
|
1487
|
+
manifest = await store.capture({
|
|
1488
|
+
round: held.round,
|
|
1489
|
+
role: "mid",
|
|
1490
|
+
cwd: process.cwd(),
|
|
1491
|
+
exclude,
|
|
1492
|
+
timeoutMs: Math.max(60_000, getPolicy().evidence.timeout_ms),
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
catch (error) {
|
|
1496
|
+
if (error instanceof CheckpointError) {
|
|
1497
|
+
return {
|
|
1498
|
+
error: error.code,
|
|
1499
|
+
errorMessage: error.message,
|
|
1500
|
+
sessionId: current.sessionId,
|
|
1501
|
+
submissionHandle: inside.handle,
|
|
1502
|
+
details: { reason_code: error.reasonCode },
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
return {
|
|
1506
|
+
error: "checkpoint_failed",
|
|
1507
|
+
errorMessage: String(error),
|
|
1508
|
+
sessionId: current.sessionId,
|
|
1509
|
+
submissionHandle: inside.handle,
|
|
1510
|
+
details: { reason_code: "capture_error" },
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
// The capture yielded to the event loop; a concurrent advance/pause may
|
|
1514
|
+
// have invalidated the held transaction. Never attach an orphan
|
|
1515
|
+
// checkpoint to a different attempt.
|
|
1516
|
+
const after = validateSubmissionHandle(parsed, this.sessions.get(session.sessionId) ?? current);
|
|
1517
|
+
if (!after.ok) {
|
|
1518
|
+
return {
|
|
1519
|
+
error: "checkpoint_invalid",
|
|
1520
|
+
errorMessage: "The held attempt changed while the checkpoint was captured.",
|
|
1521
|
+
sessionId: current.sessionId,
|
|
1522
|
+
...(after.current ? { submissionHandle: after.current } : {}),
|
|
1523
|
+
details: { reason_code: "identity_changed" },
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
const ref = buildCheckpointRef(manifest, {
|
|
1527
|
+
transactionId: held.transactionId,
|
|
1528
|
+
branchId: held.branchId,
|
|
1529
|
+
});
|
|
1530
|
+
const previous = held.checkpoints ?? [];
|
|
1531
|
+
const duplicate = previous.find((item) => item.checkpointId === ref.checkpointId);
|
|
1532
|
+
// The cap counts DISTINCT savepoints. Re-capturing an already visible
|
|
1533
|
+
// state is idempotent and must still return the existing ref at the cap.
|
|
1534
|
+
if (!duplicate &&
|
|
1535
|
+
previous.length >= MAX_MID_CHECKPOINTS_PER_TRANSACTION) {
|
|
1536
|
+
return {
|
|
1537
|
+
error: "checkpoint_limit",
|
|
1538
|
+
errorMessage: `This attempt already has ${MAX_MID_CHECKPOINTS_PER_TRANSACTION} mid-round checkpoints.`,
|
|
1539
|
+
sessionId: current.sessionId,
|
|
1540
|
+
submissionHandle: after.handle,
|
|
1541
|
+
details: { reason_code: "mid_checkpoint_cap" },
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
if (!duplicate) {
|
|
1545
|
+
held.checkpoints = [...previous, ref];
|
|
1546
|
+
try {
|
|
1547
|
+
this.lifecycle.save(current);
|
|
1548
|
+
}
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
held.checkpoints = previous;
|
|
1551
|
+
return {
|
|
1552
|
+
error: "checkpoint_failed",
|
|
1553
|
+
errorMessage: `The checkpoint manifest was written but the session could not be saved: ${String(error)}. Retry returns the same id.`,
|
|
1554
|
+
sessionId: current.sessionId,
|
|
1555
|
+
submissionHandle: after.handle,
|
|
1556
|
+
details: { reason_code: "session_save_failed" },
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
sessionId: current.sessionId,
|
|
1562
|
+
submissionHandle: after.handle,
|
|
1563
|
+
checkpointId: manifest.checkpointId,
|
|
1564
|
+
round: held.round,
|
|
1565
|
+
role: "mid",
|
|
1566
|
+
redoRound: manifest.round,
|
|
1567
|
+
createdAt: (duplicate ?? ref).createdAt,
|
|
1568
|
+
...(manifest.head ? { head: manifest.head } : {}),
|
|
1569
|
+
fileCount: Object.keys(manifest.fingerprints).length,
|
|
1570
|
+
contentCount: manifest.contents.length,
|
|
1571
|
+
contentHash: manifest.contentHash,
|
|
1572
|
+
};
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
922
1575
|
/** Replay timeline for a session — creates ReplayBackend from the stored backend. */
|
|
923
1576
|
replayTimeline(sessionId) {
|
|
924
1577
|
const session = this.sessions.get(sessionId);
|
|
@@ -938,6 +1591,45 @@ export class SessionManager {
|
|
|
938
1591
|
const timeline = replay.timeline(loopId);
|
|
939
1592
|
return timeline.length > 0 ? timeline : null;
|
|
940
1593
|
}
|
|
1594
|
+
/** v4.0.2: a bounded replay page with its window metadata. `null` only when
|
|
1595
|
+
* the session does not exist; an empty loop returns an empty page. */
|
|
1596
|
+
replayPage(sessionId, opts) {
|
|
1597
|
+
const session = this.sessions.get(sessionId);
|
|
1598
|
+
if (!session)
|
|
1599
|
+
return null;
|
|
1600
|
+
return this.replayPageByLoop(session.loopId, opts);
|
|
1601
|
+
}
|
|
1602
|
+
replayPageByLoop(loopId, opts) {
|
|
1603
|
+
validateLoopId(loopId);
|
|
1604
|
+
return new ReplayBackend(this.loopStore).page(loopId, opts);
|
|
1605
|
+
}
|
|
1606
|
+
/** v3.9.6: committed rollback directives for the replay view. They are
|
|
1607
|
+
* events, not normal rounds; the timeline stays a normal-round projection. */
|
|
1608
|
+
replayRollbacks(loopId) {
|
|
1609
|
+
validateLoopId(loopId);
|
|
1610
|
+
return loadCommittedHistory(this.loopStore, loopId).directives.map((view) => ({
|
|
1611
|
+
round: view.round,
|
|
1612
|
+
roundId: view.roundId,
|
|
1613
|
+
transactionId: view.transactionId ?? view.roundId,
|
|
1614
|
+
branchId: view.branchId ?? null,
|
|
1615
|
+
origin: view.origin === "agent" ? "agent" : "machine",
|
|
1616
|
+
redoRound: view.recovery?.target.redo_round ?? 0,
|
|
1617
|
+
targetCheckpointId: view.recovery?.target.checkpoint_id ?? "",
|
|
1618
|
+
reason: view.recovery?.reason ?? "",
|
|
1619
|
+
observedSkippedFiles: [...(view.recovery?.observed_skipped_files ??
|
|
1620
|
+
view.recovery?.skipped_files ?? [])],
|
|
1621
|
+
reportedSkippedFiles: [...(view.recovery?.reported_skipped_files ?? [])],
|
|
1622
|
+
skippedFiles: [...(view.recovery?.skipped_files ?? [])],
|
|
1623
|
+
abandonedRounds: [...(view.recovery?.abandoned_rounds ?? [])],
|
|
1624
|
+
triggerFindings: [...(view.recovery?.trigger_findings ?? [])],
|
|
1625
|
+
preservedDiscoveries: [...(view.recovery?.skipped_discoveries ?? [])],
|
|
1626
|
+
// v4.0.2: the committed abandoned-path facts, same display caps as
|
|
1627
|
+
// Working Memory / replay.
|
|
1628
|
+
failedRounds: [...(view.recovery?.failed_rounds ?? [])],
|
|
1629
|
+
approaches: [...(view.recovery?.approaches ?? [])].map((value) => value.slice(0, 200)),
|
|
1630
|
+
wrongAssumptions: [...(view.recovery?.wrong_assumptions ?? [])].map((value) => value.slice(0, 500)),
|
|
1631
|
+
}));
|
|
1632
|
+
}
|
|
941
1633
|
/** v3.9.1: unsupported envelope versions for a loop, reported explicitly
|
|
942
1634
|
* rather than silently dropped from replay/audit. */
|
|
943
1635
|
replayLegacyRounds(loopId) {
|