@visual-z/xeno 0.2.1 → 0.4.0
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 +18 -0
- package/README.md +126 -34
- package/docs/app-server.md +84 -0
- package/docs/install.md +51 -13
- package/docs/providers.md +24 -3
- package/package.json +7 -5
- package/src/apps/app-server.ts +28 -0
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +45 -8
- package/src/apps/completions.ts +78 -22
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +333 -1542
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +149 -94
- package/src/apps/ui/app.ts +1553 -0
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +86 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +37 -21
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +81 -6
- package/src/core/events.ts +99 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +70 -19
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +33 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-input.ts +6 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +11 -4
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/catalog.ts +13 -3
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +462 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1088 -282
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +146 -0
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +200 -0
- package/src/tui/ui/control-panel.ts +23 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +279 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
package/src/runtime/loop.ts
CHANGED
|
@@ -18,53 +18,78 @@
|
|
|
18
18
|
* request.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { LogEvent, AssistantBlock, NodeRef, CutReason } from "../core/events.ts";
|
|
21
|
+
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
|
|
22
22
|
import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
|
|
23
23
|
import { hydrateImages } from "./images.ts";
|
|
24
24
|
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
|
-
import type { FrozenPrefix, Message } from "../core/project.ts";
|
|
28
|
-
import { projectContext } from "../core/project.ts";
|
|
27
|
+
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
28
|
+
import { projectContext, PROJECTION_VERSION, toolCallIdentityViolations } from "../core/project.ts";
|
|
29
29
|
import { deriveHandoff, carryForward } from "../core/derive.ts";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
30
|
+
import { latestObservedFileVersion } from "../core/file-precondition.ts";
|
|
31
|
+
import { latestProcessReceipt } from "../core/process-receipt.ts";
|
|
32
|
+
import { advanceRecovery, emptyRecoveryState, recoveryReview, type RecoveryState } from "../core/failure-recovery.ts";
|
|
33
|
+
import { shouldStopAndAsk, type BoundaryConfig } from "../core/boundary.ts";
|
|
34
|
+
import {
|
|
35
|
+
contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, estimateBoundaryCost, isContextPolicy,
|
|
36
|
+
type ContextLimits, type ContextPolicy,
|
|
37
|
+
} from "../core/context-policy.ts";
|
|
38
|
+
import { retainedContext, selectRecentContext } from "../core/context-window.ts";
|
|
33
39
|
import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
|
|
34
40
|
import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
|
|
35
|
-
import { join } from "node:path";
|
|
41
|
+
import { join, resolve } from "node:path";
|
|
36
42
|
import { randomUUID } from "node:crypto";
|
|
37
43
|
import { pendingToolCalls } from "../core/execution.ts";
|
|
44
|
+
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
45
|
+
import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
|
|
38
46
|
import { executionOutcome, modelOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
47
|
+
import { commandCompletion } from "../core/command-contract.ts";
|
|
39
48
|
import { ExecutionControl } from "./execution-control.ts";
|
|
49
|
+
import { BranchJobs } from "./branch-jobs.ts";
|
|
50
|
+
import { HistoryCache } from "./history-cache.ts";
|
|
51
|
+
import { RuntimeTargets } from "./runtime-target.ts";
|
|
52
|
+
import { ToolBroker, MAX_NESTED_ARGUMENT_BYTES, MAX_INLINE_RESULT_DATA_BYTES, type BrokerReceipt, type BrokerExecution } from "./tool-broker.ts";
|
|
53
|
+
import { ToolQueue } from "./tool-queue.ts";
|
|
54
|
+
import { NATIVE_COMMAND_NAME, nativeCommandContext, nativeCommandResult, type NativeCommandInput, type NativeCommandResult } from "./tools.ts";
|
|
55
|
+
import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
|
|
56
|
+
import { archiveObservation } from "../store/observations.ts";
|
|
57
|
+
import { disposeExecSessions, runningSessions } from "./exec-sessions.ts";
|
|
58
|
+
import { branchStatus, processStatus, deriveProcesses, type ProcessObservation } from "../core/process-state.ts";
|
|
40
59
|
import { schedulingWindow } from "../core/scheduling.ts";
|
|
41
60
|
import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
|
|
42
61
|
import { isContextOverflow } from "../core/overflow.ts";
|
|
43
|
-
import {
|
|
62
|
+
import { requestCompletion } from "../core/completion-review.ts";
|
|
63
|
+
import { deriveRequestState, type RequestState } from "../core/request-state.ts";
|
|
44
64
|
import { churnReview } from "../core/churn.ts";
|
|
65
|
+
import { deriveBranchReceipt } from "../core/branch-receipt.ts";
|
|
66
|
+
import { skillTools, type Skill } from "../core/skills.ts";
|
|
67
|
+
import type { WorkItem } from "../core/work-state.ts";
|
|
68
|
+
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceState } from "../core/acceptance.ts";
|
|
45
69
|
import { deadlineNotice } from "../core/deadline.ts";
|
|
46
70
|
import { statusOf } from "./retry.ts";
|
|
47
71
|
import { assertNever } from "../core/exhaustive.ts";
|
|
48
72
|
import { isLogEvent } from "../core/event-validation.ts";
|
|
49
73
|
import {
|
|
50
74
|
initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
|
|
51
|
-
listNodes, readCursor, readCursorAt, writeCursor,
|
|
75
|
+
listNodes, readManifest, readCursor, readCursorAt, writeCursor,
|
|
52
76
|
} from "../store/log.ts";
|
|
53
|
-
import { replayContext } from "../store/replay.ts";
|
|
77
|
+
import { replayContext, carriedHandoff } from "../store/replay.ts";
|
|
54
78
|
import { recoverCut } from "../store/recovery.ts";
|
|
55
79
|
import { runHistoryTool } from "./history-tools.ts";
|
|
56
80
|
import { prepareWorkingNote } from "./working-notes.ts";
|
|
57
81
|
import { copyAncestryTo } from "../store/sessions.ts";
|
|
58
|
-
import type
|
|
82
|
+
import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
|
|
59
83
|
import {
|
|
60
84
|
scanWorkspace, diffScans, reconcile, DEFAULT_IGNORE, workspaceVersion,
|
|
61
85
|
type FileSet, type RestoreResult,
|
|
62
86
|
} from "../store/workspace.ts";
|
|
63
87
|
import { putBlob, getBlob, putFileBlob } from "../store/blobs.ts";
|
|
88
|
+
import { hydrateProcessReceipts } from "../store/process-receipts.ts";
|
|
64
89
|
import { readNodeHistory, planRestoreToNode, applyRestorePlan } from "../store/restore.ts";
|
|
65
90
|
import { createWorktree, removeWorktree } from "../store/worktree.ts";
|
|
66
91
|
import {
|
|
67
|
-
|
|
92
|
+
parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
|
|
68
93
|
BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
|
|
69
94
|
type ToolContext, type Annotation, type ToolResult,
|
|
70
95
|
} from "./tools.ts";
|
|
@@ -79,6 +104,10 @@ import { resolveHistoryPosition } from "../store/session-position.ts";
|
|
|
79
104
|
import { observeCommand } from "../store/command.ts";
|
|
80
105
|
import { headTailView } from "../core/observation-view.ts";
|
|
81
106
|
import { withProgress } from "./progress.ts";
|
|
107
|
+
import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
108
|
+
import { changedPaths, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
109
|
+
import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
|
|
110
|
+
import { planCheckpointRestore } from "../store/checkpoint-history.ts";
|
|
82
111
|
|
|
83
112
|
/** Ids and timestamps come from here so the loop stays the only impure part. */
|
|
84
113
|
export interface Clock {
|
|
@@ -122,6 +151,8 @@ export interface LoopEvent {
|
|
|
122
151
|
/** branch_start: wall-clock milliseconds, for asserting the stagger. */
|
|
123
152
|
at?: number;
|
|
124
153
|
outcome?: ExecutionOutcome;
|
|
154
|
+
/** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
|
|
155
|
+
request?: RequestState;
|
|
125
156
|
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string };
|
|
126
157
|
}
|
|
127
158
|
|
|
@@ -156,6 +187,18 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
|
|
|
156
187
|
};
|
|
157
188
|
|
|
158
189
|
export interface SessionConfig {
|
|
190
|
+
/** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
|
|
191
|
+
workspaceTracking?: "journal" | "legacy";
|
|
192
|
+
shellCheckpoints?: boolean;
|
|
193
|
+
skills?: readonly Skill[];
|
|
194
|
+
acceptanceContract?: AcceptanceContract | null;
|
|
195
|
+
strictVerification?: boolean;
|
|
196
|
+
/** O.10: opt-in carried view; legacy sessions and evaluations retain D20. */
|
|
197
|
+
workingSet?: boolean;
|
|
198
|
+
/** R9: CLI opts into source-priority views; absent preserves v1 experiment/old-session behavior. */
|
|
199
|
+
workingSetVersion?: 1 | 2;
|
|
200
|
+
/** R10: new CLI guidance; absence retains old carried/prefix wording on resume. */
|
|
201
|
+
guidanceVersion?: 1;
|
|
159
202
|
logDir: string;
|
|
160
203
|
frozen: FrozenPrefix;
|
|
161
204
|
tools: ToolContext;
|
|
@@ -287,14 +330,30 @@ export type RewindResult =
|
|
|
287
330
|
| { ok: false; error: string };
|
|
288
331
|
|
|
289
332
|
/** O.5: child execution returns facts; only the parent coordinator accepts them. */
|
|
290
|
-
type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]
|
|
333
|
+
type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent };
|
|
291
334
|
type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
|
|
335
|
+
type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
|
|
336
|
+
type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
|
|
337
|
+
export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
|
|
292
338
|
|
|
293
339
|
export class Session {
|
|
340
|
+
private readonly execOwner = randomUUID();
|
|
341
|
+
private readonly jobs = new BranchJobs<DeferredToolResult>();
|
|
342
|
+
private readonly children = new Map<number, Session>();
|
|
343
|
+
private cleanup: Promise<void> | null = null;
|
|
344
|
+
private closing: Promise<void> | null = null;
|
|
345
|
+
private turnTools: ToolContext | null = null;
|
|
294
346
|
private readonly execution = new ExecutionControl();
|
|
347
|
+
private readonly runtimeTargets = new RuntimeTargets();
|
|
348
|
+
private rootTools: ToolQueue | undefined;
|
|
349
|
+
private hostOwner: ReturnType<ExecutionControl["begin"]> | undefined;
|
|
350
|
+
private readonly hostPending = new Set<Promise<NativeCommandResult>>();
|
|
295
351
|
private stall: StallState | null = null;
|
|
352
|
+
private recovery: RecoveryState | null = null;
|
|
296
353
|
private pendingAdoption: number | null = null;
|
|
297
354
|
private readonly allocatedEventIds = new Set<string>();
|
|
355
|
+
private recordTail: Promise<void> = Promise.resolve();
|
|
356
|
+
private readonly recordListeners = new Set<(item: CommittedItem) => void>();
|
|
298
357
|
readonly config: SessionConfig;
|
|
299
358
|
/**
|
|
300
359
|
* Replaced by switchModel. Not readonly: a live session can change which
|
|
@@ -307,13 +366,36 @@ export class Session {
|
|
|
307
366
|
private readonly allocation: { next: number };
|
|
308
367
|
|
|
309
368
|
constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
|
|
369
|
+
if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
|
|
310
370
|
if (config.contextPolicy !== undefined && !isContextPolicy(config.contextPolicy)) throw new Error("unknown context policy");
|
|
311
371
|
stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
312
372
|
validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
|
|
313
373
|
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
314
374
|
this.allocation = allocation;
|
|
315
|
-
this.config = config;
|
|
375
|
+
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache() } };
|
|
376
|
+
// A child or restored Session must never inherit another live invocation scope.
|
|
377
|
+
delete this.config.tools.invoke;
|
|
378
|
+
delete this.config.tools.invokeHost;
|
|
379
|
+
delete this.config.tools.runOwnedCommand;
|
|
380
|
+
delete this.config.tools.drainNested;
|
|
381
|
+
delete this.config.tools.origin;
|
|
382
|
+
delete this.config.tools.parentCallId;
|
|
383
|
+
delete this.config.tools.callId;
|
|
384
|
+
// Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
|
|
385
|
+
if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
|
|
316
386
|
this.config.tools.outputStore ??= join(config.logDir, "artifacts");
|
|
387
|
+
// R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
|
|
388
|
+
// Each child Session installs its own callback even when contexts are copied.
|
|
389
|
+
this.config.tools.observedFileVersion = async absolute => {
|
|
390
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
391
|
+
if (!history.ok) throw new Error(history.error);
|
|
392
|
+
return latestObservedFileVersion(history.nodes.flatMap(node => node.events), absolute, path => resolve(this.config.tools.root, path));
|
|
393
|
+
};
|
|
394
|
+
this.config.tools.observedProcess = async session => {
|
|
395
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
396
|
+
if (!history.ok) throw new Error(history.error);
|
|
397
|
+
return latestProcessReceipt(await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!), session);
|
|
398
|
+
};
|
|
317
399
|
this.provider = provider;
|
|
318
400
|
this.clock = {
|
|
319
401
|
now: () => clock.now(),
|
|
@@ -333,6 +415,34 @@ export class Session {
|
|
|
333
415
|
set nextNode(value: number) { allocation.next = value; },
|
|
334
416
|
worktrees: new Map(),
|
|
335
417
|
};
|
|
418
|
+
this.installWorkspace();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private installWorkspace(): void {
|
|
422
|
+
delete this.config.tools.workspace;
|
|
423
|
+
this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
|
|
424
|
+
this.config.tools.legacyWorkspace = this.config.workspaceTracking === "legacy";
|
|
425
|
+
if (this.config.blobDir && this.config.workspaceTracking !== "legacy") this.config.tools.workspace = new WorkspaceLifecycle(
|
|
426
|
+
this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
|
|
427
|
+
event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
|
|
428
|
+
join(this.config.logDir, "checkpoints"),
|
|
429
|
+
this.config.tools.ownedStatePaths,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private async observeEvidence(): Promise<void> {
|
|
434
|
+
let proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
|
|
435
|
+
let prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
|
|
436
|
+
this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
|
|
437
|
+
// A short task need not create a completed note. Its last command still
|
|
438
|
+
// establishes the comparison version when the current segment is empty.
|
|
439
|
+
if (!proof && this.config.tools.workspace) {
|
|
440
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
441
|
+
if (!history.ok) throw new Error(history.error);
|
|
442
|
+
proof = history.nodes.flatMap(part => part.events).findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
|
|
443
|
+
if (proof?.t === "tool_result" && proof.effect?.kind === "command") prior = proof.effect.workspace_sha256;
|
|
444
|
+
}
|
|
445
|
+
if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
|
|
336
446
|
}
|
|
337
447
|
|
|
338
448
|
/**
|
|
@@ -345,7 +455,7 @@ export class Session {
|
|
|
345
455
|
* session quietly different from a continuous one.
|
|
346
456
|
*/
|
|
347
457
|
async start(): Promise<void> {
|
|
348
|
-
this.
|
|
458
|
+
this.resetDerivedToolState();
|
|
349
459
|
const dir = this.config.logDir;
|
|
350
460
|
const model = this.provider.config.spec.id;
|
|
351
461
|
const existing = await listNodes(dir);
|
|
@@ -368,6 +478,11 @@ export class Session {
|
|
|
368
478
|
break;
|
|
369
479
|
|
|
370
480
|
case "resume": {
|
|
481
|
+
this.config.acceptanceContract = null;
|
|
482
|
+
this.config.strictVerification = false;
|
|
483
|
+
this.config.workingSet = false;
|
|
484
|
+
this.config.workingSetVersion = 1;
|
|
485
|
+
delete this.config.guidanceVersion;
|
|
371
486
|
// Logs predating explicit policies meant segmented, regardless of today's default.
|
|
372
487
|
this.config.contextPolicy = "segmented";
|
|
373
488
|
this.config.maxParallelTools = 1;
|
|
@@ -377,7 +492,19 @@ export class Session {
|
|
|
377
492
|
if (plan.cursor.context_ref) {
|
|
378
493
|
const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
|
|
379
494
|
if (saved === null) throw new Error("missing frozen session context");
|
|
380
|
-
const context = JSON.parse(saved) as { frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workNamespace?: string; maxParallelTools?: number };
|
|
495
|
+
const context = JSON.parse(saved) as { skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
|
|
496
|
+
if (context.projectionVersion !== undefined && context.projectionVersion !== PROJECTION_VERSION) throw new Error("unsupported saved projection version");
|
|
497
|
+
this.config.workingSet = context.workingSet === true;
|
|
498
|
+
if (context.workingSetVersion !== undefined && ![1, 2].includes(context.workingSetVersion)) throw new Error("unsupported saved working-set version");
|
|
499
|
+
this.config.workingSetVersion = context.workingSetVersion ?? 1;
|
|
500
|
+
if (context.guidanceVersion !== undefined && context.guidanceVersion !== 1) throw new Error("unsupported saved guidance version");
|
|
501
|
+
if (context.guidanceVersion === 1) this.config.guidanceVersion = 1;
|
|
502
|
+
this.config.workspaceTracking = context.workspaceTracking ?? "journal";
|
|
503
|
+
this.config.shellCheckpoints = context.shellCheckpoints === true;
|
|
504
|
+
if (context.acceptanceContract) { parseAcceptanceContract(context.acceptanceContract); this.config.acceptanceContract = context.acceptanceContract; }
|
|
505
|
+
this.config.strictVerification = context.strictVerification === true;
|
|
506
|
+
this.config.skills = context.skills ?? [];
|
|
507
|
+
this.installSkillResources();
|
|
381
508
|
this.config.workNamespace = context.workNamespace ?? "main";
|
|
382
509
|
this.config.maxParallelTools = context.maxParallelTools ?? 1;
|
|
383
510
|
schedulingWindow([], 0, this.config.maxParallelTools);
|
|
@@ -427,7 +554,7 @@ export class Session {
|
|
|
427
554
|
this.state.anchor = null;
|
|
428
555
|
// Compare disk against the durable file projection. Treating current
|
|
429
556
|
// disk as the baseline would erase unlogged effects of a crashed tool.
|
|
430
|
-
if (this.config.blobDir) {
|
|
557
|
+
if (this.config.blobDir && this.config.workspaceTracking === "legacy") {
|
|
431
558
|
const files = await planRestoreToNode(dir, this.state.node);
|
|
432
559
|
if (!files.ok) throw new Error(files.error);
|
|
433
560
|
this.state.lastScan = files.files;
|
|
@@ -447,7 +574,13 @@ export class Session {
|
|
|
447
574
|
return assertNever(plan, "Session.start");
|
|
448
575
|
}
|
|
449
576
|
|
|
450
|
-
|
|
577
|
+
this.installWorkspace();
|
|
578
|
+
if (this.config.workspaceTracking !== "legacy") await this.recoverFileOperations();
|
|
579
|
+
this.restoreDeclaredTools();
|
|
580
|
+
// Consume the original pending receipt before another checkpoint can replace this projection.
|
|
581
|
+
const declarationCut = await this.applyDiscoveredTools();
|
|
582
|
+
await this.refreshRuntimeTargets();
|
|
583
|
+
if (!declarationCut && (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec)))) {
|
|
451
584
|
await this.performCut("model_checkpoint", model);
|
|
452
585
|
}
|
|
453
586
|
await this.saveCursor();
|
|
@@ -461,6 +594,17 @@ export class Session {
|
|
|
461
594
|
*/
|
|
462
595
|
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
463
596
|
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
597
|
+
if (untilEvent !== undefined && this.config.workspaceTracking !== "legacy") {
|
|
598
|
+
const cursor = await readCursor(destLogDir);
|
|
599
|
+
if (!cursor) throw new Error("missing fork cursor");
|
|
600
|
+
const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
|
|
601
|
+
const filesFrom = { ...await digestedRef(this.config.logDir, this.state.node), ...(tip ? { at_event: tip.id } : {}) };
|
|
602
|
+
const header = (await readNode(destLogDir, cursor.node)).header;
|
|
603
|
+
const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: header?.parent ?? filesFrom,
|
|
604
|
+
files_from: filesFrom, prompt: "Forked conversation", restored: [], removed: [], unrecoverable: [], divergence: [] };
|
|
605
|
+
await appendEvent(destLogDir, cursor.node, event);
|
|
606
|
+
await writeCursor(destLogDir, { ...cursor, at_event: event.id, updated_at: event.at });
|
|
607
|
+
}
|
|
464
608
|
}
|
|
465
609
|
|
|
466
610
|
async setThinking(effort: Effort): Promise<void> {
|
|
@@ -468,25 +612,60 @@ export class Session {
|
|
|
468
612
|
await this.saveCursor();
|
|
469
613
|
}
|
|
470
614
|
|
|
471
|
-
/**
|
|
615
|
+
/** Trusted extension commands share the Session owner without inventing a user turn. */
|
|
616
|
+
runExtensionCommand(command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<NativeCommandResult> {
|
|
617
|
+
if (this.cleanup || this.closing) return Promise.reject(new Error("session is draining or disposed"));
|
|
618
|
+
const input: NativeCommandInput = { argv: [command, ...args], ...(options?.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
619
|
+
...(options?.timeout !== undefined ? { timeout: options.timeout } : {}) };
|
|
620
|
+
if (!this.execution.running) this.hostOwner = this.execution.begin();
|
|
621
|
+
const signals = [this.execution.signal, options?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
|
|
622
|
+
const signal = AbortSignal.any(signals);
|
|
623
|
+
const result = (async () => {
|
|
624
|
+
const call: ToolCall = { type: "tool_use", call_id: `host-${randomUUID()}`, name: NATIVE_COMMAND_NAME, input };
|
|
625
|
+
const preparation = this.prepareBrokerCall(call, () => 0, false, signal, true);
|
|
626
|
+
let prepared: PreparedBrokerCall;
|
|
627
|
+
for (;;) { const step = await preparation.next(); if (step.done) { prepared = step.value; break; } }
|
|
628
|
+
const receipt = await prepared.run();
|
|
629
|
+
await receipt.commit();
|
|
630
|
+
await this.saveCursor();
|
|
631
|
+
return nativeCommandResult(receipt.value, this.config.tools.outputStore!);
|
|
632
|
+
})();
|
|
633
|
+
this.hostPending.add(result);
|
|
634
|
+
const finished = () => {
|
|
635
|
+
this.hostPending.delete(result);
|
|
636
|
+
if (this.hostPending.size === 0 && this.hostOwner) { this.hostOwner.finish(); this.hostOwner = undefined; }
|
|
637
|
+
};
|
|
638
|
+
void result.then(finished, finished);
|
|
639
|
+
return result;
|
|
640
|
+
}
|
|
641
|
+
|
|
472
642
|
async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
|
|
473
643
|
onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
|
|
644
|
+
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
474
645
|
const owned = this.execution.begin(signal);
|
|
646
|
+
let release: (() => void) | undefined;
|
|
475
647
|
try {
|
|
648
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
649
|
+
release = await this.rootTools.acquire("exclusive");
|
|
476
650
|
if (!command.trim()) throw new Error("empty user command");
|
|
477
651
|
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
478
652
|
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
479
653
|
}
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
654
|
+
const execute = async (): Promise<ToolResult> => {
|
|
655
|
+
const captured = await observeCommand({ command, cwd: this.config.tools.root,
|
|
656
|
+
outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
|
|
657
|
+
timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
|
|
658
|
+
const completion = commandCompletion(captured.exit, captured.stopped, captured.cleanup);
|
|
659
|
+
const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
|
|
660
|
+
omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
|
|
661
|
+
return { ...completion, content: `${preview}\nexit ${captured.exit} · ${completion.outcome.status} · ${completion.outcome.reason}` +
|
|
662
|
+
(captured.cleanup ? `\n${captured.cleanup.message}` : ""),
|
|
663
|
+
content_ref: captured.ref, effect: { kind: "command", cmd: command, exit: captured.exit } };
|
|
664
|
+
};
|
|
665
|
+
const result = this.config.tools.workspace ? await this.config.tools.workspace.command("bash", { command }, execute, owned.signal) : await execute();
|
|
666
|
+
if (result.effect?.kind !== "command" || !result.content_ref || !result.outcome) throw new Error("missing user command observation");
|
|
488
667
|
const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
|
|
489
|
-
command, content, exit:
|
|
668
|
+
command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
|
|
490
669
|
await this.record(event);
|
|
491
670
|
for await (const snapshot of this.snapshotTurn([])) {
|
|
492
671
|
if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
|
|
@@ -497,7 +676,11 @@ export class Session {
|
|
|
497
676
|
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
498
677
|
reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
|
|
499
678
|
throw error;
|
|
500
|
-
} finally {
|
|
679
|
+
} finally {
|
|
680
|
+
release?.();
|
|
681
|
+
while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
|
|
682
|
+
owned.finish();
|
|
683
|
+
}
|
|
501
684
|
}
|
|
502
685
|
|
|
503
686
|
async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
|
|
@@ -515,11 +698,17 @@ export class Session {
|
|
|
515
698
|
const dir = this.config.logDir;
|
|
516
699
|
const users = await this.userMessages();
|
|
517
700
|
const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
|
|
701
|
+
const previous = (await readNode(dir, this.state.node)).events.at(-1);
|
|
702
|
+
const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
|
|
518
703
|
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
519
704
|
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
520
705
|
}
|
|
521
706
|
const node = this.state.nextNode++;
|
|
522
707
|
await initNode(dir, node, target.parent, this.clock.now(), target.basis);
|
|
708
|
+
if (this.config.workspaceTracking !== "legacy") await appendEvent(dir, node, {
|
|
709
|
+
t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
|
|
710
|
+
files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
|
|
711
|
+
});
|
|
523
712
|
const cursor = await readCursor(dir);
|
|
524
713
|
if (!cursor) throw new Error("missing current session cursor");
|
|
525
714
|
const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
|
|
@@ -571,18 +760,47 @@ export class Session {
|
|
|
571
760
|
}
|
|
572
761
|
|
|
573
762
|
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
574
|
-
async reloadResources(frozen: FrozenPrefix): Promise<boolean> {
|
|
575
|
-
if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen)) return false;
|
|
763
|
+
async reloadResources(frozen: FrozenPrefix, skills = this.config.skills): Promise<boolean> {
|
|
764
|
+
if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
|
|
576
765
|
if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
|
|
577
|
-
const contextRef = await this.persistContext(frozen);
|
|
766
|
+
const contextRef = await this.persistContext(frozen, skills);
|
|
578
767
|
await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
|
|
579
768
|
this.provider.config.spec.provider, this.provider.config.effort, contextRef);
|
|
580
769
|
this.config.frozen = frozen;
|
|
770
|
+
if (skills !== undefined) this.config.skills = skills;
|
|
771
|
+
this.installSkillResources();
|
|
581
772
|
this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
582
773
|
await this.saveCursor();
|
|
583
774
|
return true;
|
|
584
775
|
}
|
|
585
776
|
|
|
777
|
+
/** Restoring a declaration never installs code: only the current selected local catalog can supply it. */
|
|
778
|
+
private restoreDeclaredTools(): void {
|
|
779
|
+
const declared = new Set(declaredToolNames(this.config.frozen.tools));
|
|
780
|
+
const loaded = currentDeferredTools(this.config.tools).filter(tool => declared.has(tool.name)).map(tool => tool.name);
|
|
781
|
+
if (loaded.length && this.config.tools.allow !== undefined) this.config.tools.allow = [...new Set([...this.config.tools.allow, ...loaded])];
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** D5/D6: consume original discovery receipts only after every invocation has its result. */
|
|
785
|
+
private async applyDiscoveredTools(): Promise<boolean> {
|
|
786
|
+
if (this.isHalfRound() || (this.config.branchDepth ?? 0) > 0 || this.config.tools.extensions?.get(DISCOVERY_TOOL_NAME) !== DISCOVERY_TOOL) return false;
|
|
787
|
+
const declared = new Set(declaredToolNames(this.config.frozen.tools));
|
|
788
|
+
const requested = new Map(recordedDeclarations(this.state.events).map(item => [item.name, item.fingerprint]));
|
|
789
|
+
const additions = currentDeferredTools(this.config.tools).filter(tool => !declared.has(tool.name) &&
|
|
790
|
+
requested.get(tool.name) === discoveryFingerprint(tool) && discoveryPermitted(this.config.tools, tool.name));
|
|
791
|
+
if (additions.length === 0) return false;
|
|
792
|
+
const frozen = { ...this.config.frozen, tools: [...this.config.frozen.tools, ...additions] };
|
|
793
|
+
const contextRef = await this.persistContext(frozen);
|
|
794
|
+
await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
|
|
795
|
+
this.provider.config.spec.provider, this.provider.config.effort, contextRef);
|
|
796
|
+
this.config.frozen = frozen;
|
|
797
|
+
this.restoreDeclaredTools();
|
|
798
|
+
// send() restores its base tool scope at the end; keep accepted declarations there too.
|
|
799
|
+
if (this.turnTools?.allow !== undefined) this.turnTools.allow = [...new Set([...this.turnTools.allow, ...additions.map(tool => tool.name)])];
|
|
800
|
+
await this.saveCursor();
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
|
|
586
804
|
/**
|
|
587
805
|
* The same pairing check switchModel already used. A user message
|
|
588
806
|
* between tool_use and tool_result is the same unpairable request a
|
|
@@ -609,6 +827,7 @@ export class Session {
|
|
|
609
827
|
if (pull === undefined || this.isHalfRound()) return null;
|
|
610
828
|
const text = await pull("after_tools");
|
|
611
829
|
if (text === null || typeof text === "string" && text.length === 0) return null;
|
|
830
|
+
if (this.turnTools) this.config.tools = { ...this.turnTools };
|
|
612
831
|
return text;
|
|
613
832
|
}
|
|
614
833
|
|
|
@@ -619,8 +838,17 @@ export class Session {
|
|
|
619
838
|
* resuming from -- a crash, a kill, a closed laptop -- do not run exit
|
|
620
839
|
* handlers.
|
|
621
840
|
*/
|
|
622
|
-
private async persistContext(frozen = this.config.frozen): Promise<string> {
|
|
841
|
+
private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
|
|
623
842
|
return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
|
|
843
|
+
projectionVersion: PROJECTION_VERSION,
|
|
844
|
+
workspaceTracking: this.config.workspaceTracking ?? "journal",
|
|
845
|
+
shellCheckpoints: this.config.shellCheckpoints === true,
|
|
846
|
+
...(skills?.length ? { skills } : {}),
|
|
847
|
+
...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
|
|
848
|
+
...(this.config.strictVerification ? { strictVerification: true } : {}),
|
|
849
|
+
...(this.config.workingSet ? { workingSet: true } : {}),
|
|
850
|
+
...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
|
|
851
|
+
...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
|
|
624
852
|
frozen, gates: this.config.tools.gates,
|
|
625
853
|
workNamespace: this.config.workNamespace ?? "main",
|
|
626
854
|
maxParallelTools: this.config.maxParallelTools ?? 3,
|
|
@@ -652,10 +880,211 @@ export class Session {
|
|
|
652
880
|
});
|
|
653
881
|
}
|
|
654
882
|
|
|
883
|
+
/** Q24: publish identities only after durable append, including non-model user actions. */
|
|
884
|
+
subscribeRecords(listener: (item: CommittedItem) => void): () => void {
|
|
885
|
+
this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
|
|
886
|
+
}
|
|
887
|
+
|
|
655
888
|
private async record(event: LogEvent): Promise<void> {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
889
|
+
// D8: runtime callers may retain mutable results. The in-memory projection
|
|
890
|
+
// must own the same JSON value that was appended, independent of later edits.
|
|
891
|
+
event = JSON.parse(JSON.stringify(event)) as LogEvent;
|
|
892
|
+
const saved = this.recordTail.then(async () => {
|
|
893
|
+
await appendEvent(this.config.logDir, this.state.node, event);
|
|
894
|
+
this.state.events.push(event);
|
|
895
|
+
if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
|
|
896
|
+
if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
|
|
897
|
+
for (const listener of this.recordListeners) {
|
|
898
|
+
try { listener({ node: this.state.node, id: event.id, type: event.t }); } catch { /* observers never own the append */ }
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
this.recordTail = saved.catch(() => {});
|
|
902
|
+
await saved;
|
|
903
|
+
if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
|
|
904
|
+
const path = resolve(this.config.tools.root, event.effect.path);
|
|
905
|
+
const skill = this.config.skills?.find(item => item.filePath === path);
|
|
906
|
+
if (skill && !skill.disableModelInvocation) {
|
|
907
|
+
await this.recordSkill(skill, "read", "model");
|
|
908
|
+
this.config.tools.allow = skillTools(this.allowedNames(), skill);
|
|
909
|
+
// Restricted skill scopes cannot grow their capability set by searching a parent directory.
|
|
910
|
+
if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private allowedNames(): string[] {
|
|
916
|
+
return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
|
|
917
|
+
(this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private adviceNames(frozen = this.config.frozen): string[] {
|
|
921
|
+
const declared = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
922
|
+
return adviceTools({ ...this.config.tools, advice: declared, ...(frozen === this.config.frozen ? {} : { allow: declared }) });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private installSkillResources(): void {
|
|
926
|
+
this.config.tools.skillResources = (this.config.skills ?? []).map(skill => ({ path: skill.filePath,
|
|
927
|
+
text: skill.sourceText ?? skill.body, modelInvocable: !skill.disableModelInvocation && skill.context !== "fork" }));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
private async recordSkill(skill: Skill, mode: "inline" | "fork" | "read", origin: "user" | "model"): Promise<void> {
|
|
931
|
+
const artifact = await putBlob(join(this.config.logDir, "artifacts"), skill.sourceText ?? skill.body);
|
|
932
|
+
await this.record({ t: "skill_loaded", id: this.clock.nextId(), at: this.clock.now(), name: skill.name, path: skill.filePath,
|
|
933
|
+
sha256: artifact, artifact, mode, origin });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** O.12: main instructions are durable; permissions apply to this invocation only. */
|
|
937
|
+
async *sendSkill(skill: Skill, prompt: UserInput, signal?: AbortSignal): AsyncGenerator<LoopEvent> {
|
|
938
|
+
if (this.running || this.cleanup || this.closing || this.isHalfRound()) throw new Error("a skill requires an idle paired session");
|
|
939
|
+
skill = this.config.skills?.find(item => item.name === skill.name && item.filePath === skill.filePath) ?? skill;
|
|
940
|
+
if (skill.userInvocable === false) throw new Error("this skill cannot be invoked by users");
|
|
941
|
+
const savedTools = this.config.tools;
|
|
942
|
+
this.config.tools = { ...savedTools, allow: skillTools(this.allowedNames(), skill) };
|
|
943
|
+
if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
|
|
944
|
+
try {
|
|
945
|
+
await this.recordSkill(skill, skill.context === "fork" ? "fork" : "inline", "user");
|
|
946
|
+
if (skill.context !== "fork") { yield* this.send(prompt, signal); return; }
|
|
947
|
+
const owned = this.execution.begin(signal);
|
|
948
|
+
try {
|
|
949
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: `Run isolated skill ${skill.name}; source ${skill.filePath}.` });
|
|
950
|
+
const writes = skill.allowedTools?.some(name => this.allowedNames().includes(name) &&
|
|
951
|
+
WRITING_BRANCH_TOOL_NAMES.includes(name) && !BRANCH_TOOL_NAMES.includes(name)) ?? false;
|
|
952
|
+
const result = yield* this.runBranch({ purpose: `skill ${skill.name}`, prompt: userInputData(prompt).text, writes }, 0, owned.signal, skill);
|
|
953
|
+
await result.accept?.();
|
|
954
|
+
await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
|
|
955
|
+
display: true, inContext: true });
|
|
956
|
+
await this.saveCursor();
|
|
957
|
+
yield { kind: "assistant_text", text: result.content };
|
|
958
|
+
yield { kind: "done", outcome: result.outcome ?? executionOutcome("execution_error"), reason: "isolated skill finished" };
|
|
959
|
+
} finally { owned.finish(); }
|
|
960
|
+
} finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** O.8: dispatch and diagnostics share exact source hints, including forks. */
|
|
964
|
+
contextInput(): ProjectInput {
|
|
965
|
+
return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
|
|
966
|
+
logHint: { manifestPath: manifestPath(this.config.logDir),
|
|
967
|
+
...(this.config.guidanceVersion === 1 ? { scopedGuidance: true } : {}),
|
|
968
|
+
segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
|
|
972
|
+
return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
973
|
+
...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
|
|
974
|
+
...(this.config.guidanceVersion === 1 ? { guidance_tools: tools } : {}) });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async acceptanceState(): Promise<AcceptanceState | undefined> {
|
|
978
|
+
if (!this.running) await this.observeEvidence();
|
|
979
|
+
// External editor changes must invalidate proof before the user reads it.
|
|
980
|
+
if (!this.running) for await (const result of this.snapshotTurn([], false, "idle")) {
|
|
981
|
+
if (result.kind === "snapshot_failed") throw new Error(`cannot inspect an unobserved workspace: ${result.reason}`);
|
|
982
|
+
}
|
|
983
|
+
if (!this.running) await this.refreshRuntimeTargets();
|
|
984
|
+
return (await this.currentHandoff()).acceptance;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async requestState(): Promise<RequestState | undefined> {
|
|
988
|
+
// Use the same idle workspace/target observation boundary as /acceptance.
|
|
989
|
+
if (!this.running) await this.acceptanceState();
|
|
990
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
991
|
+
if (!history.ok) throw new Error(history.error);
|
|
992
|
+
return deriveRequestState(history.nodes);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
|
|
996
|
+
private async refreshRuntimeTargets(): Promise<void> {
|
|
997
|
+
const state = (await this.currentHandoff()).acceptance;
|
|
998
|
+
const observations: string[] = [];
|
|
999
|
+
for (const target of state?.targets ?? []) if (target.observation || target.binding) {
|
|
1000
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1001
|
+
if (!history.ok) throw Error(history.error);
|
|
1002
|
+
const observed = await this.runtimeTargets.perform(this.config.tools, history.nodes, { action: "inspect", target: target.id });
|
|
1003
|
+
const id = this.clock.nextId();
|
|
1004
|
+
await this.record({ t: "target_observation", id, at: this.clock.now(), observation: observed.observation });
|
|
1005
|
+
observations.push(id);
|
|
1006
|
+
}
|
|
1007
|
+
if (!observations.length) return;
|
|
1008
|
+
const current = (await this.currentHandoff()).acceptance;
|
|
1009
|
+
if (current?.targets?.some(target => {
|
|
1010
|
+
const before = state?.targets?.find(item => item.id === target.id);
|
|
1011
|
+
return before?.status !== target.status || before.binding !== target.binding || before.build_event !== target.build_event;
|
|
1012
|
+
})) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "runtime_acceptance", sources: observations,
|
|
1013
|
+
text: `Runtime acceptance observations changed. Status ${current.status}; targets ${JSON.stringify(current.targets)}. Only a ready owned instance can receive human acceptance; old observations do not grant current ownership.` });
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async workItems(): Promise<readonly WorkItem[]> {
|
|
1017
|
+
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1018
|
+
return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
|
|
1019
|
+
self: await this.nodeRefFor(this.state.node) }).state.work_items ?? [];
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** What the next handoff would carry if the segment were cut now. Pure derivation; nothing is written. */
|
|
1023
|
+
async previewHandoff(): Promise<Handoff> { return this.currentHandoff(); }
|
|
1024
|
+
|
|
1025
|
+
private async currentHandoff(): Promise<Handoff> {
|
|
1026
|
+
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1027
|
+
return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth, self: await this.nodeRefFor(this.state.node) });
|
|
1028
|
+
}
|
|
1029
|
+
async branchStatuses() {
|
|
1030
|
+
return (await this.currentHandoff()).branches.map(item => ({ ...item, status: branchStatus(item, this.jobs.state(item.node)) }));
|
|
1031
|
+
}
|
|
1032
|
+
async processStatuses() {
|
|
1033
|
+
const handles = new Map(runningSessions(this.execOwner).map(session => [session.id, session.exit === null ? "running" as const : "ended_unobserved" as const]));
|
|
1034
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1035
|
+
if (!history.ok) throw new Error(history.error);
|
|
1036
|
+
let processes: ProcessObservation[] = [];
|
|
1037
|
+
for (const part of await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!)) processes = deriveProcesses(processes, part.events, part.node);
|
|
1038
|
+
return processes.map(item => ({ ...item, status: processStatus(item, handles.get(item.session)) }));
|
|
1039
|
+
}
|
|
1040
|
+
/** User lifecycle commands share the coordinator; no model request is needed. */
|
|
1041
|
+
async resolveBranch(node: number, resolution: "wait" | "cancel" | "status", waitMs = 10_000): Promise<ToolResult> {
|
|
1042
|
+
if (this.running || this.cleanup || this.closing) throw new Error("branch commands require an idle session");
|
|
1043
|
+
const owned = this.execution.begin();
|
|
1044
|
+
try {
|
|
1045
|
+
const result = await this.runResolve({ node, resolution, wait_ms: waitMs });
|
|
1046
|
+
await this.saveCursor(); return finishToolResult(result);
|
|
1047
|
+
} finally { owned.finish(); }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private async drainProcesses(): Promise<void> {
|
|
1051
|
+
const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
|
|
1052
|
+
await this.config.tools.workspace?.abandonCommands();
|
|
1053
|
+
for (const result of results) {
|
|
1054
|
+
await this.record({ t: "process_exit", id: this.clock.nextId(), at: this.clock.now(), ...result, reason: "disposed" });
|
|
1055
|
+
}
|
|
1056
|
+
if (results.length > 0) await this.saveCursor();
|
|
1057
|
+
}
|
|
1058
|
+
private async acceptReadyBranches(): Promise<void> {
|
|
1059
|
+
for (const node of this.jobs.nodes()) {
|
|
1060
|
+
const done = await this.jobs.wait(node, 0);
|
|
1061
|
+
if (done?.value?.accept) await done.value.accept();
|
|
1062
|
+
this.jobs.forget(node);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
dispose(): Promise<void> {
|
|
1066
|
+
if (this.closing) return this.closing;
|
|
1067
|
+
this.cancel();
|
|
1068
|
+
this.closing = this.whenIdle().then(() => this.rootTools?.close());
|
|
1069
|
+
return this.closing;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Only the app's user command calls this; note never grants human acceptance. */
|
|
1073
|
+
async acceptCriterion(criterion: string): Promise<AcceptanceState> {
|
|
1074
|
+
if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
|
|
1075
|
+
for await (const result of this.snapshotTurn([], true)) {
|
|
1076
|
+
if (result.kind === "snapshot_failed") throw new Error(`cannot accept an unobserved workspace: ${result.reason}`);
|
|
1077
|
+
}
|
|
1078
|
+
const current = await this.acceptanceState();
|
|
1079
|
+
const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
|
|
1080
|
+
if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
|
|
1081
|
+
const target = item.target ? current.targets?.find(target => target.id === item.target) : undefined;
|
|
1082
|
+
if (item.target && (target?.status !== "ready" || !target.binding || !target.observation)) throw Error("human acceptance requires a current ready target instance");
|
|
1083
|
+
await this.record({ t: "acceptance_decision", id: this.clock.nextId(), at: this.clock.now(), request: current.request,
|
|
1084
|
+
contract_id: current.contract_id, criterion, actor: "user",
|
|
1085
|
+
...(target?.binding && target.observation ? { target_binding: target.binding, target_observation: target.observation } : {}) });
|
|
1086
|
+
await this.saveCursor();
|
|
1087
|
+
return (await this.acceptanceState())!;
|
|
659
1088
|
}
|
|
660
1089
|
|
|
661
1090
|
async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
|
|
@@ -671,8 +1100,21 @@ export class Session {
|
|
|
671
1100
|
return totalUsage(this.state.usage);
|
|
672
1101
|
}
|
|
673
1102
|
|
|
674
|
-
cancel(): void {
|
|
675
|
-
|
|
1103
|
+
cancel(): void {
|
|
1104
|
+
this.execution.cancel(); this.jobs.cancel();
|
|
1105
|
+
for (const child of this.children.values()) child.cancel();
|
|
1106
|
+
if (!this.cleanup) this.cleanup = (async () => {
|
|
1107
|
+
await this.execution.whenIdle(); await this.jobs.drain();
|
|
1108
|
+
// A consumer can close at the preparation marker before execution is
|
|
1109
|
+
// admitted. Those children still have an owner, even without a job.
|
|
1110
|
+
for (const [node, child] of this.children) { await child.dispose(); this.children.delete(node); }
|
|
1111
|
+
await this.acceptReadyBranches(); await this.drainProcesses();
|
|
1112
|
+
})().then(() => { this.cleanup = null; });
|
|
1113
|
+
// A void cancellation caller may await later. Preserve rejection for the
|
|
1114
|
+
// owner while preventing an unhandled rejection from bypassing that path.
|
|
1115
|
+
void this.cleanup.catch(() => {});
|
|
1116
|
+
}
|
|
1117
|
+
async whenIdle(): Promise<void> { await this.execution.whenIdle(); await this.cleanup; }
|
|
676
1118
|
get running(): boolean { return this.execution.running; }
|
|
677
1119
|
|
|
678
1120
|
/**
|
|
@@ -690,9 +1132,15 @@ export class Session {
|
|
|
690
1132
|
userText: UserInput,
|
|
691
1133
|
signal?: AbortSignal,
|
|
692
1134
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1135
|
+
requestId?: string,
|
|
693
1136
|
): AsyncGenerator<LoopEvent> {
|
|
1137
|
+
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
694
1138
|
const owned = this.execution.begin(signal);
|
|
695
|
-
const
|
|
1139
|
+
const savedTools = this.config.tools;
|
|
1140
|
+
this.turnTools = savedTools;
|
|
1141
|
+
this.config.tools = { ...savedTools };
|
|
1142
|
+
this.installSkillResources();
|
|
1143
|
+
const events = this.run(userText, owned.signal, pull, requestId);
|
|
696
1144
|
let terminal = false;
|
|
697
1145
|
let exhausted = false;
|
|
698
1146
|
try {
|
|
@@ -705,13 +1153,16 @@ export class Session {
|
|
|
705
1153
|
t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
706
1154
|
reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
|
|
707
1155
|
});
|
|
1156
|
+
const request = await this.requestState();
|
|
1157
|
+
if (request) item.value.request = request;
|
|
708
1158
|
}
|
|
709
1159
|
yield item.value;
|
|
710
1160
|
}
|
|
711
1161
|
if (!terminal) {
|
|
712
1162
|
const outcome = executionOutcome("snapshot_failed");
|
|
713
1163
|
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "workspace snapshot failed", outcome });
|
|
714
|
-
|
|
1164
|
+
const request = await this.requestState();
|
|
1165
|
+
yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome, ...(request ? { request } : {}) };
|
|
715
1166
|
}
|
|
716
1167
|
} catch (error) {
|
|
717
1168
|
// Raw provider exceptions can contain credentials; persist only the execution fact.
|
|
@@ -725,7 +1176,13 @@ export class Session {
|
|
|
725
1176
|
if (!exhausted) {
|
|
726
1177
|
await events.return(undefined);
|
|
727
1178
|
}
|
|
728
|
-
} finally {
|
|
1179
|
+
} finally {
|
|
1180
|
+
while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
|
|
1181
|
+
this.config.tools = savedTools; this.turnTools = null;
|
|
1182
|
+
if (owned.signal.aborted) this.cancel();
|
|
1183
|
+
owned.finish();
|
|
1184
|
+
if (owned.signal.aborted) await this.whenIdle();
|
|
1185
|
+
}
|
|
729
1186
|
}
|
|
730
1187
|
}
|
|
731
1188
|
|
|
@@ -733,7 +1190,10 @@ export class Session {
|
|
|
733
1190
|
userText: UserInput,
|
|
734
1191
|
signal?: AbortSignal,
|
|
735
1192
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1193
|
+
requestId?: string,
|
|
736
1194
|
): AsyncGenerator<LoopEvent> {
|
|
1195
|
+
// A crash after a discovery receipt but before its cut is recovered from that same receipt.
|
|
1196
|
+
if (!signal?.aborted) await this.applyDiscoveredTools();
|
|
737
1197
|
if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
|
|
738
1198
|
if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
|
|
739
1199
|
await this.performCut("model_checkpoint");
|
|
@@ -744,9 +1204,10 @@ export class Session {
|
|
|
744
1204
|
if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
|
|
745
1205
|
await this.performCut("model_checkpoint");
|
|
746
1206
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1207
|
+
const request = requestId ?? this.clock.nextId();
|
|
1208
|
+
await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
|
|
1209
|
+
if (this.config.acceptanceContract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
|
|
1210
|
+
request: this.state.events.at(-1)!.id, contract: this.config.acceptanceContract });
|
|
750
1211
|
// Idle changes belong to the new prompt. Recording them before `user`
|
|
751
1212
|
// would silently move the preceding exchange's restore point forward.
|
|
752
1213
|
for await (const event of this.snapshotTurn([])) {
|
|
@@ -774,12 +1235,11 @@ export class Session {
|
|
|
774
1235
|
yield { kind: "turn_start", node: this.state.node };
|
|
775
1236
|
|
|
776
1237
|
if (this.config.guidance !== false) {
|
|
777
|
-
const names = this.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
});
|
|
781
|
-
const guidance = continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
|
|
1238
|
+
const names = this.adviceNames();
|
|
1239
|
+
const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
|
|
1240
|
+
continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
|
|
782
1241
|
tools: names, remainingRequests: this.config.maxTurns - turn,
|
|
1242
|
+
...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
|
|
783
1243
|
...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
|
|
784
1244
|
(this.config.timeBudgetMs === undefined ? null
|
|
785
1245
|
: deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
|
|
@@ -793,15 +1253,7 @@ export class Session {
|
|
|
793
1253
|
yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
|
|
794
1254
|
return;
|
|
795
1255
|
}
|
|
796
|
-
const projected = projectContext(
|
|
797
|
-
frozen: this.config.frozen,
|
|
798
|
-
handoff: this.state.handoff,
|
|
799
|
-
events: this.state.events,
|
|
800
|
-
logHint: {
|
|
801
|
-
manifestPath: manifestPath(this.config.logDir),
|
|
802
|
-
segmentPath: nodePath(this.config.logDir, Math.max(0, this.state.node - 1)),
|
|
803
|
-
},
|
|
804
|
-
});
|
|
1256
|
+
const projected = projectContext(this.contextInput());
|
|
805
1257
|
|
|
806
1258
|
// Pairing violations are a 400 waiting to happen; surface them here,
|
|
807
1259
|
// where the cause is still visible, rather than as a request failure.
|
|
@@ -826,6 +1278,8 @@ export class Session {
|
|
|
826
1278
|
try {
|
|
827
1279
|
result = yield* this.awaitProvider(projected.messages, signal);
|
|
828
1280
|
} catch (error) {
|
|
1281
|
+
// D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
|
|
1282
|
+
if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
|
|
829
1283
|
if (signal?.aborted) {
|
|
830
1284
|
yield* this.closeInterrupted(completedThisTurn);
|
|
831
1285
|
return;
|
|
@@ -836,8 +1290,7 @@ export class Session {
|
|
|
836
1290
|
// shrink is a cut, so force one and send once more; a second
|
|
837
1291
|
// overflow means even the retained tail is misjudged, and another
|
|
838
1292
|
// attempt would only be paid for.
|
|
839
|
-
if (overflowRetried ||
|
|
840
|
-
windowBudget(this.provider.config.spec.contextWindow, this.provider.config.maxTokens) === null) {
|
|
1293
|
+
if (overflowRetried || this.policyLimits().kind !== "window") {
|
|
841
1294
|
yield { kind: "done", reason: `stopped: provider rejected the prompt as too long (${error.message})`, outcome: executionOutcome("context_budget") };
|
|
842
1295
|
return;
|
|
843
1296
|
}
|
|
@@ -852,6 +1305,14 @@ export class Session {
|
|
|
852
1305
|
|
|
853
1306
|
const previous = this.state.usage.at(-1);
|
|
854
1307
|
this.state.usage.push(result.usage);
|
|
1308
|
+
// Q24: an adapter may finish after abort. Keep measured usage, but cancellation
|
|
1309
|
+
// owns termination and a late response cannot enter context or execute tools.
|
|
1310
|
+
if (signal?.aborted) {
|
|
1311
|
+
yield { kind: "usage", usage: result.usage, ok: true };
|
|
1312
|
+
yield { kind: "discard" };
|
|
1313
|
+
yield* this.closeInterrupted(completedThisTurn);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
855
1316
|
const promptTokens =
|
|
856
1317
|
result.usage.input_tokens +
|
|
857
1318
|
result.usage.cache_creation_input_tokens +
|
|
@@ -890,6 +1351,15 @@ export class Session {
|
|
|
890
1351
|
};
|
|
891
1352
|
}
|
|
892
1353
|
|
|
1354
|
+
// D5: ambiguous IDs cannot be persisted or paired safely. Keep sampled
|
|
1355
|
+
// usage, but stop before observer hooks or any tool effect can run.
|
|
1356
|
+
const identityViolations = toolCallIdentityViolations(result.blocks);
|
|
1357
|
+
if (identityViolations.length > 0) {
|
|
1358
|
+
yield { kind: "done", outcome: executionOutcome("provider_error"),
|
|
1359
|
+
reason: `stopped: invalid provider tool call identity (${identityViolations.map(violation => `${violation.code}: ${violation.detail}`).join("; ")})` };
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
893
1363
|
await this.record({
|
|
894
1364
|
t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
|
|
895
1365
|
replay_key: providerReplayKey(this.provider.config.spec),
|
|
@@ -912,7 +1382,7 @@ export class Session {
|
|
|
912
1382
|
}
|
|
913
1383
|
const extra = await this.pullSteering(pull);
|
|
914
1384
|
if (extra !== null) {
|
|
915
|
-
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked) });
|
|
1385
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked) });
|
|
916
1386
|
continue;
|
|
917
1387
|
}
|
|
918
1388
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
@@ -935,18 +1405,25 @@ export class Session {
|
|
|
935
1405
|
const extra = await this.pullSteering(pull);
|
|
936
1406
|
if (extra !== null) {
|
|
937
1407
|
await this.record({
|
|
938
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
|
|
1408
|
+
t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
|
|
939
1409
|
});
|
|
940
1410
|
continue;
|
|
941
1411
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1412
|
+
if (this.config.acceptanceContract) await this.refreshRuntimeTargets();
|
|
1413
|
+
const state = await this.requestState();
|
|
1414
|
+
const decision = !state || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
|
|
1415
|
+
: requestCompletion(state, this.config.maxTurns - turn - 1, {
|
|
1416
|
+
...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
|
|
1417
|
+
...(this.config.strictVerification ? { strictVerification: true } : {}),
|
|
1418
|
+
});
|
|
1419
|
+
if (decision.action === "continue") {
|
|
946
1420
|
if (yield* this.maybeCut(counter, true)) {
|
|
947
1421
|
yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
|
|
948
1422
|
return;
|
|
949
1423
|
}
|
|
1424
|
+
// Q23: a notice appended before a cut can disappear from the next
|
|
1425
|
+
// projection. Append at the destination so the provider sees it once.
|
|
1426
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...decision.guidance });
|
|
950
1427
|
continue;
|
|
951
1428
|
}
|
|
952
1429
|
const stopped = yield* this.maybeCut(counter, false);
|
|
@@ -959,7 +1436,7 @@ export class Session {
|
|
|
959
1436
|
}
|
|
960
1437
|
|
|
961
1438
|
let branchesThisTurn = 0;
|
|
962
|
-
type Settled = { ok: true; value:
|
|
1439
|
+
type Settled = { ok: true; value: SessionBrokerReceipt } | { ok: false; error: unknown };
|
|
963
1440
|
const pending = new Map<string, Promise<Settled>>();
|
|
964
1441
|
const modes = calls.map(call => this.executionMode(call));
|
|
965
1442
|
try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
|
|
@@ -975,88 +1452,33 @@ export class Session {
|
|
|
975
1452
|
const planned = calls[index]!;
|
|
976
1453
|
yield { kind: "tool_call", tool: { name: planned.name, input: planned.input } };
|
|
977
1454
|
if (signal?.aborted) break;
|
|
978
|
-
const dispatch = yield* this.
|
|
979
|
-
pending.set(planned.call_id, dispatch().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
|
|
1455
|
+
const dispatch = yield* this.prepareBrokerCall(planned, () => branchesThisTurn++, blocked, signal);
|
|
1456
|
+
pending.set(planned.call_id, dispatch.run().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
|
|
980
1457
|
}
|
|
981
1458
|
}
|
|
982
1459
|
|
|
983
|
-
let
|
|
984
|
-
let annotation: LogEvent | undefined;
|
|
1460
|
+
let receipt: SessionBrokerReceipt;
|
|
985
1461
|
const dispatched = pending.get(call.call_id);
|
|
986
1462
|
if (dispatched) {
|
|
987
1463
|
const settled = await dispatched;
|
|
988
1464
|
pending.delete(call.call_id);
|
|
989
1465
|
if (!settled.ok) throw settled.error;
|
|
990
|
-
|
|
1466
|
+
receipt = settled.value;
|
|
991
1467
|
} else {
|
|
992
1468
|
if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
|
|
993
|
-
|
|
994
1469
|
yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
// covers abort arriving between calls.
|
|
999
|
-
if (signal?.aborted) {
|
|
1000
|
-
yield* this.closeInterrupted(completedThisTurn);
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// Three tools are not workspace actions and are handled here:
|
|
1005
|
-
// `note` writes an annotation the handoff is built from, and
|
|
1006
|
-
// `branch`/`resolve` act on the tree. Each still produces an ordinary
|
|
1007
|
-
// tool_result so the turn stays pairable. They go through the same
|
|
1008
|
-
// gate as read/write/edit/bash, which runTool applies itself.
|
|
1009
|
-
const toolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
|
|
1010
|
-
if (call.name === "note" || call.name === "branch" || call.name === "resolve" || call.name === "history") {
|
|
1011
|
-
const gated = await gateTool(toolContext, call.name, call.input);
|
|
1012
|
-
if (gated) {
|
|
1013
|
-
out = gated;
|
|
1014
|
-
} else if (call.name === "history") {
|
|
1015
|
-
out = await runHistoryTool(this.config.logDir, this.state.node, toolContext, call.input);
|
|
1016
|
-
} else if (call.name === "note") {
|
|
1017
|
-
const parsed = parseNote(call.input);
|
|
1018
|
-
const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
|
|
1019
|
-
parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
|
|
1020
|
-
out = prepared.out; annotation = prepared.annotation;
|
|
1021
|
-
} else if (call.name === "branch") {
|
|
1022
|
-
out = yield* this.runBranch(call.input, branchesThisTurn++, signal);
|
|
1023
|
-
} else {
|
|
1024
|
-
out = await this.runResolve(call.input);
|
|
1025
|
-
}
|
|
1026
|
-
} else {
|
|
1027
|
-
const decoder = new TextDecoder();
|
|
1028
|
-
out = yield* withProgress<ToolResult, LoopEvent>(emit => runTool({ ...toolContext, callId: call.call_id,
|
|
1029
|
-
onOutput: chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } }),
|
|
1030
|
-
}, call.name, call.input));
|
|
1031
|
-
}
|
|
1470
|
+
if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
|
|
1471
|
+
const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
|
|
1472
|
+
receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
|
|
1032
1473
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
//
|
|
1474
|
+
await receipt.settle();
|
|
1475
|
+
for (const event of receipt.events.splice(0)) yield event;
|
|
1476
|
+
const out = finishToolResult(receipt.value);
|
|
1477
|
+
// Observation still precedes persistence: crashes here remain unknown,
|
|
1478
|
+
// while nested receipts have already been committed before their caller resumed.
|
|
1037
1479
|
yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
|
|
1480
|
+
await receipt.commit();
|
|
1038
1481
|
|
|
1039
|
-
// Recorded before the tool_result so a claim can never precede the
|
|
1040
|
-
// evidence it cites -- deriveHandoff resolves a claim against runs at
|
|
1041
|
-
// or before it, and an annotation is about work already done.
|
|
1042
|
-
if (annotation) await this.record(annotation);
|
|
1043
|
-
|
|
1044
|
-
await this.record({
|
|
1045
|
-
t: "tool_result",
|
|
1046
|
-
id: this.clock.nextId(),
|
|
1047
|
-
at: this.clock.now(),
|
|
1048
|
-
call_id: call.call_id,
|
|
1049
|
-
ok: out.ok,
|
|
1050
|
-
content: out.content,
|
|
1051
|
-
outcome: out.outcome!,
|
|
1052
|
-
...(out.data !== undefined ? { data: out.data } : {}),
|
|
1053
|
-
...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
|
|
1054
|
-
...(out.diagnostic !== undefined ? { diagnostic: out.diagnostic } : {}),
|
|
1055
|
-
...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
|
|
1056
|
-
...(out.content_ref ? { content_ref: out.content_ref } : {}),
|
|
1057
|
-
...(out.images?.length ? { images: submissionImages(out.images, blocked) } : {}),
|
|
1058
|
-
...(out.effect ? { effect: out.effect } : {}),
|
|
1059
|
-
});
|
|
1060
1482
|
completedThisTurn.push(call.call_id);
|
|
1061
1483
|
if (signal?.aborted && pending.size === 0) {
|
|
1062
1484
|
yield* this.closeInterrupted(completedThisTurn);
|
|
@@ -1068,6 +1490,8 @@ export class Session {
|
|
|
1068
1490
|
// that means anything, and per-call would be the character-level
|
|
1069
1491
|
// granularity the research warns against.
|
|
1070
1492
|
yield* this.snapshotTurn(calls.map((c) => c.call_id));
|
|
1493
|
+
const declarationNode = this.state.node;
|
|
1494
|
+
if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
|
|
1071
1495
|
if (this.pendingAdoption !== null) await this.adoptPendingBranch();
|
|
1072
1496
|
|
|
1073
1497
|
// Between complete exchanges is the only safe place to cut.
|
|
@@ -1079,7 +1503,7 @@ export class Session {
|
|
|
1079
1503
|
const extra = await this.pullSteering(pull);
|
|
1080
1504
|
if (extra !== null) {
|
|
1081
1505
|
await this.record({
|
|
1082
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
|
|
1506
|
+
t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
|
|
1083
1507
|
});
|
|
1084
1508
|
}
|
|
1085
1509
|
}
|
|
@@ -1111,6 +1535,9 @@ export class Session {
|
|
|
1111
1535
|
id: this.clock.nextId(),
|
|
1112
1536
|
at: this.clock.now(),
|
|
1113
1537
|
call_id: block.call_id,
|
|
1538
|
+
...(block.invocation_id ? { invocation_ref: { node: this.state.node, event: block.invocation_id } } : {}),
|
|
1539
|
+
...(block.parent_call_id ? { parent_call_id: block.parent_call_id } : {}),
|
|
1540
|
+
...(block.origin ? { origin: block.origin } : {}),
|
|
1114
1541
|
ok: false,
|
|
1115
1542
|
content: resultText,
|
|
1116
1543
|
outcome: executionOutcome("unknown"),
|
|
@@ -1200,6 +1627,8 @@ export class Session {
|
|
|
1200
1627
|
* delta rather than a copy of the workspace.
|
|
1201
1628
|
*/
|
|
1202
1629
|
private async *snapshotTurn(callIds: readonly string[], baseline = false, phase: "turn" | "idle" = "turn"): AsyncGenerator<LoopEvent> {
|
|
1630
|
+
// P.3: retained only for explicit historical compatibility. New sessions never scan here.
|
|
1631
|
+
if (this.config.workspaceTracking !== "legacy") return;
|
|
1203
1632
|
const blobDir = this.config.blobDir;
|
|
1204
1633
|
if (!blobDir) return;
|
|
1205
1634
|
|
|
@@ -1278,41 +1707,207 @@ export class Session {
|
|
|
1278
1707
|
* the same thing, so nothing here has to reimplement the loop.
|
|
1279
1708
|
*/
|
|
1280
1709
|
private executionMode(call: ToolCall): "parallel" | "exclusive" {
|
|
1710
|
+
if (this.config.tools.extensionHooks?.serial?.()) return "exclusive";
|
|
1711
|
+
if (call.name === "read" && typeof call.input === "object" && call.input !== null &&
|
|
1712
|
+
this.config.skills?.some(skill => skill.filePath === resolve(this.config.tools.root, String((call.input as Record<string, unknown>)["path"])))) return "exclusive";
|
|
1281
1713
|
if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
|
|
1282
|
-
(call.input as Record<string, unknown>)["writes"] !== true) return "parallel";
|
|
1714
|
+
(call.input as Record<string, unknown>)["writes"] !== true && (call.input as Record<string, unknown>)["background"] !== true) return "parallel";
|
|
1283
1715
|
return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
|
|
1284
1716
|
}
|
|
1285
1717
|
|
|
1286
|
-
private async *
|
|
1287
|
-
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1718
|
+
private async *dispatchBranch(input: unknown, index: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, DeferredToolResult> {
|
|
1719
|
+
if (typeof input !== "object" || input === null || !("background" in input) || input.background !== true) return yield* this.runBranch(input, index, signal);
|
|
1720
|
+
if (this.jobs.active >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "background branch capacity reached; wait or cancel an owned branch first" };
|
|
1721
|
+
const controller = new AbortController();
|
|
1722
|
+
const abort = () => controller.abort();
|
|
1723
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1724
|
+
if (signal?.aborted) abort();
|
|
1725
|
+
const detach = () => signal?.removeEventListener("abort", abort);
|
|
1726
|
+
const child = this.runBranch(input, index, controller.signal);
|
|
1727
|
+
let registered = false;
|
|
1728
|
+
try {
|
|
1729
|
+
for (;;) {
|
|
1730
|
+
const step = await child.next();
|
|
1731
|
+
if (step.done) return step.value;
|
|
1732
|
+
yield step.value;
|
|
1733
|
+
if (step.value.kind !== "branch_start") continue;
|
|
1734
|
+
const node = step.value.node!;
|
|
1735
|
+
this.jobs.start(node, controller, async () => {
|
|
1736
|
+
const result = await child.next();
|
|
1737
|
+
if (!result.done) throw new Error("unexpected background child yield");
|
|
1738
|
+
return result.value;
|
|
1739
|
+
}, detach);
|
|
1740
|
+
registered = true;
|
|
1741
|
+
return { ok: true, content: `branch ${node} running; ` + (this.adviceNames().includes("resolve")
|
|
1742
|
+
? `use resolve(node=${node}, resolution='status'|'wait'|'cancel')` : "branch-observation capability unavailable; completion is unknown"), data: { node, background: true, running: true } };
|
|
1743
|
+
}
|
|
1744
|
+
} finally { if (!registered) detach(); }
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
private async *prepareBrokerCall(call: ToolCall, nextBranch: () => number, blockedImages: boolean,
|
|
1748
|
+
signal?: AbortSignal, native = false): AsyncGenerator<LoopEvent, PreparedBrokerCall> {
|
|
1749
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
1750
|
+
const release = await this.rootTools.acquire(this.executionMode(call));
|
|
1751
|
+
let transferred = false;
|
|
1752
|
+
let scope: BrokerExecution<DeferredToolResult> | undefined;
|
|
1753
|
+
try {
|
|
1754
|
+
const events: LoopEvent[] = [];
|
|
1755
|
+
let emit = (event: LoopEvent) => { events.push(event); };
|
|
1756
|
+
let context: ToolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1757
|
+
advice: this.allowedNames(),
|
|
1758
|
+
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
|
|
1759
|
+
if (native) context = nativeCommandContext(context);
|
|
1760
|
+
const decoder = new TextDecoder();
|
|
1761
|
+
context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
|
|
1762
|
+
const broker = new ToolBroker<DeferredToolResult>({
|
|
1763
|
+
parallelLimit: this.config.maxParallelTools ?? 3,
|
|
1764
|
+
callId: () => `nested-${randomUUID()}`,
|
|
1765
|
+
mode: request => this.executionMode(request),
|
|
1766
|
+
invocation: (request, ctx, parent) => this.recordInvocation(request, ctx, parent),
|
|
1767
|
+
prepare: (request, ctx) => this.prepareSessionAction(request, ctx, nextBranch, event => emit(event)),
|
|
1768
|
+
settle: async result => {
|
|
1769
|
+
if (result.accept) events.push(...await result.accept());
|
|
1770
|
+
if (!result.content_ref) Object.assign(result, await presentObservation(context, result.content));
|
|
1771
|
+
},
|
|
1772
|
+
result: async (invocation, result) => {
|
|
1773
|
+
if (result.annotation) await this.record(result.annotation);
|
|
1774
|
+
await this.recordToolReceipt(invocation, finishToolResult(result), blockedImages);
|
|
1775
|
+
},
|
|
1776
|
+
});
|
|
1777
|
+
const execute = yield* withProgress<BrokerExecution<DeferredToolResult>, LoopEvent>(async output => {
|
|
1778
|
+
emit = output;
|
|
1779
|
+
scope = await broker.prepare(call, context);
|
|
1780
|
+
return scope;
|
|
1781
|
+
});
|
|
1782
|
+
emit = event => { events.push(event); };
|
|
1783
|
+
transferred = true;
|
|
1784
|
+
return { run: async output => {
|
|
1785
|
+
if (output) emit = output;
|
|
1786
|
+
let commitOwnsSlot = false;
|
|
1787
|
+
try {
|
|
1788
|
+
const receipt = await execute();
|
|
1789
|
+
if (native) {
|
|
1790
|
+
// An idle host result has no generator consumer to own its commit.
|
|
1791
|
+
// Retain the slot so a checkpoint cannot split invocation from receipt.
|
|
1792
|
+
commitOwnsSlot = true;
|
|
1793
|
+
return { ...receipt, events, commit: async () => { try { await receipt.commit(); } finally { release(); } } };
|
|
1794
|
+
}
|
|
1795
|
+
return { ...receipt, events };
|
|
1796
|
+
} finally { emit = event => { events.push(event); }; if (!commitOwnsSlot) release(); }
|
|
1797
|
+
} };
|
|
1798
|
+
} finally { if (!transferred) { try { await scope?.close(); } finally { release(); } } }
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
private async recordInvocation(call: ToolCall, ctx: ToolContext, parent?: ToolInvocationEvent): Promise<ToolInvocationEvent> {
|
|
1802
|
+
const store = ctx.outputStore ?? join(this.config.logDir, "artifacts");
|
|
1803
|
+
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
|
|
1804
|
+
const archive = async (bytes: Uint8Array) => {
|
|
1805
|
+
async function* source() { yield bytes; }
|
|
1806
|
+
return (await archiveObservation(store, source(), 0)).ref;
|
|
1807
|
+
};
|
|
1808
|
+
let valid = false;
|
|
1809
|
+
try { valid = isJsonValue(call.input); } catch { /* Cyclic or excessively deep host input is not a JSON request. */ }
|
|
1810
|
+
const bytes = encode(valid ? call.input : { unavailable: "non-JSON arguments", type: typeof call.input });
|
|
1811
|
+
const definition = toolDefinition(call.name, ctx);
|
|
1812
|
+
const declared = this.config.frozen.tools.find(tool => tool !== null && typeof tool === "object" && "name" in tool && tool.name === call.name &&
|
|
1813
|
+
"description" in tool && typeof tool.description === "string" && "input_schema" in tool) as ReturnType<typeof modelToolSchema> | undefined;
|
|
1814
|
+
const schema = definition ? declared ?? modelToolSchema(definition, adviceTools(ctx)) : undefined;
|
|
1815
|
+
const descriptor = definition ? { version: 1, ...schema,
|
|
1816
|
+
...(declared && JSON.stringify(declared.input_schema) !== JSON.stringify(definition.input_schema) ? { admission_schema: definition.input_schema } : {}),
|
|
1817
|
+
access: definition.access, execution: definition.execution, branch: definition.branch, hiddenInputs: definition.hiddenInputs ?? [], result_version: 1 } : null;
|
|
1818
|
+
const original = this.state.events.findLast(event => event.t === "assistant" && event.blocks.some(block => block.type === "tool_use" && block.call_id === call.call_id));
|
|
1819
|
+
const originalInput = original?.t === "assistant" ? original.blocks.find(block => block.type === "tool_use" && block.call_id === call.call_id) : undefined;
|
|
1820
|
+
const changed = originalInput?.type !== "tool_use" || valid && JSON.stringify(originalInput.input) !== JSON.stringify(call.input);
|
|
1821
|
+
const event: ToolInvocationEvent = { t: "tool_invocation", id: this.clock.nextId(), at: this.clock.now(), call_id: call.call_id, name: call.name,
|
|
1822
|
+
...(ctx.origin ? { origin: ctx.origin } : {}),
|
|
1823
|
+
input_ref: await archive(bytes), schema_ref: descriptor ? await archive(encode(descriptor)) : null,
|
|
1824
|
+
...(parent ? { parent_call_id: parent.call_id, parent_ref: { node: this.state.node, event: parent.id } } : {}),
|
|
1825
|
+
...(valid && bytes.length <= MAX_NESTED_ARGUMENT_BYTES && (parent || changed) ? { input: call.input as JsonValue } : {}) };
|
|
1826
|
+
await this.record(event); return event;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
private async recordToolReceipt(invocation: ToolInvocationEvent, out: ToolResult, blockedImages: boolean): Promise<void> {
|
|
1830
|
+
let data = out.data;
|
|
1831
|
+
let dataRef;
|
|
1832
|
+
if (data !== undefined) {
|
|
1833
|
+
const bytes = new TextEncoder().encode(JSON.stringify(data));
|
|
1834
|
+
if (bytes.length > MAX_INLINE_RESULT_DATA_BYTES) {
|
|
1835
|
+
async function* source() { yield bytes; }
|
|
1836
|
+
dataRef = (await archiveObservation(this.config.tools.outputStore!, source(), 0)).ref;
|
|
1837
|
+
data = undefined;
|
|
1838
|
+
}
|
|
1306
1839
|
}
|
|
1840
|
+
await this.record({ t: "tool_result", id: this.clock.nextId(), at: this.clock.now(), call_id: invocation.call_id,
|
|
1841
|
+
invocation_ref: { node: this.state.node, event: invocation.id },
|
|
1842
|
+
...(invocation.origin ? { origin: invocation.origin } : {}),
|
|
1843
|
+
...(invocation.parent_call_id ? { parent_call_id: invocation.parent_call_id } : {}), ok: out.ok, content: out.content,
|
|
1844
|
+
...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
|
|
1845
|
+
...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
|
|
1846
|
+
...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
|
|
1847
|
+
...(out.warnings ? { warnings: out.warnings } : {}), ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
|
|
1848
|
+
...(out.content_ref ? { content_ref: out.content_ref } : {}),
|
|
1849
|
+
...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
private async prepareSessionAction(call: ToolCall, ctx: ToolContext, nextBranch: () => number,
|
|
1853
|
+
emit: (event: LoopEvent) => void): Promise<DeferredToolResult | (() => Promise<DeferredToolResult>)> {
|
|
1854
|
+
// SDK callers may omit allow or preauthorize an optional name. Permission is
|
|
1855
|
+
// distinct from a declaration: neither direct nor nested calls may run it early.
|
|
1856
|
+
const declared = declaredToolNames(this.config.frozen.tools);
|
|
1857
|
+
if (ctx.deferredTools?.some(tool => tool.name === call.name) && !declared.includes(call.name)) {
|
|
1858
|
+
return prepareNormalizedTool({ ...ctx, allow: (ctx.allow ?? declared).filter(name => name !== call.name) }, call.name, call.input);
|
|
1859
|
+
}
|
|
1860
|
+
const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
|
|
1861
|
+
skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
|
|
1862
|
+
const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
|
|
1863
|
+
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
1864
|
+
const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
|
|
1865
|
+
return prepareNormalizedTool(ctx, call.name, call.input, async () => {
|
|
1866
|
+
if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
|
|
1867
|
+
if (call.name === "history") return () => runHistoryTool(this.config.logDir, this.state.node, ctx, call.input);
|
|
1868
|
+
if (call.name === "target") return async () => {
|
|
1869
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1870
|
+
if (!history.ok) throw Error(history.error);
|
|
1871
|
+
const observed = await this.runtimeTargets.perform(ctx, history.nodes, call.input as Parameters<RuntimeTargets["perform"]>[2]);
|
|
1872
|
+
return { ...observed.result, annotation: { t: "target_observation" as const, id: this.clock.nextId(), at: this.clock.now(), observation: observed.observation } };
|
|
1873
|
+
};
|
|
1874
|
+
if (call.name === "note") return async () => {
|
|
1875
|
+
await this.observeEvidence();
|
|
1876
|
+
const parsed = parseNote(call.input);
|
|
1877
|
+
const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
|
|
1878
|
+
parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
|
|
1879
|
+
return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
|
|
1880
|
+
};
|
|
1881
|
+
if (call.name === "resolve") return () => this.runResolve(call.input);
|
|
1882
|
+
if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
|
|
1883
|
+
if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
|
|
1884
|
+
const task = this.state.events.findLast(event => event.t === "user");
|
|
1885
|
+
const branch = forkSkill ? this.runBranch({ purpose: `skill ${forkSkill.name}`, prompt: `${forkSkill.body}\n\nCurrent task: ${task?.t === "user" ? task.text : ""}` },
|
|
1886
|
+
branchIndex, ctx.signal, forkSkill, skillTools(this.allowedNames(), forkSkill), "model") : this.dispatchBranch(call.input, branchIndex, ctx.signal);
|
|
1887
|
+
while (true) {
|
|
1888
|
+
const step = await branch.next();
|
|
1889
|
+
if (step.done) return async () => step.value;
|
|
1890
|
+
emit(step.value);
|
|
1891
|
+
if (step.value.kind === "branch_start") return async () => {
|
|
1892
|
+
const result = await branch.next();
|
|
1893
|
+
if (!result.done) throw new Error(`unexpected child execution event: ${result.value.kind}`);
|
|
1894
|
+
return result.value;
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1307
1898
|
}
|
|
1308
1899
|
|
|
1309
1900
|
private async *runBranch(
|
|
1310
1901
|
input: unknown,
|
|
1311
1902
|
indexThisTurn: number,
|
|
1312
1903
|
signal?: AbortSignal,
|
|
1904
|
+
skill?: Skill,
|
|
1905
|
+
allowedOverride?: readonly string[],
|
|
1906
|
+
skillOrigin: "user" | "model" = "user",
|
|
1313
1907
|
): AsyncGenerator<LoopEvent, DeferredToolResult> {
|
|
1314
1908
|
const limits = this.limits;
|
|
1315
1909
|
const depth = this.config.branchDepth ?? 0;
|
|
1910
|
+
if (this.children.size >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "branch capacity reached; wait or cancel an owned branch first" };
|
|
1316
1911
|
|
|
1317
1912
|
// Every refusal is a normal tool_result: an unanswered tool_use makes the
|
|
1318
1913
|
// next request unpairable, and a refusal can say what to do instead.
|
|
@@ -1349,7 +1944,7 @@ export class Session {
|
|
|
1349
1944
|
if (!purpose || !prompt) {
|
|
1350
1945
|
return { content: "a branch needs both a purpose and a prompt", ok: false };
|
|
1351
1946
|
}
|
|
1352
|
-
const parentAllow = this.config.tools.allow;
|
|
1947
|
+
const parentAllow = allowedOverride ?? this.config.tools.allow;
|
|
1353
1948
|
const branchNames = writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES;
|
|
1354
1949
|
const allowed: string[] = parentAllow
|
|
1355
1950
|
? branchNames.filter(name => parentAllow.includes(name)) : [...branchNames];
|
|
@@ -1387,6 +1982,7 @@ export class Session {
|
|
|
1387
1982
|
|
|
1388
1983
|
await this.record({
|
|
1389
1984
|
t: "spawn", id: this.clock.nextId(), at: this.clock.now(), branch: ref, purpose,
|
|
1985
|
+
...(args["background"] === true ? { background: true as const } : {}),
|
|
1390
1986
|
input: { source_event: sourceEvent, tools: allowed, max_turns: Math.min(this.config.maxTurns, 15), workspace: inputWorkspace },
|
|
1391
1987
|
...(worktree ? { workspace: worktree } : {}),
|
|
1392
1988
|
});
|
|
@@ -1399,7 +1995,8 @@ export class Session {
|
|
|
1399
1995
|
const branch = new Session(
|
|
1400
1996
|
{
|
|
1401
1997
|
...this.config,
|
|
1402
|
-
|
|
1998
|
+
acceptanceContract: null,
|
|
1999
|
+
frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
|
|
1403
2000
|
// The same list twice: the schema shapes what the model asks for, the
|
|
1404
2001
|
// allow-list decides what actually runs. Intersected with the parent
|
|
1405
2002
|
// so `--exclude-tools bash` cannot be undone by opening a branch.
|
|
@@ -1407,6 +2004,7 @@ export class Session {
|
|
|
1407
2004
|
...this.config.tools,
|
|
1408
2005
|
...(worktree ? { root: worktree.path } : {}),
|
|
1409
2006
|
allow: allowed,
|
|
2007
|
+
readonlyBranch: !writes,
|
|
1410
2008
|
},
|
|
1411
2009
|
branchDepth: depth + 1,
|
|
1412
2010
|
workNamespace: `branch-${node}`,
|
|
@@ -1419,14 +2017,16 @@ export class Session {
|
|
|
1419
2017
|
branch.state.node = node;
|
|
1420
2018
|
branch.state.nextNode = this.state.nextNode;
|
|
1421
2019
|
branch.state.lastScan = { ...this.state.lastScan };
|
|
2020
|
+
if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
|
|
2021
|
+
this.children.set(node, branch);
|
|
1422
2022
|
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
1423
|
-
for await (const event of branch.snapshotTurn([], true)) {
|
|
1424
|
-
if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
|
|
1425
|
-
}
|
|
1426
2023
|
|
|
1427
2024
|
let text = "";
|
|
1428
2025
|
let outcome = executionOutcome("unknown");
|
|
1429
2026
|
try {
|
|
2027
|
+
for await (const event of branch.snapshotTurn([], true)) {
|
|
2028
|
+
if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
|
|
2029
|
+
}
|
|
1430
2030
|
for await (const e of branch.send(prompt, signal)) {
|
|
1431
2031
|
if (e.kind === "assistant_text" && e.text) text = e.text;
|
|
1432
2032
|
if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
|
|
@@ -1435,7 +2035,8 @@ export class Session {
|
|
|
1435
2035
|
// Persist a bounded failure classification, never a provider's raw error body.
|
|
1436
2036
|
outcome = executionOutcome(signal?.aborted ? "cancelled" : "execution_error");
|
|
1437
2037
|
await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
|
|
1438
|
-
}
|
|
2038
|
+
} finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
|
|
2039
|
+
if (args["background"] === true) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
|
|
1439
2040
|
// Node numbers the branch allocated are taken.
|
|
1440
2041
|
|
|
1441
2042
|
const tip = branch.state.node;
|
|
@@ -1455,29 +2056,40 @@ export class Session {
|
|
|
1455
2056
|
"not in your files. Adopting continues in that checkout without merging it into this one."
|
|
1456
2057
|
: "";
|
|
1457
2058
|
const summary = text.trim() || "(the branch produced no closing message)";
|
|
2059
|
+
const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2060
|
+
if (!receiptHistory.ok) throw new Error(receiptHistory.error);
|
|
2061
|
+
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
|
|
2062
|
+
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
1458
2063
|
const joined: LogEvent = {
|
|
1459
2064
|
t: "joined", id: this.clock.nextId(), at: this.clock.now(),
|
|
1460
2065
|
branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
|
|
1461
2066
|
model: branch.provider.config.spec.id,
|
|
1462
2067
|
outcome,
|
|
2068
|
+
receipt_ref: receiptRef,
|
|
1463
2069
|
};
|
|
1464
2070
|
|
|
1465
2071
|
// Only the summary crosses back. The branch's own calls stay in its node,
|
|
1466
2072
|
// which is the entire reason to branch rather than read in place.
|
|
1467
2073
|
return {
|
|
1468
2074
|
content:
|
|
1469
|
-
`branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n
|
|
2075
|
+
`branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n` +
|
|
2076
|
+
`Evidence receipt: ${receipt.commands.length} commands, ${receipt.changes.length} file edits, ${receipt.unfinished.length} unfinished items; verification=${receipt.verification}; task acceptance=not assessed.\n` +
|
|
2077
|
+
(this.adviceNames().includes("history") ? `Read history(artifact='${receiptRef}'), then history(branch=${node}, event='source ID') for original evidence.\n`
|
|
2078
|
+
: `Evidence receipt artifact=${receiptRef}; original-source recovery capability unavailable.\n`) +
|
|
2079
|
+
`Child closing statement (unverified):\n${summary}${where}\n\n` +
|
|
1470
2080
|
`Its full state is at ${handoffPath(this.config.logDir, tip)}. ` +
|
|
1471
|
-
`Resolve it with resolve(node: ${node}, ...)
|
|
2081
|
+
(this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
|
|
1472
2082
|
ok: outcome.status === "completed",
|
|
1473
2083
|
outcome,
|
|
1474
2084
|
accept: async () => {
|
|
2085
|
+
if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
|
|
1475
2086
|
this.state.usage.push(...branch.state.usage);
|
|
1476
|
-
await this.saveCursor();
|
|
1477
2087
|
await this.record(joined);
|
|
2088
|
+
await this.saveCursor();
|
|
1478
2089
|
return [{ kind: "branch_done", node, text: summary, outcome, ok: outcome.status === "completed" }];
|
|
1479
2090
|
},
|
|
1480
2091
|
data: { node, outcome, handoff_ref: handoffPath(this.config.logDir, tip), requests: branch.state.usage.length,
|
|
2092
|
+
receipt_ref: receiptRef,
|
|
1481
2093
|
input: { source_event: sourceEvent, workspace: inputWorkspace } },
|
|
1482
2094
|
};
|
|
1483
2095
|
}
|
|
@@ -1502,10 +2114,33 @@ export class Session {
|
|
|
1502
2114
|
return { ok: false, content: `branch ${node} is already resolved` };
|
|
1503
2115
|
}
|
|
1504
2116
|
|
|
2117
|
+
const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
|
|
2118
|
+
if (resolution === "status") {
|
|
2119
|
+
const item = (await this.branchStatuses()).find(branch => branch.node === node)!;
|
|
2120
|
+
return { ok: true, content: `branch ${node}: ${item.status}`, data: { node, status: item.status } };
|
|
2121
|
+
}
|
|
2122
|
+
if (resolution === "wait" || resolution === "cancel") {
|
|
2123
|
+
if (joined) return { ok: true, content: `branch ${node} already joined; receipt ${joined.t === "joined" ? joined.receipt_ref ?? "not recorded" : ""}` };
|
|
2124
|
+
if (resolution === "cancel" && this.jobs.state(node) === undefined) return { ok: false, content: `branch ${node}: unknown after restart; no owned handle to cancel` };
|
|
2125
|
+
if (resolution === "cancel") { this.jobs.cancel(node); await this.jobs.drain(node); }
|
|
2126
|
+
const waitMs = args["wait_ms"] === undefined ? 10_000 : args["wait_ms"];
|
|
2127
|
+
if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0 || waitMs > 60_000) return { ok: false, content: "wait_ms must be between 0 and 60000" };
|
|
2128
|
+
const result = await this.jobs.wait(node, waitMs);
|
|
2129
|
+
if (result?.running) return { ok: true, content: `branch ${node} running; wait reached its deadline`, data: { node, running: true } };
|
|
2130
|
+
if (result?.error) return { ok: false, content: result.error };
|
|
2131
|
+
const complete = result?.value ?? await this.recoverBranchResult(node);
|
|
2132
|
+
if (!complete) return { ok: false, content: `branch ${node}: unknown after restart; no sealed execution outcome; inspect before retrying` };
|
|
2133
|
+
if (complete.accept) await complete.accept();
|
|
2134
|
+
this.jobs.forget(node);
|
|
2135
|
+
return complete;
|
|
2136
|
+
}
|
|
2137
|
+
if (this.jobs.state(node) === "running") return { ok: false, content: `branch ${node} running; wait or cancel before resolving it` };
|
|
2138
|
+
if (!joined && this.jobs.state(node) === "ready") return { ok: false, content: `branch ${node} has not joined; wait before resolving it` };
|
|
2139
|
+
|
|
1505
2140
|
const ref = await this.nodeRefFor(node);
|
|
1506
2141
|
if (resolution === "adopt") {
|
|
2142
|
+
if ((await this.branchStatuses()).some(item => item.node !== node && item.background && !item.handoff_ref && item.resolution === null)) return { ok: false, content: "wait or cancel other unjoined background branches before adoption" };
|
|
1507
2143
|
if (this.pendingAdoption !== null) return { content: "resolve one adoption per tool round", ok: false };
|
|
1508
|
-
const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
|
|
1509
2144
|
if (!joined) return { content: `branch ${node} has not joined`, ok: false };
|
|
1510
2145
|
await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: ref });
|
|
1511
2146
|
this.pendingAdoption = node;
|
|
@@ -1533,52 +2168,97 @@ export class Session {
|
|
|
1533
2168
|
}
|
|
1534
2169
|
return { content: `discarded branch ${node}`, ok: true };
|
|
1535
2170
|
}
|
|
1536
|
-
return { content: `resolution must be
|
|
2171
|
+
return { content: `resolution must be wait, cancel, status, adopt or discard`, ok: false };
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
private async recoverBranchResult(root: number): Promise<DeferredToolResult | undefined> {
|
|
2175
|
+
// Recover only a unique sealed continuation. An open or ambiguous branch
|
|
2176
|
+
// cannot be replayed safely because tools may already have had effects.
|
|
2177
|
+
const headers = await Promise.all((await listNodes(this.config.logDir)).map(async node => ({ node, header: (await readNode(this.config.logDir, node)).header })));
|
|
2178
|
+
let tip = root;
|
|
2179
|
+
const seen = new Set<number>();
|
|
2180
|
+
for (;;) {
|
|
2181
|
+
if (seen.has(tip)) return undefined;
|
|
2182
|
+
seen.add(tip);
|
|
2183
|
+
const children = headers.filter(item => item.header?.parent?.node === tip && item.header.basis !== "none");
|
|
2184
|
+
if (children.length > 1) return undefined;
|
|
2185
|
+
if (children.length === 0) break;
|
|
2186
|
+
tip = children[0]!.node;
|
|
2187
|
+
}
|
|
2188
|
+
if (!(await readManifest(this.config.logDir)).nodes.some(item => item.node === tip && item.handoff_ref)) return undefined;
|
|
2189
|
+
const history = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2190
|
+
if (!history.ok) throw new Error(history.error);
|
|
2191
|
+
const outcomeEvent = history.nodes.flatMap(part => part.events).findLast(event => event.t === "outcome");
|
|
2192
|
+
if (outcomeEvent?.t !== "outcome") return undefined;
|
|
2193
|
+
const handoff = await replayContext(this.config.logDir, tip);
|
|
2194
|
+
const full = deriveHandoff({ prev: handoff.fullHandoff, events: handoff.events, depth: handoff.depth, self: await this.nodeRefFor(tip) });
|
|
2195
|
+
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome: outcomeEvent.outcome, handoff: full, nodes: history.nodes });
|
|
2196
|
+
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
2197
|
+
const last = history.nodes.flatMap(part => part.events).findLast(event => event.t === "assistant");
|
|
2198
|
+
const summary = last?.t === "assistant" ? last.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") : "(no closing message)";
|
|
2199
|
+
return { ok: outcomeEvent.outcome.status === "completed", outcome: outcomeEvent.outcome,
|
|
2200
|
+
content: `branch ${root} recovered from sealed node ${tip}; receipt ${receiptRef}; closing statement is unverified: ${summary}`,
|
|
2201
|
+
data: { node: root, outcome: outcomeEvent.outcome, receipt_ref: receiptRef, handoff_ref: handoffPath(this.config.logDir, tip) },
|
|
2202
|
+
accept: async () => {
|
|
2203
|
+
if ((await this.branchStatuses()).some(item => item.node === root && item.handoff_ref)) return [];
|
|
2204
|
+
await this.record({ t: "joined", id: this.clock.nextId(), at: this.clock.now(), branch: await this.nodeRefFor(root), tip: await this.nodeRefFor(tip),
|
|
2205
|
+
handoff_ref: handoffPath(this.config.logDir, tip), receipt_ref: receiptRef, summary, outcome: outcomeEvent.outcome });
|
|
2206
|
+
await this.saveCursor(); return [];
|
|
2207
|
+
} };
|
|
1537
2208
|
}
|
|
1538
2209
|
|
|
1539
2210
|
/** D15: finish the parent's tool round before moving to the chosen branch. */
|
|
1540
2211
|
private async adoptPendingBranch(): Promise<void> {
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
2212
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
2213
|
+
const release = await this.rootTools.acquire("exclusive");
|
|
2214
|
+
let checkpoint = false;
|
|
2215
|
+
try {
|
|
2216
|
+
const root = this.pendingAdoption;
|
|
2217
|
+
if (root === null) return;
|
|
2218
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node);
|
|
2219
|
+
if (!history.ok) throw new Error(history.error);
|
|
2220
|
+
const events = history.nodes.flatMap((n) => n.events);
|
|
2221
|
+
const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
|
|
2222
|
+
const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
|
|
2223
|
+
if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
|
|
2224
|
+
const parent = this.state.node;
|
|
2225
|
+
const annotations = carryForward(events, spawned.id, parent);
|
|
2226
|
+
const tip = joined.tip ?? joined.branch;
|
|
2227
|
+
const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
|
|
2228
|
+
const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
|
|
2229
|
+
if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
|
|
2230
|
+
const parentRef = await this.nodeRefFor(parent);
|
|
2231
|
+
const existing = await readNode(this.config.logDir, node);
|
|
2232
|
+
if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
|
|
2233
|
+
else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
|
|
2234
|
+
throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
|
|
2235
|
+
}
|
|
2236
|
+
const rebuilt = await replayContext(this.config.logDir, node);
|
|
2237
|
+
this.state.node = node;
|
|
2238
|
+
this.state.nextNode = Math.max(this.state.nextNode, node + 1);
|
|
2239
|
+
this.state.events = rebuilt.contextEvents;
|
|
2240
|
+
this.resetDerivedToolState();
|
|
2241
|
+
this.state.handoff = rebuilt.handoff;
|
|
2242
|
+
this.state.depth = rebuilt.depth;
|
|
2243
|
+
this.state.anchor = null;
|
|
2244
|
+
const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
|
|
2245
|
+
for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
|
|
2246
|
+
if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
|
|
2247
|
+
await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
|
|
2248
|
+
}
|
|
2249
|
+
const checkout = this.state.worktrees.get(root);
|
|
2250
|
+
if (checkout) this.config.tools.root = checkout.path;
|
|
2251
|
+
if (this.config.workspaceTracking === "legacy") {
|
|
2252
|
+
const scan = await scanWorkspace(this.config.tools.root, this.ignore);
|
|
2253
|
+
if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
|
|
2254
|
+
this.state.lastScan = scan.files;
|
|
2255
|
+
}
|
|
2256
|
+
this.installWorkspace();
|
|
2257
|
+
await this.saveCursor();
|
|
2258
|
+
this.pendingAdoption = null;
|
|
2259
|
+
checkpoint = joined.model !== undefined && joined.model !== this.provider.config.spec.id;
|
|
2260
|
+
} finally { release(); }
|
|
2261
|
+
if (checkpoint) await this.performCut("model_checkpoint");
|
|
1582
2262
|
}
|
|
1583
2263
|
|
|
1584
2264
|
private toEvent(a: Annotation): LogEvent {
|
|
@@ -1595,7 +2275,9 @@ export class Session {
|
|
|
1595
2275
|
};
|
|
1596
2276
|
}
|
|
1597
2277
|
case "next_action":
|
|
1598
|
-
return { t: "next_action", ...stamp, what: a.what, next_action: a.next_action, ...(a.work_id ? { work_id: a.work_id } : {}) };
|
|
2278
|
+
return { t: "next_action", ...stamp, what: a.what, next_action: a.next_action, ...(a.work_id ? { work_id: a.work_id } : {}), ...(a.depends_on ? { depends_on: a.depends_on } : {}) };
|
|
2279
|
+
case "work_owner": return { ...a, ...stamp, owner: this.config.workNamespace ?? "main" };
|
|
2280
|
+
case "work_dependencies": return { ...a, ...stamp };
|
|
1599
2281
|
case "decision":
|
|
1600
2282
|
return {
|
|
1601
2283
|
t: "decision", ...stamp, chose: a.chose, because: a.because,
|
|
@@ -1617,12 +2299,85 @@ export class Session {
|
|
|
1617
2299
|
|
|
1618
2300
|
// ---- rewind ---------------------------------------------------------------
|
|
1619
2301
|
|
|
2302
|
+
/** A prepared edit can be reconciled by its exact path; unknown commands are never replayed. */
|
|
2303
|
+
private async recoverFileOperations(): Promise<void> {
|
|
2304
|
+
if (!this.config.blobDir) return;
|
|
2305
|
+
// A cut is only legal after paired tools, so pending operations belong to this node.
|
|
2306
|
+
const history = (await readNode(this.config.logDir, this.state.node)).events.filter((event): event is CheckpointEvent => event.t === "checkpoint");
|
|
2307
|
+
const pending = new Map<string, CheckpointEvent>();
|
|
2308
|
+
for (const event of history) {
|
|
2309
|
+
if (event.phase === "prepared") pending.set(event.operation, event);
|
|
2310
|
+
else pending.delete(event.operation);
|
|
2311
|
+
}
|
|
2312
|
+
for (const event of pending.values()) {
|
|
2313
|
+
let applied = event.source !== "git" && changedPaths(event.changes).length > 0;
|
|
2314
|
+
for (const [path, change] of Object.entries(event.changes)) {
|
|
2315
|
+
try { if (!sameVersion((await readEditableFile(this.config.tools.root, path)).version, change.after)) applied = false; }
|
|
2316
|
+
catch { applied = false; }
|
|
2317
|
+
}
|
|
2318
|
+
await this.record({ ...event, id: this.clock.nextId(), at: this.clock.now(), phase: applied ? "applied" : "unavailable",
|
|
2319
|
+
reason: applied ? "observed the prepared file version after restart; tool outcome remains unknown" : "interrupted checkpoint; inspect files before retrying" });
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
private async rewindJournal(point: RestorePoint, mode: RewindMode): Promise<RewindResult> {
|
|
2324
|
+
const dir = this.config.logDir, root = this.config.tools.root, blobs = this.config.blobDir;
|
|
2325
|
+
if (mode !== "context" && !blobs) return { ok: false, error: "this session stores no file checkpoints" };
|
|
2326
|
+
try {
|
|
2327
|
+
const target = { node: point.node, atEvent: point.atEvent };
|
|
2328
|
+
// Reading the log validates the target. Context-only navigation never reads workspace contents.
|
|
2329
|
+
const history = await readNodeHistory(dir, point.node);
|
|
2330
|
+
if (!history.ok) throw new Error(history.error);
|
|
2331
|
+
if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) throw new Error(`NO_SUCH_EVENT: ${point.atEvent}`);
|
|
2332
|
+
const changes: FileChanges = await planCheckpointRestore(dir, this.state.node, target).catch(error => {
|
|
2333
|
+
if (mode !== "context") throw error;
|
|
2334
|
+
return {};
|
|
2335
|
+
});
|
|
2336
|
+
const paths = changedPaths(changes);
|
|
2337
|
+
const historical = await readCursorAt(dir, point.node, point.atEvent);
|
|
2338
|
+
const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
|
|
2339
|
+
const latest = (await readNode(dir, this.state.node)).events.at(-1);
|
|
2340
|
+
const filesFrom: NodeRef = { ...await digestedRef(dir, this.state.node), ...(latest ? { at_event: latest.id } : {}) };
|
|
2341
|
+
if (mode !== "context" && blobs) {
|
|
2342
|
+
await preflightChanges(root, blobs, changes);
|
|
2343
|
+
// Persist the intention before files move; recovery never invents command completion.
|
|
2344
|
+
const operation = this.clock.nextId();
|
|
2345
|
+
await this.record({ t: "checkpoint", id: operation, at: this.clock.now(), operation, source: "restore", phase: "prepared", changes });
|
|
2346
|
+
await applyFileChanges(root, blobs, changes);
|
|
2347
|
+
await this.record({ t: "checkpoint", id: this.clock.nextId(), at: this.clock.now(), operation, source: "restore", phase: "applied", changes });
|
|
2348
|
+
}
|
|
2349
|
+
const restored = mode === "context" ? [] : paths.filter(path => changes[path]!.after !== null);
|
|
2350
|
+
const removed = mode === "context" ? [] : paths.filter(path => changes[path]!.after === null);
|
|
2351
|
+
const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode, from, prompt: point.prompt,
|
|
2352
|
+
restored, removed, unrecoverable: [], divergence: mode === "both" ? [] : paths,
|
|
2353
|
+
...(mode === "context" ? { files_from: filesFrom } : {}) };
|
|
2354
|
+
if (mode === "files") await this.record(event);
|
|
2355
|
+
else {
|
|
2356
|
+
const node = this.state.nextNode++;
|
|
2357
|
+
await initNode(dir, node, from, this.clock.now(), "inherit");
|
|
2358
|
+
const replayed = await replayContext(dir, node);
|
|
2359
|
+
this.state.node = node; this.state.events = [...replayed.contextEvents]; this.state.handoff = replayed.handoff;
|
|
2360
|
+
this.state.depth = replayed.depth; this.state.anchor = null; this.resetDerivedToolState();
|
|
2361
|
+
await this.record(event);
|
|
2362
|
+
if (historical === null || !sameModel(historical, { model: this.provider.config.spec.id, provider: this.provider.config.spec.provider })) {
|
|
2363
|
+
const cursor = await readCursor(dir);
|
|
2364
|
+
if (cursor) await writeCursor(dir, { ...cursor, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
|
|
2365
|
+
await this.performCut("model_checkpoint", this.provider.config.spec.id);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
await this.saveCursor();
|
|
2369
|
+
return { ok: true, node: this.state.node, restored, removed, unrecoverable: [], note: mode === "context"
|
|
2370
|
+
? "Only the conversation moved; workspace files were left unchanged."
|
|
2371
|
+
: "Restored recorded file changes. Unrelated files, ignored paths and external effects were left unchanged; command recovery requires captured Git checkpoints." };
|
|
2372
|
+
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2373
|
+
}
|
|
2374
|
+
|
|
1620
2375
|
private get ignore(): readonly string[] {
|
|
1621
2376
|
return this.config.snapshotIgnore ?? DEFAULT_IGNORE;
|
|
1622
2377
|
}
|
|
1623
2378
|
|
|
1624
2379
|
/**
|
|
1625
|
-
* Every completed exchange
|
|
2380
|
+
* Every completed exchange, including abandoned branches and conversation-only turns.
|
|
1626
2381
|
*
|
|
1627
2382
|
* An exchange runs from one user message to just before the next, so its
|
|
1628
2383
|
* last event is where a fork belongs: the conversation is whole there, and
|
|
@@ -1668,6 +2423,8 @@ export class Session {
|
|
|
1668
2423
|
}
|
|
1669
2424
|
};
|
|
1670
2425
|
for (const e of n.events) {
|
|
2426
|
+
if (e.t === "workspace_observation") continue;
|
|
2427
|
+
if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
|
|
1671
2428
|
if (e.t === "snapshot" && e.phase === "idle") {
|
|
1672
2429
|
close();
|
|
1673
2430
|
open = null;
|
|
@@ -1683,6 +2440,7 @@ export class Session {
|
|
|
1683
2440
|
}
|
|
1684
2441
|
if (!open) continue;
|
|
1685
2442
|
open.last = e.id;
|
|
2443
|
+
if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
|
|
1686
2444
|
if (e.t === "snapshot") {
|
|
1687
2445
|
open.changed +=
|
|
1688
2446
|
Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length;
|
|
@@ -1696,12 +2454,26 @@ export class Session {
|
|
|
1696
2454
|
/**
|
|
1697
2455
|
* What a rewind would do, without doing it.
|
|
1698
2456
|
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1701
|
-
* is the right behaviour -- a half-restored workspace is worse -- but it is
|
|
1702
|
-
* the half of the operation nobody asked for, so it is shown first.
|
|
2457
|
+
* P.3 previews only logged paths and preflights conflicts when files will move.
|
|
2458
|
+
* Context-only navigation consults history without reading workspace contents.
|
|
1703
2459
|
*/
|
|
1704
|
-
async previewRewind(point: RestorePoint): Promise<RewindPreview> {
|
|
2460
|
+
async previewRewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindPreview> {
|
|
2461
|
+
if (mode === "context") {
|
|
2462
|
+
const history = await readNodeHistory(this.config.logDir, point.node);
|
|
2463
|
+
if (!history.ok) return history;
|
|
2464
|
+
if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) return { ok: false, error: `NO_SUCH_EVENT: ${point.atEvent}` };
|
|
2465
|
+
const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent }).catch((): FileChanges => ({}));
|
|
2466
|
+
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2467
|
+
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2468
|
+
}
|
|
2469
|
+
if (this.config.workspaceTracking !== "legacy") {
|
|
2470
|
+
try {
|
|
2471
|
+
const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
|
|
2472
|
+
if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
|
|
2473
|
+
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2474
|
+
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2475
|
+
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2476
|
+
}
|
|
1705
2477
|
const plan = await planRestoreToNode(
|
|
1706
2478
|
this.config.logDir, { node: point.node, atEvent: point.atEvent },
|
|
1707
2479
|
undefined, this.config.tools.root, this.ignore,
|
|
@@ -1721,14 +2493,12 @@ export class Session {
|
|
|
1721
2493
|
* still a node, so it is still a restore target, and "rewound too far" is
|
|
1722
2494
|
* recoverable rather than terminal.
|
|
1723
2495
|
*
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
* behaviour; we would additionally flag the whole restore as undeclared
|
|
1728
|
-
* damage at the next turn boundary. So the files move first and the node is
|
|
1729
|
-
* opened only if that worked.
|
|
2496
|
+
* P.3 records a separate physical file cursor for context-only navigation.
|
|
2497
|
+
* In combined mode, file restoration must succeed before opening the new
|
|
2498
|
+
* conversation node. File-only restoration appends its observed transition.
|
|
1730
2499
|
*/
|
|
1731
2500
|
async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
|
|
2501
|
+
if (this.config.workspaceTracking !== "legacy") return this.rewindJournal(point, mode);
|
|
1732
2502
|
const dir = this.config.logDir;
|
|
1733
2503
|
const root = this.config.tools.root;
|
|
1734
2504
|
const target = { node: point.node, atEvent: point.atEvent };
|
|
@@ -1817,7 +2587,7 @@ export class Session {
|
|
|
1817
2587
|
this.state.node = node;
|
|
1818
2588
|
this.state.events = [];
|
|
1819
2589
|
this.state.handoff = replayed.handoff;
|
|
1820
|
-
this.
|
|
2590
|
+
this.resetDerivedToolState();
|
|
1821
2591
|
this.state.depth = replayed.depth;
|
|
1822
2592
|
// The new node's prefix has never been sent, so nothing to anchor to.
|
|
1823
2593
|
this.state.anchor = null;
|
|
@@ -1868,6 +2638,20 @@ export class Session {
|
|
|
1868
2638
|
].sort();
|
|
1869
2639
|
}
|
|
1870
2640
|
|
|
2641
|
+
private resetDerivedToolState(): void {
|
|
2642
|
+
this.stall = null;
|
|
2643
|
+
this.recovery = null;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
private async recoveryGuidance(tools: readonly string[]): Promise<ReturnType<typeof recoveryReview>> {
|
|
2647
|
+
if (this.recovery === null) {
|
|
2648
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2649
|
+
if (!history.ok) throw new Error(history.error);
|
|
2650
|
+
this.recovery = advanceRecovery(emptyRecoveryState(), history.nodes.flatMap(node => node.events), path => resolve(this.config.tools.root, path));
|
|
2651
|
+
}
|
|
2652
|
+
return recoveryReview(this.recovery, tools);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
1871
2655
|
private async *checkStall(): AsyncGenerator<LoopEvent, boolean> {
|
|
1872
2656
|
const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
|
|
1873
2657
|
if (limit === 0) return false;
|
|
@@ -1894,8 +2678,8 @@ export class Session {
|
|
|
1894
2678
|
* prompt reads as exactly over the usable window.
|
|
1895
2679
|
*/
|
|
1896
2680
|
private async *maybeWindowCut(counter: TokenCounter, willContinue: boolean, overflowed = false): AsyncGenerator<LoopEvent, boolean> {
|
|
1897
|
-
const budget =
|
|
1898
|
-
if (!willContinue || budget
|
|
2681
|
+
const budget = this.policyLimits();
|
|
2682
|
+
if (!willContinue || budget.kind !== "window" || pendingToolCalls(this.state.events).length > 0) return false;
|
|
1899
2683
|
const project = () => projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
|
|
1900
2684
|
let projected = project();
|
|
1901
2685
|
const measured = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
|
|
@@ -1906,7 +2690,7 @@ export class Session {
|
|
|
1906
2690
|
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1907
2691
|
let draft: Handoff;
|
|
1908
2692
|
try {
|
|
1909
|
-
draft =
|
|
2693
|
+
draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
|
|
1910
2694
|
depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
|
|
1911
2695
|
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
|
|
1912
2696
|
} catch (error) {
|
|
@@ -1948,8 +2732,19 @@ export class Session {
|
|
|
1948
2732
|
return false;
|
|
1949
2733
|
}
|
|
1950
2734
|
|
|
2735
|
+
/** Every threshold the loop acts at. The footer and banner read the same function. */
|
|
2736
|
+
private policyLimits(): ContextLimits {
|
|
2737
|
+
return contextLimits({
|
|
2738
|
+
policy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
2739
|
+
contextWindow: this.provider.config.spec.contextWindow,
|
|
2740
|
+
outputReserve: this.provider.config.maxTokens,
|
|
2741
|
+
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
2742
|
+
});
|
|
2743
|
+
}
|
|
2744
|
+
|
|
1951
2745
|
private async *maybeCut(counter: TokenCounter, canPrepare: boolean): AsyncGenerator<LoopEvent, boolean> {
|
|
1952
|
-
|
|
2746
|
+
const limits = this.policyLimits();
|
|
2747
|
+
if (limits.kind === "window") {
|
|
1953
2748
|
return yield* this.maybeWindowCut(counter, canPrepare);
|
|
1954
2749
|
}
|
|
1955
2750
|
const projected = projectContext({
|
|
@@ -1976,20 +2771,19 @@ export class Session {
|
|
|
1976
2771
|
const inheritedTokens = counter.count(floor.messages, floor.system, this.config.frozen.tools);
|
|
1977
2772
|
|
|
1978
2773
|
const policy = this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
|
|
1979
|
-
const cfg = { ...DEFAULT_BOUNDARY, ...this.config.boundary };
|
|
1980
2774
|
let draft: Handoff | null = null;
|
|
1981
|
-
if (
|
|
2775
|
+
if (limits.kind === "segment" && tokens >= limits.prepare) {
|
|
1982
2776
|
// Read the same full ancestry used by the actual cut. A bounded carried
|
|
1983
2777
|
// page is insufficient to decide whether all active work is completed.
|
|
1984
2778
|
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1985
|
-
draft =
|
|
2779
|
+
draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
|
|
1986
2780
|
depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
|
|
1987
2781
|
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
|
|
1988
2782
|
const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
|
|
1989
2783
|
(this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
|
|
1990
|
-
if (canPrepare && this.config.guidance !== false && noteEnabled && tokens <
|
|
2784
|
+
if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < limits.hard) {
|
|
1991
2785
|
const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens,
|
|
1992
|
-
continuationReady: draft.continuation_valid, config:
|
|
2786
|
+
continuationReady: draft.continuation_valid, config: limits.config });
|
|
1993
2787
|
if (notice) {
|
|
1994
2788
|
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
|
|
1995
2789
|
const updated = projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
|
|
@@ -2058,56 +2852,68 @@ export class Session {
|
|
|
2058
2852
|
*/
|
|
2059
2853
|
private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
|
|
2060
2854
|
nextProvider = this.provider.config.spec.provider, nextThinking = this.provider.config.effort, nextContextRef?: string): Promise<void> {
|
|
2061
|
-
|
|
2062
|
-
const
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2855
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
2856
|
+
const release = await this.rootTools.acquire("exclusive");
|
|
2857
|
+
try {
|
|
2858
|
+
const dir = this.config.logDir;
|
|
2859
|
+
const node = this.state.node;
|
|
2860
|
+
|
|
2861
|
+
const replayed = await replayContext(dir, node);
|
|
2862
|
+
const fromDisk = replayed.events;
|
|
2863
|
+
const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
|
|
2864
|
+
let guidanceTools = this.adviceNames();
|
|
2865
|
+
if (this.config.guidanceVersion === 1 && nextContextRef !== undefined) {
|
|
2866
|
+
const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
|
|
2867
|
+
if (saved === null) throw new Error("missing successor guidance context");
|
|
2868
|
+
guidanceTools = this.adviceNames((JSON.parse(saved) as { frozen: FrozenPrefix }).frozen);
|
|
2869
|
+
}
|
|
2067
2870
|
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2871
|
+
// The cut event is part of the closed file. Digest after appending it
|
|
2872
|
+
// so predecessor.sha256 matches what closeNode writes into the manifest.
|
|
2873
|
+
await this.record({
|
|
2874
|
+
t: "cut",
|
|
2875
|
+
id: this.clock.nextId(),
|
|
2876
|
+
at: this.clock.now(),
|
|
2877
|
+
handoff_ref: handoffPath(dir, node),
|
|
2878
|
+
predecessor: this.state.handoff?.predecessor ?? null,
|
|
2879
|
+
reason,
|
|
2880
|
+
next_node: this.state.nextNode,
|
|
2881
|
+
next_model: nextModel,
|
|
2882
|
+
next_provider: nextProvider,
|
|
2883
|
+
next_thinking: nextThinking,
|
|
2884
|
+
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
2885
|
+
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
2886
|
+
...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
|
|
2887
|
+
...(this.config.guidanceVersion === 1 ? { guidance_tools: guidanceTools } : {}),
|
|
2888
|
+
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
2889
|
+
});
|
|
2890
|
+
const self = await digestedRef(dir, node);
|
|
2891
|
+
const handoff = await this.boundCarried(deriveHandoff({
|
|
2892
|
+
prev: replayed.fullHandoff,
|
|
2893
|
+
depth: replayed.depth,
|
|
2894
|
+
events: fromDisk,
|
|
2895
|
+
self,
|
|
2896
|
+
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
2897
|
+
}), guidanceTools);
|
|
2898
|
+
await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
|
|
2899
|
+
|
|
2900
|
+
// One allocator for the whole session. A cut used to take node + 1 while
|
|
2901
|
+
// a branch took nextNode++, so a cut after a branch reached the branch's
|
|
2902
|
+
// id and overwrote its log.
|
|
2903
|
+
this.state.node = this.state.nextNode++;
|
|
2904
|
+
this.state.depth += 1;
|
|
2905
|
+
this.state.handoff = handoff;
|
|
2906
|
+
this.state.events = retained;
|
|
2907
|
+
// The new node's prefix has never been sent, so there is nothing to anchor
|
|
2908
|
+
// to until its first response comes back.
|
|
2909
|
+
this.state.anchor = null;
|
|
2910
|
+
// A cut declares a handoff basis: the closed node's events do not replay,
|
|
2911
|
+
// its derived state does. Relying on the default here would silently make
|
|
2912
|
+
// every boundary a fork and replay the whole history.
|
|
2913
|
+
await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
|
|
2914
|
+
// The durable successor already belongs to the target provider, even if
|
|
2915
|
+
// the process exits before switchModel installs its in-memory adapter.
|
|
2916
|
+
await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
|
|
2917
|
+
} finally { release(); }
|
|
2112
2918
|
}
|
|
2113
2919
|
}
|