@visual-z/xeno 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +36 -9
- package/docs/foundations.md +1 -1
- package/package.json +3 -1
- package/src/apps/cli.ts +4 -3
- package/src/apps/commands.ts +8 -0
- package/src/apps/goal-flow.ts +70 -0
- package/src/apps/render.ts +0 -2
- package/src/apps/session-hub.ts +6 -5
- package/src/apps/session-lifecycle.ts +64 -0
- package/src/apps/shell.ts +89 -34
- package/src/apps/startup.ts +32 -31
- package/src/apps/ui/app.ts +105 -13
- package/src/apps/ui/control-model.ts +18 -1
- package/src/apps/ui/surface.ts +2 -0
- package/src/apps/update-check.ts +1 -1
- package/src/core/acceptance.ts +41 -9
- package/src/core/blind-checks.ts +72 -0
- package/src/core/branch-receipt.ts +3 -1
- package/src/core/checkpoints.ts +21 -0
- package/src/core/churn.ts +2 -2
- package/src/core/completion-review.ts +18 -11
- package/src/core/continuation.ts +11 -10
- package/src/core/control-view.ts +5 -8
- package/src/core/deadline.ts +2 -1
- package/src/core/derive.ts +0 -7
- package/src/core/environment.ts +2 -2
- package/src/core/event-validation.ts +19 -6
- package/src/core/events.ts +41 -32
- package/src/core/evidence-ladder.ts +62 -0
- package/src/core/evidence-links.ts +0 -10
- package/src/core/evidence.ts +1 -2
- package/src/core/export.ts +6 -11
- package/src/core/goal-view.ts +64 -0
- package/src/core/goal.ts +200 -0
- package/src/core/history.ts +1 -11
- package/src/core/json-event.ts +0 -6
- package/src/core/outcome.ts +0 -1
- package/src/core/program-status.ts +11 -2
- package/src/core/progress.ts +14 -8
- package/src/core/project.ts +6 -16
- package/src/core/read-bounds.ts +11 -2
- package/src/core/read-observation.ts +24 -0
- package/src/core/request-budget.ts +13 -3
- package/src/core/request-state.ts +178 -19
- package/src/core/response-recovery.ts +15 -2
- package/src/core/session-entry.ts +8 -3
- package/src/core/session-exit.ts +19 -3
- package/src/core/session-format.ts +169 -0
- package/src/core/settings.ts +7 -27
- package/src/core/stall.ts +30 -7
- package/src/core/stop-report.ts +4 -3
- package/src/core/transcript.ts +22 -2
- package/src/runtime/blind-check.ts +36 -0
- package/src/runtime/counter-check.ts +74 -0
- package/src/runtime/exec-sessions.ts +11 -0
- package/src/runtime/foundation.ts +46 -8
- package/src/runtime/loop.ts +239 -430
- package/src/runtime/oauth.ts +1 -3
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider-request.ts +2 -1
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/runtime-target.ts +2 -2
- package/src/runtime/session-control.ts +4 -4
- package/src/runtime/tools.ts +44 -42
- package/src/runtime/usage.ts +28 -0
- package/src/runtime/version.ts +12 -0
- package/src/runtime/workspace-lifecycle.ts +31 -0
- package/src/store/blind-checks.ts +32 -0
- package/src/store/checkpoint-history.ts +1 -12
- package/src/store/file-index.ts +88 -7
- package/src/store/log.ts +25 -27
- package/src/store/recovery.ts +1 -3
- package/src/store/replay.ts +2 -2
- package/src/store/restore.ts +10 -202
- package/src/store/search.ts +7 -5
- package/src/store/session-archive.ts +0 -1
- package/src/store/sessions.ts +31 -33
- package/src/store/workspace-clone.ts +42 -0
- package/src/store/workspace.ts +10 -157
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +13 -7
- package/src/tui/ui/status.ts +99 -17
- package/src/core/benchmark.ts +0 -195
- package/src/core/evaluation-coverage.ts +0 -74
- package/src/core/experiment.ts +0 -173
- package/src/core/harness-comparison.ts +0 -90
- package/src/core/harness-diagnostics.ts +0 -206
- package/src/core/native-eval.ts +0 -25
- package/src/core/policy-evaluation.ts +0 -55
- package/src/core/query-eval-contract.ts +0 -14
- package/src/core/recall-analysis.ts +0 -90
- package/src/core/recall-trial.ts +0 -153
- package/src/core/recovery-corpus.ts +0 -17
- package/src/core/recovery-evaluation.ts +0 -60
- package/src/core/tool-mode-protocol.ts +0 -25
- package/src/core/trial-lifecycle.ts +0 -14
- package/src/core/window-trial.ts +0 -65
- package/src/index.ts +0 -10
- package/src/runtime/benchmark.ts +0 -187
- package/src/runtime/harness-comparison.ts +0 -376
- package/src/runtime/harness-eval.ts +0 -462
- package/src/runtime/native-eval-fixtures.ts +0 -33
- package/src/runtime/native-eval.ts +0 -146
- package/src/runtime/query-eval-verifier.ts +0 -28
- package/src/runtime/recall-trial.ts +0 -226
- package/src/runtime/recovery-coverage.ts +0 -71
- package/src/runtime/recovery-evidence.ts +0 -76
- package/src/runtime/recovery-fixtures.ts +0 -116
- package/src/runtime/tool-mode-eval.ts +0 -146
- package/src/runtime/trial-control.ts +0 -45
- package/src/runtime/window-trial.ts +0 -141
- package/src/store/git-branch.ts +0 -35
- package/src/tui/ui/testing.ts +0 -5
package/src/runtime/loop.ts
CHANGED
|
@@ -25,7 +25,12 @@ import type { Handoff } from "../core/handoff.ts";
|
|
|
25
25
|
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
26
26
|
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
27
27
|
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
28
|
-
import { projectContext,
|
|
28
|
+
import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
|
|
29
|
+
import { parseSessionContext, type SessionContext } from "../core/session-format.ts";
|
|
30
|
+
import { activeGoal, goalContract, goalDecision, idleGoalChecks, type ActiveGoal } from "../core/goal.ts";
|
|
31
|
+
import { counterCheck } from "./counter-check.ts";
|
|
32
|
+
import { runBlindChecks } from "./blind-check.ts";
|
|
33
|
+
import { blindExposure, type BlindChecks } from "../core/blind-checks.ts";
|
|
29
34
|
import { deriveHandoff, carryForward } from "../core/derive.ts";
|
|
30
35
|
import { latestObservedFileVersion } from "../core/file-precondition.ts";
|
|
31
36
|
import { latestProcessReceipt } from "../core/process-receipt.ts";
|
|
@@ -44,7 +49,7 @@ import { pendingToolCalls } from "../core/execution.ts";
|
|
|
44
49
|
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
45
50
|
import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
|
|
46
51
|
import { executionOutcome, modelOutcome, type ExecutionOutcome, type ExecutionReason } from "../core/outcome.ts";
|
|
47
|
-
import { finalizationNotice, requestPhase } from "../core/request-budget.ts";
|
|
52
|
+
import { finalizationNotice, requestPhase, validateRequestBudget } from "../core/request-budget.ts";
|
|
48
53
|
import { stopReport } from "../core/stop-report.ts";
|
|
49
54
|
import { supportsToolDisabling, type ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
50
55
|
import { commandCompletion } from "../core/command-contract.ts";
|
|
@@ -63,12 +68,12 @@ import { schedulingWindow } from "../core/scheduling.ts";
|
|
|
63
68
|
import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
|
|
64
69
|
import { isContextOverflow } from "../core/overflow.ts";
|
|
65
70
|
import { requestCompletion } from "../core/completion-review.ts";
|
|
66
|
-
import { deriveRequestState, type RequestState } from "../core/request-state.ts";
|
|
71
|
+
import { currentRequestRecords, deriveRequestState, type RequestState } from "../core/request-state.ts";
|
|
67
72
|
import { churnReview } from "../core/churn.ts";
|
|
68
73
|
import { deriveBranchReceipt } from "../core/branch-receipt.ts";
|
|
69
74
|
import { skillTools, type Skill } from "../core/skills.ts";
|
|
70
75
|
import type { WorkItem } from "../core/work-state.ts";
|
|
71
|
-
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceState } from "../core/acceptance.ts";
|
|
76
|
+
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
|
|
72
77
|
import { deadlineNotice } from "../core/deadline.ts";
|
|
73
78
|
import { statusOf } from "./retry.ts";
|
|
74
79
|
import { assertNever } from "../core/exhaustive.ts";
|
|
@@ -83,13 +88,9 @@ import { runHistoryTool } from "./history-tools.ts";
|
|
|
83
88
|
import { prepareWorkingNote } from "./working-notes.ts";
|
|
84
89
|
import { copyAncestryTo } from "../store/sessions.ts";
|
|
85
90
|
import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
|
|
86
|
-
import {
|
|
87
|
-
scanWorkspace, diffScans, reconcile, DEFAULT_IGNORE, workspaceVersion,
|
|
88
|
-
type FileSet, type RestoreResult,
|
|
89
|
-
} from "../store/workspace.ts";
|
|
90
|
-
import { putBlob, getBlob, putFileBlob } from "../store/blobs.ts";
|
|
91
|
+
import { putBlob, getBlob } from "../store/blobs.ts";
|
|
91
92
|
import { hydrateProcessReceipts } from "../store/process-receipts.ts";
|
|
92
|
-
import { readNodeHistory
|
|
93
|
+
import { readNodeHistory } from "../store/restore.ts";
|
|
93
94
|
import { createWorktree, removeWorktree } from "../store/worktree.ts";
|
|
94
95
|
import {
|
|
95
96
|
parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
|
|
@@ -108,7 +109,8 @@ import { observeCommand } from "../store/command.ts";
|
|
|
108
109
|
import { headTailView } from "../core/observation-view.ts";
|
|
109
110
|
import { withProgress } from "./progress.ts";
|
|
110
111
|
import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
111
|
-
import { changedPaths, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
112
|
+
import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
113
|
+
import type { GitCheckpoint } from "../store/git-checkpoints.ts";
|
|
112
114
|
import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
|
|
113
115
|
import { planCheckpointRestore } from "../store/checkpoint-history.ts";
|
|
114
116
|
|
|
@@ -125,6 +127,27 @@ export function systemClock(): Clock {
|
|
|
125
127
|
};
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/** Per-request options that are not session configuration. */
|
|
131
|
+
export interface RequestOptions {
|
|
132
|
+
/**
|
|
133
|
+
* A read-only planning request (S.5 goal drafts). The Plan-mode tool gate
|
|
134
|
+
* refuses anything that could change the workspace, the request runs at
|
|
135
|
+
* most READ_ONLY_REQUEST_LIMIT model requests with the last one answering
|
|
136
|
+
* without tools, and no goal or completion check applies. A draft written
|
|
137
|
+
* without seeing the workspace guessed its commands, and a request with
|
|
138
|
+
* tools disabled outright was answered with tool calls anyway
|
|
139
|
+
* (fix-goal-read-only-draft).
|
|
140
|
+
*/
|
|
141
|
+
readOnly?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Assumed, not measured: room to list the workspace and read a few files
|
|
146
|
+
* before answering. The last request answers with tools disabled.
|
|
147
|
+
*/
|
|
148
|
+
export const READ_ONLY_REQUEST_LIMIT = 8;
|
|
149
|
+
const READ_ONLY_HINT = "this request only reads the workspace. Use read, find or grep, then answer.";
|
|
150
|
+
|
|
128
151
|
export interface LoopEvent {
|
|
129
152
|
kind:
|
|
130
153
|
| "turn_start"
|
|
@@ -141,8 +164,6 @@ export interface LoopEvent {
|
|
|
141
164
|
| "stop_and_ask"
|
|
142
165
|
| "branch_start"
|
|
143
166
|
| "branch_done"
|
|
144
|
-
| "snapshot_failed"
|
|
145
|
-
| "undeclared_change"
|
|
146
167
|
| "done";
|
|
147
168
|
text?: string;
|
|
148
169
|
tool?: { name: string; input: unknown };
|
|
@@ -197,18 +218,14 @@ export interface SessionConfig {
|
|
|
197
218
|
agentProfile?: { name: string; workflowExplicit: boolean };
|
|
198
219
|
/** An explicit mode command's capability default, independent of role authority. */
|
|
199
220
|
workflowToolProfile?: "foundation";
|
|
200
|
-
/** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
|
|
201
|
-
workspaceTracking?: "journal" | "legacy";
|
|
202
221
|
shellCheckpoints?: boolean;
|
|
203
222
|
skills?: readonly Skill[];
|
|
204
223
|
acceptanceContract?: AcceptanceContract | null;
|
|
224
|
+
/** feat-blind-checks: hidden checks, frozen at session start; never part of any projection. */
|
|
225
|
+
blindChecks?: BlindChecks;
|
|
205
226
|
strictVerification?: boolean;
|
|
206
|
-
/** O.10: opt-in carried view;
|
|
227
|
+
/** O.10: opt-in carried view (source-priority, R9); evaluations compare it against D20's plain handoff. */
|
|
207
228
|
workingSet?: boolean;
|
|
208
|
-
/** R9: CLI opts into source-priority views; absent preserves v1 experiment/old-session behavior. */
|
|
209
|
-
workingSetVersion?: 1 | 2;
|
|
210
|
-
/** R10: new CLI guidance; absence retains old carried/prefix wording on resume. */
|
|
211
|
-
guidanceVersion?: 1;
|
|
212
229
|
logDir: string;
|
|
213
230
|
frozen: FrozenPrefix;
|
|
214
231
|
tools: ToolContext;
|
|
@@ -258,8 +275,6 @@ export interface SessionConfig {
|
|
|
258
275
|
*/
|
|
259
276
|
blobDir?: string;
|
|
260
277
|
imageOptions?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
|
|
261
|
-
/** Directory names the scan skips, relative to the workspace root. */
|
|
262
|
-
snapshotIgnore?: readonly string[];
|
|
263
278
|
}
|
|
264
279
|
|
|
265
280
|
export interface SessionState {
|
|
@@ -278,7 +293,6 @@ export interface SessionState {
|
|
|
278
293
|
/** Next free node number. Branches take one each. */
|
|
279
294
|
nextNode: number;
|
|
280
295
|
/** The workspace as of the last snapshot, for diffing the next one. */
|
|
281
|
-
lastScan: FileSet;
|
|
282
296
|
/** Worktrees created for writing branches, by node. */
|
|
283
297
|
worktrees: Map<number, { path: string; branch: string }>;
|
|
284
298
|
}
|
|
@@ -353,6 +367,10 @@ export class Session {
|
|
|
353
367
|
private cleanup: Promise<void> | null = null;
|
|
354
368
|
private closing: Promise<void> | null = null;
|
|
355
369
|
private turnTools: ToolContext | null = null;
|
|
370
|
+
/** The running request is read-only (RequestOptions.readOnly); every tool call goes through the Plan gate. */
|
|
371
|
+
private readOnlyRequest = false;
|
|
372
|
+
/** The source snapshot taken when the current goal request began; a counter-check reverts to it. In memory: a request never outlives its process. */
|
|
373
|
+
private goalBaseline: { request: string; snapshot: GitCheckpoint } | null = null;
|
|
356
374
|
private readonly execution = new ExecutionControl();
|
|
357
375
|
private readonly runtimeTargets = new RuntimeTargets();
|
|
358
376
|
private rootTools: ToolQueue | undefined;
|
|
@@ -382,7 +400,7 @@ export class Session {
|
|
|
382
400
|
stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
383
401
|
validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
|
|
384
402
|
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
385
|
-
|
|
403
|
+
validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
386
404
|
this.allocation = allocation;
|
|
387
405
|
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
|
|
388
406
|
processWait: { wake: () => this.processWake.signal } } };
|
|
@@ -430,7 +448,7 @@ export class Session {
|
|
|
430
448
|
};
|
|
431
449
|
this.state = {
|
|
432
450
|
node: 0, depth: 1, handoff: null, events: [], usage: [],
|
|
433
|
-
anchor: null, branchesOpened: 0,
|
|
451
|
+
anchor: null, branchesOpened: 0,
|
|
434
452
|
get nextNode() { return allocation.next; },
|
|
435
453
|
set nextNode(value: number) { allocation.next = value; },
|
|
436
454
|
worktrees: new Map(),
|
|
@@ -441,8 +459,7 @@ export class Session {
|
|
|
441
459
|
private installWorkspace(): void {
|
|
442
460
|
delete this.config.tools.workspace;
|
|
443
461
|
this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
|
|
444
|
-
this.config.
|
|
445
|
-
if (this.config.blobDir && this.config.workspaceTracking !== "legacy") this.config.tools.workspace = new WorkspaceLifecycle(
|
|
462
|
+
if (this.config.blobDir) this.config.tools.workspace = new WorkspaceLifecycle(
|
|
446
463
|
this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
|
|
447
464
|
event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
|
|
448
465
|
join(this.config.logDir, "checkpoints"),
|
|
@@ -492,68 +509,39 @@ export class Session {
|
|
|
492
509
|
this.config.stallLimit ??= DEFAULT_STALL_LIMIT;
|
|
493
510
|
this.config.outputRecoveryLimit ??= DEFAULT_OUTPUT_RECOVERY_LIMIT;
|
|
494
511
|
await initNode(dir, 0, null, this.clock.now());
|
|
495
|
-
for await (const event of this.snapshotTurn([], true)) {
|
|
496
|
-
if (event.kind === "snapshot_failed") throw new Error(`cannot establish workspace baseline: ${event.reason}`);
|
|
497
|
-
}
|
|
498
512
|
break;
|
|
499
513
|
|
|
500
514
|
case "resume": {
|
|
501
|
-
this.config.acceptanceContract = null;
|
|
502
|
-
this.config.strictVerification = false;
|
|
503
|
-
this.config.workingSet = false;
|
|
504
|
-
this.config.workingSetVersion = 1;
|
|
505
|
-
delete this.config.guidanceVersion;
|
|
506
|
-
// Logs predating explicit policies meant segmented, regardless of today's default.
|
|
507
|
-
this.config.contextPolicy = "segmented";
|
|
508
|
-
this.config.maxParallelTools = 1;
|
|
509
|
-
this.config.stallLimit = 0;
|
|
510
|
-
this.config.outputRecoveryLimit = 0;
|
|
511
515
|
const replayed = await replayContext(dir, plan.cursor.node);
|
|
512
|
-
if (plan.cursor.context_ref)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
this.config.stallLimit = context.stallLimit ?? 0;
|
|
542
|
-
validateOutputRecoveryLimit(context.outputRecoveryLimit ?? 0);
|
|
543
|
-
this.config.outputRecoveryLimit = context.outputRecoveryLimit ?? 0;
|
|
544
|
-
this.config.frozen = context.frozen;
|
|
545
|
-
this.config.tools.gates = context.gates;
|
|
546
|
-
if (context.boundary !== undefined) this.config.boundary = context.boundary;
|
|
547
|
-
else delete this.config.boundary;
|
|
548
|
-
if (context.contextPolicy !== undefined) {
|
|
549
|
-
if (!isContextPolicy(context.contextPolicy)) throw new Error("unknown saved context policy");
|
|
550
|
-
this.config.contextPolicy = context.contextPolicy;
|
|
551
|
-
} else this.config.contextPolicy = "segmented";
|
|
552
|
-
if (context.guidance !== undefined) this.config.guidance = context.guidance;
|
|
553
|
-
else delete this.config.guidance;
|
|
554
|
-
if (context.snapshotIgnore !== undefined) this.config.snapshotIgnore = context.snapshotIgnore;
|
|
555
|
-
else delete this.config.snapshotIgnore;
|
|
556
|
-
}
|
|
516
|
+
if (!plan.cursor.context_ref) throw new Error("missing frozen session context");
|
|
517
|
+
const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
|
|
518
|
+
if (saved === null) throw new Error("missing frozen session context");
|
|
519
|
+
// S.3: one format, parsed once. Every field below was written by a
|
|
520
|
+
// session of this format, so it is restored as recorded, not defaulted.
|
|
521
|
+
const context = parseSessionContext(JSON.parse(saved));
|
|
522
|
+
if (context.agentProfile) this.config.agentProfile = { ...context.agentProfile };
|
|
523
|
+
else delete this.config.agentProfile;
|
|
524
|
+
if (context.workflowMode) this.config.tools.workflowMode = context.workflowMode;
|
|
525
|
+
if (context.workflowToolProfile) this.config.workflowToolProfile = context.workflowToolProfile;
|
|
526
|
+
this.config.workingSet = context.workingSet;
|
|
527
|
+
this.config.shellCheckpoints = context.shellCheckpoints;
|
|
528
|
+
this.config.acceptanceContract = context.acceptanceContract ?? null;
|
|
529
|
+
this.config.strictVerification = context.strictVerification;
|
|
530
|
+
this.config.skills = context.skills;
|
|
531
|
+
this.installSkillResources();
|
|
532
|
+
this.config.workNamespace = context.workNamespace;
|
|
533
|
+
this.config.maxParallelTools = context.maxParallelTools;
|
|
534
|
+
this.config.stallLimit = context.stallLimit;
|
|
535
|
+
this.config.outputRecoveryLimit = context.outputRecoveryLimit;
|
|
536
|
+
this.config.frozen = context.frozen;
|
|
537
|
+
this.config.tools.gates = context.gates;
|
|
538
|
+
this.config.contextPolicy = context.contextPolicy;
|
|
539
|
+
if (context.boundary !== undefined) this.config.boundary = context.boundary;
|
|
540
|
+
else delete this.config.boundary;
|
|
541
|
+
if (context.guidance !== undefined) this.config.guidance = context.guidance;
|
|
542
|
+
else delete this.config.guidance;
|
|
543
|
+
if (context.blindChecks) this.config.blindChecks = context.blindChecks;
|
|
544
|
+
else delete this.config.blindChecks;
|
|
557
545
|
if (plan.cursor.workspace !== undefined) this.config.tools.root = plan.cursor.workspace;
|
|
558
546
|
if (plan.cursor.blob_dir !== undefined) this.config.blobDir = plan.cursor.blob_dir;
|
|
559
547
|
for (const id of existing) {
|
|
@@ -583,14 +571,6 @@ export class Session {
|
|
|
583
571
|
this.state.anchor = null;
|
|
584
572
|
// Compare disk against the durable file projection. Treating current
|
|
585
573
|
// disk as the baseline would erase unlogged effects of a crashed tool.
|
|
586
|
-
if (this.config.blobDir && this.config.workspaceTracking === "legacy") {
|
|
587
|
-
const files = await planRestoreToNode(dir, this.state.node);
|
|
588
|
-
if (!files.ok) throw new Error(files.error);
|
|
589
|
-
this.state.lastScan = files.files;
|
|
590
|
-
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
591
|
-
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
574
|
const pending = (await readNode(dir, plan.cursor.node)).events.findLast((e) => e.t === "adopt" && !e.applied);
|
|
595
575
|
if (pending?.t === "adopt") {
|
|
596
576
|
this.pendingAdoption = pending.branch.node;
|
|
@@ -604,7 +584,7 @@ export class Session {
|
|
|
604
584
|
}
|
|
605
585
|
|
|
606
586
|
this.installWorkspace();
|
|
607
|
-
|
|
587
|
+
await this.recoverFileOperations();
|
|
608
588
|
this.restoreDeclaredTools();
|
|
609
589
|
// Consume the original pending receipt before another checkpoint can replace this projection.
|
|
610
590
|
const declarationCut = await this.applyDiscoveredTools();
|
|
@@ -623,7 +603,7 @@ export class Session {
|
|
|
623
603
|
*/
|
|
624
604
|
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
625
605
|
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
626
|
-
if (untilEvent !== undefined
|
|
606
|
+
if (untilEvent !== undefined) {
|
|
627
607
|
const cursor = await readCursor(destLogDir);
|
|
628
608
|
if (!cursor) throw new Error("missing fork cursor");
|
|
629
609
|
const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
|
|
@@ -677,9 +657,6 @@ export class Session {
|
|
|
677
657
|
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
678
658
|
release = await this.rootTools.acquire("exclusive");
|
|
679
659
|
if (!command.trim()) throw new Error("empty user command");
|
|
680
|
-
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
681
|
-
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
682
|
-
}
|
|
683
660
|
const execute = async (): Promise<ToolResult> => {
|
|
684
661
|
const captured = await observeCommand({ command, cwd: this.config.tools.root,
|
|
685
662
|
outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
|
|
@@ -696,9 +673,6 @@ export class Session {
|
|
|
696
673
|
const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
|
|
697
674
|
command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
|
|
698
675
|
await this.record(event);
|
|
699
|
-
for await (const snapshot of this.snapshotTurn([])) {
|
|
700
|
-
if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
|
|
701
|
-
}
|
|
702
676
|
await this.saveCursor();
|
|
703
677
|
return event;
|
|
704
678
|
} catch (error) {
|
|
@@ -729,12 +703,9 @@ export class Session {
|
|
|
729
703
|
const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
|
|
730
704
|
const previous = (await readNode(dir, this.state.node)).events.at(-1);
|
|
731
705
|
const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
|
|
732
|
-
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
733
|
-
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
734
|
-
}
|
|
735
706
|
const node = this.state.nextNode++;
|
|
736
707
|
await initNode(dir, node, target.parent, this.clock.now(), target.basis);
|
|
737
|
-
|
|
708
|
+
await appendEvent(dir, node, {
|
|
738
709
|
t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
|
|
739
710
|
files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
|
|
740
711
|
});
|
|
@@ -877,29 +848,26 @@ export class Session {
|
|
|
877
848
|
* handlers.
|
|
878
849
|
*/
|
|
879
850
|
private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
|
|
880
|
-
|
|
881
|
-
projectionVersion: PROJECTION_VERSION,
|
|
882
|
-
workspaceTracking: this.config.workspaceTracking ?? "journal",
|
|
883
|
-
shellCheckpoints: this.config.shellCheckpoints === true,
|
|
884
|
-
...(skills?.length ? { skills } : {}),
|
|
885
|
-
...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
|
|
886
|
-
...(this.config.strictVerification ? { strictVerification: true } : {}),
|
|
887
|
-
...(this.config.workingSet ? { workingSet: true } : {}),
|
|
888
|
-
...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
|
|
889
|
-
...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
|
|
851
|
+
const context: SessionContext = {
|
|
890
852
|
frozen, gates: this.config.tools.gates,
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
853
|
+
shellCheckpoints: this.config.shellCheckpoints === true,
|
|
854
|
+
skills: skills ?? [],
|
|
855
|
+
strictVerification: this.config.strictVerification === true,
|
|
856
|
+
workingSet: this.config.workingSet === true,
|
|
894
857
|
workNamespace: this.config.workNamespace ?? "main",
|
|
895
858
|
maxParallelTools: this.config.maxParallelTools ?? 3,
|
|
896
|
-
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
897
859
|
contextPolicy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
898
860
|
stallLimit: this.config.stallLimit ?? DEFAULT_STALL_LIMIT,
|
|
899
861
|
outputRecoveryLimit: this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT,
|
|
862
|
+
...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
|
|
863
|
+
...(this.config.tools.workflowMode ? { workflowMode: this.config.tools.workflowMode } : {}),
|
|
864
|
+
...(this.config.agentProfile ? { agentProfile: this.config.agentProfile } : {}),
|
|
865
|
+
...(this.config.workflowToolProfile ? { workflowToolProfile: this.config.workflowToolProfile } : {}),
|
|
866
|
+
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
900
867
|
...(this.config.guidance === undefined ? {} : { guidance: this.config.guidance }),
|
|
901
|
-
...(this.config.
|
|
902
|
-
}
|
|
868
|
+
...(this.config.blindChecks ? { blindChecks: this.config.blindChecks } : {}),
|
|
869
|
+
};
|
|
870
|
+
return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(context));
|
|
903
871
|
}
|
|
904
872
|
|
|
905
873
|
private async saveCursor(model = this.provider.config.spec.id, provider = this.provider.config.spec.provider,
|
|
@@ -1023,32 +991,106 @@ export class Session {
|
|
|
1023
991
|
contextInput(): ProjectInput {
|
|
1024
992
|
return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
|
|
1025
993
|
logHint: { manifestPath: manifestPath(this.config.logDir),
|
|
1026
|
-
...(this.config.guidanceVersion === 1 ? { scopedGuidance: true } : {}),
|
|
1027
994
|
segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
|
|
1028
995
|
}
|
|
1029
996
|
|
|
1030
997
|
private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
|
|
1031
998
|
return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
1032
|
-
...(this.config.workingSet ? { working_set_version:
|
|
1033
|
-
...(this.config.guidanceVersion === 1 ? { guidance_tools: tools } : {}) });
|
|
999
|
+
...(this.config.workingSet ? { working_set_version: 2 as const } : {}), guidance_tools: tools });
|
|
1034
1000
|
}
|
|
1035
1001
|
|
|
1036
1002
|
async acceptanceState(): Promise<AcceptanceState | undefined> {
|
|
1037
1003
|
if (!this.running) await this.observeEvidence();
|
|
1038
1004
|
// External editor changes must invalidate proof before the user reads it.
|
|
1039
|
-
if (!this.running) for await (const result of this.snapshotTurn([], false, "idle")) {
|
|
1040
|
-
if (result.kind === "snapshot_failed") throw new Error(`cannot inspect an unobserved workspace: ${result.reason}`);
|
|
1041
|
-
}
|
|
1042
1005
|
if (!this.running) await this.refreshRuntimeTargets();
|
|
1043
1006
|
return (await this.currentHandoff()).acceptance;
|
|
1044
1007
|
}
|
|
1045
1008
|
|
|
1009
|
+
/** S.5: the goal in force, derived from the active log ancestry, so it survives restarts and resume. */
|
|
1010
|
+
async goal(): Promise<ActiveGoal | null> {
|
|
1011
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1012
|
+
if (!history.ok) throw new Error(history.error);
|
|
1013
|
+
return activeGoal(history.nodes.flatMap(part => part.events));
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Records a goal from criteria the user accepted out of a model draft. The
|
|
1018
|
+
* draft event must be this session's assistant reply; the contract holds
|
|
1019
|
+
* only what was accepted (S.2).
|
|
1020
|
+
*/
|
|
1021
|
+
async setGoal(input: { objective: string; draft: string; criteria: readonly AcceptanceCriterion[]; accepted: readonly string[] }): Promise<ActiveGoal> {
|
|
1022
|
+
if (this.running || this.isHalfRound()) throw new Error("a goal can be set only while the session is idle");
|
|
1023
|
+
if (!input.objective.trim()) throw new Error("a goal needs an objective");
|
|
1024
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1025
|
+
if (!history.ok) throw new Error(history.error);
|
|
1026
|
+
if (!history.nodes.some(part => part.events.some(event => event.t === "assistant" && event.id === input.draft))) throw new Error("the goal draft is not in this session");
|
|
1027
|
+
const contract = goalContract(input.criteria, input.accepted);
|
|
1028
|
+
const accepted = contract.criteria.map(item => item.id);
|
|
1029
|
+
const event: ActiveGoal = { t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "set", objective: input.objective.trim(),
|
|
1030
|
+
contract, draft: input.draft, accepted, rejected: input.criteria.map(item => item.id).filter(id => !accepted.includes(id)) };
|
|
1031
|
+
await this.record(event);
|
|
1032
|
+
await this.saveCursor();
|
|
1033
|
+
return event;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The discriminating rung: each passing accepted check re-run in a clone with
|
|
1038
|
+
* this request's changes undone, recorded as counter_check facts with the
|
|
1039
|
+
* role each criterion was declared with. Guards run too: passing there is
|
|
1040
|
+
* what makes a guard a guard (S.7). The goal's verdict does not depend on it;
|
|
1041
|
+
* it tells the user which checks actually tested the change.
|
|
1042
|
+
*/
|
|
1043
|
+
private async counterCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
|
|
1044
|
+
const checks = state.criteria.flatMap(item => item.kind === "command" && item.status === "pass" && item.command
|
|
1045
|
+
? [{ id: item.id, command: item.command, ...(item.guard ? { guard: true as const } : {}) }] : []);
|
|
1046
|
+
if (checks.length === 0 || signal?.aborted) return;
|
|
1047
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1048
|
+
if (!history.ok) throw new Error(history.error);
|
|
1049
|
+
const events = currentRequestRecords(history.nodes).map(record => record.event);
|
|
1050
|
+
// A snapshot taken when this request began reverts shell changes too; otherwise the journal does.
|
|
1051
|
+
const workspace = this.config.tools.workspace, base = this.goalBaseline?.request === state.request.event ? this.goalBaseline.snapshot : null;
|
|
1052
|
+
const now = base && workspace ? await workspace.snapshot(signal) : null;
|
|
1053
|
+
const revert = base && now && workspace ? invertFileChanges(await workspace.snapshotChanges(base, now, signal)) : undefined;
|
|
1054
|
+
const results = !this.config.blobDir ? checks.map(check => ({ ...check, reason: "this session keeps no file history to undo changes from" }))
|
|
1055
|
+
: await counterCheck({ root: this.config.tools.root, blobs: this.config.blobDir, ownedPaths: this.config.tools.ownedStatePaths ?? [],
|
|
1056
|
+
events, checks, timeoutMs: this.config.tools.timeoutMs, maxOutputBytes: this.config.tools.maxOutputBytes,
|
|
1057
|
+
outputStore: join(this.config.logDir, "artifacts"), ...(revert ? { revert } : {}), ...(signal ? { signal } : {}) });
|
|
1058
|
+
for (const result of results) await this.record({ t: "counter_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
|
|
1059
|
+
criterion: result.id, command: result.command, ...(result.guard ? { guard: true as const } : {}),
|
|
1060
|
+
...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The independent rung: blind checks run on a clone of the final result once
|
|
1065
|
+
* the goal's own checks pass. The scan for exposure covers the whole log, not
|
|
1066
|
+
* just this request -- a blind file read yesterday is still seen.
|
|
1067
|
+
*/
|
|
1068
|
+
private async blindCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
|
|
1069
|
+
const blind = this.config.blindChecks;
|
|
1070
|
+
if (!blind || signal?.aborted) return;
|
|
1071
|
+
const nodes = await listNodes(this.config.logDir);
|
|
1072
|
+
const everything = (await Promise.all(nodes.map(node => readNode(this.config.logDir, node)))).flatMap(read => read.events);
|
|
1073
|
+
const exposure = blindExposure(everything, blind);
|
|
1074
|
+
const results = await runBlindChecks({ root: this.config.tools.root, blind, timeoutMs: this.config.tools.timeoutMs,
|
|
1075
|
+
maxOutputBytes: this.config.tools.maxOutputBytes, outputStore: join(this.config.logDir, "artifacts"), ...(signal ? { signal } : {}) });
|
|
1076
|
+
for (const result of results) await this.record({ t: "blind_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
|
|
1077
|
+
check: result.id, exposed: exposure !== null, ...(exposure ? { exposure } : {}), ...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async clearGoal(): Promise<boolean> {
|
|
1081
|
+
if (this.running) throw new Error("a goal can be cleared only while the session is idle");
|
|
1082
|
+
if (!await this.goal()) return false;
|
|
1083
|
+
await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "user" });
|
|
1084
|
+
await this.saveCursor();
|
|
1085
|
+
return true;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1046
1088
|
async requestState(): Promise<RequestState | undefined> {
|
|
1047
1089
|
// Use the same idle workspace/target observation boundary as /acceptance.
|
|
1048
1090
|
if (!this.running) await this.acceptanceState();
|
|
1049
1091
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1050
1092
|
if (!history.ok) throw new Error(history.error);
|
|
1051
|
-
return deriveRequestState(history.nodes);
|
|
1093
|
+
return deriveRequestState(history.nodes, { strict: this.config.strictVerification === true, blind: this.config.blindChecks !== undefined });
|
|
1052
1094
|
}
|
|
1053
1095
|
|
|
1054
1096
|
/** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
|
|
@@ -1131,9 +1173,6 @@ export class Session {
|
|
|
1131
1173
|
/** Only the app's user command calls this; note never grants human acceptance. */
|
|
1132
1174
|
async acceptCriterion(criterion: string): Promise<AcceptanceState> {
|
|
1133
1175
|
if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
|
|
1134
|
-
for await (const result of this.snapshotTurn([], true)) {
|
|
1135
|
-
if (result.kind === "snapshot_failed") throw new Error(`cannot accept an unobserved workspace: ${result.reason}`);
|
|
1136
|
-
}
|
|
1137
1176
|
const current = await this.acceptanceState();
|
|
1138
1177
|
const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
|
|
1139
1178
|
if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
|
|
@@ -1192,6 +1231,7 @@ export class Session {
|
|
|
1192
1231
|
signal?: AbortSignal,
|
|
1193
1232
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1194
1233
|
requestId?: string,
|
|
1234
|
+
options: RequestOptions = {},
|
|
1195
1235
|
): AsyncGenerator<LoopEvent> {
|
|
1196
1236
|
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
1197
1237
|
const owned = this.execution.begin(signal);
|
|
@@ -1200,7 +1240,7 @@ export class Session {
|
|
|
1200
1240
|
this.turnTools = savedTools;
|
|
1201
1241
|
this.config.tools = { ...savedTools };
|
|
1202
1242
|
this.installSkillResources();
|
|
1203
|
-
const events = this.run(userText, owned.signal, pull, requestId);
|
|
1243
|
+
const events = this.run(userText, owned.signal, pull, requestId, options);
|
|
1204
1244
|
let terminal = false;
|
|
1205
1245
|
let exhausted = false;
|
|
1206
1246
|
try {
|
|
@@ -1219,10 +1259,11 @@ export class Session {
|
|
|
1219
1259
|
yield item.value;
|
|
1220
1260
|
}
|
|
1221
1261
|
if (!terminal) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1262
|
+
// Every path through run() ends with `done` or throws; this records the impossible case honestly.
|
|
1263
|
+
const outcome = executionOutcome("execution_error");
|
|
1264
|
+
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "request ended without a terminal outcome", outcome });
|
|
1224
1265
|
const request = await this.requestState();
|
|
1225
|
-
yield { kind: "done", reason: "stopped:
|
|
1266
|
+
yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
|
|
1226
1267
|
}
|
|
1227
1268
|
} catch (error) {
|
|
1228
1269
|
// Raw provider exceptions can contain credentials; persist only the execution fact.
|
|
@@ -1238,7 +1279,7 @@ export class Session {
|
|
|
1238
1279
|
}
|
|
1239
1280
|
} finally {
|
|
1240
1281
|
while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
|
|
1241
|
-
this.config.tools = savedTools; this.turnTools = null;
|
|
1282
|
+
this.config.tools = savedTools; this.turnTools = null; this.readOnlyRequest = false;
|
|
1242
1283
|
if (owned.signal.aborted) this.cancel();
|
|
1243
1284
|
owned.finish();
|
|
1244
1285
|
if (owned.signal.aborted) await this.whenIdle();
|
|
@@ -1251,6 +1292,7 @@ export class Session {
|
|
|
1251
1292
|
signal?: AbortSignal,
|
|
1252
1293
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1253
1294
|
requestId?: string,
|
|
1295
|
+
options: RequestOptions = {},
|
|
1254
1296
|
): AsyncGenerator<LoopEvent> {
|
|
1255
1297
|
// A crash after a discovery receipt but before its cut is recovered from that same receipt.
|
|
1256
1298
|
if (!signal?.aborted) await this.applyDiscoveredTools();
|
|
@@ -1265,48 +1307,53 @@ export class Session {
|
|
|
1265
1307
|
await this.performCut("model_checkpoint");
|
|
1266
1308
|
}
|
|
1267
1309
|
const request = requestId ?? this.clock.nextId();
|
|
1310
|
+
// S.5: a draft request has no goal yet and must not inherit the current one.
|
|
1311
|
+
this.readOnlyRequest = options.readOnly === true;
|
|
1312
|
+
const goal = options.readOnly ? null : await this.goal();
|
|
1313
|
+
const contract = goal?.contract ?? this.config.acceptanceContract;
|
|
1268
1314
|
await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
|
|
1269
|
-
if (
|
|
1270
|
-
request: this.state.events.at(-1)!.id, contract
|
|
1315
|
+
if (contract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
|
|
1316
|
+
request: this.state.events.at(-1)!.id, contract });
|
|
1317
|
+
// The state a counter-check must rebuild before it may run (feat-counter-check).
|
|
1318
|
+
if (goal) {
|
|
1319
|
+
await this.config.tools.workspace?.baseline(signal);
|
|
1320
|
+
const snapshot = await this.config.tools.workspace?.snapshot(signal);
|
|
1321
|
+
this.goalBaseline = snapshot ? { request, snapshot } : null;
|
|
1322
|
+
}
|
|
1271
1323
|
// Idle changes belong to the new prompt. Recording them before `user`
|
|
1272
1324
|
// would silently move the preceding exchange's restore point forward.
|
|
1273
|
-
for await (const event of this.snapshotTurn([])) {
|
|
1274
|
-
yield event;
|
|
1275
|
-
if (event.kind === "snapshot_failed") return;
|
|
1276
|
-
}
|
|
1277
1325
|
|
|
1278
1326
|
const counter = this.config.counter ?? estimateTokens;
|
|
1279
|
-
const completedThisTurn: string[] = [];
|
|
1280
1327
|
|
|
1281
1328
|
try {
|
|
1282
1329
|
let overflowRetried = false;
|
|
1283
1330
|
const requestStarted = this.clock.now();
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1331
|
+
const requestLimit = options.readOnly ? Math.min(READ_ONLY_REQUEST_LIMIT, this.config.maxTurns) : this.config.maxTurns;
|
|
1332
|
+
const report = (cause: string, requests: number) => this.terminationReport(cause, requests, requestLimit);
|
|
1333
|
+
for (let turn = 0; turn < requestLimit; turn++) {
|
|
1334
|
+
const phase = requestPhase(turn, requestLimit);
|
|
1287
1335
|
if (signal?.aborted) {
|
|
1288
|
-
yield* this.closeInterrupted(
|
|
1336
|
+
yield* this.closeInterrupted();
|
|
1289
1337
|
return;
|
|
1290
1338
|
}
|
|
1291
1339
|
|
|
1292
1340
|
if (yield* this.checkStall()) {
|
|
1293
|
-
yield { kind: "done", reason: await
|
|
1341
|
+
yield { kind: "done", reason: await report("no-progress allowance exhausted", turn), outcome: executionOutcome("stalled") };
|
|
1294
1342
|
return;
|
|
1295
1343
|
}
|
|
1296
1344
|
if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
|
|
1297
|
-
yield { kind: "done", reason: await
|
|
1345
|
+
yield { kind: "done", reason: await report("work budget exhausted; protocol cannot disable tools while preserving frozen definitions", turn), outcome: executionOutcome("max_turns") };
|
|
1298
1346
|
return;
|
|
1299
1347
|
}
|
|
1300
1348
|
yield { kind: "turn_start", node: this.state.node };
|
|
1301
1349
|
|
|
1302
1350
|
if (phase === "finalize") {
|
|
1303
1351
|
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "budget", sources: [], text: finalizationNotice() });
|
|
1304
|
-
} else if (this.config.guidance !== false) {
|
|
1352
|
+
} else if (!options.readOnly && this.config.guidance !== false) {
|
|
1305
1353
|
const names = this.adviceNames();
|
|
1306
1354
|
const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
|
|
1307
1355
|
continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
|
|
1308
1356
|
tools: names, remainingRequests: this.config.maxTurns - turn,
|
|
1309
|
-
...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
|
|
1310
1357
|
...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
|
|
1311
1358
|
(this.config.timeBudgetMs === undefined ? null
|
|
1312
1359
|
: deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
|
|
@@ -1348,12 +1395,12 @@ export class Session {
|
|
|
1348
1395
|
// D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
|
|
1349
1396
|
if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
|
|
1350
1397
|
if (signal?.aborted) {
|
|
1351
|
-
yield* this.closeInterrupted(
|
|
1398
|
+
yield* this.closeInterrupted();
|
|
1352
1399
|
return;
|
|
1353
1400
|
}
|
|
1354
1401
|
if (phase === "finalize") {
|
|
1355
1402
|
yield { kind: "discard" };
|
|
1356
|
-
yield { kind: "done", reason: await
|
|
1403
|
+
yield { kind: "done", reason: await report("final response failed; no additional request was made", turn + 1), outcome: executionOutcome("provider_error") };
|
|
1357
1404
|
return;
|
|
1358
1405
|
}
|
|
1359
1406
|
if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
|
|
@@ -1382,7 +1429,7 @@ export class Session {
|
|
|
1382
1429
|
if (signal?.aborted) {
|
|
1383
1430
|
yield { kind: "usage", usage: result.usage, ok: true };
|
|
1384
1431
|
yield { kind: "discard" };
|
|
1385
|
-
yield* this.closeInterrupted(
|
|
1432
|
+
yield* this.closeInterrupted();
|
|
1386
1433
|
return;
|
|
1387
1434
|
}
|
|
1388
1435
|
const promptTokens =
|
|
@@ -1452,7 +1499,7 @@ export class Session {
|
|
|
1452
1499
|
!result.blocks.some(block => block.type === "text" && block.text.trim()))) {
|
|
1453
1500
|
for (const item of await this.answerUnpairedTools("not executed: tools are disabled in the final request", "not_started"))
|
|
1454
1501
|
yield { kind: "tool_result", text: item.content, ok: false };
|
|
1455
|
-
yield { kind: "done", reason: await
|
|
1502
|
+
yield { kind: "done", reason: await report("request budget exhausted; final response did not provide a complete text answer", turn + 1), outcome: executionOutcome("max_turns") };
|
|
1456
1503
|
return;
|
|
1457
1504
|
}
|
|
1458
1505
|
|
|
@@ -1491,13 +1538,21 @@ export class Session {
|
|
|
1491
1538
|
});
|
|
1492
1539
|
continue;
|
|
1493
1540
|
}
|
|
1494
|
-
if (
|
|
1541
|
+
if (contract) await this.refreshRuntimeTargets();
|
|
1495
1542
|
const state = await this.requestState();
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1543
|
+
const remaining = requestLimit - turn - 1;
|
|
1544
|
+
// S.5: an active goal replaces the one-time completion check. It keeps
|
|
1545
|
+
// the request going until the ledger says the goal is met; a stated
|
|
1546
|
+
// blocker, a spent budget or the stall watchdog still end the run.
|
|
1547
|
+
const verdict = goal && state ? goalDecision(state, remaining, idleGoalChecks(this.state.events)) : null;
|
|
1548
|
+
if (state && (verdict?.action === "met" || verdict?.action === "await_human")) {
|
|
1549
|
+
await this.counterCheckRequest(state, signal);
|
|
1550
|
+
await this.blindCheckRequest(state, signal);
|
|
1551
|
+
}
|
|
1552
|
+
if (verdict?.action === "met") await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "met" });
|
|
1553
|
+
const decision = verdict?.action === "continue" ? { action: "continue" as const, guidance: { reason: "goal" as const, sources: [state!.request.event], text: verdict.text } }
|
|
1554
|
+
: verdict || !state || options.readOnly || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
|
|
1555
|
+
: requestCompletion(state, remaining);
|
|
1501
1556
|
if (decision.action === "continue") {
|
|
1502
1557
|
if (yield* this.maybeCut(counter, true)) {
|
|
1503
1558
|
yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
|
|
@@ -1530,7 +1585,7 @@ export class Session {
|
|
|
1530
1585
|
try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
|
|
1531
1586
|
const call = calls[callIndex]!;
|
|
1532
1587
|
if (signal?.aborted && !pending.has(call.call_id)) {
|
|
1533
|
-
yield* this.closeInterrupted(
|
|
1588
|
+
yield* this.closeInterrupted();
|
|
1534
1589
|
return;
|
|
1535
1590
|
}
|
|
1536
1591
|
|
|
@@ -1553,9 +1608,9 @@ export class Session {
|
|
|
1553
1608
|
if (!settled.ok) throw settled.error;
|
|
1554
1609
|
receipt = settled.value;
|
|
1555
1610
|
} else {
|
|
1556
|
-
if (signal?.aborted) { yield* this.closeInterrupted(
|
|
1611
|
+
if (signal?.aborted) { yield* this.closeInterrupted(); return; }
|
|
1557
1612
|
yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
|
|
1558
|
-
if (signal?.aborted) { yield* this.closeInterrupted(
|
|
1613
|
+
if (signal?.aborted) { yield* this.closeInterrupted(); return; }
|
|
1559
1614
|
const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
|
|
1560
1615
|
receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
|
|
1561
1616
|
}
|
|
@@ -1567,9 +1622,8 @@ export class Session {
|
|
|
1567
1622
|
yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
|
|
1568
1623
|
await receipt.commit();
|
|
1569
1624
|
|
|
1570
|
-
completedThisTurn.push(call.call_id);
|
|
1571
1625
|
if (signal?.aborted && pending.size === 0) {
|
|
1572
|
-
yield* this.closeInterrupted(
|
|
1626
|
+
yield* this.closeInterrupted();
|
|
1573
1627
|
return;
|
|
1574
1628
|
}
|
|
1575
1629
|
} } finally { await Promise.allSettled(pending.values()); }
|
|
@@ -1577,7 +1631,6 @@ export class Session {
|
|
|
1577
1631
|
// One scan per turn, not per tool call: a turn is the smallest unit
|
|
1578
1632
|
// that means anything, and per-call would be the character-level
|
|
1579
1633
|
// granularity the research warns against.
|
|
1580
|
-
yield* this.snapshotTurn(calls.map((c) => c.call_id));
|
|
1581
1634
|
const declarationNode = this.state.node;
|
|
1582
1635
|
if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
|
|
1583
1636
|
if (this.pendingAdoption !== null) await this.adoptPendingBranch();
|
|
@@ -1596,7 +1649,7 @@ export class Session {
|
|
|
1596
1649
|
}
|
|
1597
1650
|
}
|
|
1598
1651
|
|
|
1599
|
-
yield { kind: "done", reason: await
|
|
1652
|
+
yield { kind: "done", reason: await report("request budget exhausted", requestLimit), outcome: executionOutcome("max_turns") };
|
|
1600
1653
|
} finally {
|
|
1601
1654
|
// If the consumer aborts and stops pulling, the generator is closed
|
|
1602
1655
|
// without running the yield* closeInterrupted path. Pairing still
|
|
@@ -1636,14 +1689,11 @@ export class Session {
|
|
|
1636
1689
|
return added;
|
|
1637
1690
|
}
|
|
1638
1691
|
|
|
1639
|
-
private async *closeInterrupted(
|
|
1692
|
+
private async *closeInterrupted(): AsyncGenerator<LoopEvent> {
|
|
1640
1693
|
const added = await this.answerUnpairedTools();
|
|
1641
1694
|
for (const item of added) {
|
|
1642
1695
|
yield { kind: "tool_result", text: item.content, ok: false };
|
|
1643
1696
|
}
|
|
1644
|
-
if (completedCallIds.length > 0) {
|
|
1645
|
-
yield* this.snapshotTurn(completedCallIds);
|
|
1646
|
-
}
|
|
1647
1697
|
await this.recordInterrupt();
|
|
1648
1698
|
yield { kind: "done", reason: "interrupted", outcome: executionOutcome("cancelled") };
|
|
1649
1699
|
}
|
|
@@ -1709,78 +1759,6 @@ export class Session {
|
|
|
1709
1759
|
* Returning the verdict rather than only announcing it is the point: an
|
|
1710
1760
|
* advisory the loop ignores looks like a guard while the budget drains.
|
|
1711
1761
|
*/
|
|
1712
|
-
/**
|
|
1713
|
-
* Records what the workspace actually did this turn.
|
|
1714
|
-
*
|
|
1715
|
-
* Content goes to the blob store so a later restore has something to
|
|
1716
|
-
* restore from; the event holds only digests, which is what keeps it a
|
|
1717
|
-
* delta rather than a copy of the workspace.
|
|
1718
|
-
*/
|
|
1719
|
-
private async *snapshotTurn(callIds: readonly string[], baseline = false, phase: "turn" | "idle" = "turn"): AsyncGenerator<LoopEvent> {
|
|
1720
|
-
// P.3: retained only for explicit historical compatibility. New sessions never scan here.
|
|
1721
|
-
if (this.config.workspaceTracking !== "legacy") return;
|
|
1722
|
-
const blobDir = this.config.blobDir;
|
|
1723
|
-
if (!blobDir) return;
|
|
1724
|
-
|
|
1725
|
-
const scan = await scanWorkspace(
|
|
1726
|
-
this.config.tools.root,
|
|
1727
|
-
this.config.snapshotIgnore ?? DEFAULT_IGNORE,
|
|
1728
|
-
);
|
|
1729
|
-
if (!scan.ok) {
|
|
1730
|
-
// Reported rather than skipped: without a scan a restore would delete
|
|
1731
|
-
// files it never recorded, so the caller has to know it is blind.
|
|
1732
|
-
yield { kind: "snapshot_failed", reason: scan.error };
|
|
1733
|
-
return;
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
const delta = diffScans(this.state.lastScan, scan.files);
|
|
1737
|
-
const nothingHappened =
|
|
1738
|
-
Object.keys(delta.added).length === 0 &&
|
|
1739
|
-
Object.keys(delta.changed).length === 0 &&
|
|
1740
|
-
delta.removed.length === 0;
|
|
1741
|
-
if (nothingHappened) return;
|
|
1742
|
-
|
|
1743
|
-
// Only this turn's effects: reconciling against the whole node would
|
|
1744
|
-
// report every earlier edit as declared-but-unchanged.
|
|
1745
|
-
const answered = new Set(callIds);
|
|
1746
|
-
const effects = this.state.events
|
|
1747
|
-
.filter((e) => e.t === "tool_result" && answered.has(e.call_id) && e.effect)
|
|
1748
|
-
.map((e) => (e as Extract<LogEvent, { t: "tool_result" }>).effect!);
|
|
1749
|
-
const rec = reconcile(effects, delta);
|
|
1750
|
-
|
|
1751
|
-
for (const [path, sha] of Object.entries({ ...delta.added, ...delta.changed })) {
|
|
1752
|
-
const stored = await putFileBlob(blobDir, join(this.config.tools.root, path));
|
|
1753
|
-
if (stored !== sha) {
|
|
1754
|
-
// The file changed between the scan and the read. Recording the
|
|
1755
|
-
// digest that was actually stored keeps the event honest.
|
|
1756
|
-
if (path in delta.added) delta.added[path] = stored;
|
|
1757
|
-
else delta.changed[path] = stored;
|
|
1758
|
-
scan.files[path] = stored;
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
await this.record({
|
|
1763
|
-
t: "snapshot",
|
|
1764
|
-
id: this.clock.nextId(),
|
|
1765
|
-
at: this.clock.now(),
|
|
1766
|
-
added: delta.added,
|
|
1767
|
-
changed: delta.changed,
|
|
1768
|
-
removed: delta.removed,
|
|
1769
|
-
undeclared: baseline ? [] : rec.undeclared,
|
|
1770
|
-
declared_but_unchanged: rec.declaredButUnchanged,
|
|
1771
|
-
workspace_sha256: workspaceVersion(scan.files),
|
|
1772
|
-
phase,
|
|
1773
|
-
});
|
|
1774
|
-
this.state.lastScan = scan.files;
|
|
1775
|
-
|
|
1776
|
-
if (!baseline && rec.undeclared.length > 0) {
|
|
1777
|
-
yield {
|
|
1778
|
-
kind: "undeclared_change",
|
|
1779
|
-
reason: `changed with no tool claiming it: ${rec.undeclared.join(", ")}`,
|
|
1780
|
-
};
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
1762
|
private get limits(): BranchLimits {
|
|
1785
1763
|
return { ...DEFAULT_BRANCH_LIMITS, ...this.config.branches };
|
|
1786
1764
|
}
|
|
@@ -1843,7 +1821,8 @@ export class Session {
|
|
|
1843
1821
|
try {
|
|
1844
1822
|
const events: LoopEvent[] = [];
|
|
1845
1823
|
let emit = (event: LoopEvent) => { events.push(event); };
|
|
1846
|
-
let context: ToolContext = { ...this.config.tools,
|
|
1824
|
+
let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
|
|
1825
|
+
imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1847
1826
|
advice: this.allowedNames(),
|
|
1848
1827
|
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
|
|
1849
1828
|
if (native) context = nativeCommandContext(context);
|
|
@@ -1935,7 +1914,8 @@ export class Session {
|
|
|
1935
1914
|
...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
|
|
1936
1915
|
...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
|
|
1937
1916
|
...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
|
|
1938
|
-
...(out.warnings ? { warnings: out.warnings } : {}), ...(out.
|
|
1917
|
+
...(out.warnings ? { warnings: out.warnings } : {}), ...(out.instructions ? { instructions: out.instructions } : {}),
|
|
1918
|
+
...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
|
|
1939
1919
|
...(out.content_ref ? { content_ref: out.content_ref } : {}),
|
|
1940
1920
|
...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
|
|
1941
1921
|
}
|
|
@@ -2052,7 +2032,6 @@ export class Session {
|
|
|
2052
2032
|
this.state.branchesOpened += 1;
|
|
2053
2033
|
const ref = await this.nodeRefFor(node);
|
|
2054
2034
|
// A fork point must include any preceding tool effects in this batch.
|
|
2055
|
-
yield* this.snapshotTurn([]);
|
|
2056
2035
|
|
|
2057
2036
|
// A writing branch needs its own checkout, or parallel branches overwrite
|
|
2058
2037
|
// each other's files with nothing to show isolation was never in place.
|
|
@@ -2107,7 +2086,6 @@ export class Session {
|
|
|
2107
2086
|
);
|
|
2108
2087
|
branch.state.node = node;
|
|
2109
2088
|
branch.state.nextNode = this.state.nextNode;
|
|
2110
|
-
branch.state.lastScan = { ...this.state.lastScan };
|
|
2111
2089
|
if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
|
|
2112
2090
|
this.children.set(node, branch);
|
|
2113
2091
|
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
@@ -2115,9 +2093,6 @@ export class Session {
|
|
|
2115
2093
|
let finalParts: string[] = [];
|
|
2116
2094
|
let outcome = executionOutcome("unknown");
|
|
2117
2095
|
try {
|
|
2118
|
-
for await (const event of branch.snapshotTurn([], true)) {
|
|
2119
|
-
if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
|
|
2120
|
-
}
|
|
2121
2096
|
for await (const e of branch.send(prompt, signal)) {
|
|
2122
2097
|
if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
|
|
2123
2098
|
if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
|
|
@@ -2342,11 +2317,6 @@ export class Session {
|
|
|
2342
2317
|
}
|
|
2343
2318
|
const checkout = this.state.worktrees.get(root);
|
|
2344
2319
|
if (checkout) this.config.tools.root = checkout.path;
|
|
2345
|
-
if (this.config.workspaceTracking === "legacy") {
|
|
2346
|
-
const scan = await scanWorkspace(this.config.tools.root, this.ignore);
|
|
2347
|
-
if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
|
|
2348
|
-
this.state.lastScan = scan.files;
|
|
2349
|
-
}
|
|
2350
2320
|
this.installWorkspace();
|
|
2351
2321
|
await this.saveCursor();
|
|
2352
2322
|
this.pendingAdoption = null;
|
|
@@ -2466,10 +2436,6 @@ export class Session {
|
|
|
2466
2436
|
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2467
2437
|
}
|
|
2468
2438
|
|
|
2469
|
-
private get ignore(): readonly string[] {
|
|
2470
|
-
return this.config.snapshotIgnore ?? DEFAULT_IGNORE;
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
2439
|
/**
|
|
2474
2440
|
* Every completed exchange, including abandoned branches and conversation-only turns.
|
|
2475
2441
|
*
|
|
@@ -2519,14 +2485,6 @@ export class Session {
|
|
|
2519
2485
|
for (const e of n.events) {
|
|
2520
2486
|
if (e.t === "workspace_observation") continue;
|
|
2521
2487
|
if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
|
|
2522
|
-
if (e.t === "snapshot" && e.phase === "idle") {
|
|
2523
|
-
close();
|
|
2524
|
-
open = null;
|
|
2525
|
-
points.push({ node: n.node, atEvent: e.id, prompt: "Observed workspace changes",
|
|
2526
|
-
changed: Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length,
|
|
2527
|
-
onPath: onPath.has(e.id) });
|
|
2528
|
-
continue;
|
|
2529
|
-
}
|
|
2530
2488
|
if (e.t === "user") {
|
|
2531
2489
|
close();
|
|
2532
2490
|
open = { prompt: e.text, changed: 0, last: e.id, userId: e.id };
|
|
@@ -2535,10 +2493,6 @@ export class Session {
|
|
|
2535
2493
|
if (!open) continue;
|
|
2536
2494
|
open.last = e.id;
|
|
2537
2495
|
if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
|
|
2538
|
-
if (e.t === "snapshot") {
|
|
2539
|
-
open.changed +=
|
|
2540
|
-
Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length;
|
|
2541
|
-
}
|
|
2542
2496
|
}
|
|
2543
2497
|
close();
|
|
2544
2498
|
}
|
|
@@ -2560,21 +2514,12 @@ export class Session {
|
|
|
2560
2514
|
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2561
2515
|
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2562
2516
|
}
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2570
|
-
}
|
|
2571
|
-
const plan = await planRestoreToNode(
|
|
2572
|
-
this.config.logDir, { node: point.node, atEvent: point.atEvent },
|
|
2573
|
-
undefined, this.config.tools.root, this.ignore,
|
|
2574
|
-
);
|
|
2575
|
-
return plan.ok
|
|
2576
|
-
? { ok: true, willWrite: plan.willWrite, willDelete: plan.willDelete }
|
|
2577
|
-
: { ok: false, error: plan.error };
|
|
2517
|
+
try {
|
|
2518
|
+
const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
|
|
2519
|
+
if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
|
|
2520
|
+
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2521
|
+
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2522
|
+
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2578
2523
|
}
|
|
2579
2524
|
|
|
2580
2525
|
/**
|
|
@@ -2592,145 +2537,9 @@ export class Session {
|
|
|
2592
2537
|
* conversation node. File-only restoration appends its observed transition.
|
|
2593
2538
|
*/
|
|
2594
2539
|
async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
|
|
2595
|
-
|
|
2596
|
-
const dir = this.config.logDir;
|
|
2597
|
-
const root = this.config.tools.root;
|
|
2598
|
-
const target = { node: point.node, atEvent: point.atEvent };
|
|
2599
|
-
const movesFiles = mode !== "context";
|
|
2600
|
-
|
|
2601
|
-
const blobDir = this.config.blobDir;
|
|
2602
|
-
// Only the modes that move files need somewhere to read them from. A
|
|
2603
|
-
// context-only rewind is the one thing still worth offering a session
|
|
2604
|
-
// that never took snapshots, so requiring them would refuse it for no
|
|
2605
|
-
// reason.
|
|
2606
|
-
if (movesFiles && !blobDir) {
|
|
2607
|
-
return {
|
|
2608
|
-
ok: false,
|
|
2609
|
-
error: "this session stores no snapshots, so the files cannot be moved back",
|
|
2610
|
-
};
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
// Planned before anything is written: an unresolvable target must leave
|
|
2614
|
-
// the session exactly where it was, not half moved. The plan is also
|
|
2615
|
-
// what every mode needs -- to apply, or to say what it is NOT applying.
|
|
2616
|
-
const plan = await planRestoreToNode(dir, target, undefined, root, this.ignore);
|
|
2617
|
-
if (!plan.ok) return { ok: false, error: plan.error };
|
|
2618
|
-
const historical = await readCursorAt(dir, point.node, point.atEvent);
|
|
2619
|
-
// Preserve idle edits before moving away from them. An unobserved file is
|
|
2620
|
-
// not disposable just because the target predates it (D8/appendix H).
|
|
2621
|
-
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
2622
|
-
if (event.kind === "snapshot_failed") return { ok: false, error: event.reason ?? "snapshot failed" };
|
|
2623
|
-
}
|
|
2624
|
-
const beforeFiles = { ...this.state.lastScan };
|
|
2625
|
-
|
|
2626
|
-
// Computed BEFORE anything is applied. Both partial modes diverge over
|
|
2627
|
-
// the same set -- where the target and the workspace as it stands now
|
|
2628
|
-
// disagree -- and only the story differs, which `mode` already carries.
|
|
2629
|
-
// Computing it afterwards gave "files" an empty set, because by then the
|
|
2630
|
-
// disk had become the target: the note still said to re-read but named
|
|
2631
|
-
// nothing, which is the vague form this is meant to replace.
|
|
2632
|
-
const divergence = mode === "both" ? [] : await this.divergenceFrom(plan.files);
|
|
2633
|
-
|
|
2634
|
-
let moved: RestoreResult = {
|
|
2635
|
-
restored: [], removed: [], unrecoverable: [],
|
|
2636
|
-
note: "The workspace was left exactly as it is; only the conversation moved.",
|
|
2637
|
-
};
|
|
2638
|
-
if (movesFiles && blobDir) {
|
|
2639
|
-
// Applied from the plan just computed rather than through
|
|
2640
|
-
// restoreToNode, which would re-plan: a second traversal, and a guard
|
|
2641
|
-
// here that could never fire because the only way the apply fails is a
|
|
2642
|
-
// plan that failed.
|
|
2643
|
-
moved = await applyRestorePlan(
|
|
2644
|
-
root, blobDir, plan.files, this.ignore,
|
|
2645
|
-
`the point where you were asked: ${point.prompt.slice(0, 40)}`,
|
|
2646
|
-
);
|
|
2647
|
-
if (moved.unrecoverable.length > 0) return { ok: true, node: this.state.node, ...moved };
|
|
2648
|
-
}
|
|
2649
|
-
|
|
2650
|
-
const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
|
|
2651
|
-
const stamp = { id: this.clock.nextId(), at: this.clock.now() };
|
|
2652
|
-
const event: LogEvent = {
|
|
2653
|
-
t: "rewind", ...stamp, mode, from, prompt: point.prompt,
|
|
2654
|
-
restored: moved.restored, removed: moved.removed,
|
|
2655
|
-
unrecoverable: moved.unrecoverable, divergence,
|
|
2656
|
-
};
|
|
2657
|
-
|
|
2658
|
-
if (mode === "files") {
|
|
2659
|
-
// No fork. The conversation did not move, so the node did not either;
|
|
2660
|
-
// a new node would be identical to this one but for a single event.
|
|
2661
|
-
await this.record(event);
|
|
2662
|
-
this.state.lastScan = beforeFiles;
|
|
2663
|
-
for await (const result of this.snapshotTurn([], true)) {
|
|
2664
|
-
if (result.kind === "snapshot_failed") throw new Error(result.reason);
|
|
2665
|
-
}
|
|
2666
|
-
return { ok: true, node: this.state.node, ...moved };
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2669
|
-
const node = this.state.nextNode++;
|
|
2670
|
-
// "inherit": the point of a rewind is to continue from the target as if
|
|
2671
|
-
// the turns after it had not happened, so the target's events replay.
|
|
2672
|
-
await initNode(dir, node, from, this.clock.now(), "inherit");
|
|
2673
|
-
|
|
2674
|
-
// The running session projects from state.events, which a new node starts
|
|
2675
|
-
// empty -- correct for a cut, where the handoff replaces them, and wrong
|
|
2676
|
-
// here. The inherited events come from the ancestry walk, which is the
|
|
2677
|
-
// same function the projection would use and already honours both the
|
|
2678
|
-
// fork point and any cut further up.
|
|
2679
|
-
const replayed = await replayContext(dir, node);
|
|
2680
|
-
|
|
2681
|
-
this.state.node = node;
|
|
2682
|
-
this.state.events = [];
|
|
2683
|
-
this.state.handoff = replayed.handoff;
|
|
2684
|
-
this.resetDerivedToolState();
|
|
2685
|
-
this.state.depth = replayed.depth;
|
|
2686
|
-
// The new node's prefix has never been sent, so nothing to anchor to.
|
|
2687
|
-
this.state.anchor = null;
|
|
2688
|
-
// The scan baseline follows the files, which a context-only rewind did
|
|
2689
|
-
// not move. Setting it to the plan there would make the next turn report
|
|
2690
|
-
// the untouched workspace as a change nobody declared.
|
|
2691
|
-
this.state.lastScan = plan.files;
|
|
2692
|
-
// Path depth comes from replay; the monotonic allocator still accounts
|
|
2693
|
-
// for every node spent outside this selected path.
|
|
2694
|
-
|
|
2695
|
-
await this.record(event);
|
|
2696
|
-
// Recorded first, then replayed, so the rewind event stays the node's own
|
|
2697
|
-
// annotation rather than something the inherited context contains.
|
|
2698
|
-
this.state.events = [...replayed.contextEvents, ...this.state.events];
|
|
2699
|
-
// A context-only fork inherits old file history while the disk stays
|
|
2700
|
-
// newer. Record that delta now, so later replay cannot resurrect a stale
|
|
2701
|
-
// workspace and the next tool starts from the actual files.
|
|
2702
|
-
for await (const result of this.snapshotTurn([], true)) {
|
|
2703
|
-
if (result.kind === "snapshot_failed") throw new Error(result.reason);
|
|
2704
|
-
}
|
|
2705
|
-
const incompatible = historical === null || !sameModel(historical, {
|
|
2706
|
-
model: this.provider.config.spec.id, provider: this.provider.config.spec.provider,
|
|
2707
|
-
});
|
|
2708
|
-
if (incompatible) {
|
|
2709
|
-
// Persist the guard before opening the new model context, including on crash recovery.
|
|
2710
|
-
const current = await readCursor(dir);
|
|
2711
|
-
if (current) await writeCursor(dir, { ...current, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
|
|
2712
|
-
await this.performCut("model_checkpoint", this.provider.config.spec.id);
|
|
2713
|
-
}
|
|
2714
|
-
await this.saveCursor();
|
|
2715
|
-
|
|
2716
|
-
return { ok: true, node: this.state.node, ...moved };
|
|
2540
|
+
return this.rewindJournal(point, mode);
|
|
2717
2541
|
}
|
|
2718
2542
|
|
|
2719
|
-
/**
|
|
2720
|
-
* Paths where the target and the workspace as it stands now disagree.
|
|
2721
|
-
*
|
|
2722
|
-
* Read off the disk rather than inferred from the plan, because a file a
|
|
2723
|
-
* person changed by hand belongs in the answer just as much as one a
|
|
2724
|
-
* rewound turn wrote.
|
|
2725
|
-
*/
|
|
2726
|
-
private async divergenceFrom(target: FileSet): Promise<string[]> {
|
|
2727
|
-
const current = await scanWorkspace(this.config.tools.root, this.ignore);
|
|
2728
|
-
if (!current.ok) return [];
|
|
2729
|
-
const delta = diffScans(target, current.files);
|
|
2730
|
-
return [
|
|
2731
|
-
...Object.keys(delta.added), ...Object.keys(delta.changed), ...delta.removed,
|
|
2732
|
-
].sort();
|
|
2733
|
-
}
|
|
2734
2543
|
|
|
2735
2544
|
private resetDerivedToolState(): void {
|
|
2736
2545
|
this.stall = null;
|
|
@@ -2767,10 +2576,10 @@ export class Session {
|
|
|
2767
2576
|
/** A steering signal releases only the wait; cancellation owns process termination. */
|
|
2768
2577
|
wakeProcessWaits(): void { this.processWake.abort(); }
|
|
2769
2578
|
|
|
2770
|
-
private async terminationReport(cause: string, requests: number): Promise<string> {
|
|
2579
|
+
private async terminationReport(cause: string, requests: number, limit = this.config.maxTurns): Promise<string> {
|
|
2771
2580
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2772
2581
|
if (!history.ok) throw new Error(history.error);
|
|
2773
|
-
return stopReport(history.nodes.flatMap(part => part.events), requests,
|
|
2582
|
+
return stopReport(history.nodes.flatMap(part => part.events), requests, limit, cause);
|
|
2774
2583
|
}
|
|
2775
2584
|
|
|
2776
2585
|
/** N.8: build and check the replacement before committing a durable boundary. */
|
|
@@ -2965,10 +2774,10 @@ export class Session {
|
|
|
2965
2774
|
const fromDisk = replayed.events;
|
|
2966
2775
|
const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
|
|
2967
2776
|
let guidanceTools = this.adviceNames();
|
|
2968
|
-
if (
|
|
2777
|
+
if (nextContextRef !== undefined) {
|
|
2969
2778
|
const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
|
|
2970
2779
|
if (saved === null) throw new Error("missing successor guidance context");
|
|
2971
|
-
guidanceTools = this.adviceNames((JSON.parse(saved)
|
|
2780
|
+
guidanceTools = this.adviceNames(parseSessionContext(JSON.parse(saved)).frozen);
|
|
2972
2781
|
}
|
|
2973
2782
|
|
|
2974
2783
|
// The cut event is part of the closed file. Digest after appending it
|
|
@@ -2986,8 +2795,8 @@ export class Session {
|
|
|
2986
2795
|
next_thinking: nextThinking,
|
|
2987
2796
|
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
2988
2797
|
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
2989
|
-
...(this.config.workingSet ? { working_set_version:
|
|
2990
|
-
|
|
2798
|
+
...(this.config.workingSet ? { working_set_version: 2 as const } : {}),
|
|
2799
|
+
guidance_tools: guidanceTools,
|
|
2991
2800
|
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
2992
2801
|
});
|
|
2993
2802
|
const self = await digestedRef(dir, node);
|