spexcode 0.6.7 → 0.7.0-next.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/README.md +12 -7
- package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
- package/node_modules/@spexcode/session-application/dist/index.js +35 -0
- package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
- package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
- package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
- package/node_modules/@spexcode/session-application/dist/production.js +438 -0
- package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
- package/node_modules/@spexcode/session-application/package.json +29 -0
- package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
- package/node_modules/@spexcode/session-events/dist/index.js +245 -0
- package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
- package/node_modules/@spexcode/session-events/package.json +24 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
- package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
- package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
- package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
- package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
- package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
- package/node_modules/@spexcode/session-runtime/package.json +24 -0
- package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +31 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.js +135 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
- package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
- package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
- package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
- package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
- package/node_modules/@spexcode/session-topology/package.json +24 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +175 -21
- package/node_modules/@spexcode/spec-cli/dist/cli.js +121 -59
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
- package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
- package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
- package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
- package/node_modules/@spexcode/spec-cli/dist/guide.js +29 -18
- package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +24 -6
- package/node_modules/@spexcode/spec-cli/dist/harness.js +510 -72
- package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
- package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
- package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/host.js +98 -1
- package/node_modules/@spexcode/spec-cli/dist/index.js +294 -36
- package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
- package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +46 -15
- package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
- package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
- package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
- package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
- package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
- package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
- package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
- package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
- package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
- package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
- package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +112 -15
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +1553 -764
- package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
- package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
- package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
- package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
- package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
- package/node_modules/@spexcode/spec-cli/package.json +8 -6
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
- package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
- package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
- package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
- package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -1
- package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
- package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
- package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
- package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
- package/node_modules/@spexcode/spec-core/package.json +4 -1
- package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
- package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
- package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
- package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
- package/node_modules/@spexcode/spec-eval/package.json +2 -2
- package/node_modules/@spexcode/spec-forge/package.json +2 -2
- package/node_modules/@spexcode/transcript/dist/frames.d.ts +44 -0
- package/node_modules/@spexcode/transcript/dist/frames.js +85 -0
- package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
- package/node_modules/@spexcode/transcript/dist/index.js +5 -0
- package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
- package/node_modules/@spexcode/transcript/dist/live.js +65 -0
- package/node_modules/@spexcode/transcript/dist/parsers.d.ts +47 -0
- package/node_modules/@spexcode/transcript/dist/parsers.js +285 -0
- package/node_modules/@spexcode/transcript/dist/readers.d.ts +10 -0
- package/node_modules/@spexcode/transcript/dist/readers.js +296 -0
- package/node_modules/@spexcode/transcript/dist/turns.d.ts +42 -0
- package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
- package/node_modules/@spexcode/transcript/package.json +30 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +418 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +127 -0
- package/node_modules/smol-toml/dist/error.d.ts +38 -0
- package/node_modules/smol-toml/dist/error.js +63 -0
- package/node_modules/smol-toml/dist/extract.js +69 -0
- package/node_modules/smol-toml/dist/index.cjs +734 -0
- package/node_modules/smol-toml/dist/index.d.ts +43 -0
- package/node_modules/smol-toml/dist/index.js +33 -0
- package/node_modules/smol-toml/dist/parse.d.ts +36 -0
- package/node_modules/smol-toml/dist/parse.js +149 -0
- package/node_modules/smol-toml/dist/primitive.js +238 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
- package/node_modules/smol-toml/dist/stringify.js +181 -0
- package/node_modules/smol-toml/dist/struct.js +179 -0
- package/node_modules/smol-toml/dist/util.d.ts +38 -0
- package/node_modules/smol-toml/dist/util.js +89 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/package.json +9 -3
- package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -24
- package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -206
- package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
- package/node_modules/@spexcode/session-core/dist/index.js +0 -5
- package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
- package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
- package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
- package/node_modules/@spexcode/session-core/dist/message.js +0 -53
- package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
- package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -62
- package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -326
- package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
- package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
- package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -47
- package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -26
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
- package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
- package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
- package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
|
@@ -1,31 +1,87 @@
|
|
|
1
|
-
import { execFile, spawn } from 'node:child_process';
|
|
1
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { readFileSync, writeFileSync,
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
|
|
5
5
|
import { join, dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { rm as rmAsync, readdir as readdirAsync } from 'node:fs/promises';
|
|
7
8
|
import { seedWorktreeHostState } from './worktree-sources.js';
|
|
8
|
-
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
|
|
9
|
+
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, parseStatPath, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
|
|
9
10
|
import { loadConfig, loadSpecs, loadSpecsLite } from '@spexcode/spec-core';
|
|
10
11
|
import { adapterLoadedReferenceState, assertRvSockPath, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
|
|
11
12
|
import { materialize } from './materialize.js';
|
|
12
13
|
import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal } from '@spexcode/spec-core';
|
|
13
14
|
import { readSessionFiles } from './session-files.js';
|
|
14
15
|
import { readSessionWebs } from './session-web.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { acquireFreshSessionApplicationForCreate, configuredSessionApplicationIfCutover, initializeFreshSessionApplication, releaseFreshSessionApplicationForCreate, sessionApplicationCutoverState, setSessionApplicationCommitWake } from './session-application.js';
|
|
17
|
+
import { jsonMigrationFencePath } from '@spexcode/session-application';
|
|
18
|
+
import { withDeliveryLocks } from './delivery-lock.js';
|
|
19
|
+
import { withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from './session-record-lock.js';
|
|
17
20
|
import { stripRefSigil } from './mentions.js';
|
|
18
21
|
import { shQuote } from './sh.js';
|
|
19
22
|
import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js';
|
|
20
23
|
import { processStartToken } from '@spexcode/spec-core';
|
|
21
|
-
import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration,
|
|
24
|
+
import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js';
|
|
22
25
|
import { cliEntrypointArgs } from './tsx-bin.js';
|
|
26
|
+
import { lastHumanSendVia, recordStatus } from './session-timeline.js';
|
|
23
27
|
const pexec = promisify(execFile);
|
|
24
28
|
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
|
|
25
29
|
const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' };
|
|
26
30
|
const HARNESS = defaultHarness;
|
|
27
31
|
const COLS = 120, ROWS = 32;
|
|
28
32
|
const DEFAULT_MAX_ACTIVE = 8;
|
|
33
|
+
const worktreeTrashDir = (root) => join(root, '.worktrees', '.trash');
|
|
34
|
+
const pendingTrashDeletes = [];
|
|
35
|
+
let trashDeleteRunning = false;
|
|
36
|
+
let trashDeleteScheduled = false;
|
|
37
|
+
async function drainWorktreeTrash() {
|
|
38
|
+
while (pendingTrashDeletes.length) {
|
|
39
|
+
const path = pendingTrashDeletes.shift();
|
|
40
|
+
try {
|
|
41
|
+
await rmAsync(path, { recursive: true, force: true });
|
|
42
|
+
if (existsSync(path))
|
|
43
|
+
throw new Error('path remains after recursive removal');
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(`spex: deferred worktree deletion failed for ${path}; retained for next backend startup: ${error instanceof Error ? error.message : error}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
trashDeleteRunning = false;
|
|
50
|
+
}
|
|
51
|
+
function queueWorktreeTrash(path) {
|
|
52
|
+
pendingTrashDeletes.push(path);
|
|
53
|
+
if (trashDeleteRunning || trashDeleteScheduled)
|
|
54
|
+
return;
|
|
55
|
+
trashDeleteScheduled = true;
|
|
56
|
+
setImmediate(() => {
|
|
57
|
+
trashDeleteScheduled = false;
|
|
58
|
+
trashDeleteRunning = true;
|
|
59
|
+
void drainWorktreeTrash();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Start the one process-local serial reaper and resume any crash leftovers. */
|
|
63
|
+
export function startWorktreeTrashReaper() {
|
|
64
|
+
const dir = worktreeTrashDir(mainRoot());
|
|
65
|
+
readdirAsync(dir, { withFileTypes: true }).then((entries) => {
|
|
66
|
+
for (const entry of entries)
|
|
67
|
+
queueWorktreeTrash(join(dir, entry.name));
|
|
68
|
+
}).catch((error) => {
|
|
69
|
+
if (error?.code !== 'ENOENT')
|
|
70
|
+
console.error(`spex: deferred worktree trash cleanup failed for ${dir}; retrying next startup: ${error instanceof Error ? error.message : error}`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function moveWorktreeToTrash(root, path) {
|
|
74
|
+
const worktrees = resolve(join(root, '.worktrees'));
|
|
75
|
+
const source = resolve(path);
|
|
76
|
+
// Session creation uses <root>/.worktrees/<name>. Keep an adjacent trash for legacy/manual records whose
|
|
77
|
+
// recorded path predates that layout; the normal product path always lands in the governed .worktrees/.trash.
|
|
78
|
+
const parent = dirname(source);
|
|
79
|
+
const dir = parent === worktrees ? worktreeTrashDir(root) : join(parent, '.trash');
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
const target = join(dir, `wt-${Date.now()}-${randomUUID().slice(0, 12)}`);
|
|
82
|
+
renameSync(source, target);
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
29
85
|
function maxActive() {
|
|
30
86
|
let v;
|
|
31
87
|
try {
|
|
@@ -47,7 +103,7 @@ function maxActive() {
|
|
|
47
103
|
// propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
|
|
48
104
|
// an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
|
|
49
105
|
// ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
|
|
50
|
-
const rvEnv = (id, harness = HARNESS) => {
|
|
106
|
+
const rvEnv = (id, harness = HARNESS, nativeStartToken) => {
|
|
51
107
|
// SPEXCODE_SESSION_ID is the governed record id, and it is the SESSION'S OWN — so the launch STRIPS every
|
|
52
108
|
// session-identity variable it may have inherited (the pane inherits the tmux SERVER's env, which may carry
|
|
53
109
|
// a foreign session's ids from whoever started it) before setting this one. Identity is established HERE,
|
|
@@ -65,9 +121,15 @@ const rvEnv = (id, harness = HARNESS) => {
|
|
|
65
121
|
`SPEXCODE_SESSION_ID=${id}`,
|
|
66
122
|
`SPEXCODE_SESSION_IDENTITY_VARS=${shQuote(sessionIdentityEnvVars().join(','))}`,
|
|
67
123
|
`SPEXCODE_PROJECT_ROOT=${shQuote(mainRoot())}`,
|
|
124
|
+
...(nativeStartToken ? [`SPEXCODE_NATIVE_START_TOKEN=${shQuote(nativeStartToken)}`] : []),
|
|
68
125
|
...harness.launchEnv(id), ...homeVars].join(' ');
|
|
69
126
|
};
|
|
70
127
|
const PROPOSAL_STATUS = { merge: 'review', nothing: 'done', close: 'close-pending' };
|
|
128
|
+
// Awaiting is the durable lifecycle row; its proposal selects the user-facing display status. Keep this
|
|
129
|
+
// projection in the session package so backend reconciliation and offline client reads cannot drift apart.
|
|
130
|
+
export function displayStatusForProposal(proposal) {
|
|
131
|
+
return PROPOSAL_STATUS[proposal ?? 'nothing'];
|
|
132
|
+
}
|
|
71
133
|
function normalizeCloseSource(raw) {
|
|
72
134
|
if (raw == null)
|
|
73
135
|
return { kind: 'user' };
|
|
@@ -80,64 +142,6 @@ function normalizeCloseSource(raw) {
|
|
|
80
142
|
return { kind: 'unverified-session-claim', id: source.id.trim() };
|
|
81
143
|
throw new ResourceConflict('refusing session close: source must be user or an unverified session claim');
|
|
82
144
|
}
|
|
83
|
-
function appendCloseLedger(id, rec, source) {
|
|
84
|
-
const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
|
|
85
|
-
const event = {
|
|
86
|
-
version: 1,
|
|
87
|
-
action: 'close-authorized',
|
|
88
|
-
at: new Date().toISOString(),
|
|
89
|
-
source,
|
|
90
|
-
target: {
|
|
91
|
-
id,
|
|
92
|
-
harness: rec.harness,
|
|
93
|
-
thread: rec.harnessSessionId,
|
|
94
|
-
worktree: rec.worktreePath,
|
|
95
|
-
branch: rec.branch,
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
appendFileSync(path, `${JSON.stringify(event)}\n`);
|
|
99
|
-
}
|
|
100
|
-
export function findSessionClosure(selector) {
|
|
101
|
-
const query = stripRefSigil(selector).trim();
|
|
102
|
-
if (!query)
|
|
103
|
-
return null;
|
|
104
|
-
const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
|
|
105
|
-
let text;
|
|
106
|
-
try {
|
|
107
|
-
text = readFileSync(path, 'utf8');
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
if (error.code === 'ENOENT')
|
|
111
|
-
return null;
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
const matches = new Map();
|
|
115
|
-
for (const line of text.split('\n')) {
|
|
116
|
-
if (!line.trim())
|
|
117
|
-
continue;
|
|
118
|
-
let event;
|
|
119
|
-
try {
|
|
120
|
-
event = JSON.parse(line);
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
throw new ResourceConflict('session close history is unreadable');
|
|
124
|
-
}
|
|
125
|
-
if (!event || typeof event !== 'object' || event.action !== 'close-authorized')
|
|
126
|
-
continue;
|
|
127
|
-
const target = event.target;
|
|
128
|
-
const id = target && typeof target === 'object' ? target.id : undefined;
|
|
129
|
-
const closedAt = event.at;
|
|
130
|
-
if (typeof id !== 'string' || !id || typeof closedAt !== 'string' || !closedAt)
|
|
131
|
-
throw new ResourceConflict('session close history is malformed');
|
|
132
|
-
if (id === query || id.startsWith(query))
|
|
133
|
-
matches.set(id, { id, closedAt });
|
|
134
|
-
}
|
|
135
|
-
if (!matches.size)
|
|
136
|
-
return null;
|
|
137
|
-
if (matches.size > 1)
|
|
138
|
-
throw new ResourceConflict(`close history for ${query} is ambiguous: ${[...matches.keys()].map((id) => id.slice(0, 8)).join(', ')}`);
|
|
139
|
-
return [...matches.values()][0];
|
|
140
|
-
}
|
|
141
145
|
function storeDir(id) { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d; }
|
|
142
146
|
function writePromptFile(id, prompt) {
|
|
143
147
|
try {
|
|
@@ -245,19 +249,57 @@ export function canDrainQueued(rec, authority = backendLaunchAuthority()) {
|
|
|
245
249
|
return rec.status === 'queued' && !rec.stopped && (rec.launchOwner === null || rec.launchOwner === authority);
|
|
246
250
|
}
|
|
247
251
|
// typed read of a session's record from the global store (null if it has none — a self-launched session that
|
|
248
|
-
// only ever wrote spec-discipline sentinels has a store dir but no
|
|
252
|
+
// only ever wrote spec-discipline sentinels has a store dir but no runtime.json). Goes through layout's
|
|
249
253
|
// readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
|
|
250
254
|
// on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
|
|
251
255
|
function readRecord(id) {
|
|
252
256
|
const entry = readAliasedRecordEntry(id);
|
|
253
|
-
if (entry.kind === 'absent')
|
|
254
|
-
|
|
257
|
+
if (entry.kind === 'absent') {
|
|
258
|
+
// A migrated session may retain its canonical application row after an envelope was removed or never
|
|
259
|
+
// materialized. Lifecycle hooks must still reach that row; do not turn missing runtime metadata into a
|
|
260
|
+
// silent "not governed" result. The minimal projection deliberately carries no guessed resource identity.
|
|
261
|
+
const application = configuredSessionApplicationIfCutover();
|
|
262
|
+
const state = application?.readState(id);
|
|
263
|
+
if (!state)
|
|
264
|
+
return null;
|
|
265
|
+
return {
|
|
266
|
+
session: id,
|
|
267
|
+
governed: true,
|
|
268
|
+
worktreePath: '', branch: null, node: null, title: null, name: null, parent: state.parentSessionId,
|
|
269
|
+
status: state.status,
|
|
270
|
+
proposal: isSessionProposal(state.proposal) ? state.proposal : null,
|
|
271
|
+
merges: 0, note: state.note, sortKey: null, createdAt: state.updatedAtMs,
|
|
272
|
+
harness: 'claude', harnessSessionId: null, runtimeStartToken: null,
|
|
273
|
+
stopped: false, archived: false, closedAt: null, coldProof: null, adapterRecovery: null,
|
|
274
|
+
launcher: null, launchCmd: null, launchOwner: null, launchReadinessStartedAt: null,
|
|
275
|
+
createRequestId: null, createPayloadHash: null, zcodeChildSessionIds: [], base: null,
|
|
276
|
+
diffComments: [], launchReadinessPending: null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
255
279
|
if (entry.kind === 'corrupt')
|
|
256
280
|
throw new SessionRecordUnusable('corrupt', id, corruptReason(entry));
|
|
257
281
|
try {
|
|
258
|
-
|
|
282
|
+
const record = fromRaw(entry.raw);
|
|
283
|
+
// After cutover, runtime.json is only the runtime/worktree envelope. Lifecycle is owned by the
|
|
284
|
+
// session application. Overlaying here keeps every internal caller on the same fact instead of
|
|
285
|
+
// letting a stale JSON snapshot steer a launch, close, or hook decision.
|
|
286
|
+
const application = configuredSessionApplicationIfCutover();
|
|
287
|
+
if (!application || !record.governed)
|
|
288
|
+
return record;
|
|
289
|
+
const state = application.readState(record.session);
|
|
290
|
+
if (!state)
|
|
291
|
+
throw new ResourceConflict(`session ${record.session} has no canonical application state after JSON cutover`);
|
|
292
|
+
return {
|
|
293
|
+
...record,
|
|
294
|
+
status: state.status,
|
|
295
|
+
proposal: isSessionProposal(state.proposal) ? state.proposal : null,
|
|
296
|
+
note: state.note,
|
|
297
|
+
parent: state.parentSessionId,
|
|
298
|
+
};
|
|
259
299
|
}
|
|
260
300
|
catch (error) {
|
|
301
|
+
if (error instanceof ResourceConflict)
|
|
302
|
+
throw error;
|
|
261
303
|
throw new SessionRecordUnusable('corrupt', id, `session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`);
|
|
262
304
|
}
|
|
263
305
|
}
|
|
@@ -275,7 +317,9 @@ const corruptReason = (e) => `session record is unreadable: ${e.path} — ${e.er
|
|
|
275
317
|
function retirementReason(rec) {
|
|
276
318
|
if (!rec.worktreePath || existsSync(rec.worktreePath))
|
|
277
319
|
return null;
|
|
278
|
-
|
|
320
|
+
if (rec.archived)
|
|
321
|
+
return `session ${rec.session.slice(0, 8)} is closed and read-only: its worktree ${rec.worktreePath} no longer exists`;
|
|
322
|
+
return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched`;
|
|
279
323
|
}
|
|
280
324
|
function readLiveRecord(id) {
|
|
281
325
|
const rec = readRecord(id);
|
|
@@ -291,22 +335,6 @@ export function withSessionRecordLockSync(id, body) {
|
|
|
291
335
|
return coreWithSessionRecordLockSync(id, body);
|
|
292
336
|
}
|
|
293
337
|
const withRecordLockSync = withSessionRecordLockSync;
|
|
294
|
-
// Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
|
|
295
|
-
// enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
|
|
296
|
-
// and then race a just-queued TUI turn.
|
|
297
|
-
export function withSessionInputLock(id, body) {
|
|
298
|
-
// PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
|
|
299
|
-
// regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
|
|
300
|
-
const release = trySessionRecordLockSync(id);
|
|
301
|
-
if (!release)
|
|
302
|
-
return null;
|
|
303
|
-
try {
|
|
304
|
-
return body();
|
|
305
|
-
}
|
|
306
|
-
finally {
|
|
307
|
-
release();
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
338
|
const COLD_PROOF_VERSION = 'cold-v1';
|
|
311
339
|
function coldProofFor(rec) {
|
|
312
340
|
const adapter = harnessById(rec.harness || defaultHarness.id).id;
|
|
@@ -332,6 +360,9 @@ export function fromRaw(raw) {
|
|
|
332
360
|
if (pendingRaw && !pendingStatus)
|
|
333
361
|
throw new Error(`session '${raw.session_id}' launch readiness original has invalid lifecycle '${pendingRaw.status}'`);
|
|
334
362
|
const pendingProposal = pendingRaw && isSessionProposal(pendingRaw.proposal) ? pendingRaw.proposal : null;
|
|
363
|
+
if (raw.closed_at != null && raw.closed_at !== ''
|
|
364
|
+
&& (typeof raw.closed_at !== 'string' || !Number.isFinite(Date.parse(raw.closed_at))))
|
|
365
|
+
throw new Error(`session '${raw.session_id}' has invalid closed_at`);
|
|
335
366
|
if (pendingRaw?.proposal && !pendingProposal)
|
|
336
367
|
throw new Error(`session '${raw.session_id}' launch readiness original has invalid proposal '${pendingRaw.proposal}'`);
|
|
337
368
|
const zcodeChildSessionIds = raw.zcode_child_session_ids ?? [];
|
|
@@ -339,6 +370,18 @@ export function fromRaw(raw) {
|
|
|
339
370
|
|| zcodeChildSessionIds.some((id) => typeof id !== 'string' || !id || id.trim() !== id)
|
|
340
371
|
|| new Set(zcodeChildSessionIds).size !== zcodeChildSessionIds.length)
|
|
341
372
|
throw new Error(`session '${raw.session_id}' has invalid zcode_child_session_ids`);
|
|
373
|
+
const diffComments = raw.diff_comments ?? [];
|
|
374
|
+
if (!Array.isArray(diffComments) || diffComments.some((comment) => !comment || typeof comment !== 'object'))
|
|
375
|
+
throw new Error(`session '${raw.session_id}' has invalid diff_comments`);
|
|
376
|
+
const parsedDiffComments = diffComments.map((comment) => {
|
|
377
|
+
const c = comment;
|
|
378
|
+
if (!c.id || typeof c.id !== 'string' || typeof c.file_path !== 'string' || !c.file_path
|
|
379
|
+
|| !Number.isInteger(c.line_start) || c.line_start < 1 || !Number.isInteger(c.line_end) || c.line_end < c.line_start
|
|
380
|
+
|| typeof c.body !== 'string' || !c.body.trim() || typeof c.diff_identity !== 'string'
|
|
381
|
+
|| !(c.sent_at === null || typeof c.sent_at === 'string'))
|
|
382
|
+
throw new Error(`session '${raw.session_id}' has invalid diff comment`);
|
|
383
|
+
return { id: c.id, filePath: c.file_path, lineStart: c.line_start, lineEnd: c.line_end, body: c.body, diffIdentity: c.diff_identity, sentAt: c.sent_at };
|
|
384
|
+
});
|
|
342
385
|
return {
|
|
343
386
|
session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
|
|
344
387
|
node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
|
|
@@ -346,23 +389,30 @@ export function fromRaw(raw) {
|
|
|
346
389
|
note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
|
|
347
390
|
harness: raw.harness || 'claude', // records written before the harness field default to claude
|
|
348
391
|
harnessSessionId: raw.harness_session_id || null,
|
|
392
|
+
runtimeStartToken: raw.runtime_start_token || null,
|
|
349
393
|
stopped: !!raw.stopped, // records written before explicit stop tracking were not stopped
|
|
350
|
-
archived: !!raw.archived, // records written before
|
|
394
|
+
archived: !!raw.archived, // records written before close retention → absent → working
|
|
395
|
+
closedAt: typeof raw.closed_at === 'string' && raw.closed_at ? raw.closed_at : null,
|
|
351
396
|
coldProof: raw.cold_proof || null, // legacy archived rows have no proof and remain visible until re-archived
|
|
352
397
|
adapterRecovery: raw.adapter_recovery || null,
|
|
353
398
|
launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
|
|
354
399
|
launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
|
|
355
400
|
launchOwner: launchOwner || null,
|
|
401
|
+
launchReadinessStartedAt: Number.isFinite(Number(raw.launch_readiness_started_at))
|
|
402
|
+
? Number(raw.launch_readiness_started_at) : null,
|
|
356
403
|
createRequestId: raw.create_request_id || null,
|
|
357
404
|
createPayloadHash: raw.create_payload_hash || null,
|
|
358
405
|
zcodeChildSessionIds: [...zcodeChildSessionIds],
|
|
359
406
|
base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
|
|
407
|
+
forkCommit: raw.fork_commit || null, // records written before the fork commit → null → recovered from the branch reflog
|
|
408
|
+
diffComments: parsedDiffComments,
|
|
360
409
|
launchReadinessPending: pendingRaw ? {
|
|
361
410
|
version: 1,
|
|
362
411
|
startedAt: raw.launch_readiness_pending.startedAt,
|
|
363
412
|
original: {
|
|
364
413
|
status: pendingStatus, proposal: pendingProposal, note: pendingRaw.note || null,
|
|
365
414
|
stopped: pendingRaw.stopped, archived: pendingRaw.archived,
|
|
415
|
+
closedAt: pendingRaw.closed_at || null,
|
|
366
416
|
coldProof: pendingRaw.cold_proof || null, adapterRecovery: pendingRaw.adapter_recovery || null,
|
|
367
417
|
},
|
|
368
418
|
} : null,
|
|
@@ -382,6 +432,7 @@ function launchReadinessPending(original) {
|
|
|
382
432
|
note: original.note,
|
|
383
433
|
stopped: original.stopped,
|
|
384
434
|
archived: original.archived,
|
|
435
|
+
closedAt: original.closedAt,
|
|
385
436
|
coldProof: original.coldProof ?? null,
|
|
386
437
|
adapterRecovery: original.adapterRecovery ?? null,
|
|
387
438
|
},
|
|
@@ -392,12 +443,42 @@ function restoreLaunchReadinessOriginal(rec) {
|
|
|
392
443
|
return original ? { ...rec, ...original, launchReadinessPending: null } : rec;
|
|
393
444
|
}
|
|
394
445
|
// Rebuild the full disk projection so retired keys disappear on the next write.
|
|
446
|
+
function assertLegacyJsonWritesAllowed() {
|
|
447
|
+
const fence = jsonMigrationFencePath(join(runtimeRoot(), 'sessions'));
|
|
448
|
+
if (existsSync(fence) && !configuredSessionApplicationIfCutover()) {
|
|
449
|
+
throw new ResourceConflict(`legacy JSON session store is fenced for one-time migration: ${fence}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
395
452
|
function writeRecord(rec) {
|
|
453
|
+
assertLegacyJsonWritesAllowed();
|
|
454
|
+
const application = configuredSessionApplicationIfCutover();
|
|
455
|
+
// The JSON file is runtime/worktree metadata after cutover, not a lifecycle store. Once the canonical row
|
|
456
|
+
// exists, omit the four old lifecycle keys entirely; retaining them would leave a second apparent fact for
|
|
457
|
+
// readers and tempt a future path to trust the wrong writer. New records still need the legacy shape until
|
|
458
|
+
// their canonical row is created, and non-governed external runtime records keep their own contract.
|
|
459
|
+
const envelope = application && rec.governed ? readAliasedRecordEntry(rec.session) : null;
|
|
460
|
+
const canonicalMetadataOnly = envelope?.kind === 'ok' && rec.governed && !!application;
|
|
461
|
+
const lifecycle = { status: rawLifecycleStatus(rec), proposal: rec.proposal, note: rec.note, parent: rec.parent };
|
|
462
|
+
// A queued legacy envelope may still carry its lease until this metadata rewrite. The lease is an
|
|
463
|
+
// operational launch claim, not a lifecycle fact, so preserve only that field while the typed record clears it.
|
|
464
|
+
const envelopeLaunchOwner = envelope?.kind === 'ok'
|
|
465
|
+
? envelope.raw.launch_owner?.trim() || null
|
|
466
|
+
: null;
|
|
396
467
|
let previous = null;
|
|
397
468
|
try {
|
|
398
469
|
previous = readRecord(rec.session);
|
|
399
470
|
}
|
|
400
471
|
catch { /* a new or damaged record has no prior transition */ }
|
|
472
|
+
const metadataChanged = !previous || [
|
|
473
|
+
'governed', 'worktreePath', 'branch', 'node', 'title', 'name', 'merges', 'sortKey', 'createdAt',
|
|
474
|
+
'harness', 'harnessSessionId', 'runtimeStartToken', 'stopped', 'archived', 'closedAt', 'coldProof',
|
|
475
|
+
'adapterRecovery', 'launcher', 'launchCmd', 'launchOwner', 'launchReadinessStartedAt', 'createRequestId',
|
|
476
|
+
'createPayloadHash', 'zcodeChildSessionIds', 'base', 'forkCommit', 'diffComments', 'launchReadinessPending',
|
|
477
|
+
].some((key) => JSON.stringify(previous[key]) !== JSON.stringify(rec[key]));
|
|
478
|
+
// Once a canonical row exists, a lifecycle-only write is already complete when the application transition
|
|
479
|
+
// commits. Rewriting runtime.json here would recreate a second, stale status/proposal/note authority.
|
|
480
|
+
if (canonicalMetadataOnly && previous && !metadataChanged)
|
|
481
|
+
return;
|
|
401
482
|
const obj = {
|
|
402
483
|
session_id: rec.session,
|
|
403
484
|
governed: rec.governed,
|
|
@@ -406,28 +487,37 @@ function writeRecord(rec) {
|
|
|
406
487
|
node: rec.node ?? '',
|
|
407
488
|
title: rec.title ?? '',
|
|
408
489
|
name: rec.name ?? '',
|
|
409
|
-
parent: rec.parent ?? '',
|
|
410
|
-
status: rawLifecycleStatus(rec),
|
|
411
|
-
proposal: rec.proposal ?? '',
|
|
412
490
|
merges: rec.merges,
|
|
413
|
-
note: rec.note ?? '',
|
|
414
491
|
sortkey: rec.sortKey ?? '',
|
|
415
492
|
createdAt: rec.createdAt,
|
|
416
493
|
harness: rec.harness || 'claude',
|
|
417
494
|
harness_session_id: rec.harnessSessionId ?? '',
|
|
418
495
|
stopped: rec.stopped,
|
|
419
496
|
archived: rec.archived,
|
|
497
|
+
// Pre-field records stay byte-shape compatible until a real close publishes the timestamp. In particular,
|
|
498
|
+
// a failed resume must be able to restore an old working record without inventing an empty metadata key.
|
|
499
|
+
...(rec.closedAt ? { closed_at: rec.closedAt } : {}),
|
|
420
500
|
cold_proof: rec.coldProof ?? '',
|
|
421
501
|
adapter_recovery: rec.adapterRecovery ?? '',
|
|
422
502
|
launcher: rec.launcher ?? '',
|
|
423
503
|
launch_cmd: rec.launchCmd ?? '',
|
|
424
|
-
launch_owner:
|
|
504
|
+
launch_owner: (lifecycle.status === 'queued' || lifecycle.status === OWNED_QUEUE_RAW_STATUS)
|
|
505
|
+
? rec.launchOwner ?? envelopeLaunchOwner ?? '' : '',
|
|
506
|
+
...(rec.launchReadinessStartedAt ? { launch_readiness_started_at: rec.launchReadinessStartedAt } : {}),
|
|
507
|
+
...(rec.runtimeStartToken ? { runtime_start_token: rec.runtimeStartToken } : {}),
|
|
425
508
|
create_request_id: rec.createRequestId ?? '',
|
|
426
509
|
create_payload_hash: rec.createPayloadHash ?? '',
|
|
427
510
|
...(rec.zcodeChildSessionIds?.length ? { zcode_child_session_ids: rec.zcodeChildSessionIds } : {}),
|
|
428
511
|
// Written only when the creator pinned one: an unpinned record keeps its exact legacy bytes, so a
|
|
429
512
|
// restore-the-frozen-record path stays byte-identical instead of silently gaining a key.
|
|
430
513
|
...(rec.base ? { base: rec.base } : {}),
|
|
514
|
+
// The commit `git worktree add` actually started from, written on every create since it was introduced.
|
|
515
|
+
// Conditional like `base` above, so a record written before it keeps its exact legacy bytes.
|
|
516
|
+
...(rec.forkCommit ? { fork_commit: rec.forkCommit } : {}),
|
|
517
|
+
...((rec.diffComments ?? []).length ? { diff_comments: (rec.diffComments ?? []).map((comment) => ({
|
|
518
|
+
id: comment.id, file_path: comment.filePath, line_start: comment.lineStart, line_end: comment.lineEnd,
|
|
519
|
+
body: comment.body, diff_identity: comment.diffIdentity, sent_at: comment.sentAt,
|
|
520
|
+
})) } : {}),
|
|
431
521
|
launch_readiness_pending: rec.launchReadinessPending ? {
|
|
432
522
|
version: 1,
|
|
433
523
|
startedAt: rec.launchReadinessPending.startedAt,
|
|
@@ -437,104 +527,62 @@ function writeRecord(rec) {
|
|
|
437
527
|
note: rec.launchReadinessPending.original.note ?? '',
|
|
438
528
|
stopped: rec.launchReadinessPending.original.stopped,
|
|
439
529
|
archived: rec.launchReadinessPending.original.archived,
|
|
530
|
+
closed_at: rec.launchReadinessPending.original.closedAt,
|
|
440
531
|
cold_proof: rec.launchReadinessPending.original.coldProof ?? '',
|
|
441
532
|
adapter_recovery: rec.launchReadinessPending.original.adapterRecovery ?? '',
|
|
442
533
|
},
|
|
443
534
|
} : '',
|
|
535
|
+
...(canonicalMetadataOnly ? {} : {
|
|
536
|
+
parent: lifecycle.parent ?? '',
|
|
537
|
+
status: lifecycle.status,
|
|
538
|
+
proposal: lifecycle.proposal ?? '',
|
|
539
|
+
note: lifecycle.note ?? '',
|
|
540
|
+
}),
|
|
444
541
|
};
|
|
445
542
|
const dir = sessionStoreDir(rec.session);
|
|
446
543
|
mkdirSync(dir, { recursive: true });
|
|
447
544
|
const path = sessionRecordPath(rec.session);
|
|
448
|
-
const tmp = join(dir, `.
|
|
545
|
+
const tmp = join(dir, `.runtime.json.${process.pid}.tmp`);
|
|
449
546
|
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
|
|
450
547
|
renameSync(tmp, path); // atomic within the dir: a concurrent reader sees the old record or the new one
|
|
451
548
|
const previousPublic = previous ? publicRecord(previous) : null;
|
|
452
549
|
const nextPublic = publicRecord(rec);
|
|
453
|
-
if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
550
|
+
if (!application && rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
454
551
|
|| previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
|
|
455
552
|
recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note);
|
|
456
553
|
scheduleWatchNotifications(rec);
|
|
457
554
|
}
|
|
458
555
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
return [];
|
|
465
|
-
const parent = readRecord(target)?.parent ?? '';
|
|
466
|
-
const seen = new Set();
|
|
467
|
-
return raw.flatMap((entry) => {
|
|
468
|
-
if (!entry || typeof entry !== 'object')
|
|
469
|
-
return [];
|
|
470
|
-
const watcher = entry.watcher;
|
|
471
|
-
const createdAt = entry.createdAt;
|
|
472
|
-
if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher))
|
|
473
|
-
return [];
|
|
474
|
-
seen.add(watcher);
|
|
475
|
-
const rawSources = entry.sources;
|
|
476
|
-
const sources = Array.isArray(rawSources)
|
|
477
|
-
? [...new Set(rawSources.filter((source) => source === 'manual' || source === 'parent'))]
|
|
478
|
-
// The former one-source format cannot name an origin. Its child pointer is the only durable witness
|
|
479
|
-
// that this watcher was installed for parent supervision; every other legacy row is a manual watch.
|
|
480
|
-
: [watcher === parent ? 'parent' : 'manual'];
|
|
481
|
-
const snapshotPending = entry.snapshotPending;
|
|
482
|
-
return sources.length ? [{ watcher, createdAt, sources,
|
|
483
|
-
...(sources.includes('parent') && typeof snapshotPending === 'string' && snapshotPending ? { snapshotPending } : {}),
|
|
484
|
-
}] : [];
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
catch {
|
|
556
|
+
function canonicalWatchEntries(target) {
|
|
557
|
+
const application = configuredSessionApplicationIfCutover();
|
|
558
|
+
if (!application)
|
|
559
|
+
return null;
|
|
560
|
+
if (!application.readState(target))
|
|
488
561
|
return [];
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
562
|
+
const seen = new Map();
|
|
563
|
+
for (const edge of application.topology.parents(target)) {
|
|
564
|
+
if (edge.relationType !== 'parent' && !edge.relationType.startsWith('watch'))
|
|
565
|
+
continue;
|
|
566
|
+
const source = edge.relationType === 'parent' || edge.relationType === 'watch:parent' ? 'parent' : 'manual';
|
|
567
|
+
const current = seen.get(edge.fromSessionId);
|
|
568
|
+
if (current) {
|
|
569
|
+
if (!current.sources.includes(source))
|
|
570
|
+
current.sources.push(source);
|
|
571
|
+
continue;
|
|
496
572
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
573
|
+
seen.set(edge.fromSessionId, {
|
|
574
|
+
watcher: edge.fromSessionId,
|
|
575
|
+
createdAt: new Date(edge.createdAtMs).toISOString(),
|
|
576
|
+
sources: [source],
|
|
577
|
+
});
|
|
500
578
|
}
|
|
501
|
-
|
|
502
|
-
mkdirSync(dir, { recursive: true });
|
|
503
|
-
const tmp = join(dir, `.watchers.json.${process.pid}.tmp`);
|
|
504
|
-
writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
|
|
505
|
-
renameSync(tmp, path);
|
|
506
|
-
}
|
|
507
|
-
function addWatchSource(entries, watcher, source, deferInitialSnapshot = false) {
|
|
508
|
-
const existing = entries.find((entry) => entry.watcher === watcher);
|
|
509
|
-
const snapshotPending = deferInitialSnapshot && !existing?.sources.includes('manual') ? randomUUID() : undefined;
|
|
510
|
-
if (!existing)
|
|
511
|
-
return { entries: [...entries, {
|
|
512
|
-
watcher, createdAt: new Date().toISOString(), sources: [source], ...(snapshotPending ? { snapshotPending } : {}),
|
|
513
|
-
}], added: true };
|
|
514
|
-
if (existing.sources.includes(source))
|
|
515
|
-
return { entries, added: false };
|
|
516
|
-
return {
|
|
517
|
-
entries: entries.map((entry) => entry === existing ? {
|
|
518
|
-
...entry, sources: [...entry.sources, source], ...(snapshotPending ? { snapshotPending } : {}),
|
|
519
|
-
} : entry),
|
|
520
|
-
added: true,
|
|
521
|
-
};
|
|
579
|
+
return [...seen.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.watcher.localeCompare(b.watcher));
|
|
522
580
|
}
|
|
523
|
-
function
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
removed = true;
|
|
529
|
-
const sources = entry.sources.filter((candidate) => candidate !== source);
|
|
530
|
-
if (!sources.length)
|
|
531
|
-
return [];
|
|
532
|
-
if (source !== 'parent')
|
|
533
|
-
return [{ ...entry, sources }];
|
|
534
|
-
const { snapshotPending: _pending, ...withoutParentDebt } = entry;
|
|
535
|
-
return [{ ...withoutParentDebt, sources }];
|
|
536
|
-
});
|
|
537
|
-
return { entries: next, removed };
|
|
581
|
+
function readWatchEntries(target) {
|
|
582
|
+
const canonical = canonicalWatchEntries(target);
|
|
583
|
+
if (canonical)
|
|
584
|
+
return canonical;
|
|
585
|
+
throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
|
|
538
586
|
}
|
|
539
587
|
function managedWatchRecord(id) {
|
|
540
588
|
const rec = readRecord(id);
|
|
@@ -544,7 +592,7 @@ function managedWatchRecord(id) {
|
|
|
544
592
|
}
|
|
545
593
|
function watchMessage(target) {
|
|
546
594
|
const status = target.status === 'awaiting'
|
|
547
|
-
?
|
|
595
|
+
? displayStatusForProposal(target.proposal)
|
|
548
596
|
: target.status === 'active' ? 'working' : target.status;
|
|
549
597
|
const note = target.note ? ` — ${target.note}` : '';
|
|
550
598
|
return `[spex watch] ${target.session} is ${status}${note}`;
|
|
@@ -555,180 +603,81 @@ function shouldDeliverWatchTransition(target, sources) {
|
|
|
555
603
|
}
|
|
556
604
|
function scheduleWatchNotifications(target) {
|
|
557
605
|
const watchers = readWatchEntries(target.session)
|
|
558
|
-
.filter((entry) =>
|
|
606
|
+
.filter((entry) => shouldDeliverWatchTransition(target, entry.sources))
|
|
559
607
|
.map((entry) => entry.watcher);
|
|
560
608
|
if (!watchers.length)
|
|
561
609
|
return;
|
|
562
610
|
queueMicrotask(() => {
|
|
563
611
|
for (const watcher of watchers) {
|
|
564
|
-
void sendText(watcher, watchMessage(target), target.session).then((result) => {
|
|
612
|
+
void sendText(watcher, watchMessage(target), target.session, { allowStranded: true }).then((result) => {
|
|
565
613
|
if (!result.ok)
|
|
566
614
|
console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`);
|
|
567
615
|
});
|
|
568
616
|
}
|
|
569
617
|
});
|
|
570
618
|
}
|
|
571
|
-
const watchSnapshotState = (target) => JSON.stringify([target.status, target.proposal, target.note]);
|
|
572
|
-
async function deliverPendingWatchSnapshots(targetId, forceCurrent = true) {
|
|
573
|
-
const pending = readWatchEntries(targetId).filter((entry) => entry.snapshotPending);
|
|
574
|
-
for (const original of pending) {
|
|
575
|
-
const token = original.snapshotPending;
|
|
576
|
-
let force = forceCurrent;
|
|
577
|
-
for (;;) {
|
|
578
|
-
const target = readRecord(targetId);
|
|
579
|
-
const entry = readWatchEntries(targetId)
|
|
580
|
-
.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
|
|
581
|
-
if (!target || !entry)
|
|
582
|
-
break;
|
|
583
|
-
const state = watchSnapshotState(target);
|
|
584
|
-
const shouldDeliver = force || shouldDeliverWatchTransition(target, entry.sources);
|
|
585
|
-
if (!shouldDeliver) {
|
|
586
|
-
let settled = false;
|
|
587
|
-
await withRecordLock(targetId, async () => {
|
|
588
|
-
const current = readRecord(targetId);
|
|
589
|
-
const entries = readWatchEntries(targetId);
|
|
590
|
-
const pendingEntry = entries.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
|
|
591
|
-
if (!current || !pendingEntry || watchSnapshotState(current) !== state)
|
|
592
|
-
return;
|
|
593
|
-
const next = entries.map((candidate) => {
|
|
594
|
-
if (candidate !== pendingEntry)
|
|
595
|
-
return candidate;
|
|
596
|
-
const { snapshotPending: _pending, ...cleared } = candidate;
|
|
597
|
-
return cleared;
|
|
598
|
-
});
|
|
599
|
-
writeWatchEntries(targetId, next);
|
|
600
|
-
settled = true;
|
|
601
|
-
});
|
|
602
|
-
if (settled)
|
|
603
|
-
break;
|
|
604
|
-
force = false;
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
const identity = `${targetId}\0${entry.watcher}\0${token}\0${state}`;
|
|
608
|
-
const delivered = await sendText(entry.watcher, watchMessage(target), targetId, {
|
|
609
|
-
idempotency: {
|
|
610
|
-
operation: 'watch-initial-snapshot',
|
|
611
|
-
requestDigest: digest(identity),
|
|
612
|
-
payloadHash: digest(`watch-initial-snapshot\0${identity}\0${watchMessage(target)}`),
|
|
613
|
-
},
|
|
614
|
-
acceptGuard: async () => {
|
|
615
|
-
const current = readRecord(targetId);
|
|
616
|
-
const stillPending = readWatchEntries(targetId)
|
|
617
|
-
.some((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
|
|
618
|
-
if (!current || !stillPending || watchSnapshotState(current) !== state)
|
|
619
|
-
throw new ResourceConflict('watch initial snapshot changed before acceptance');
|
|
620
|
-
},
|
|
621
|
-
});
|
|
622
|
-
if (!delivered.ok) {
|
|
623
|
-
if (delivered.error?.includes('watch initial snapshot changed before acceptance'))
|
|
624
|
-
continue;
|
|
625
|
-
console.error(`spex session watch: could not deliver initial ${targetId} state to ${entry.watcher}: ${delivered.error}`);
|
|
626
|
-
break;
|
|
627
|
-
}
|
|
628
|
-
force = false;
|
|
629
|
-
let settled = false;
|
|
630
|
-
await withRecordLock(targetId, async () => {
|
|
631
|
-
const current = readRecord(targetId);
|
|
632
|
-
const entries = readWatchEntries(targetId);
|
|
633
|
-
const pendingEntry = entries.find((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
|
|
634
|
-
if (!current || !pendingEntry) {
|
|
635
|
-
settled = true;
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
const currentState = watchSnapshotState(current);
|
|
639
|
-
if (currentState !== state && shouldDeliverWatchTransition(current, pendingEntry.sources))
|
|
640
|
-
return;
|
|
641
|
-
const next = entries.map((candidate) => {
|
|
642
|
-
if (candidate !== pendingEntry)
|
|
643
|
-
return candidate;
|
|
644
|
-
const { snapshotPending: _pending, ...cleared } = candidate;
|
|
645
|
-
return cleared;
|
|
646
|
-
});
|
|
647
|
-
writeWatchEntries(targetId, next);
|
|
648
|
-
settled = true;
|
|
649
|
-
});
|
|
650
|
-
if (settled)
|
|
651
|
-
break;
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
async function clearPendingWatchSnapshots(targetId) {
|
|
656
|
-
await withRecordLock(targetId, async () => {
|
|
657
|
-
const entries = readWatchEntries(targetId);
|
|
658
|
-
const next = entries.map((entry) => {
|
|
659
|
-
if (!entry.snapshotPending)
|
|
660
|
-
return entry;
|
|
661
|
-
const { snapshotPending: _pending, ...settled } = entry;
|
|
662
|
-
return settled;
|
|
663
|
-
});
|
|
664
|
-
if (next.some((entry, index) => entry !== entries[index]))
|
|
665
|
-
writeWatchEntries(targetId, next);
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
619
|
export async function subscribeSessionWatch(watcher, targets, source = 'manual') {
|
|
669
620
|
managedWatchRecord(watcher);
|
|
621
|
+
const application = configuredSessionApplicationIfCutover();
|
|
622
|
+
if (!application)
|
|
623
|
+
throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
|
|
670
624
|
const watched = [];
|
|
625
|
+
const channel = source === 'parent' ? 'watch:parent' : 'watch:manual';
|
|
671
626
|
for (const target of [...new Set(targets)]) {
|
|
672
627
|
if (target === watcher)
|
|
673
628
|
throw new ResourceConflict('a session cannot watch itself');
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
withRecordLockSync(target, () => {
|
|
678
|
-
targetRecord = managedWatchRecord(target);
|
|
679
|
-
const entries = readWatchEntries(target);
|
|
680
|
-
const next = addWatchSource(entries, watcher, source, source === 'parent' && targetRecord.status === 'queued');
|
|
681
|
-
if (next.added)
|
|
682
|
-
writeWatchEntries(target, next.entries);
|
|
683
|
-
added = next.added;
|
|
684
|
-
pending = next.entries.some((entry) => entry.watcher === watcher && !!entry.snapshotPending);
|
|
685
|
-
});
|
|
686
|
-
if (pending) {
|
|
687
|
-
if (source === 'manual')
|
|
688
|
-
await deliverPendingWatchSnapshots(target);
|
|
629
|
+
const targetRecord = managedWatchRecord(target);
|
|
630
|
+
try {
|
|
631
|
+
application.attachWatcher(watcher, target, channel);
|
|
689
632
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`);
|
|
633
|
+
catch (error) {
|
|
634
|
+
if (!(error instanceof Error) || !/already exists|duplicate|active topology edge/i.test(error.message))
|
|
635
|
+
throw error;
|
|
694
636
|
}
|
|
637
|
+
const message = watchMessage(targetRecord);
|
|
638
|
+
application.enqueueMessage(watcher, {
|
|
639
|
+
kind: 'session.prompt.v1',
|
|
640
|
+
body: Buffer.from(message, 'utf8'),
|
|
641
|
+
senderSessionId: target,
|
|
642
|
+
idempotencyKey: digest(`watch-initial-snapshot\0${watcher}\0${target}\0${source}\0${message}`),
|
|
643
|
+
});
|
|
695
644
|
watched.push(target);
|
|
696
645
|
}
|
|
697
646
|
return { watched };
|
|
698
647
|
}
|
|
699
648
|
export function listSessionWatches(watcher) {
|
|
700
649
|
managedWatchRecord(watcher);
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
})
|
|
712
|
-
|
|
713
|
-
writeWatchEntries(target, active);
|
|
714
|
-
for (const entry of active)
|
|
715
|
-
if (entry.watcher === watcher)
|
|
716
|
-
watches.push({ target, createdAt: entry.createdAt });
|
|
717
|
-
}
|
|
718
|
-
return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target));
|
|
650
|
+
const application = configuredSessionApplicationIfCutover();
|
|
651
|
+
if (!application)
|
|
652
|
+
throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
|
|
653
|
+
// `listWatchers` defaults to the bare `watch` channel. Canonical policy stores the source
|
|
654
|
+
// (`watch:parent`/`watch:manual`) in the relation type, so listing must inspect every watch
|
|
655
|
+
// channel or a valid parent watch appears to have disappeared.
|
|
656
|
+
const edges = ['watch', 'watch:parent', 'watch:manual']
|
|
657
|
+
.flatMap(channel => application.listWatchers(watcher, channel))
|
|
658
|
+
.filter((edge, index, all) => all.findIndex(other => other.toSessionId === edge.toSessionId) === index);
|
|
659
|
+
return edges
|
|
660
|
+
.map(edge => ({ target: edge.toSessionId, createdAt: new Date(edge.createdAtMs).toISOString() }))
|
|
661
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target));
|
|
719
662
|
}
|
|
720
663
|
export function cancelSessionWatch(watcher, targets) {
|
|
721
664
|
managedWatchRecord(watcher);
|
|
665
|
+
const application = configuredSessionApplicationIfCutover();
|
|
666
|
+
if (!application)
|
|
667
|
+
throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
|
|
722
668
|
let cancelled = 0;
|
|
723
669
|
for (const target of [...new Set(targets)]) {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
if (next.removed) {
|
|
728
|
-
writeWatchEntries(target, next.entries);
|
|
670
|
+
for (const channel of ['watch:manual', 'watch']) {
|
|
671
|
+
try {
|
|
672
|
+
application.detachWatcher(watcher, target, channel);
|
|
729
673
|
cancelled++;
|
|
674
|
+
break;
|
|
730
675
|
}
|
|
731
|
-
|
|
676
|
+
catch (error) {
|
|
677
|
+
if (!(error instanceof Error) || !/does not exist|unknown/i.test(error.message))
|
|
678
|
+
throw error;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
732
681
|
}
|
|
733
682
|
return cancelled;
|
|
734
683
|
}
|
|
@@ -762,65 +711,65 @@ function assertReparentable(children, parent, records) {
|
|
|
762
711
|
}
|
|
763
712
|
export async function reparentSessionRecords(rawChildren, parent) {
|
|
764
713
|
const children = [...new Set(rawChildren)].sort();
|
|
714
|
+
const application = configuredSessionApplicationIfCutover();
|
|
715
|
+
if (!application)
|
|
716
|
+
throw new ResourceConflict('session application is unavailable; refusing the legacy reparent path');
|
|
765
717
|
const notify = [];
|
|
766
718
|
await withRecordLock('session-reparent-transaction', async () => {
|
|
767
|
-
// Read former supervisors only after the transaction fence: a concurrent reparent may change exactly
|
|
768
|
-
// this relation, and its real sender lock is part of the next transaction's outgoing-message boundary.
|
|
769
719
|
const before = new Map(children.map((id) => [id, managedWatchRecord(id)]));
|
|
770
720
|
assertReparentable(children, parent, before);
|
|
771
721
|
const formerParents = [...new Set([...before.values()].flatMap((record) => record.parent ? [record.parent] : []))];
|
|
772
722
|
await withRecordLocks([...children, ...formerParents].sort(), () => withDeliveryLocks(children, async () => {
|
|
773
723
|
const current = new Map(children.map((id) => [id, managedWatchRecord(id)]));
|
|
774
724
|
assertReparentable(children, parent, current);
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
? removeWatchSource(watchers, record.parent, 'parent').entries
|
|
782
|
-
: watchers;
|
|
783
|
-
const nextWatchers = parent
|
|
784
|
-
? addWatchSource(withoutFormerParent, parent, 'parent').entries
|
|
785
|
-
: withoutFormerParent;
|
|
786
|
-
if (nextWatchers !== watchers)
|
|
787
|
-
writeWatchEntries(snapshot.id, nextWatchers);
|
|
788
|
-
if (record.parent !== parent)
|
|
789
|
-
writeRecord({ ...record, parent });
|
|
790
|
-
if (parent && (record.parent !== parent || !hadNewParent))
|
|
791
|
-
notify.push({ ...record, parent });
|
|
792
|
-
}
|
|
793
|
-
for (const snapshot of snapshots) {
|
|
794
|
-
if (snapshot.record.parent && snapshot.record.parent !== parent)
|
|
795
|
-
revokePendingFromWhileLocked(snapshot.id, snapshot.record.parent);
|
|
725
|
+
for (const [id, record] of current) {
|
|
726
|
+
const state = application.readState(id);
|
|
727
|
+
if (!state)
|
|
728
|
+
throw new ResourceConflict(`session ${id} has no canonical application state during reparent`);
|
|
729
|
+
if (state.parentSessionId !== record.parent) {
|
|
730
|
+
throw new ResourceConflict(`session ${id} canonical/record parent mismatch: record=${record.parent ?? 'null'} canonical=${state.parentSessionId ?? 'null'}`);
|
|
796
731
|
}
|
|
797
732
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
733
|
+
for (const [id, record] of current) {
|
|
734
|
+
if (record.parent === parent)
|
|
735
|
+
continue;
|
|
736
|
+
const change = application.transitionSession(id, { parentSessionId: parent, reason: 'reparent' });
|
|
737
|
+
if (record.parent) {
|
|
801
738
|
try {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
739
|
+
application.detachWatcher(record.parent, id, 'watch:parent');
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
if (!(error instanceof Error) || !/does not exist|unknown/i.test(error.message))
|
|
743
|
+
throw error;
|
|
805
744
|
}
|
|
806
|
-
|
|
807
|
-
|
|
745
|
+
for (const message of application.readPendingMessages(id)) {
|
|
746
|
+
if (message.senderSessionId === record.parent)
|
|
747
|
+
application.dequeuePendingMessage(id, message.messageId);
|
|
808
748
|
}
|
|
809
749
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
750
|
+
if (parent) {
|
|
751
|
+
try {
|
|
752
|
+
application.attachWatcher(parent, id, 'watch:parent');
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
if (!(error instanceof Error) || !/already exists|duplicate|active topology edge/i.test(error.message))
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
// The transition above published to the OLD watcher set. The new supervisor learns the child's current
|
|
759
|
+
// state here, keyed by that transition's own event so a retried rewrite never sends it twice.
|
|
760
|
+
const moved = { ...record, parent };
|
|
761
|
+
application.enqueueMessage(parent, {
|
|
762
|
+
kind: 'session.prompt.v1',
|
|
763
|
+
body: Buffer.from(watchMessage(moved), 'utf8'),
|
|
764
|
+
senderSessionId: id,
|
|
765
|
+
idempotencyKey: digest(`reparent-snapshot\0${change.event.eventId}`),
|
|
766
|
+
});
|
|
767
|
+
notify.push(moved);
|
|
768
|
+
}
|
|
813
769
|
}
|
|
814
770
|
}));
|
|
815
771
|
});
|
|
816
|
-
const notified =
|
|
817
|
-
if (parent)
|
|
818
|
-
for (const child of notify) {
|
|
819
|
-
const delivered = await sendText(parent, watchMessage(child), child.session);
|
|
820
|
-
if (!delivered.ok)
|
|
821
|
-
throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`);
|
|
822
|
-
notified.push(child.session);
|
|
823
|
-
}
|
|
772
|
+
const notified = notify.map((child) => child.session);
|
|
824
773
|
return { children, parent, notified };
|
|
825
774
|
}
|
|
826
775
|
// tmux rewrites CONTROL characters in a format string before printing them — 3.6a turns both a tab and a raw
|
|
@@ -1083,18 +1032,18 @@ export function liveness(rec, snap) {
|
|
|
1083
1032
|
return 'unknown';
|
|
1084
1033
|
return 'offline';
|
|
1085
1034
|
}
|
|
1086
|
-
function reconcile(rec, snap) {
|
|
1035
|
+
function reconcile(rec, snap, residentLiveness) {
|
|
1087
1036
|
// record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
|
|
1088
1037
|
// about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
|
|
1089
|
-
if (retirementReason(rec))
|
|
1090
|
-
return 'retired';
|
|
1091
1038
|
if (rec.archived)
|
|
1092
1039
|
return 'offline';
|
|
1040
|
+
if (retirementReason(rec))
|
|
1041
|
+
return 'retired';
|
|
1093
1042
|
if (rec.status === 'awaiting')
|
|
1094
|
-
return
|
|
1043
|
+
return displayStatusForProposal(rec.proposal);
|
|
1095
1044
|
if (rec.status !== 'active' && rec.status !== 'idle')
|
|
1096
1045
|
return rec.status; // parked | error | asking | queued (no tmux yet)
|
|
1097
|
-
const lv = liveness(rec, snap);
|
|
1046
|
+
const lv = residentLiveness ?? liveness(rec, snap);
|
|
1098
1047
|
if (lv !== 'online')
|
|
1099
1048
|
return lv; // 'offline' | 'starting' | 'unknown'
|
|
1100
1049
|
return rec.status === 'idle' ? 'idle' : 'working';
|
|
@@ -1119,13 +1068,26 @@ export function reviewIdentity(id) {
|
|
|
1119
1068
|
label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
|
|
1120
1069
|
};
|
|
1121
1070
|
}
|
|
1071
|
+
export function sessionHookState(id) {
|
|
1072
|
+
const rec = readRecord(id);
|
|
1073
|
+
if (!rec)
|
|
1074
|
+
return null;
|
|
1075
|
+
const application = configuredSessionApplicationIfCutover();
|
|
1076
|
+
const state = application?.readState(id);
|
|
1077
|
+
return {
|
|
1078
|
+
governed: rec.governed,
|
|
1079
|
+
status: (state?.status ?? rec.status),
|
|
1080
|
+
proposal: (state?.proposal || rec.proposal || null),
|
|
1081
|
+
note: state?.note ?? (rec.note || null),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1122
1084
|
function corruptSession(id, entry) {
|
|
1123
1085
|
const label = `${id.slice(0, 8)} (unreadable record)`;
|
|
1124
1086
|
return {
|
|
1125
1087
|
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
1126
1088
|
parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
|
|
1127
1089
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
1128
|
-
note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
|
|
1090
|
+
note: corruptReason(entry), archived: false, closedAt: null, prompt: null, promptPreview: null, created: 0,
|
|
1129
1091
|
activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
|
|
1130
1092
|
};
|
|
1131
1093
|
}
|
|
@@ -1138,7 +1100,7 @@ export function toSession(rec, status, lv, activity = null) {
|
|
|
1138
1100
|
const pp = prompt ? oneLinePreview(prompt) : null;
|
|
1139
1101
|
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp };
|
|
1140
1102
|
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
1141
|
-
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
|
|
1103
|
+
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, closedAt: rec.archived ? rec.closedAt : null, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
|
|
1142
1104
|
}
|
|
1143
1105
|
// @@@zcode child identity - ZCode owns the child id and SpexCode owns the session record. The writer accepts
|
|
1144
1106
|
// only their exact declared pair; names, worktrees, branches, and timestamps are deliberately not candidates.
|
|
@@ -1197,6 +1159,44 @@ export async function sessionPrompt(id) {
|
|
|
1197
1159
|
throw e;
|
|
1198
1160
|
}
|
|
1199
1161
|
}
|
|
1162
|
+
// The archive overlay has no reader for the session model. Keep this projection separate from listSessions so
|
|
1163
|
+
// opening it skips the live tmux census, resident adapter probes, and files/web reads, and carries no full prompt bytes.
|
|
1164
|
+
export async function listArchivedSessionIndex(probe) {
|
|
1165
|
+
const rows = [];
|
|
1166
|
+
for (const id of listSessionIds()) {
|
|
1167
|
+
let entry;
|
|
1168
|
+
try {
|
|
1169
|
+
entry = readPublicRecordEntry(id);
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
if (entry.kind !== 'ok')
|
|
1175
|
+
continue;
|
|
1176
|
+
const rec = fromRaw(entry.raw);
|
|
1177
|
+
if (!rec.governed || !rec.archived)
|
|
1178
|
+
continue;
|
|
1179
|
+
const parts = {
|
|
1180
|
+
id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch,
|
|
1181
|
+
activity: null, note: rec.note, promptPreview: null,
|
|
1182
|
+
};
|
|
1183
|
+
// Name and note are ahead of the prompt in deriveTitle's precedence. Avoid touching the prompt artifact
|
|
1184
|
+
// unless both are absent; only then can its preview change the visible title.
|
|
1185
|
+
if (!rec.name && !rec.note?.trim()) {
|
|
1186
|
+
probe && (probe.promptReads = (probe.promptReads || 0) + 1);
|
|
1187
|
+
const prompt = readPromptFile(id);
|
|
1188
|
+
parts.promptPreview = prompt ? oneLinePreview(prompt) : null;
|
|
1189
|
+
}
|
|
1190
|
+
rows.push({
|
|
1191
|
+
id: rec.session,
|
|
1192
|
+
title: deriveTitle(parts),
|
|
1193
|
+
label: deriveLabel(parts),
|
|
1194
|
+
closedAt: rec.closedAt,
|
|
1195
|
+
node: rec.node,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
return rows;
|
|
1199
|
+
}
|
|
1200
1200
|
// Preserve rows through a transient record-read failure; prune after the store entry disappears.
|
|
1201
1201
|
const lastKnownSession = new Map();
|
|
1202
1202
|
// A BOARD row carries the launch ask only as its one-line preview. The full text is served by the
|
|
@@ -1222,9 +1222,23 @@ export async function listSessions(includeArchived = false) {
|
|
|
1222
1222
|
}
|
|
1223
1223
|
catch { /* guardSession below preserves the last-known row for a transient read failure */ }
|
|
1224
1224
|
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1225
|
+
const canonical = configuredSessionApplicationIfCutover();
|
|
1226
|
+
const canonicalStates = new Map();
|
|
1227
|
+
if (canonical) {
|
|
1228
|
+
for (const [id, snapshot] of snapshots) {
|
|
1229
|
+
if (!snapshot.rec?.governed)
|
|
1230
|
+
continue;
|
|
1231
|
+
const state = canonical.readState(id);
|
|
1232
|
+
if (!state)
|
|
1233
|
+
throw new ResourceConflict(`session ${id} has no canonical application state after JSON cutover`);
|
|
1234
|
+
canonicalStates.set(id, state);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// Adapter-owned records have no pane witness. Join one project-wide resident-ID census to every exact
|
|
1238
|
+
// bound target, including live rows; otherwise a dead shared app-server could leave a stale headless record
|
|
1239
|
+
// online indefinitely. The descriptor probe remains one-per-generation, not one RPC per session.
|
|
1240
|
+
const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && rec && rec.governed
|
|
1241
|
+
&& rec.harnessSessionId && harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter'
|
|
1228
1242
|
? [{ ...rec, harness: rec.harness || defaultHarness.id }]
|
|
1229
1243
|
: []);
|
|
1230
1244
|
const residentCensus = censusRecords.length ? await adapterLoadedReferenceState(censusRecords) : new Map();
|
|
@@ -1263,35 +1277,43 @@ export async function listSessions(includeArchived = false) {
|
|
|
1263
1277
|
lastKnownSession.delete(id);
|
|
1264
1278
|
return null;
|
|
1265
1279
|
} // no record, or a self-launched (non-board) one
|
|
1280
|
+
const projectedRecord = canonicalRecordProjection(rec, canonicalStates.get(id));
|
|
1266
1281
|
// A forced public liveness comes only from the shared record projection. Do not let live process/thread
|
|
1267
1282
|
// evidence punch through it (including archive hazard repair).
|
|
1268
1283
|
if (entry.kind === 'ok' && entry.liveness === 'offline') {
|
|
1269
|
-
const pending = boardRow(toSession(
|
|
1284
|
+
const pending = boardRow(toSession(projectedRecord, 'offline', 'offline'));
|
|
1270
1285
|
lastKnownSession.set(id, pending);
|
|
1271
1286
|
return pending;
|
|
1272
1287
|
}
|
|
1273
1288
|
// the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
|
|
1274
1289
|
// is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
|
|
1275
1290
|
const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id));
|
|
1276
|
-
const sessionHarness = harnessById(
|
|
1277
|
-
const resident =
|
|
1278
|
-
? residentCensus.get(`${
|
|
1291
|
+
const sessionHarness = harnessById(projectedRecord.harness || defaultHarness.id);
|
|
1292
|
+
const resident = projectedRecord.harnessSessionId
|
|
1293
|
+
? residentCensus.get(`${projectedRecord.harness || defaultHarness.id}:${projectedRecord.harnessSessionId}`)
|
|
1279
1294
|
: undefined;
|
|
1280
|
-
const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!
|
|
1281
|
-
const physical =
|
|
1295
|
+
const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!projectedRecord.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length;
|
|
1296
|
+
const physical = projectedRecord.archived
|
|
1282
1297
|
? (sessionHarness.runtimeOwnership === 'adapter'
|
|
1283
1298
|
? (resident && !resident.healthy ? 'unknown' : resident?.loaded ? 'online' : snap.windows.has(id) ? 'online' : 'offline')
|
|
1284
|
-
: liveness({ ...
|
|
1299
|
+
: liveness({ ...projectedRecord, archived: false, stopped: false }, snap))
|
|
1285
1300
|
: null;
|
|
1286
1301
|
// Only a physically-offline record projects as archived. A legacy archived+live/unknown record is exposed
|
|
1287
1302
|
// as ordinary working-set state with its real liveness/status and one backend-owned hazard marker. A
|
|
1288
1303
|
// missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
|
|
1289
1304
|
// unloaded, so it remains visible until an explicit archive repair.
|
|
1290
|
-
const cleanCold =
|
|
1291
|
-
const projected =
|
|
1292
|
-
const projectedLv = projected ===
|
|
1293
|
-
|
|
1294
|
-
|
|
1305
|
+
const cleanCold = projectedRecord.archived && !changedDuringCensus.has(id) && hasValidColdProof(projectedRecord) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
|
|
1306
|
+
const projected = projectedRecord.archived && !cleanCold ? { ...projectedRecord, archived: false, stopped: false } : projectedRecord;
|
|
1307
|
+
const projectedLv = projected === projectedRecord
|
|
1308
|
+
? sessionHarness.runtimeOwnership === 'adapter'
|
|
1309
|
+
? adapterResidentLiveness(projectedRecord, resident)
|
|
1310
|
+
: liveness(projectedRecord, snap)
|
|
1311
|
+
: physical;
|
|
1312
|
+
const s = boardRow(toSession(projected, reconcile(projected, snap, projectedLv), projectedLv, activity));
|
|
1313
|
+
// Canonical projection deliberately creates a fresh object for every governed row. That identity change is not
|
|
1314
|
+
// an archive failure: a hazard belongs only to a record that was actually archived and then had its cold proof
|
|
1315
|
+
// rejected. Otherwise every live row would inherit the missing-cold-witness message after cutover.
|
|
1316
|
+
if (projectedRecord.archived && !cleanCold)
|
|
1295
1317
|
s.archiveHazard = changedDuringCensus.has(id)
|
|
1296
1318
|
? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
|
|
1297
1319
|
: hasValidColdProof(rec)
|
|
@@ -1308,7 +1330,7 @@ export async function listSessions(includeArchived = false) {
|
|
|
1308
1330
|
lastKnownSession.set(id, s);
|
|
1309
1331
|
return s;
|
|
1310
1332
|
}, () => {
|
|
1311
|
-
// DEGRADED: the record dir still exists but reading
|
|
1333
|
+
// DEGRADED: the record dir still exists but reading runtime.json failed transiently. NEVER drop a live
|
|
1312
1334
|
// session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
|
|
1313
1335
|
// show yet, and it reappears on the next build.)
|
|
1314
1336
|
return lastKnownSession.get(id) ?? null;
|
|
@@ -1436,7 +1458,7 @@ export function withPeerSenderHint(text, sender, sshAddress, machineId) {
|
|
|
1436
1458
|
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`;
|
|
1437
1459
|
return `${text}\n\n— from ${who} on machine ${machineId}. To reply: spex session send --ssh ${sshAddress} ${sender.id} "<your reply>"`;
|
|
1438
1460
|
}
|
|
1439
|
-
export const withNoteReplyHint = (text) => `${text}\n\n—
|
|
1461
|
+
export const withNoteReplyHint = (text) => `${text}\n\n— REPLY TRANSPORT: This sender cannot read normal assistant output. Before ending this turn, make your FINAL tool call a Spex declaration carrying the COMPLETE reply in --note: use \`session ask\` when waiting for a human reply; use \`done\` or \`park\` when that is the truthful state. This rule applies even when asked to only print/reply or make no tool calls.\n\nFor multi-line replies, preserve real LF characters. \`functions.exec\` runs a shell command through bash, so never interpolate \`JSON.stringify(note)\` into it; use stdin, a heredoc, or base64, then pass \`--note \"$note\"\`. Never use \`String.raw\` or literal backslash+n. Do not call any tool after the declaration.`;
|
|
1440
1462
|
export const withTerminalReplyHint = (text) => `${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`;
|
|
1441
1463
|
export const slugify = (s) => (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session';
|
|
1442
1464
|
const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u;
|
|
@@ -1552,6 +1574,10 @@ export function launchPreflight(rec) {
|
|
|
1552
1574
|
}
|
|
1553
1575
|
// @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
|
|
1554
1576
|
// invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
|
|
1577
|
+
// 后端把这条命令输入交互式 shell,脚本路径必须作为一个 shell 参数传递。
|
|
1578
|
+
export function launchShellCommand(file) {
|
|
1579
|
+
return `bash ${shQuote(file)}`;
|
|
1580
|
+
}
|
|
1555
1581
|
export function launchScript(id, tail, harness = HARNESS, cmd) {
|
|
1556
1582
|
const file = join(storeDir(id), 'launch.sh');
|
|
1557
1583
|
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
@@ -1559,7 +1585,7 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
|
|
|
1559
1585
|
// agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
|
|
1560
1586
|
// `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
|
|
1561
1587
|
// ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
|
|
1562
|
-
const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`;
|
|
1588
|
+
const invocation = `${rvEnv(id, harness, readRecord(id)?.runtimeStartToken)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`;
|
|
1563
1589
|
// @@@ birth registration - record the AGENT's real pid BEFORE exec, the anchor of the 100ms hot death tier
|
|
1564
1590
|
// ([[state]]). Each attempt runs `sh -c '<pid-write>; exec env <invocation>'`: the sh writes its own `$$` to
|
|
1565
1591
|
// agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
|
|
@@ -1613,7 +1639,7 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
|
|
|
1613
1639
|
// -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
|
|
1614
1640
|
// can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
|
|
1615
1641
|
// bounded retry stands.
|
|
1616
|
-
` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE
|
|
1642
|
+
` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-.}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
|
|
1617
1643
|
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
1618
1644
|
` exit $__spex_rc`,
|
|
1619
1645
|
` fi`,
|
|
@@ -1634,7 +1660,8 @@ async function launch(id, path, tail, harness = HARNESS, cmd) {
|
|
|
1634
1660
|
if (harness.ownsRendezvous)
|
|
1635
1661
|
stampRvSock(id);
|
|
1636
1662
|
await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path]);
|
|
1637
|
-
|
|
1663
|
+
const file = launchScript(id, tail, harness, cmd);
|
|
1664
|
+
await tmux(['send-keys', '-t', id, '-l', '--', launchShellCommand(file)]);
|
|
1638
1665
|
await tmux(['send-keys', '-t', id, 'Enter']);
|
|
1639
1666
|
launchedAt.set(id, Date.now()); // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
|
|
1640
1667
|
}
|
|
@@ -1642,10 +1669,9 @@ const OCCUPIES_SLOT = new Set(['working', 'parked', 'starting']); // starting's
|
|
|
1642
1669
|
function isOccupying(s, snap) {
|
|
1643
1670
|
if (!OCCUPIES_SLOT.has(s.status))
|
|
1644
1671
|
return false; // waiting-on-human / proposed / queued / dead → free
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online';
|
|
1672
|
+
// `listSessions` already joined the adapter resident census and projected the resulting liveness. Re-reading
|
|
1673
|
+
// the harness here would resurrect the old record-backed codex-headless shortcut and disagree with the row.
|
|
1674
|
+
return s.liveness === 'online';
|
|
1649
1675
|
}
|
|
1650
1676
|
// sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
|
|
1651
1677
|
// `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
|
|
@@ -1675,23 +1701,167 @@ async function withSessionTransition(id, body) {
|
|
|
1675
1701
|
}
|
|
1676
1702
|
}
|
|
1677
1703
|
let draining = false; // re-entrancy guard: only one drain pass runs at a time (no double-launch)
|
|
1678
|
-
|
|
1704
|
+
// A native receipt is bound before the readiness fence validates it. Suppress only that immediate wake so
|
|
1705
|
+
// queued prompts cannot drain during the candidate window; the successful publication path drains normally.
|
|
1706
|
+
const readinessWakeSuppressed = new Set();
|
|
1707
|
+
function noteQueuedLaunchFailureUnlocked(id, error, terminal = true, label, live = false) {
|
|
1679
1708
|
const reason = error instanceof Error ? error.message : String(error);
|
|
1680
|
-
const note =
|
|
1709
|
+
const note = `${label ?? (terminal ? 'queued launch readiness failed' : 'launch readiness warning')}: ${reason}`;
|
|
1681
1710
|
console.error(`spex: session ${id}: ${note}`);
|
|
1682
1711
|
const rec = readRecord(id);
|
|
1683
|
-
if (rec && rec.note !== note
|
|
1684
|
-
|
|
1712
|
+
if (rec && !retirementReason(rec) && (rec.note !== note
|
|
1713
|
+
|| (terminal && (rec.status !== 'error' || !rec.stopped || rec.launchReadinessStartedAt != null))
|
|
1714
|
+
|| (!terminal && live && (rec.status === 'error' || rec.stopped)))) {
|
|
1715
|
+
// Readiness failure is terminal for this launch attempt. Keep the exact reason on the record,
|
|
1716
|
+
// publish an offline/error transition, and clear every durable/in-memory ownership marker so close
|
|
1717
|
+
// and a later explicit resume have an honest starting point.
|
|
1718
|
+
if (terminal) {
|
|
1719
|
+
publishCanonicalLifecycle(rec, 'error', null, note);
|
|
1720
|
+
writeRecord({ ...rec, status: 'error', proposal: null, stopped: true, note, launchOwner: null, launchReadinessStartedAt: null });
|
|
1721
|
+
}
|
|
1722
|
+
else {
|
|
1723
|
+
const status = live && (rec.status === 'error' || rec.stopped) ? 'active' : rec.status;
|
|
1724
|
+
const stopped = live ? false : rec.stopped;
|
|
1725
|
+
const restored = { ...rec, status, stopped, note, launchOwner: null, launchReadinessStartedAt: null };
|
|
1726
|
+
// A live post-receipt timeout is a diagnostic, not a new parent-watch transition. If an older failed
|
|
1727
|
+
// attempt already published `error`, however, the canonical row must be repaired to the live status or
|
|
1728
|
+
// the JSON write below would leave the sole lifecycle authority disagreeing with the runtime witness.
|
|
1729
|
+
// Publish even when status is unchanged: the warning note is canonical too. Active recipients exclude
|
|
1730
|
+
// the parent, so this diagnostic cannot manufacture a parent-watch transition.
|
|
1731
|
+
publishCanonicalLifecycle(restored, status, restored.proposal, note);
|
|
1732
|
+
writeRecord(restored);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
function clearReadinessResidueUnlocked(rec, clearDiagnostic) {
|
|
1737
|
+
const application = configuredSessionApplicationIfCutover();
|
|
1738
|
+
const next = {
|
|
1739
|
+
...rec,
|
|
1740
|
+
status: 'active',
|
|
1741
|
+
stopped: false,
|
|
1742
|
+
note: clearDiagnostic ? null : rec.note,
|
|
1743
|
+
launchReadinessStartedAt: null,
|
|
1744
|
+
};
|
|
1745
|
+
if (application?.readState(rec.session) && (clearDiagnostic || rec.status !== 'active' || rec.stopped)) {
|
|
1746
|
+
application.transitionSession(rec.session, {
|
|
1747
|
+
status: 'active',
|
|
1748
|
+
proposal: rec.proposal,
|
|
1749
|
+
note: next.note,
|
|
1750
|
+
parentSessionId: rec.parent,
|
|
1751
|
+
recipientSessionIds: [],
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
writeRecord(next);
|
|
1755
|
+
}
|
|
1756
|
+
export function canonicalWatchRecipients(application, sessionId, status) {
|
|
1757
|
+
const recipients = new Set();
|
|
1758
|
+
for (const edge of application.topology.parents(sessionId)) {
|
|
1759
|
+
// The canonical topology stores the structural parent edge as the durable parent-watch source. Older
|
|
1760
|
+
// migrated rows may also have an explicit watch:parent edge; both represent the same policy source.
|
|
1761
|
+
if (edge.relationType !== 'parent' && !edge.relationType.startsWith('watch'))
|
|
1762
|
+
continue;
|
|
1763
|
+
if (status === 'active' && (edge.relationType === 'parent' || edge.relationType === 'watch:parent'))
|
|
1764
|
+
continue;
|
|
1765
|
+
recipients.add(edge.fromSessionId);
|
|
1766
|
+
}
|
|
1767
|
+
return [...recipients];
|
|
1685
1768
|
}
|
|
1686
|
-
function
|
|
1687
|
-
|
|
1769
|
+
export function sessionHasPendingDelivery(id, application = configuredSessionApplicationIfCutover() ?? null) {
|
|
1770
|
+
if (!application)
|
|
1771
|
+
throw new ResourceConflict(`session application is unavailable for ${id}`);
|
|
1772
|
+
const runtime = application.resolveRuntime?.(id, 'spex-governed');
|
|
1773
|
+
if (runtime === null)
|
|
1774
|
+
return false;
|
|
1775
|
+
try {
|
|
1776
|
+
return application.readPendingMessages(id).length > 0;
|
|
1777
|
+
}
|
|
1778
|
+
catch (error) {
|
|
1779
|
+
// A legacy record can outlive its migrated protocol address. It has no canonical queue to drain;
|
|
1780
|
+
// treating that address as owed makes the supervisor retry the same impossible lookup forever.
|
|
1781
|
+
if (error?.code === 'PROTOCOL_SESSION_UNKNOWN'
|
|
1782
|
+
|| /unknown protocol address/i.test(error instanceof Error ? error.message : String(error)))
|
|
1783
|
+
return false;
|
|
1784
|
+
throw error;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
export function canonicalRecordProjection(rec, canonical) {
|
|
1788
|
+
// The application row is the only lifecycle fact after cutover. A JSON status is historical envelope data,
|
|
1789
|
+
// so it must not win merely because it says waiting/error/archived while the canonical row says otherwise.
|
|
1790
|
+
if (!canonical) {
|
|
1791
|
+
return rec;
|
|
1792
|
+
}
|
|
1793
|
+
return {
|
|
1794
|
+
...rec,
|
|
1795
|
+
status: canonical.status,
|
|
1796
|
+
proposal: canonical.proposal,
|
|
1797
|
+
note: canonical.note,
|
|
1798
|
+
parent: canonical.parentSessionId,
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
function publishCanonicalLifecycle(rec, status, proposal, note) {
|
|
1802
|
+
const application = configuredSessionApplicationIfCutover();
|
|
1803
|
+
if (!application)
|
|
1804
|
+
return;
|
|
1805
|
+
if (!application.readState(rec.session)) {
|
|
1806
|
+
application.createSession({ sessionId: rec.session, status, proposal, note, parentSessionId: rec.parent });
|
|
1807
|
+
if (rec.parent)
|
|
1808
|
+
application.attachWatcher(rec.parent, rec.session, 'watch:parent');
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
application.transitionSession(rec.session, {
|
|
1812
|
+
status,
|
|
1813
|
+
proposal,
|
|
1814
|
+
note,
|
|
1815
|
+
parentSessionId: rec.parent,
|
|
1816
|
+
recipientSessionIds: canonicalWatchRecipients(application, rec.session, status),
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
async function launchReadinessWitnessAlive(id, harness, current) {
|
|
1820
|
+
if (harness.runtimeOwnership === 'adapter') {
|
|
1821
|
+
const state = await adapterRuntimeLiveness({ ...current, stopped: false, archived: false });
|
|
1822
|
+
return state === 'online';
|
|
1823
|
+
}
|
|
1824
|
+
if (agentAlive(id) === true)
|
|
1825
|
+
return true;
|
|
1826
|
+
try {
|
|
1827
|
+
const snap = await liveSnapshot(id);
|
|
1828
|
+
return harness.liveness(current, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online';
|
|
1829
|
+
}
|
|
1830
|
+
catch {
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
export function adapterResidentLiveness(rec, resident) {
|
|
1835
|
+
if (rec.stopped || rec.archived)
|
|
1836
|
+
return 'offline';
|
|
1837
|
+
if (!rec.harnessSessionId)
|
|
1838
|
+
return 'offline';
|
|
1839
|
+
if (!resident)
|
|
1840
|
+
return 'unknown';
|
|
1841
|
+
if (!resident.healthy)
|
|
1842
|
+
return 'unknown';
|
|
1843
|
+
return resident.loaded ? 'online' : 'offline';
|
|
1844
|
+
}
|
|
1845
|
+
async function adapterRuntimeLiveness(rec) {
|
|
1846
|
+
if (rec.stopped || rec.archived)
|
|
1847
|
+
return 'offline';
|
|
1848
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
1849
|
+
if (harness.runtimeOwnership !== 'adapter')
|
|
1850
|
+
return liveness(rec, await liveSnapshot());
|
|
1851
|
+
if (!rec.harnessSessionId)
|
|
1852
|
+
return 'offline';
|
|
1853
|
+
const states = await adapterLoadedReferenceState([{ ...rec, harness: harness.id }], runtimeRoot());
|
|
1854
|
+
return adapterResidentLiveness(rec, states.get(`${harness.id}:${rec.harnessSessionId}`));
|
|
1855
|
+
}
|
|
1856
|
+
function observeQueuedLaunchReadiness(id, harness, timeoutMs = SOCKET_READY_TIMEOUT_MS) {
|
|
1857
|
+
void waitForReady(id, harness, undefined, timeoutMs)
|
|
1688
1858
|
.then(async (readiness) => {
|
|
1689
1859
|
if (!readiness) {
|
|
1690
1860
|
const committed = !!readRecord(id)?.harnessSessionId;
|
|
1691
1861
|
throw new ResourceConflict(harness.launchPayloadProof
|
|
1692
1862
|
? committed
|
|
1693
|
-
? 'post-
|
|
1694
|
-
: 'native identity and first-turn rollout
|
|
1863
|
+
? 'post-receipt adapter liveness did not become ready before launch readiness timed out'
|
|
1864
|
+
: 'native identity and first-turn rollout receipt did not arrive before launch readiness timed out'
|
|
1695
1865
|
: 'adapter liveness did not become ready before launch readiness timed out');
|
|
1696
1866
|
}
|
|
1697
1867
|
let readyToPublish = false;
|
|
@@ -1708,23 +1878,35 @@ function observeQueuedLaunchReadiness(id, harness) {
|
|
|
1708
1878
|
const current = readRecord(id);
|
|
1709
1879
|
if (!current)
|
|
1710
1880
|
return;
|
|
1711
|
-
if (current.status === 'queued')
|
|
1712
|
-
|
|
1881
|
+
if (current.status === 'queued') {
|
|
1882
|
+
publishCanonicalLifecycle(current, 'active', null, null);
|
|
1883
|
+
writeRecord({ ...current, status: 'active', proposal: null, note: null, stopped: false, launchOwner: null, launchReadinessStartedAt: null });
|
|
1884
|
+
}
|
|
1885
|
+
else if (current.launchReadinessStartedAt != null)
|
|
1886
|
+
writeRecord({ ...current, launchReadinessStartedAt: null });
|
|
1713
1887
|
readyToPublish = true;
|
|
1714
1888
|
});
|
|
1715
1889
|
if (!readyToPublish)
|
|
1716
1890
|
return;
|
|
1717
|
-
await deliverPendingWatchSnapshots(id);
|
|
1718
1891
|
await drainSession(id);
|
|
1719
1892
|
})
|
|
1720
1893
|
.catch(async (error) => {
|
|
1894
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1895
|
+
const timedOut = /timed out|did not become ready/i.test(reason);
|
|
1896
|
+
let live = false;
|
|
1897
|
+
let terminal = timedOut;
|
|
1721
1898
|
try {
|
|
1722
|
-
await withRecordLock(id, async () =>
|
|
1899
|
+
await withRecordLock(id, async () => {
|
|
1900
|
+
const current = readRecord(id);
|
|
1901
|
+
if (timedOut && current)
|
|
1902
|
+
live = await launchReadinessWitnessAlive(id, harness, current);
|
|
1903
|
+
terminal = timedOut && !live;
|
|
1904
|
+
noteQueuedLaunchFailureUnlocked(id, error, terminal, live ? 'launch readiness warning' : undefined, live);
|
|
1905
|
+
});
|
|
1723
1906
|
}
|
|
1724
1907
|
catch (recordError) {
|
|
1725
|
-
console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${
|
|
1908
|
+
console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${reason}`);
|
|
1726
1909
|
}
|
|
1727
|
-
await clearPendingWatchSnapshots(id);
|
|
1728
1910
|
})
|
|
1729
1911
|
.finally(() => launching.delete(id));
|
|
1730
1912
|
}
|
|
@@ -1743,7 +1925,7 @@ async function startQueuedUnlocked(id) {
|
|
|
1743
1925
|
if (!canDrainQueued(wt.rec))
|
|
1744
1926
|
return 'retryable';
|
|
1745
1927
|
const h = harnessById(wt.rec.harness || defaultHarness.id);
|
|
1746
|
-
if (h.launchPayloadProof &&
|
|
1928
|
+
if (h.launchPayloadProof && hasReadableLaunchReceipt(id)) {
|
|
1747
1929
|
launching.add(id);
|
|
1748
1930
|
let readinessOwnsSlot = false;
|
|
1749
1931
|
try {
|
|
@@ -1751,11 +1933,13 @@ async function startQueuedUnlocked(id) {
|
|
|
1751
1933
|
consumeHarnessLaunchProofUnlocked(id);
|
|
1752
1934
|
}
|
|
1753
1935
|
catch (error) {
|
|
1754
|
-
noteQueuedLaunchFailureUnlocked(id, error);
|
|
1936
|
+
noteQueuedLaunchFailureUnlocked(id, error, false);
|
|
1755
1937
|
throw error;
|
|
1756
1938
|
}
|
|
1757
1939
|
const recovered = readRecord(id) || wt.rec;
|
|
1758
|
-
|
|
1940
|
+
const readinessStartedAt = Date.now();
|
|
1941
|
+
publishCanonicalLifecycle(recovered, 'active', null, null);
|
|
1942
|
+
writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
|
|
1759
1943
|
observeQueuedLaunchReadiness(id, h);
|
|
1760
1944
|
readinessOwnsSlot = true;
|
|
1761
1945
|
return 'started';
|
|
@@ -1788,11 +1972,17 @@ async function startQueuedUnlocked(id) {
|
|
|
1788
1972
|
launching.add(id); // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
|
|
1789
1973
|
let readinessOwnsSlot = false;
|
|
1790
1974
|
try {
|
|
1975
|
+
const readinessStartedAt = Date.now();
|
|
1976
|
+
const stamped = readRecord(id) || wt.rec;
|
|
1977
|
+
writeRecord({ ...stamped, launchReadinessStartedAt: readinessStartedAt });
|
|
1791
1978
|
try {
|
|
1792
1979
|
const sq = shQuote(launchPrompt);
|
|
1793
1980
|
await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec));
|
|
1794
1981
|
}
|
|
1795
1982
|
catch {
|
|
1983
|
+
const failedLaunch = readRecord(id);
|
|
1984
|
+
if (failedLaunch)
|
|
1985
|
+
writeRecord({ ...failedLaunch, launchReadinessStartedAt: null });
|
|
1796
1986
|
return 'retryable'; // launch failed → stays `queued`, with its initial debt, for the next drain tick
|
|
1797
1987
|
}
|
|
1798
1988
|
// the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
|
|
@@ -1800,7 +1990,8 @@ async function startQueuedUnlocked(id) {
|
|
|
1800
1990
|
// belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
|
|
1801
1991
|
// headline precedence stands on.
|
|
1802
1992
|
const launched = readRecord(id) || wt.rec;
|
|
1803
|
-
|
|
1993
|
+
publishCanonicalLifecycle(launched, 'active', null, null);
|
|
1994
|
+
writeRecord({ ...launched, status: 'active', proposal: null, note: null, stopped: false, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
|
|
1804
1995
|
if (!h.launchPayloadProof)
|
|
1805
1996
|
removeLaunchFile(id);
|
|
1806
1997
|
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
@@ -1825,16 +2016,60 @@ async function drainQueueUnlocked() {
|
|
|
1825
2016
|
const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()]);
|
|
1826
2017
|
for (const session of sessions) {
|
|
1827
2018
|
const rec = readRecord(session.id);
|
|
1828
|
-
if (!rec || launching.has(session.id)
|
|
2019
|
+
if (!rec || launching.has(session.id))
|
|
1829
2020
|
continue;
|
|
1830
|
-
|
|
2021
|
+
// Older timed-out rows predate the durable readiness timestamp. Reconcile their recorded failure
|
|
2022
|
+
// before any queue/watch work so a backend restart cannot resurrect the old active/limbo projection.
|
|
2023
|
+
if (rec.status !== 'queued' && /^queued launch readiness failed:/.test(rec.note || '')) {
|
|
2024
|
+
const priorReason = (rec.note || '').replace(/^queued launch readiness failed:\s*/, '') || 'launch readiness timed out';
|
|
2025
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
2026
|
+
const live = await launchReadinessWitnessAlive(session.id, harness, rec);
|
|
2027
|
+
if (live) {
|
|
2028
|
+
await withRecordLock(session.id, async () => {
|
|
2029
|
+
const current = readRecord(session.id);
|
|
2030
|
+
if (current && !current.archived && !current.stopped)
|
|
2031
|
+
clearReadinessResidueUnlocked(current, true);
|
|
2032
|
+
});
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
await withRecordLock(session.id, async () => noteQueuedLaunchFailureUnlocked(session.id, priorReason, !live, live ? 'launch readiness warning' : undefined, live));
|
|
1831
2036
|
continue;
|
|
1832
|
-
|
|
2037
|
+
}
|
|
2038
|
+
// A pre-fix active row may still carry the authoritative launch artifact without a timestamp. Its
|
|
2039
|
+
// mtime is the only durable age witness available; seed the new field so the same bounded recovery
|
|
2040
|
+
// rule applies on this and later restarts.
|
|
2041
|
+
if (rec.status === 'active' && !rec.stopped && existsSync(sessionArtifactPath(session.id, 'launch')) && rec.launchReadinessStartedAt == null) {
|
|
2042
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
2043
|
+
const live = await launchReadinessWitnessAlive(session.id, harness, rec);
|
|
2044
|
+
if (!live) {
|
|
2045
|
+
let startedAt = Date.now();
|
|
2046
|
+
try {
|
|
2047
|
+
startedAt = statSync(sessionArtifactPath(session.id, 'launch')).mtimeMs;
|
|
2048
|
+
}
|
|
2049
|
+
catch { /* race: observer below will fail loud */ }
|
|
2050
|
+
writeRecord({ ...rec, launchReadinessStartedAt: startedAt });
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
const refreshed = readRecord(session.id) || rec;
|
|
2054
|
+
if (refreshed.launchReadinessStartedAt && !refreshed.stopped && !refreshed.archived) {
|
|
2055
|
+
const harness = harnessById(refreshed.harness || defaultHarness.id);
|
|
2056
|
+
const live = await launchReadinessWitnessAlive(session.id, harness, refreshed);
|
|
2057
|
+
if (live) {
|
|
2058
|
+
await withRecordLock(session.id, async () => {
|
|
2059
|
+
const current = readRecord(session.id);
|
|
2060
|
+
if (current && !current.archived && !current.stopped) {
|
|
2061
|
+
clearReadinessResidueUnlocked(current, /^launch readiness warning:/.test(current.note || ''));
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
1833
2066
|
launching.add(session.id);
|
|
1834
|
-
|
|
2067
|
+
const remaining = Math.max(0, SOCKET_READY_TIMEOUT_MS - (Date.now() - refreshed.launchReadinessStartedAt));
|
|
2068
|
+
observeQueuedLaunchReadiness(session.id, harness, remaining);
|
|
1835
2069
|
continue;
|
|
1836
2070
|
}
|
|
1837
|
-
|
|
2071
|
+
if (rec.status === 'queued')
|
|
2072
|
+
continue;
|
|
1838
2073
|
}
|
|
1839
2074
|
// if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
|
|
1840
2075
|
// session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
|
|
@@ -1844,13 +2079,6 @@ async function drainQueueUnlocked() {
|
|
|
1844
2079
|
break;
|
|
1845
2080
|
const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0);
|
|
1846
2081
|
if (occupied >= cap) {
|
|
1847
|
-
const authority = backendLaunchAuthority();
|
|
1848
|
-
await Promise.all(sessions.filter((session) => {
|
|
1849
|
-
if (session.status !== 'queued')
|
|
1850
|
-
return false;
|
|
1851
|
-
const rec = readRecord(session.id);
|
|
1852
|
-
return !!rec && canDrainQueued(rec, authority);
|
|
1853
|
-
}).map((session) => deliverPendingWatchSnapshots(session.id)));
|
|
1854
2082
|
break;
|
|
1855
2083
|
}
|
|
1856
2084
|
const authority = backendLaunchAuthority();
|
|
@@ -1864,8 +2092,6 @@ async function drainQueueUnlocked() {
|
|
|
1864
2092
|
break;
|
|
1865
2093
|
const started = await startQueued(next.id);
|
|
1866
2094
|
if (started !== 'started') {
|
|
1867
|
-
if (started === 'blocked')
|
|
1868
|
-
await clearPendingWatchSnapshots(next.id);
|
|
1869
2095
|
break; // launch failed → stop this pass; a later tick retries
|
|
1870
2096
|
}
|
|
1871
2097
|
}
|
|
@@ -1880,6 +2106,18 @@ const requestQueueDrain = () => {
|
|
|
1880
2106
|
console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1881
2107
|
});
|
|
1882
2108
|
};
|
|
2109
|
+
// Canonical state commits already own the durable recipient queue. This is only the post-commit wake that hands
|
|
2110
|
+
// each queued recipient to its existing runtime; a failed or absent runtime leaves the message pending for retry.
|
|
2111
|
+
setSessionApplicationCommitWake((recipients) => {
|
|
2112
|
+
const wakeRecipients = recipients.filter(recipient => !readinessWakeSuppressed.has(recipient));
|
|
2113
|
+
queueMicrotask(() => {
|
|
2114
|
+
for (const recipient of wakeRecipients) {
|
|
2115
|
+
void drainSession(recipient).catch((error) => {
|
|
2116
|
+
console.error(`spex: canonical delivery wake failed for ${recipient}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
});
|
|
2120
|
+
});
|
|
1883
2121
|
let supervisingQueue = false;
|
|
1884
2122
|
export function superviseQueue(intervalMs = 3000) {
|
|
1885
2123
|
if (supervisingQueue)
|
|
@@ -1907,22 +2145,28 @@ export function superviseDelivery(intervalMs = 2000) {
|
|
|
1907
2145
|
supervisingDelivery = true;
|
|
1908
2146
|
const tick = async () => {
|
|
1909
2147
|
try {
|
|
2148
|
+
const application = configuredSessionApplicationIfCutover();
|
|
1910
2149
|
for (const id of listSessionIds()) {
|
|
1911
|
-
if (!
|
|
2150
|
+
if (!sessionHasPendingDelivery(id, application))
|
|
1912
2151
|
continue;
|
|
1913
2152
|
try {
|
|
1914
2153
|
await drainSession(id);
|
|
1915
2154
|
}
|
|
1916
|
-
catch {
|
|
2155
|
+
catch (error) {
|
|
2156
|
+
console.error(`spex: delivery retry failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2157
|
+
}
|
|
1917
2158
|
}
|
|
1918
2159
|
}
|
|
1919
|
-
catch {
|
|
2160
|
+
catch (error) {
|
|
2161
|
+
console.error(`spex: delivery retry sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2162
|
+
}
|
|
1920
2163
|
setTimeout(tick, intervalMs).unref();
|
|
1921
2164
|
};
|
|
1922
2165
|
void tick();
|
|
1923
2166
|
}
|
|
1924
2167
|
const turnFailureObservers = new Map();
|
|
1925
2168
|
let supervisingTurnFailures = false;
|
|
2169
|
+
let startingTurnFailureObserver = false;
|
|
1926
2170
|
const TURN_FAILURE_OBSERVER_STABLE_MS = 5000;
|
|
1927
2171
|
export function turnFailureNote(harness, failure) {
|
|
1928
2172
|
const message = failure.message.replace(/\s+/g, ' ').trim().slice(0, 500) || 'turn failed';
|
|
@@ -1953,7 +2197,10 @@ export function reconcileTurnFailureObservers() {
|
|
|
1953
2197
|
catch {
|
|
1954
2198
|
continue;
|
|
1955
2199
|
}
|
|
1956
|
-
|
|
2200
|
+
// Native turn failure observation is for an executing turn, not a durable roster census. Asking, awaiting,
|
|
2201
|
+
// and parked records have no turn to observe; subscribing them creates one expensive app-server resume per
|
|
2202
|
+
// idle record and lets stale observers accumulate after a backend restart.
|
|
2203
|
+
if (!rec?.governed || rec.stopped || rec.archived || rec.status !== 'active' || !rec.harnessSessionId)
|
|
1957
2204
|
continue;
|
|
1958
2205
|
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
1959
2206
|
if (!harness.observeTurnFailures)
|
|
@@ -1977,11 +2224,16 @@ export function reconcileTurnFailureObservers() {
|
|
|
1977
2224
|
}
|
|
1978
2225
|
continue;
|
|
1979
2226
|
}
|
|
2227
|
+
// Codex thread/resume is an expensive native subscription under load. Admit one observer at a time so a
|
|
2228
|
+
// backend restart cannot fan out N concurrent history reconciliations and exhaust CPU/RSS before any can settle.
|
|
2229
|
+
if (startingTurnFailureObserver)
|
|
2230
|
+
continue;
|
|
1980
2231
|
if (state && now < state.retryAt)
|
|
1981
2232
|
continue;
|
|
1982
2233
|
state ??= { fingerprint: target.fingerprint, subscription: null, startedAt: 0, failures: 0, retryAt: 0, lastReason: null };
|
|
1983
2234
|
state.startedAt = now;
|
|
1984
2235
|
turnFailureObservers.set(id, state);
|
|
2236
|
+
startingTurnFailureObserver = true;
|
|
1985
2237
|
try {
|
|
1986
2238
|
const subscription = target.harness.observeTurnFailures({
|
|
1987
2239
|
session: id,
|
|
@@ -2000,7 +2252,12 @@ export function reconcileTurnFailureObservers() {
|
|
|
2000
2252
|
}
|
|
2001
2253
|
});
|
|
2002
2254
|
state.subscription = subscription;
|
|
2255
|
+
if (subscription.ready)
|
|
2256
|
+
void subscription.ready.then(() => { startingTurnFailureObserver = false; }, () => { startingTurnFailureObserver = false; });
|
|
2257
|
+
else
|
|
2258
|
+
startingTurnFailureObserver = false;
|
|
2003
2259
|
void subscription.closed.then((reason) => {
|
|
2260
|
+
startingTurnFailureObserver = false;
|
|
2004
2261
|
if (turnFailureObservers.get(id) !== state)
|
|
2005
2262
|
return;
|
|
2006
2263
|
if (reason)
|
|
@@ -2010,6 +2267,7 @@ export function reconcileTurnFailureObservers() {
|
|
|
2010
2267
|
});
|
|
2011
2268
|
}
|
|
2012
2269
|
catch (error) {
|
|
2270
|
+
startingTurnFailureObserver = false;
|
|
2013
2271
|
deferTurnFailureObserver(id, target.harness.id, state, error instanceof Error ? error.message : String(error));
|
|
2014
2272
|
}
|
|
2015
2273
|
}
|
|
@@ -2150,6 +2408,13 @@ export async function sessionCreateRequest(body, options = {}) {
|
|
|
2150
2408
|
if (input.base !== undefined && typeof input.base !== 'string')
|
|
2151
2409
|
return { status: 400, error: 'session-create base must be a string' };
|
|
2152
2410
|
const base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null;
|
|
2411
|
+
const cutoverState = sessionApplicationCutoverState();
|
|
2412
|
+
if (cutoverState === 'fenced')
|
|
2413
|
+
return { status: 409, error: 'legacy JSON session store is fenced for one-time migration', code: 'session_create_failed', phase: 'request' };
|
|
2414
|
+
if (cutoverState === 'migration-required')
|
|
2415
|
+
return { status: 409, error: 'legacy JSON session store must be migrated before creating sessions', code: 'session_create_failed', phase: 'request' };
|
|
2416
|
+
if (cutoverState === 'ambiguous')
|
|
2417
|
+
return { status: 409, error: 'session database exists without a migration marker', code: 'session_create_failed', phase: 'request' };
|
|
2153
2418
|
let key;
|
|
2154
2419
|
try {
|
|
2155
2420
|
key = normalizeCreateKey(options.requestKey);
|
|
@@ -2164,6 +2429,23 @@ export async function sessionCreateRequest(body, options = {}) {
|
|
|
2164
2429
|
// immutable creation input because it publishes the record's existing display override. `base` joins them
|
|
2165
2430
|
// for the same reason and with the same shape: absent, it must not perturb an existing receipt's bytes.
|
|
2166
2431
|
const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null, ...(name ? { name } : {}), ...(base ? { base } : {}) }));
|
|
2432
|
+
let freshStoreOwned = false;
|
|
2433
|
+
let freshStoreCommitted = false;
|
|
2434
|
+
try {
|
|
2435
|
+
const acquired = acquireFreshSessionApplicationForCreate();
|
|
2436
|
+
freshStoreOwned = acquired.owned;
|
|
2437
|
+
}
|
|
2438
|
+
catch (error) {
|
|
2439
|
+
return { status: 409, error: error instanceof Error ? error.message : String(error), code: 'session_create_failed', phase: 'request' };
|
|
2440
|
+
}
|
|
2441
|
+
try {
|
|
2442
|
+
assertLegacyJsonWritesAllowed();
|
|
2443
|
+
}
|
|
2444
|
+
catch (error) {
|
|
2445
|
+
releaseFreshSessionApplicationForCreate(freshStoreOwned, false);
|
|
2446
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2447
|
+
return { status: 409, error: message, code: 'session_create_failed', phase: 'request' };
|
|
2448
|
+
}
|
|
2167
2449
|
const controller = new AbortController();
|
|
2168
2450
|
const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408));
|
|
2169
2451
|
if (options.signal?.aborted)
|
|
@@ -2176,6 +2458,8 @@ export async function sessionCreateRequest(body, options = {}) {
|
|
|
2176
2458
|
try {
|
|
2177
2459
|
try {
|
|
2178
2460
|
const session = await prepareSession(prompt, parent, launcher, name, { id, requestDigest, payloadHash, base, signal: controller.signal });
|
|
2461
|
+
await options.onPublished?.(session);
|
|
2462
|
+
freshStoreCommitted = true;
|
|
2179
2463
|
traceSessionCreate(id, requestDigest, 'request', 'finish');
|
|
2180
2464
|
return { status: 201, session };
|
|
2181
2465
|
}
|
|
@@ -2189,6 +2473,7 @@ export async function sessionCreateRequest(body, options = {}) {
|
|
|
2189
2473
|
}
|
|
2190
2474
|
}
|
|
2191
2475
|
finally {
|
|
2476
|
+
releaseFreshSessionApplicationForCreate(freshStoreOwned, freshStoreCommitted);
|
|
2192
2477
|
clearTimeout(timer);
|
|
2193
2478
|
options.signal?.removeEventListener('abort', cancel);
|
|
2194
2479
|
}
|
|
@@ -2244,7 +2529,7 @@ export async function createSession(prompt, launcher, name, base) {
|
|
|
2244
2529
|
const refused = await probeSessionCreateAuthority(target);
|
|
2245
2530
|
if (refused) {
|
|
2246
2531
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)');
|
|
2247
|
-
const fallback = await sessionCreateRequest(body, { requestKey });
|
|
2532
|
+
const fallback = await sessionCreateRequest(body, { requestKey, onPublished: projectCreatedSession });
|
|
2248
2533
|
if (fallback.status === 201)
|
|
2249
2534
|
return fallback.session;
|
|
2250
2535
|
const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`);
|
|
@@ -2284,6 +2569,27 @@ export async function createSession(prompt, launcher, name, base) {
|
|
|
2284
2569
|
}
|
|
2285
2570
|
return await res.json();
|
|
2286
2571
|
}
|
|
2572
|
+
export function projectCreatedSession(session) {
|
|
2573
|
+
const application = initializeFreshSessionApplication();
|
|
2574
|
+
try {
|
|
2575
|
+
application.createSession({
|
|
2576
|
+
sessionId: session.id,
|
|
2577
|
+
status: session.lifecycle,
|
|
2578
|
+
parentSessionId: session.parent,
|
|
2579
|
+
proposal: session.proposal,
|
|
2580
|
+
note: session.note,
|
|
2581
|
+
});
|
|
2582
|
+
if (session.parent)
|
|
2583
|
+
application.attachWatcher(session.parent, session.id, 'watch:parent');
|
|
2584
|
+
}
|
|
2585
|
+
catch (error) {
|
|
2586
|
+
const state = application.readState(session.id);
|
|
2587
|
+
const sameProjection = state?.status === session.lifecycle
|
|
2588
|
+
&& state.parentSessionId === (session.parent ?? null);
|
|
2589
|
+
if (!sameProjection)
|
|
2590
|
+
throw error;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2287
2593
|
export function spawnerClause(p) {
|
|
2288
2594
|
if (!p?.worktreePath)
|
|
2289
2595
|
return '';
|
|
@@ -2545,7 +2851,7 @@ function existingCreateReceipt(rec) {
|
|
|
2545
2851
|
const h = harnessById(rec.harness || defaultHarness.id);
|
|
2546
2852
|
if (rec.status === 'queued')
|
|
2547
2853
|
return toSession(rec, 'queued', 'offline');
|
|
2548
|
-
const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ?
|
|
2854
|
+
const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? displayStatusForProposal(rec.proposal) : rec.status;
|
|
2549
2855
|
return toSession(rec, status, rec.stopped ? 'offline' : h.headless ? 'online' : 'starting');
|
|
2550
2856
|
}
|
|
2551
2857
|
async function proveSessionCandidate(path, branch, signal) {
|
|
@@ -2695,13 +3001,21 @@ async function prepareSession(prompt, parent, launcher, name, context) {
|
|
|
2695
3001
|
traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state');
|
|
2696
3002
|
seedWorktreeHostState(root, path);
|
|
2697
3003
|
traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state');
|
|
3004
|
+
// The branch ref right after `worktree add` IS the fork point. Record it: it is the only thing that
|
|
3005
|
+
// later separates "this branch never authored a commit" from "its commits landed in the base", and
|
|
3006
|
+
// git ancestry alone cannot tell those apart. A read that fails leaves it null — the diff reader
|
|
3007
|
+
// recovers the same commit from the branch's creation reflog entry.
|
|
3008
|
+
const forkResolved = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'rev-parse', '--verify', `refs/heads/${branch}^{commit}`]));
|
|
3009
|
+
const forkCommit = forkResolved.ok && isGitObjectId(root, forkResolved.stdout.trim()) ? forkResolved.stdout.trim() : null;
|
|
2698
3010
|
let rec = {
|
|
2699
3011
|
session: id, governed: true, worktreePath: path, branch,
|
|
2700
3012
|
node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
|
|
2701
3013
|
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
2702
|
-
harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
|
|
3014
|
+
harness: h.id, harnessSessionId: null, runtimeStartToken: randomUUID(), stopped: false, archived: false, closedAt: null, coldProof: null, adapterRecovery: null, launcher: chosen.name,
|
|
2703
3015
|
launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
|
|
3016
|
+
diffComments: [],
|
|
2704
3017
|
...(base ? { base } : {}),
|
|
3018
|
+
...(forkCommit ? { forkCommit } : {}),
|
|
2705
3019
|
};
|
|
2706
3020
|
owned.store = true;
|
|
2707
3021
|
const dir = storeDir(id);
|
|
@@ -2720,13 +3034,8 @@ async function prepareSession(prompt, parent, launcher, name, context) {
|
|
|
2720
3034
|
if (gitMismatch)
|
|
2721
3035
|
throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500);
|
|
2722
3036
|
throwIfCreateAborted(signal, phase);
|
|
3037
|
+
publishCanonicalLifecycle(rec, rec.status, rec.proposal, rec.note);
|
|
2723
3038
|
writeRecord(rec);
|
|
2724
|
-
if (rec.parent && readRecord(rec.parent)?.governed) {
|
|
2725
|
-
const watchers = readWatchEntries(id);
|
|
2726
|
-
const next = addWatchSource(watchers, rec.parent, 'parent', true);
|
|
2727
|
-
if (next.added)
|
|
2728
|
-
writeWatchEntries(id, next.entries);
|
|
2729
|
-
}
|
|
2730
3039
|
published = true;
|
|
2731
3040
|
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root);
|
|
2732
3041
|
if (receiptFailure)
|
|
@@ -2819,12 +3128,24 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
|
|
|
2819
3128
|
const deadline = Date.now() + timeoutMs;
|
|
2820
3129
|
if (harness.launchPayloadProof && !current()?.harnessSessionId) {
|
|
2821
3130
|
for (;;) {
|
|
2822
|
-
if (
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
3131
|
+
if (hasReadableLaunchReceipt(id)) {
|
|
3132
|
+
// The probe above ran outside the fence, so another consumer (the drain, a resume recovery) may have
|
|
3133
|
+
// taken the receipt first. Under the fence a receipt that is already gone with the identity bound is
|
|
3134
|
+
// that consumer's success, not a missing receipt; gone and unbound means keep waiting.
|
|
3135
|
+
const consume = () => {
|
|
3136
|
+
readinessWakeSuppressed.add(id);
|
|
3137
|
+
try {
|
|
3138
|
+
if (!hasReadableLaunchReceipt(id))
|
|
3139
|
+
return !!readRecord(id)?.harnessSessionId;
|
|
3140
|
+
consumeHarnessLaunchProofUnlocked(id);
|
|
3141
|
+
return true;
|
|
3142
|
+
}
|
|
3143
|
+
finally {
|
|
3144
|
+
readinessWakeSuppressed.delete(id);
|
|
3145
|
+
}
|
|
3146
|
+
};
|
|
3147
|
+
if (recordLockHeld ? consume() : await withRecordLock(id, async () => consume()))
|
|
3148
|
+
break;
|
|
2828
3149
|
}
|
|
2829
3150
|
if (Date.now() >= deadline)
|
|
2830
3151
|
return null;
|
|
@@ -2851,7 +3172,57 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
|
|
|
2851
3172
|
await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
|
|
2852
3173
|
}
|
|
2853
3174
|
}
|
|
2854
|
-
|
|
3175
|
+
// An explicit successful resume is a new runtime attempt. A prior terminal launch/turn error must not
|
|
3176
|
+
// survive that handoff as current lifecycle truth; waiting declarations remain waiting declarations.
|
|
3177
|
+
const restingLifecycle = (status) => status === 'active' || status === 'queued' || status === 'error' ? 'idle' : status;
|
|
3178
|
+
const resumeNote = (status, note) => status === 'error' ? null : note;
|
|
3179
|
+
const archiveRef = (id) => `refs/spex-archive/${id}`;
|
|
3180
|
+
function archiveWorktreeState(id, path) {
|
|
3181
|
+
const root = mainRoot();
|
|
3182
|
+
try {
|
|
3183
|
+
const parent = git(['-C', path, 'rev-parse', 'HEAD']).trim();
|
|
3184
|
+
git(['-C', path, 'add', '-A']);
|
|
3185
|
+
const tree = git(['-C', path, 'write-tree']).trim();
|
|
3186
|
+
const commit = git(['-C', path, '-c', 'user.name=SpexCode', '-c', 'user.email=spexcode@localhost', 'commit-tree', tree, '-p', parent, '-m', `spex close archive ${id}`]).trim();
|
|
3187
|
+
if (!/^[0-9a-f]{40,64}$/.test(commit))
|
|
3188
|
+
throw new Error('archive commit was malformed');
|
|
3189
|
+
git(['-C', root, 'update-ref', archiveRef(id), commit]);
|
|
3190
|
+
const stored = git(['-C', root, 'rev-parse', '--verify', `${archiveRef(id)}^{commit}`]).trim();
|
|
3191
|
+
if (stored !== commit)
|
|
3192
|
+
throw new Error('archive ref publication was not verified');
|
|
3193
|
+
return commit;
|
|
3194
|
+
}
|
|
3195
|
+
catch (error) {
|
|
3196
|
+
const detail = error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
3197
|
+
throw new ResourceConflict(`refusing to close ${id}: could not publish ${archiveRef(id)}${detail ? ` - ${detail}` : ''}`);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
async function restoreArchivedWorktree(id, rec) {
|
|
3201
|
+
if (existsSync(rec.worktreePath))
|
|
3202
|
+
return;
|
|
3203
|
+
if (!rec.branch)
|
|
3204
|
+
throw new ResourceConflict(`session ${id} has no branch to restore its archived worktree`);
|
|
3205
|
+
const ref = archiveRef(id);
|
|
3206
|
+
const archive = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${ref}^{commit}`]);
|
|
3207
|
+
const start = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `refs/heads/${rec.branch}^{commit}`]);
|
|
3208
|
+
if (!start.ok)
|
|
3209
|
+
throw new ResourceConflict(`session ${id} branch ${rec.branch} is missing`);
|
|
3210
|
+
await gitTry(['-C', mainRoot(), 'worktree', 'add', rec.worktreePath, rec.branch]).then((result) => {
|
|
3211
|
+
if (!result.ok)
|
|
3212
|
+
throw new ResourceConflict(`git worktree add failed: ${result.stderr.trim() || result.failure}`);
|
|
3213
|
+
});
|
|
3214
|
+
if (!archive.ok)
|
|
3215
|
+
return;
|
|
3216
|
+
const patch = git(['-C', mainRoot(), 'diff', '--binary', `${rec.branch}..${ref}`]);
|
|
3217
|
+
if (!patch)
|
|
3218
|
+
return;
|
|
3219
|
+
try {
|
|
3220
|
+
execFileSync('git', ['-C', rec.worktreePath, 'apply', '--binary', '-'], { input: patch, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
3221
|
+
}
|
|
3222
|
+
catch (error) {
|
|
3223
|
+
throw new ResourceConflict(`session ${id} archived changes could not be restored: ${error instanceof Error ? error.message : String(error)}`);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
2855
3226
|
async function resumeSessionUnlocked(id, opts = {}) {
|
|
2856
3227
|
const { force = false, guard = true } = opts;
|
|
2857
3228
|
let wt;
|
|
@@ -2865,6 +3236,17 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2865
3236
|
}
|
|
2866
3237
|
if (!wt)
|
|
2867
3238
|
return { ok: false, error: `no such session ${id}` };
|
|
3239
|
+
if (wt.rec.archived && retirementReason(wt.rec)) {
|
|
3240
|
+
try {
|
|
3241
|
+
await restoreArchivedWorktree(id, wt.rec);
|
|
3242
|
+
wt = await findWorktree(id);
|
|
3243
|
+
if (!wt)
|
|
3244
|
+
return { ok: false, error: `session ${id} disappeared while restoring its archived worktree` };
|
|
3245
|
+
}
|
|
3246
|
+
catch (error) {
|
|
3247
|
+
return { ok: false, refused: true, error: `session ${id}: archived worktree restore failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
2868
3250
|
// A process that died while validating left an internal candidate behind. This record lock proves no live
|
|
2869
3251
|
// resume still owns it. Restore the frozen public original before doing any transport work and require an
|
|
2870
3252
|
// explicit retry; stale runtime evidence is never adopted into a fresh launch attempt.
|
|
@@ -2891,19 +3273,19 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2891
3273
|
// A prior adapter process may have proven identity + first-turn durability just before its session owner
|
|
2892
3274
|
// died. Consume that receipt before choosing a recovery tail, so retry resumes the proven thread instead of
|
|
2893
3275
|
// creating another one with the same first prompt.
|
|
2894
|
-
if (h.launchPayloadProof &&
|
|
3276
|
+
if (h.launchPayloadProof && hasReadableLaunchReceipt(id)) {
|
|
2895
3277
|
consumeHarnessLaunchProofUnlocked(id);
|
|
2896
3278
|
wt = await findWorktree(id);
|
|
2897
3279
|
if (!wt)
|
|
2898
|
-
return { ok: false, error: `session ${id} disappeared while recovering native launch
|
|
3280
|
+
return { ok: false, error: `session ${id} disappeared while recovering native launch receipt` };
|
|
2899
3281
|
}
|
|
2900
3282
|
// An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
|
|
2901
3283
|
// it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
|
|
2902
3284
|
// resume can never kill a live leaf merely because the record was hidden.
|
|
2903
3285
|
const probeRec = wt.rec.archived ? { ...wt.rec, archived: false, stopped: false } : wt.rec;
|
|
2904
|
-
const resumeSnap = await liveSnapshot();
|
|
3286
|
+
const resumeSnap = h.runtimeOwnership === 'adapter' ? null : await liveSnapshot();
|
|
2905
3287
|
const lv = h.runtimeOwnership === 'adapter'
|
|
2906
|
-
? (
|
|
3288
|
+
? await adapterRuntimeLiveness(probeRec)
|
|
2907
3289
|
: liveness(probeRec, resumeSnap); // FRESH, honest liveness (listener-verified)
|
|
2908
3290
|
if (guard && !force && lv === 'online')
|
|
2909
3291
|
return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` };
|
|
@@ -2914,7 +3296,7 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2914
3296
|
const recovery = await h.restoreRuntime?.(wt.rec);
|
|
2915
3297
|
if (recovery && !recovery.ok)
|
|
2916
3298
|
return { ok: false, refused: true, error: `session ${id}: recovery required before resume — ${recovery.reason}` };
|
|
2917
|
-
writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, stopped: true });
|
|
3299
|
+
writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, closedAt: null, stopped: true });
|
|
2918
3300
|
wt = await findWorktree(id);
|
|
2919
3301
|
if (!wt)
|
|
2920
3302
|
return { ok: false, error: `session ${id} disappeared during adapter recovery` };
|
|
@@ -2923,8 +3305,8 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2923
3305
|
// Make the durable row visible/offline before any adapter unarchive or launch RPC. Any later failure leaves
|
|
2924
3306
|
// a retryable unarchived record rather than archived:true with a newly loaded target thread.
|
|
2925
3307
|
const pendingRecovery = wt.rec.adapterRecovery || 'restore-runtime-pending';
|
|
2926
|
-
writeRecord({ ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery });
|
|
2927
|
-
const visible = readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery };
|
|
3308
|
+
writeRecord({ ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery });
|
|
3309
|
+
const visible = readRecord(id) || { ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery };
|
|
2928
3310
|
const restored = await h.restoreRuntime?.(visible);
|
|
2929
3311
|
if (restored && !restored.ok)
|
|
2930
3312
|
return { ok: false, refused: true, error: `session ${id}: ${restored.reason}` };
|
|
@@ -2936,8 +3318,16 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2936
3318
|
// fall through to a metadata-only no-op.
|
|
2937
3319
|
// Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
|
|
2938
3320
|
// starting -> online launch path recreates the same conversation.
|
|
2939
|
-
const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec;
|
|
2940
|
-
const resumed = {
|
|
3321
|
+
const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: null }) : wt.rec;
|
|
3322
|
+
const resumed = {
|
|
3323
|
+
...current,
|
|
3324
|
+
archived: false,
|
|
3325
|
+
closedAt: null,
|
|
3326
|
+
coldProof: null,
|
|
3327
|
+
status: restingLifecycle(current.status),
|
|
3328
|
+
note: resumeNote(current.status, current.note),
|
|
3329
|
+
stopped: false,
|
|
3330
|
+
};
|
|
2941
3331
|
if (force || lv === 'offline') {
|
|
2942
3332
|
let resumeTail;
|
|
2943
3333
|
try {
|
|
@@ -2969,8 +3359,10 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2969
3359
|
const candidate = {
|
|
2970
3360
|
...latest,
|
|
2971
3361
|
archived: false,
|
|
3362
|
+
closedAt: null,
|
|
2972
3363
|
coldProof: null,
|
|
2973
3364
|
status: restingLifecycle(latest.status),
|
|
3365
|
+
note: resumeNote(latest.status, latest.note),
|
|
2974
3366
|
stopped: false,
|
|
2975
3367
|
launchReadinessPending: launchReadinessPending(preResume),
|
|
2976
3368
|
};
|
|
@@ -2994,11 +3386,19 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2994
3386
|
error: `session ${id}: launch readiness changed across the pending publication${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
|
|
2995
3387
|
};
|
|
2996
3388
|
}
|
|
2997
|
-
|
|
3389
|
+
// `readRecord` projects the still-public pre-resume lifecycle while the candidate fence is pending.
|
|
3390
|
+
// Carrying that stale projection into the final publish used to leave queued/error rows unchanged in
|
|
3391
|
+
// SQLite even though the runtime envelope had crossed readiness. Publish the candidate lifecycle while
|
|
3392
|
+
// retaining the latest non-lifecycle envelope fields.
|
|
3393
|
+
const latestPublished = readRecord(id) || candidate;
|
|
3394
|
+
const published = { ...latestPublished, status: candidate.status, proposal: candidate.proposal, note: candidate.note };
|
|
3395
|
+
publishCanonicalLifecycle(published, candidate.status, candidate.proposal, candidate.note);
|
|
2998
3396
|
writeRecord({ ...published, launchReadinessPending: null });
|
|
2999
3397
|
}
|
|
3000
|
-
else
|
|
3398
|
+
else {
|
|
3399
|
+
publishCanonicalLifecycle(current, resumed.status, resumed.proposal, resumed.note);
|
|
3001
3400
|
writeRecord(resumed);
|
|
3401
|
+
}
|
|
3002
3402
|
return { ok: true };
|
|
3003
3403
|
}
|
|
3004
3404
|
export const resumeSession = (id, opts = {}) => withSessionTransition(id, async () => {
|
|
@@ -3012,9 +3412,28 @@ export function markState(status, opts = {}) {
|
|
|
3012
3412
|
if (!id)
|
|
3013
3413
|
return false;
|
|
3014
3414
|
return withRecordLockSync(id, () => {
|
|
3415
|
+
const raw = readRecord(id);
|
|
3416
|
+
if (raw?.archived)
|
|
3417
|
+
throw new ResourceConflict(`refusing lifecycle change for closed session ${id}: it is read-only; resume it before changing state`);
|
|
3015
3418
|
const rec = readLiveRecord(id);
|
|
3016
|
-
if (!rec)
|
|
3419
|
+
if (!rec?.governed)
|
|
3017
3420
|
return false;
|
|
3421
|
+
const application = configuredSessionApplicationIfCutover();
|
|
3422
|
+
if (application) {
|
|
3423
|
+
const proposal = status === 'awaiting' ? (opts.proposal ?? 'nothing') : null;
|
|
3424
|
+
const note = opts.note ?? null;
|
|
3425
|
+
const current = application.readState(id);
|
|
3426
|
+
if (current && current.status === status && current.proposal === proposal && current.note === note)
|
|
3427
|
+
return true;
|
|
3428
|
+
const recipients = canonicalWatchRecipients(application, id, status);
|
|
3429
|
+
application.transitionSession(id, {
|
|
3430
|
+
status,
|
|
3431
|
+
proposal,
|
|
3432
|
+
note,
|
|
3433
|
+
recipientSessionIds: recipients,
|
|
3434
|
+
});
|
|
3435
|
+
return true;
|
|
3436
|
+
}
|
|
3018
3437
|
const proposal = status === 'awaiting' ? (opts.proposal ?? 'nothing') : null;
|
|
3019
3438
|
writeRecord({
|
|
3020
3439
|
...rec, status,
|
|
@@ -3024,22 +3443,95 @@ export function markState(status, opts = {}) {
|
|
|
3024
3443
|
return true;
|
|
3025
3444
|
});
|
|
3026
3445
|
}
|
|
3446
|
+
// A human prompt is the explicit re-entry from a waiting turn; runtime liveness is not.
|
|
3447
|
+
export function markHumanPromptActive(sessionId) {
|
|
3448
|
+
try {
|
|
3449
|
+
const rec = readRecord(sessionId);
|
|
3450
|
+
const canonical = sessionHookState(sessionId);
|
|
3451
|
+
// The canonical lifecycle decides whether this record is writable. Any real human re-entry can
|
|
3452
|
+
// resume a waiting declaration, including an `awaiting` close/merge proposal; the old envelope
|
|
3453
|
+
// status is only migration metadata and must never veto the re-entry.
|
|
3454
|
+
if (!rec || !canonical || rec.archived || retirementReason(rec))
|
|
3455
|
+
return false;
|
|
3456
|
+
return markState('active', { sessionId });
|
|
3457
|
+
}
|
|
3458
|
+
catch (error) {
|
|
3459
|
+
// The message/PTY write is already accepted; a raced close or unreadable record must not turn it into a false send failure.
|
|
3460
|
+
console.error(`spex: could not publish human-input activity for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3461
|
+
return false;
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3027
3464
|
export const markDone = (proposal = 'nothing', sessionId, note) => markState('awaiting', { proposal, note, sessionId });
|
|
3028
|
-
export const markError = (sessionId) => markState('error', { sessionId });
|
|
3029
3465
|
export function markTurnFailure(sessionId, note) {
|
|
3030
3466
|
if (!sessionId)
|
|
3031
3467
|
return false;
|
|
3032
3468
|
return withRecordLockSync(sessionId, () => {
|
|
3033
3469
|
const rec = readLiveRecord(sessionId);
|
|
3034
|
-
if (!rec || rec.status !== 'active' || rec.stopped || rec.archived)
|
|
3470
|
+
if (!rec?.governed || rec.status !== 'active' || rec.stopped || rec.archived)
|
|
3035
3471
|
return false;
|
|
3472
|
+
const application = configuredSessionApplicationIfCutover();
|
|
3473
|
+
if (application) {
|
|
3474
|
+
application.transitionSession(sessionId, {
|
|
3475
|
+
status: 'error', proposal: null, note,
|
|
3476
|
+
recipientSessionIds: canonicalWatchRecipients(application, sessionId, 'error'),
|
|
3477
|
+
});
|
|
3478
|
+
return true;
|
|
3479
|
+
}
|
|
3036
3480
|
writeRecord({ ...rec, status: 'error', proposal: null, note });
|
|
3037
3481
|
return true;
|
|
3038
3482
|
});
|
|
3039
3483
|
}
|
|
3484
|
+
// @@@ interrupt projection - a CONFIRMED human interrupt ended the turn, and the record says so the way an
|
|
3485
|
+
// undeclared stop does: `asking`, with the reason, because the agent now waits for the human's next message.
|
|
3486
|
+
// Active-only like every other turn-outcome writer: a declaration that landed first (the agent answered
|
|
3487
|
+
// before the abort reached it) stays authoritative. The marker is stamped before the abort is sent so the
|
|
3488
|
+
// adapter's own exit report — a one-turn process leaves with a non-zero code when aborted — reads the same
|
|
3489
|
+
// outcome instead of filing a failed turn; it expires so a genuine failure later is never mistaken for it.
|
|
3490
|
+
export const INTERRUPTED_NOTE = 'interrupted: the human stopped this turn; the next message continues the conversation';
|
|
3491
|
+
const INTERRUPT_MARKER_TTL_MS = 15_000;
|
|
3492
|
+
const interruptMarkerPath = (id) => sessionArtifactPath(id, 'turn.interrupted');
|
|
3493
|
+
export function stampInterrupt(id) {
|
|
3494
|
+
mkdirSync(storeDir(id), { recursive: true });
|
|
3495
|
+
writeFileSync(interruptMarkerPath(id), String(Date.now()));
|
|
3496
|
+
}
|
|
3497
|
+
export function clearInterruptMarker(id) {
|
|
3498
|
+
try {
|
|
3499
|
+
unlinkSync(interruptMarkerPath(id));
|
|
3500
|
+
}
|
|
3501
|
+
catch { /* never stamped, or already consumed */ }
|
|
3502
|
+
}
|
|
3503
|
+
function consumeInterruptMarker(id) {
|
|
3504
|
+
let at = NaN;
|
|
3505
|
+
try {
|
|
3506
|
+
at = Number(readFileSync(interruptMarkerPath(id), 'utf8'));
|
|
3507
|
+
}
|
|
3508
|
+
catch {
|
|
3509
|
+
return false;
|
|
3510
|
+
}
|
|
3511
|
+
clearInterruptMarker(id);
|
|
3512
|
+
return Number.isFinite(at) && Date.now() - at <= INTERRUPT_MARKER_TTL_MS;
|
|
3513
|
+
}
|
|
3514
|
+
function projectInterruptedUnlocked(sessionId) {
|
|
3515
|
+
const rec = readLiveRecord(sessionId);
|
|
3516
|
+
if (!rec?.governed || rec.status !== 'active' || rec.stopped || rec.archived)
|
|
3517
|
+
return false;
|
|
3518
|
+
const application = configuredSessionApplicationIfCutover();
|
|
3519
|
+
if (application) {
|
|
3520
|
+
application.transitionSession(sessionId, {
|
|
3521
|
+
status: 'asking', proposal: null, note: INTERRUPTED_NOTE,
|
|
3522
|
+
recipientSessionIds: canonicalWatchRecipients(application, sessionId, 'asking'),
|
|
3523
|
+
});
|
|
3524
|
+
return true;
|
|
3525
|
+
}
|
|
3526
|
+
writeRecord({ ...rec, status: 'asking', proposal: null, note: INTERRUPTED_NOTE });
|
|
3527
|
+
return true;
|
|
3528
|
+
}
|
|
3529
|
+
export const markInterrupted = (sessionId) => withRecordLockSync(sessionId, () => projectInterruptedUnlocked(sessionId));
|
|
3040
3530
|
export function markHeadlessTurnFailure(sessionId, harness, exitCode) {
|
|
3041
3531
|
if (exitCode === '0')
|
|
3042
3532
|
return false;
|
|
3533
|
+
if (consumeInterruptMarker(sessionId))
|
|
3534
|
+
return markInterrupted(sessionId);
|
|
3043
3535
|
const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`;
|
|
3044
3536
|
return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`);
|
|
3045
3537
|
}
|
|
@@ -3063,8 +3555,22 @@ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = proc
|
|
|
3063
3555
|
registrationPrepared = true;
|
|
3064
3556
|
}
|
|
3065
3557
|
}
|
|
3558
|
+
const application = configuredSessionApplicationIfCutover();
|
|
3559
|
+
const nativeStartToken = rec.runtimeStartToken || process.env.SPEXCODE_NATIVE_START_TOKEN?.trim();
|
|
3560
|
+
if (application && !nativeStartToken)
|
|
3561
|
+
throw new ResourceConflict(`refusing to bind runtime for ${id}: native start token is missing`);
|
|
3066
3562
|
try {
|
|
3067
3563
|
writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null });
|
|
3564
|
+
if (application) {
|
|
3565
|
+
if (!nativeStartToken)
|
|
3566
|
+
throw new ResourceConflict(`refusing to bind runtime for ${id}: native start token is missing`);
|
|
3567
|
+
application.bindRuntime(id, {
|
|
3568
|
+
namespace: 'spex-governed',
|
|
3569
|
+
runtimeKind: rec.harness || defaultHarness.id,
|
|
3570
|
+
nativeSessionId: harnessSessionId,
|
|
3571
|
+
nativeStartToken,
|
|
3572
|
+
});
|
|
3573
|
+
}
|
|
3068
3574
|
}
|
|
3069
3575
|
catch (error) {
|
|
3070
3576
|
if (codex && generationId && registrationPrepared) {
|
|
@@ -3080,9 +3586,26 @@ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = proc
|
|
|
3080
3586
|
if (codex && generationId)
|
|
3081
3587
|
commitCodexGenerationRegistration(root, id, harnessSessionId, generationId);
|
|
3082
3588
|
}
|
|
3083
|
-
|
|
3589
|
+
const NATIVE_LAUNCH_RECEIPT_FILE = 'launch.receipt';
|
|
3590
|
+
const LEGACY_NATIVE_LAUNCH_RECEIPT_FILE = 'launch.proof'; // dead-words-ok: one-release reader preserves staged receipts created before the protocol rename
|
|
3591
|
+
function launchReceiptPath(id) {
|
|
3592
|
+
return sessionArtifactPath(id, NATIVE_LAUNCH_RECEIPT_FILE);
|
|
3593
|
+
}
|
|
3594
|
+
function readableLaunchReceiptPath(id) {
|
|
3595
|
+
const current = launchReceiptPath(id);
|
|
3596
|
+
if (existsSync(current))
|
|
3597
|
+
return current;
|
|
3598
|
+
const legacy = sessionArtifactPath(id, LEGACY_NATIVE_LAUNCH_RECEIPT_FILE);
|
|
3599
|
+
return existsSync(legacy) ? legacy : null;
|
|
3600
|
+
}
|
|
3601
|
+
function hasReadableLaunchReceipt(id) {
|
|
3602
|
+
return readableLaunchReceiptPath(id) !== null;
|
|
3603
|
+
}
|
|
3604
|
+
function readHarnessLaunchProof(id, path = readableLaunchReceiptPath(id)) {
|
|
3605
|
+
if (!path)
|
|
3606
|
+
throw new ResourceConflict(`native launch receipt for ${id} is missing`);
|
|
3084
3607
|
try {
|
|
3085
|
-
const proof = JSON.parse(readFileSync(
|
|
3608
|
+
const proof = JSON.parse(readFileSync(path, 'utf8'));
|
|
3086
3609
|
if (!proof || proof.version !== 1 || typeof proof.sessionId !== 'string'
|
|
3087
3610
|
|| typeof proof.harnessId !== 'string' || typeof proof.harnessSessionId !== 'string' || !proof.harnessSessionId
|
|
3088
3611
|
|| typeof proof.launchPayloadHash !== 'string'
|
|
@@ -3091,7 +3614,7 @@ function readHarnessLaunchProof(id) {
|
|
|
3091
3614
|
return proof;
|
|
3092
3615
|
}
|
|
3093
3616
|
catch (error) {
|
|
3094
|
-
throw new ResourceConflict(`native launch
|
|
3617
|
+
throw new ResourceConflict(`native launch receipt for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
3095
3618
|
}
|
|
3096
3619
|
}
|
|
3097
3620
|
function sameHarnessLaunchProof(left, right) {
|
|
@@ -3099,6 +3622,32 @@ function sameHarnessLaunchProof(left, right) {
|
|
|
3099
3622
|
&& left.harnessSessionId === right.harnessSessionId && left.launchPayloadHash === right.launchPayloadHash
|
|
3100
3623
|
&& left.generationId === right.generationId;
|
|
3101
3624
|
}
|
|
3625
|
+
// A native launch can be proven just before its visible TUI exits. A shell-level retry must be able to ask
|
|
3626
|
+
// for that exact target without replaying the first prompt. This read-only resolver accepts either side of the
|
|
3627
|
+
// proof-consumption boundary: the durable record after identity binding, or the staged receipt while the record
|
|
3628
|
+
// lock has not consumed it yet. Any mismatch remains a loud resource conflict; returning null means no proof has
|
|
3629
|
+
// been established and a fresh first-turn attempt is still allowed.
|
|
3630
|
+
export function existingHarnessLaunchTarget(id) {
|
|
3631
|
+
const rec = readLiveRecord(id);
|
|
3632
|
+
if (!rec)
|
|
3633
|
+
return null;
|
|
3634
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3635
|
+
if (!harness.launchPayloadProof)
|
|
3636
|
+
return null;
|
|
3637
|
+
const receiptPath = readableLaunchReceiptPath(id);
|
|
3638
|
+
if (receiptPath) {
|
|
3639
|
+
const proof = readHarnessLaunchProof(id, receiptPath);
|
|
3640
|
+
if (proof.sessionId !== id || proof.harnessId !== harness.id)
|
|
3641
|
+
throw new ResourceConflict(`native launch receipt for ${id} does not match the governed adapter identity`);
|
|
3642
|
+
const pending = readLaunchFile(id);
|
|
3643
|
+
if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
|
|
3644
|
+
throw new ResourceConflict(`native launch receipt for ${id} does not match the authoritative resolved launch payload`);
|
|
3645
|
+
if (rec.harnessSessionId && rec.harnessSessionId !== proof.harnessSessionId)
|
|
3646
|
+
throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; staged launch receipt differs from the record`);
|
|
3647
|
+
return proof.harnessSessionId;
|
|
3648
|
+
}
|
|
3649
|
+
return rec.harnessSessionId || null;
|
|
3650
|
+
}
|
|
3102
3651
|
export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPayload) {
|
|
3103
3652
|
const id = sessionId || ownSessionId();
|
|
3104
3653
|
if (!id || !harnessSessionId)
|
|
@@ -3108,17 +3657,17 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
|
|
|
3108
3657
|
return false;
|
|
3109
3658
|
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3110
3659
|
if (!harness.launchPayloadProof)
|
|
3111
|
-
throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload
|
|
3660
|
+
throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload receipts`);
|
|
3112
3661
|
const pending = readLaunchFile(id);
|
|
3113
3662
|
if (pending == null)
|
|
3114
|
-
throw new ResourceConflict(`refusing native launch
|
|
3663
|
+
throw new ResourceConflict(`refusing native launch receipt for ${id}: authoritative resolved launch payload is missing`);
|
|
3115
3664
|
if (pending !== launchPayload)
|
|
3116
|
-
throw new ResourceConflict(`refusing native launch
|
|
3665
|
+
throw new ResourceConflict(`refusing native launch receipt for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
|
|
3117
3666
|
const generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim() || null;
|
|
3118
3667
|
if (rec.harness === 'codex' || rec.harness === 'codex-headless') {
|
|
3119
3668
|
const ledger = readCodexGenerationLedger(runtimeRoot());
|
|
3120
3669
|
if (ledger.revision > 0 && !generationId)
|
|
3121
|
-
throw new ResourceConflict(`refusing native launch
|
|
3670
|
+
throw new ResourceConflict(`refusing native launch receipt for ${id}: launch did not provide an exact Codex generation id`);
|
|
3122
3671
|
if (generationId && (!ledger.generations[generationId] || ledger.generations[generationId].state === 'reclaimed'))
|
|
3123
3672
|
throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: generation ${generationId} is absent or reclaimed`);
|
|
3124
3673
|
}
|
|
@@ -3130,7 +3679,14 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
|
|
|
3130
3679
|
launchPayloadHash: createHash('sha256').update(launchPayload).digest('hex'),
|
|
3131
3680
|
generationId,
|
|
3132
3681
|
};
|
|
3133
|
-
const
|
|
3682
|
+
const existingPath = readableLaunchReceiptPath(id);
|
|
3683
|
+
if (existingPath) {
|
|
3684
|
+
const staged = readHarnessLaunchProof(id, existingPath);
|
|
3685
|
+
if (sameHarnessLaunchProof(staged, proof))
|
|
3686
|
+
return true;
|
|
3687
|
+
throw new ResourceConflict(`refusing to replace native launch receipt for ${id}: the staged session, thread, payload, or generation differs`);
|
|
3688
|
+
}
|
|
3689
|
+
const path = launchReceiptPath(id);
|
|
3134
3690
|
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
3135
3691
|
writeFileSync(temp, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
|
3136
3692
|
try {
|
|
@@ -3140,10 +3696,10 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
|
|
|
3140
3696
|
catch (error) {
|
|
3141
3697
|
if (error.code !== 'EEXIST')
|
|
3142
3698
|
throw error;
|
|
3143
|
-
const staged = readHarnessLaunchProof(id);
|
|
3699
|
+
const staged = readHarnessLaunchProof(id, path);
|
|
3144
3700
|
if (sameHarnessLaunchProof(staged, proof))
|
|
3145
3701
|
return true;
|
|
3146
|
-
throw new ResourceConflict(`refusing to replace native launch
|
|
3702
|
+
throw new ResourceConflict(`refusing to replace native launch receipt for ${id}: the staged session, thread, payload, or generation differs`);
|
|
3147
3703
|
}
|
|
3148
3704
|
finally {
|
|
3149
3705
|
rmSync(temp, { force: true });
|
|
@@ -3154,14 +3710,15 @@ function consumeHarnessLaunchProofUnlocked(id) {
|
|
|
3154
3710
|
if (!rec)
|
|
3155
3711
|
return false;
|
|
3156
3712
|
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3157
|
-
const
|
|
3713
|
+
const receiptPath = readableLaunchReceiptPath(id);
|
|
3714
|
+
const proof = readHarnessLaunchProof(id, receiptPath);
|
|
3158
3715
|
if (proof.sessionId !== id || proof.harnessId !== harness.id)
|
|
3159
|
-
throw new ResourceConflict(`native launch
|
|
3716
|
+
throw new ResourceConflict(`native launch receipt for ${id} does not match the governed adapter identity`);
|
|
3160
3717
|
const pending = readLaunchFile(id);
|
|
3161
3718
|
if (pending == null && rec.harnessSessionId !== proof.harnessSessionId)
|
|
3162
|
-
throw new ResourceConflict(`refusing native launch
|
|
3719
|
+
throw new ResourceConflict(`refusing native launch receipt for ${id}: authoritative resolved launch payload is missing`);
|
|
3163
3720
|
if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
|
|
3164
|
-
throw new ResourceConflict(`native launch
|
|
3721
|
+
throw new ResourceConflict(`native launch receipt for ${id} does not match the authoritative resolved launch payload`);
|
|
3165
3722
|
bindHarnessSessionIdUnlocked(rec, proof.harnessSessionId, proof.generationId || undefined);
|
|
3166
3723
|
if (pending != null) {
|
|
3167
3724
|
try {
|
|
@@ -3169,17 +3726,18 @@ function consumeHarnessLaunchProofUnlocked(id) {
|
|
|
3169
3726
|
}
|
|
3170
3727
|
catch (error) {
|
|
3171
3728
|
if (error.code !== 'ENOENT') {
|
|
3172
|
-
console.error(`spex: native launch
|
|
3729
|
+
console.error(`spex: native launch receipt committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3173
3730
|
return true;
|
|
3174
3731
|
}
|
|
3175
3732
|
}
|
|
3176
3733
|
}
|
|
3177
3734
|
try {
|
|
3178
|
-
|
|
3735
|
+
if (receiptPath)
|
|
3736
|
+
rmSync(receiptPath);
|
|
3179
3737
|
}
|
|
3180
3738
|
catch (error) {
|
|
3181
3739
|
if (error.code !== 'ENOENT')
|
|
3182
|
-
console.error(`spex: native launch
|
|
3740
|
+
console.error(`spex: native launch receipt committed for ${id}, but the receipt could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3183
3741
|
}
|
|
3184
3742
|
return true;
|
|
3185
3743
|
}
|
|
@@ -3193,7 +3751,7 @@ export function markHarnessSessionId(sessionId, harnessSessionId) {
|
|
|
3193
3751
|
return false;
|
|
3194
3752
|
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3195
3753
|
if (harness.launchPayloadProof)
|
|
3196
|
-
throw new ResourceConflict(`harness ${harness.id} must stage native identity together with authoritative first-turn payload
|
|
3754
|
+
throw new ResourceConflict(`harness ${harness.id} must stage native identity together with an authoritative first-turn payload receipt`);
|
|
3197
3755
|
bindHarnessSessionIdUnlocked(rec, harnessSessionId);
|
|
3198
3756
|
return true;
|
|
3199
3757
|
});
|
|
@@ -3204,9 +3762,13 @@ export function markIdle(sessionId) {
|
|
|
3204
3762
|
return false;
|
|
3205
3763
|
return withRecordLockSync(id, () => {
|
|
3206
3764
|
const rec = readLiveRecord(id);
|
|
3207
|
-
if (!rec || rec.status !== 'active')
|
|
3208
|
-
return false; // active-only: never clobber a declaration
|
|
3209
|
-
|
|
3765
|
+
if (!rec?.governed || rec.status !== 'active')
|
|
3766
|
+
return false; // managed active-only: never clobber a declaration
|
|
3767
|
+
publishCanonicalLifecycle(rec, 'idle', null, null);
|
|
3768
|
+
// After cutover the JSON file is only the runtime/worktree envelope. Do not mirror this inferred
|
|
3769
|
+
// lifecycle transition into it: doing so creates a second, stale-looking status surface for hooks.
|
|
3770
|
+
if (!configuredSessionApplicationIfCutover())
|
|
3771
|
+
writeRecord({ ...rec, status: 'idle' });
|
|
3210
3772
|
return true;
|
|
3211
3773
|
});
|
|
3212
3774
|
}
|
|
@@ -3244,6 +3806,265 @@ function porcelainPath(line) {
|
|
|
3244
3806
|
p = p.slice(arrow + 4);
|
|
3245
3807
|
return p;
|
|
3246
3808
|
}
|
|
3809
|
+
export function commitUrlForRemote(remote, commit) {
|
|
3810
|
+
const raw = remote.trim();
|
|
3811
|
+
let host = '', path = '';
|
|
3812
|
+
try {
|
|
3813
|
+
const url = new URL(raw);
|
|
3814
|
+
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ssh:') {
|
|
3815
|
+
host = url.host;
|
|
3816
|
+
path = url.pathname;
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
catch {
|
|
3820
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(raw);
|
|
3821
|
+
if (scp)
|
|
3822
|
+
[, host, path] = scp;
|
|
3823
|
+
}
|
|
3824
|
+
path = path.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
|
|
3825
|
+
if (!host || !path)
|
|
3826
|
+
return null;
|
|
3827
|
+
const commitPath = host.toLowerCase().includes('gitlab') ? '-/commit' : 'commit';
|
|
3828
|
+
return `https://${host}/${path}/${commitPath}/${commit}`;
|
|
3829
|
+
}
|
|
3830
|
+
// The branch diff is a proof over commits, not over a working directory: refs and objects are shared with
|
|
3831
|
+
// the main checkout, so a session whose worktree directory is gone (landed and cleaned, or reaped) keeps a
|
|
3832
|
+
// provable diff for as long as its branch ref survives. Anchor git at the live worktree when it exists and
|
|
3833
|
+
// at the main checkout otherwise; only a branch whose ref is gone everywhere is honestly unavailable, and
|
|
3834
|
+
// that refusal is a structured conflict (409 {error, code}) — never a raw git ENOENT turned into a 500.
|
|
3835
|
+
async function diffAnchorRoot(wt) {
|
|
3836
|
+
if (!wt.branch)
|
|
3837
|
+
throw new ResourceConflict(`session ${wt.rec.session} has no branch to diff`, 'diff-unavailable');
|
|
3838
|
+
if (wt.path && existsSync(wt.path))
|
|
3839
|
+
return wt.path;
|
|
3840
|
+
const main = mainRoot();
|
|
3841
|
+
const proven = await gitTry(['-C', main, 'rev-parse', '--verify', `refs/heads/${wt.branch}^{commit}`]);
|
|
3842
|
+
if (proven.ok)
|
|
3843
|
+
return main;
|
|
3844
|
+
throw new ResourceConflict(`session ${wt.rec.session} has no worktree on disk and its branch ${wt.branch} no longer exists`, 'diff-unavailable');
|
|
3845
|
+
}
|
|
3846
|
+
// @@@ forkCommitOf - the commit the branch was created at, from the most authoritative source that has it.
|
|
3847
|
+
// The record carries it for every session created since it was introduced. Older records recover the same
|
|
3848
|
+
// commit from the branch ref's OLDEST reflog entry, which is where git itself wrote the `worktree add` start
|
|
3849
|
+
// point. Neither available (reflog pruned, or a branch adopted from outside this flow) → null, and the caller
|
|
3850
|
+
// falls back to what ancestry alone can prove.
|
|
3851
|
+
async function forkCommitOf(root, wt) {
|
|
3852
|
+
if (wt.rec.forkCommit && isGitObjectId(root, wt.rec.forkCommit))
|
|
3853
|
+
return wt.rec.forkCommit;
|
|
3854
|
+
if (!wt.branch)
|
|
3855
|
+
return null;
|
|
3856
|
+
const log = await gitTry(['-C', root, 'reflog', 'show', '--no-abbrev', '--format=%H', `refs/heads/${wt.branch}`]);
|
|
3857
|
+
if (!log.ok)
|
|
3858
|
+
return null;
|
|
3859
|
+
const entries = log.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
3860
|
+
const created = entries[entries.length - 1];
|
|
3861
|
+
return created && isGitObjectId(root, created) ? created : null;
|
|
3862
|
+
}
|
|
3863
|
+
async function diffHeadPair(root, wt) {
|
|
3864
|
+
if (!wt.branch)
|
|
3865
|
+
throw new ResourceConflict(`session ${wt.rec.session} has no branch to diff`, 'diff-unavailable');
|
|
3866
|
+
const baseRef = wt.rec.base || mainBranch();
|
|
3867
|
+
const [headOut, baseOut] = await Promise.all([
|
|
3868
|
+
gitTry(['-C', root, 'rev-parse', '--verify', `refs/heads/${wt.branch}^{commit}`]),
|
|
3869
|
+
gitTry(['-C', root, 'rev-parse', '--verify', `${baseRef}^{commit}`]),
|
|
3870
|
+
]);
|
|
3871
|
+
const head = headOut.ok ? headOut.stdout.trim() : '', resolvedBase = baseOut.ok ? baseOut.stdout.trim() : '';
|
|
3872
|
+
if (!head || !resolvedBase || !isGitObjectId(root, head) || !isGitObjectId(root, resolvedBase))
|
|
3873
|
+
throw new ResourceConflict(`session ${wt.rec.session} diff heads are unproven`, 'diff-unavailable');
|
|
3874
|
+
const mergeBaseOut = await gitTry(['-C', root, 'merge-base', resolvedBase, head]);
|
|
3875
|
+
const mergeBase = mergeBaseOut.ok ? mergeBaseOut.stdout.trim() : '';
|
|
3876
|
+
if (!mergeBase || !isGitObjectId(root, mergeBase))
|
|
3877
|
+
throw new ResourceConflict(`session ${wt.rec.session} diff merge-base is unproven`, 'diff-unavailable');
|
|
3878
|
+
const [ancestor, remote, forkCommit] = await Promise.all([
|
|
3879
|
+
gitTry(['-C', root, 'merge-base', '--is-ancestor', head, resolvedBase]),
|
|
3880
|
+
gitTry(['-C', root, 'remote', 'get-url', 'origin']),
|
|
3881
|
+
forkCommitOf(root, wt),
|
|
3882
|
+
]);
|
|
3883
|
+
// A branch that never authored a commit is ALSO an ancestor of its base, so ancestry must be asked second.
|
|
3884
|
+
// Without a fork commit the only provable form of "authored nothing" is a head that is still the base head.
|
|
3885
|
+
const authoredNothing = forkCommit ? head === forkCommit : head === resolvedBase;
|
|
3886
|
+
return {
|
|
3887
|
+
branch: wt.branch, baseRef, base: resolvedBase, head, mergeBase,
|
|
3888
|
+
branchState: authoredNothing ? 'no-commits' : ancestor.ok ? 'merged' : 'open',
|
|
3889
|
+
commitUrl: remote.ok ? commitUrlForRemote(remote.stdout, head) : null,
|
|
3890
|
+
};
|
|
3891
|
+
}
|
|
3892
|
+
// @@@ workingFiles - the session's uncommitted changes, enumerated from ONE porcelain status plus ONE numstat.
|
|
3893
|
+
// Untracked files count their own lines rather than spawning a git child each: the metadata call stays two
|
|
3894
|
+
// processes however dirty the tree is, and nothing here touches the index — an `--intent-to-add` would mutate
|
|
3895
|
+
// the worktree a live agent is working in.
|
|
3896
|
+
const WORKING_STATUS = { '??': 'untracked', A: 'added', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' };
|
|
3897
|
+
async function workingFiles(root) {
|
|
3898
|
+
const [statusOut, numstatOut] = await Promise.all([
|
|
3899
|
+
gitA(['-C', root, '-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all']),
|
|
3900
|
+
gitA(['-C', root, '-c', 'core.quotePath=false', 'diff', '--numstat', '-M', 'HEAD']),
|
|
3901
|
+
]);
|
|
3902
|
+
const counts = new Map();
|
|
3903
|
+
for (const line of numstatOut.split('\n')) {
|
|
3904
|
+
const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/);
|
|
3905
|
+
if (!m)
|
|
3906
|
+
continue;
|
|
3907
|
+
const { to } = parseStatPath(m[3]);
|
|
3908
|
+
counts.set(to, { additions: m[1] === '-' ? 0 : +m[1], deletions: m[2] === '-' ? 0 : +m[2] });
|
|
3909
|
+
}
|
|
3910
|
+
const files = [];
|
|
3911
|
+
for (const line of statusOut.split('\n')) {
|
|
3912
|
+
if (!line.trim())
|
|
3913
|
+
continue;
|
|
3914
|
+
const code = line.slice(0, 2);
|
|
3915
|
+
const path = porcelainPath(line);
|
|
3916
|
+
const arrow = line.indexOf(' -> ');
|
|
3917
|
+
const oldPath = arrow >= 0 ? line.slice(3, arrow) : '';
|
|
3918
|
+
const letter = code.trim().replace(/[^A-Z?]/g, '').slice(0, 1) || 'M';
|
|
3919
|
+
const status = WORKING_STATUS[code === '??' ? '??' : letter] ?? 'modified';
|
|
3920
|
+
files.push({
|
|
3921
|
+
path,
|
|
3922
|
+
...(oldPath && oldPath !== path ? { oldPath } : {}),
|
|
3923
|
+
status,
|
|
3924
|
+
...(counts.get(path) ?? (code === '??' ? untrackedCounts(join(root, path)) : { additions: 0, deletions: 0 })),
|
|
3925
|
+
});
|
|
3926
|
+
}
|
|
3927
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
3928
|
+
}
|
|
3929
|
+
// An untracked file is entirely new, so its addition count is its line count. A NUL byte means git would
|
|
3930
|
+
// print `-`/`-` for a binary blob; report the same nothing rather than a line count of bytes.
|
|
3931
|
+
function untrackedCounts(absolute) {
|
|
3932
|
+
try {
|
|
3933
|
+
const body = readFileSync(absolute);
|
|
3934
|
+
if (body.includes(0))
|
|
3935
|
+
return { additions: 0, deletions: 0 };
|
|
3936
|
+
const text = body.toString('utf8');
|
|
3937
|
+
return { additions: text.length ? text.replace(/\n$/, '').split('\n').length : 0, deletions: 0 };
|
|
3938
|
+
}
|
|
3939
|
+
catch {
|
|
3940
|
+
return { additions: 0, deletions: 0 };
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
async function workingPatch(root, file, untracked) {
|
|
3944
|
+
if (untracked) {
|
|
3945
|
+
// --no-index against /dev/null renders a whole new file as one addition hunk. It exits 1 when the two
|
|
3946
|
+
// sides differ, which is the normal case here, so the patch is read off stdout rather than off `ok`.
|
|
3947
|
+
const out = await gitTry(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', '--no-index', '--', '/dev/null', file.path]);
|
|
3948
|
+
return out.stdout;
|
|
3949
|
+
}
|
|
3950
|
+
return gitA(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', 'HEAD', '--', ...(file.oldPath ? [file.oldPath, file.path] : [file.path])]);
|
|
3951
|
+
}
|
|
3952
|
+
// A working file's identity must move when its CONTENT moves, or a stale editor and a stale comment anchor
|
|
3953
|
+
// would survive an edit. Size and mtime are what change on every write, and they cost one stat.
|
|
3954
|
+
function workingIdentity(root, file) {
|
|
3955
|
+
let stamp = 'gone';
|
|
3956
|
+
try {
|
|
3957
|
+
const s = statSync(join(root, file.path));
|
|
3958
|
+
stamp = `${s.size}:${s.mtimeMs}`;
|
|
3959
|
+
}
|
|
3960
|
+
catch { /* deleted in the worktree */ }
|
|
3961
|
+
return createHash('sha256').update(`working\0${file.path}\0${file.oldPath || ''}\0${stamp}`).digest('hex');
|
|
3962
|
+
}
|
|
3963
|
+
export async function sessionDiff(id, filePath, offset = 0, limit = 120_000, scope = 'branch') {
|
|
3964
|
+
const wt = await findWorktree(id);
|
|
3965
|
+
if (!wt)
|
|
3966
|
+
return null;
|
|
3967
|
+
const root = await diffAnchorRoot(wt);
|
|
3968
|
+
const pair = await diffHeadPair(root, wt);
|
|
3969
|
+
// The working tree is the session's OWN directory or it is not knowable. `root` falls back to the main
|
|
3970
|
+
// checkout once the worktree is gone ([[diff-document]]), and that checkout's dirty files belong to whoever
|
|
3971
|
+
// is working there — never to this session.
|
|
3972
|
+
const liveRoot = wt.path && existsSync(wt.path) ? wt.path : null;
|
|
3973
|
+
const window = (patch) => patch.slice(offset, offset + limit);
|
|
3974
|
+
// A per-file fetch names its scope, so only that scope is enumerated: opening one file in a worktree with a
|
|
3975
|
+
// hundred dirty paths must not re-walk the other scope's git reads.
|
|
3976
|
+
const branch = scope === 'branch' || !filePath ? await mergeBaseDiff(root, pair.base, pair.head) : [];
|
|
3977
|
+
const branchSelected = scope === 'branch' && filePath ? branch.filter((file) => file.path === filePath || file.oldPath === filePath) : (filePath ? [] : branch);
|
|
3978
|
+
const files = await Promise.all(branchSelected.map(async (file) => {
|
|
3979
|
+
const identity = createHash('sha256').update(`${pair.mergeBase}\0${pair.head}\0${file.path}\0${file.oldPath || ''}`).digest('hex');
|
|
3980
|
+
if (!filePath)
|
|
3981
|
+
return { ...file, patch: '', diffIdentity: identity };
|
|
3982
|
+
const patch = await gitA(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', pair.mergeBase, pair.head, '--', ...(file.oldPath ? [file.oldPath, file.path] : [file.path])]);
|
|
3983
|
+
return { ...file, patch: window(patch), diffIdentity: identity };
|
|
3984
|
+
}));
|
|
3985
|
+
const dirty = liveRoot && (scope === 'working' || !filePath) ? await workingFiles(liveRoot) : [];
|
|
3986
|
+
const workingSelected = scope === 'working' && filePath ? dirty.filter((file) => file.path === filePath || file.oldPath === filePath) : (filePath ? [] : dirty);
|
|
3987
|
+
const working = await Promise.all(workingSelected.map(async (file) => {
|
|
3988
|
+
const identity = workingIdentity(liveRoot, file);
|
|
3989
|
+
if (!filePath)
|
|
3990
|
+
return { ...file, patch: '', diffIdentity: identity };
|
|
3991
|
+
const patch = await workingPatch(liveRoot, file, file.status === 'untracked');
|
|
3992
|
+
return { ...file, patch: window(patch), diffIdentity: identity };
|
|
3993
|
+
}));
|
|
3994
|
+
return {
|
|
3995
|
+
id, scope: 'branch', ...pair, files,
|
|
3996
|
+
working: { readable: !!liveRoot, files: working },
|
|
3997
|
+
comments: wt.rec.diffComments ?? [],
|
|
3998
|
+
};
|
|
3999
|
+
}
|
|
4000
|
+
export async function saveDiffComment(id, input) {
|
|
4001
|
+
const body = input.body.trim();
|
|
4002
|
+
if (!input.filePath || !body || !Number.isInteger(input.lineStart) || input.lineStart < 1 || !Number.isInteger(input.lineEnd) || input.lineEnd < input.lineStart || !input.diffIdentity)
|
|
4003
|
+
throw new ResourceConflict('diff comment needs a file, line range, body, and diff identity');
|
|
4004
|
+
return withRecordLock(id, async () => {
|
|
4005
|
+
const rec = readLiveRecord(id);
|
|
4006
|
+
if (!rec)
|
|
4007
|
+
return null;
|
|
4008
|
+
const comment = { id: input.id || randomUUID(), filePath: input.filePath, lineStart: input.lineStart, lineEnd: input.lineEnd, body, diffIdentity: input.diffIdentity, sentAt: null };
|
|
4009
|
+
const comments = (rec.diffComments ?? []).filter((candidate) => candidate.id !== comment.id);
|
|
4010
|
+
writeRecord({ ...rec, diffComments: [...comments, comment] });
|
|
4011
|
+
return comment;
|
|
4012
|
+
});
|
|
4013
|
+
}
|
|
4014
|
+
// A review conversation you can only append to is not a conversation. Saving, editing and sending all
|
|
4015
|
+
// existed; nothing could take a row back, so a comment filed on the wrong line — or a probe left by a
|
|
4016
|
+
// measurement — stayed on the record forever. Retract is the same shape as the other two `retract` verbs
|
|
4017
|
+
// this product already has ([[session-files]], eval): it removes the row under the record lock and says
|
|
4018
|
+
// which one it removed. Already-DELIVERED text is not recalled — the agent read it — so this retracts the
|
|
4019
|
+
// record's row, never the message that was sent.
|
|
4020
|
+
export async function retractDiffComment(id, commentId) {
|
|
4021
|
+
if (!commentId)
|
|
4022
|
+
throw new ResourceConflict('retracting a diff comment needs its id');
|
|
4023
|
+
return withRecordLock(id, async () => {
|
|
4024
|
+
const rec = readLiveRecord(id);
|
|
4025
|
+
if (!rec)
|
|
4026
|
+
return null;
|
|
4027
|
+
const comments = rec.diffComments ?? [];
|
|
4028
|
+
const removed = comments.find((comment) => comment.id === commentId);
|
|
4029
|
+
if (!removed)
|
|
4030
|
+
return null;
|
|
4031
|
+
writeRecord({ ...rec, diffComments: comments.filter((comment) => comment.id !== commentId) });
|
|
4032
|
+
return removed;
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
export async function sendDiffComments(id, ids) {
|
|
4036
|
+
const selected = await withRecordLock(id, async () => {
|
|
4037
|
+
const rec = readLiveRecord(id);
|
|
4038
|
+
if (!rec)
|
|
4039
|
+
return null;
|
|
4040
|
+
const wanted = ids?.length ? new Set(ids) : null;
|
|
4041
|
+
return (rec.diffComments ?? []).filter((comment) => !comment.sentAt && (!wanted || wanted.has(comment.id)));
|
|
4042
|
+
});
|
|
4043
|
+
if (!selected)
|
|
4044
|
+
return { ok: false, error: `no such session ${id}` };
|
|
4045
|
+
if (!selected.length)
|
|
4046
|
+
return { ok: false, error: 'no unsent diff comments' };
|
|
4047
|
+
const text = ['Review comments on the branch diff:', ...selected.map((comment) => {
|
|
4048
|
+
const lines = comment.lineStart === comment.lineEnd ? `L${comment.lineStart}` : `L${comment.lineStart}-L${comment.lineEnd}`;
|
|
4049
|
+
return `- ${comment.filePath}:${lines}\n ${comment.body.replace(/\n/g, '\n ')}`;
|
|
4050
|
+
})].join('\n');
|
|
4051
|
+
const sent = await sendText(id, text);
|
|
4052
|
+
if (!sent.ok)
|
|
4053
|
+
return { ok: false, error: sent.error || 'could not send diff comments' };
|
|
4054
|
+
const sentAt = new Date().toISOString();
|
|
4055
|
+
await withRecordLock(id, async () => {
|
|
4056
|
+
const rec = readLiveRecord(id);
|
|
4057
|
+
if (!rec)
|
|
4058
|
+
return;
|
|
4059
|
+
const selectedById = new Map(selected.map((comment) => [comment.id, comment]));
|
|
4060
|
+
writeRecord({ ...rec, diffComments: (rec.diffComments ?? []).map((comment) => {
|
|
4061
|
+
const before = selectedById.get(comment.id);
|
|
4062
|
+
const unchanged = before && !comment.sentAt && comment.body === before.body && comment.diffIdentity === before.diffIdentity;
|
|
4063
|
+
return unchanged ? { ...comment, sentAt } : comment;
|
|
4064
|
+
}) });
|
|
4065
|
+
});
|
|
4066
|
+
return { ok: true, sentAt, count: selected.length };
|
|
4067
|
+
}
|
|
3247
4068
|
async function reviewHeadPair(root, branch, base) {
|
|
3248
4069
|
const branchRef = `refs/heads/${branch}`, baseRef = `refs/heads/${base}`;
|
|
3249
4070
|
const output = await gitA(['-C', root, 'for-each-ref', '--sort=refname', '--format=%(refname)%00%(objectname)', branchRef, baseRef]);
|
|
@@ -3350,16 +4171,7 @@ export async function mergeSession(id) {
|
|
|
3350
4171
|
return { dispatched: false, reason: 'no such mergeable session' };
|
|
3351
4172
|
const r = await sendText(id, MERGE_PROMPT, undefined, {
|
|
3352
4173
|
deferDrain: true,
|
|
3353
|
-
acceptGuard: async (rec) => {
|
|
3354
|
-
if (!rec.governed || rec.status !== 'awaiting' || rec.proposal !== 'merge') {
|
|
3355
|
-
const error = new ResourceConflict(`session ${id} is not a governed awaiting merge proposal`);
|
|
3356
|
-
Object.assign(error, { code: 'session_merge_not_proposed' });
|
|
3357
|
-
throw error;
|
|
3358
|
-
}
|
|
3359
|
-
},
|
|
3360
4174
|
});
|
|
3361
|
-
if (r.code === 'session_merge_not_proposed')
|
|
3362
|
-
return { dispatched: false, reason: r.error || 'merge dispatch refused', code: r.code, status: 409 };
|
|
3363
4175
|
if (!r.ok)
|
|
3364
4176
|
return { dispatched: false, reason: r.error || 'could not dispatch merge prompt' };
|
|
3365
4177
|
await resumeSession(id, { guard: false });
|
|
@@ -3627,7 +4439,7 @@ async function inspectSessionLeafIdentity(id, rec) {
|
|
|
3627
4439
|
const startAfter = sessionLeafStartToken(pid);
|
|
3628
4440
|
const candidate = sessionLeafReceiptCandidate(id, pid, panePid, procs, startBefore, startAfter);
|
|
3629
4441
|
if (!candidate.ok || !candidate.receipt)
|
|
3630
|
-
return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt
|
|
4442
|
+
return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt validation failed' };
|
|
3631
4443
|
if (readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
|
|
3632
4444
|
return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed before receipt commit` };
|
|
3633
4445
|
writeSessionLeafReceipt(id, candidate.receipt);
|
|
@@ -3681,7 +4493,7 @@ async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
|
|
|
3681
4493
|
if (requireCold) {
|
|
3682
4494
|
const cold = await harness.coldRuntime?.(rec, coldReceipt);
|
|
3683
4495
|
if (cold && !cold.ok)
|
|
3684
|
-
throw new ResourceConflict(`refusing to
|
|
4496
|
+
throw new ResourceConflict(`refusing to close ${id}: ${cold.reason}`);
|
|
3685
4497
|
}
|
|
3686
4498
|
}
|
|
3687
4499
|
async function stopSessionUnlocked(id) {
|
|
@@ -3706,25 +4518,23 @@ async function stopSessionUnlocked(id) {
|
|
|
3706
4518
|
return !!wt;
|
|
3707
4519
|
}
|
|
3708
4520
|
export const stopSession = (id) => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)));
|
|
3709
|
-
async function
|
|
4521
|
+
async function coldStopSessionUnlocked(id) {
|
|
3710
4522
|
let wt;
|
|
3711
4523
|
try {
|
|
3712
4524
|
wt = await findWorktree(id);
|
|
3713
4525
|
}
|
|
3714
4526
|
catch (e) {
|
|
3715
4527
|
if (e instanceof SessionRecordUnusable)
|
|
3716
|
-
throw new ResourceConflict(`refusing to
|
|
4528
|
+
throw new ResourceConflict(`refusing to close ${id}: ${e.message}`);
|
|
3717
4529
|
throw e;
|
|
3718
4530
|
}
|
|
3719
4531
|
if (!wt)
|
|
3720
4532
|
return false;
|
|
3721
|
-
if (!on)
|
|
3722
|
-
throw new ResourceConflict('unarchive is not a record-only transition; use resume to restore the runtime');
|
|
3723
4533
|
if (wt.rec.status === 'queued')
|
|
3724
|
-
throw new ResourceConflict(`refusing to
|
|
4534
|
+
throw new ResourceConflict(`refusing to close ${id}: queued sessions have only a prepared launch prompt; close handles their prepared tree directly`);
|
|
3725
4535
|
const retired = retirementReason(wt.rec);
|
|
3726
4536
|
if (retired)
|
|
3727
|
-
throw new ResourceConflict(`refusing to
|
|
4537
|
+
throw new ResourceConflict(`refusing to close ${id}: ${retired}`);
|
|
3728
4538
|
archiving.add(id);
|
|
3729
4539
|
try {
|
|
3730
4540
|
const h = harnessById(wt.rec.harness || defaultHarness.id);
|
|
@@ -3738,12 +4548,12 @@ async function archiveSessionUnlocked(id, on = true) {
|
|
|
3738
4548
|
if (wt.rec.archived && hasValidColdProof(wt.rec)) {
|
|
3739
4549
|
const proofSnap = await liveSnapshot(id);
|
|
3740
4550
|
if (proofSnap.probeFailed)
|
|
3741
|
-
throw new ResourceConflict(`refusing to
|
|
4551
|
+
throw new ResourceConflict(`refusing to close ${id}: liveness probe failed; the exact leaf may have respawned`);
|
|
3742
4552
|
const proofLv = h.runtimeOwnership === 'adapter'
|
|
3743
4553
|
? (proofSnap.windows.has(id) ? 'online' : 'offline')
|
|
3744
4554
|
: liveness({ ...wt.rec, archived: false, stopped: false }, proofSnap);
|
|
3745
4555
|
if (proofLv === 'unknown' || proofLv === 'starting')
|
|
3746
|
-
throw new ResourceConflict(`refusing to
|
|
4556
|
+
throw new ResourceConflict(`refusing to close ${id}: session liveness is ${proofLv}; exact cold state is unproven`);
|
|
3747
4557
|
if (proofLv === 'offline') {
|
|
3748
4558
|
// A deliberately stopped shared control plane is a valid empty resident census. A durable proof plus
|
|
3749
4559
|
// an adapter-owned root-absent fact is the only idempotent short-circuit; a live root still has to prove
|
|
@@ -3768,28 +4578,25 @@ async function archiveSessionUnlocked(id, on = true) {
|
|
|
3768
4578
|
}
|
|
3769
4579
|
}
|
|
3770
4580
|
}
|
|
3771
|
-
// Legacy/respawned
|
|
3772
|
-
//
|
|
4581
|
+
// Legacy/respawned closed rows are probed as ordinary runtime records, but the durable archived bit is kept
|
|
4582
|
+
// untouched until the new close publication succeeds.
|
|
3773
4583
|
if (wt.rec.archived) {
|
|
3774
|
-
|
|
3775
|
-
wt = await findWorktree(id);
|
|
3776
|
-
if (!wt)
|
|
3777
|
-
return false;
|
|
4584
|
+
wt = { ...wt, rec: { ...wt.rec, archived: false, coldProof: null } };
|
|
3778
4585
|
}
|
|
3779
4586
|
const snap = await liveSnapshot(id);
|
|
3780
4587
|
if (snap.probeFailed)
|
|
3781
|
-
throw new ResourceConflict(`refusing to
|
|
4588
|
+
throw new ResourceConflict(`refusing to close ${id}: liveness probe failed; the leaf may still be live`);
|
|
3782
4589
|
const lv = h.runtimeOwnership === 'adapter'
|
|
3783
4590
|
? 'offline'
|
|
3784
4591
|
: liveness({ ...wt.rec, archived: false, stopped: false }, snap);
|
|
3785
4592
|
if (lv === 'unknown' || lv === 'starting')
|
|
3786
|
-
throw new ResourceConflict(`refusing to
|
|
4593
|
+
throw new ResourceConflict(`refusing to close ${id}: session liveness is ${lv}; exact leaf ownership is unproven`);
|
|
3787
4594
|
// The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
|
|
3788
4595
|
// ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
|
|
3789
4596
|
assertSessionOwnerSafe(id, h.id);
|
|
3790
4597
|
const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' });
|
|
3791
4598
|
if (preflight && !preflight.ok)
|
|
3792
|
-
throw new ResourceConflict(`refusing to
|
|
4599
|
+
throw new ResourceConflict(`refusing to close ${id}: ${preflight.reason}`);
|
|
3793
4600
|
// Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
|
|
3794
4601
|
// teardown seam with the explicit stopped marker so cleanupRuntime gets its ownership check and no second
|
|
3795
4602
|
// cleanup primitive is invented.
|
|
@@ -3801,12 +4608,12 @@ async function archiveSessionUnlocked(id, on = true) {
|
|
|
3801
4608
|
coldCommitted = true;
|
|
3802
4609
|
const latest = readRecord(id);
|
|
3803
4610
|
if (!latest)
|
|
3804
|
-
throw new ResourceConflict(`refusing to
|
|
4611
|
+
throw new ResourceConflict(`refusing to close ${id}: session record disappeared before archive-ref publication`);
|
|
3805
4612
|
// The leaf identity kill and adapter cold proof established process/transport absence. Record-backed
|
|
3806
4613
|
// adapters intentionally project online until the archive write, so display liveness is not physical
|
|
3807
4614
|
// evidence here. A target pane appearing after the stop proof is the remaining shared runtime witness.
|
|
3808
4615
|
await assertTargetTmuxAbsent(id, 'before archive filing');
|
|
3809
|
-
writeRecord({ ...latest,
|
|
4616
|
+
writeRecord({ ...latest, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null });
|
|
3810
4617
|
}
|
|
3811
4618
|
catch (error) {
|
|
3812
4619
|
if (coldCommitted) {
|
|
@@ -3832,102 +4639,10 @@ async function archiveSessionUnlocked(id, on = true) {
|
|
|
3832
4639
|
archiving.delete(id);
|
|
3833
4640
|
}
|
|
3834
4641
|
}
|
|
3835
|
-
export const archiveSession = (id, on = true) => {
|
|
3836
|
-
if (!on)
|
|
3837
|
-
return archiveSessionUnarchive(id);
|
|
3838
|
-
return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)));
|
|
3839
|
-
};
|
|
3840
|
-
async function archiveSessionUnarchive(id) {
|
|
3841
|
-
const wt = await findWorktree(id);
|
|
3842
|
-
if (!wt)
|
|
3843
|
-
return false;
|
|
3844
|
-
if (!wt.rec.archived)
|
|
3845
|
-
return true;
|
|
3846
|
-
const resumed = await resumeSession(id);
|
|
3847
|
-
if (!resumed.ok)
|
|
3848
|
-
throw new ResourceConflict(resumed.error || `refusing to resume ${id}`);
|
|
3849
|
-
return true;
|
|
3850
|
-
}
|
|
3851
|
-
// @@@ cold retirement - archive already returned the target's runtime, so closing a proven-cold row must not
|
|
3852
|
-
// re-enter the live stop guard and make unrelated shared-root references prove ownership again. Verify only
|
|
3853
|
-
// that the target-bound cold proof is still current and that no target PID/window/socket/thread has reappeared.
|
|
3854
|
-
// This is read-only: no signal, adapter mutation, or shared-root cleanup belongs on the cold path.
|
|
3855
|
-
async function assertColdRetirementSafe(id, rec) {
|
|
3856
|
-
if (!rec.archived || !rec.stopped || !hasValidColdProof(rec))
|
|
3857
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target-bound cold witness is missing or stale`);
|
|
3858
|
-
if (rec.adapterRecovery)
|
|
3859
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`);
|
|
3860
|
-
const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
|
|
3861
|
-
if (snap.probeFailed)
|
|
3862
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`);
|
|
3863
|
-
if (snap.windows.has(id))
|
|
3864
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`);
|
|
3865
|
-
if (socket === 'live')
|
|
3866
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`);
|
|
3867
|
-
if (socket === 'unproven')
|
|
3868
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`);
|
|
3869
|
-
const leaf = await inspectSessionLeafIdentity(id, rec);
|
|
3870
|
-
if (leaf.state === 'owned')
|
|
3871
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${leaf.identity.pid} is live or recycled; ownership is ambiguous`);
|
|
3872
|
-
if (leaf.state === 'unknown')
|
|
3873
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: ${leaf.reason}; ownership is ambiguous`);
|
|
3874
|
-
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3875
|
-
if (harness.coldRetirementPreflight) {
|
|
3876
|
-
const proof = await harness.coldRetirementPreflight(rec);
|
|
3877
|
-
if (!proof.ok)
|
|
3878
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
|
|
3879
|
-
return;
|
|
3880
|
-
}
|
|
3881
|
-
const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? [];
|
|
3882
|
-
let everySharedRootAbsent = descriptors.length > 0;
|
|
3883
|
-
for (const descriptor of descriptors) {
|
|
3884
|
-
const resident = descriptor.residency
|
|
3885
|
-
? await descriptor.residency()
|
|
3886
|
-
: await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }));
|
|
3887
|
-
if (!resident.healthy)
|
|
3888
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: ${resident.error || `${descriptor.label} resident census is unhealthy`}`);
|
|
3889
|
-
if (rec.harnessSessionId && resident.referenceIds.includes(rec.harnessSessionId))
|
|
3890
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target adapter thread ${rec.harnessSessionId} is loaded`);
|
|
3891
|
-
everySharedRootAbsent = everySharedRootAbsent && resident.rootAbsent === true;
|
|
3892
|
-
}
|
|
3893
|
-
if (harness.coldPreflight && !everySharedRootAbsent) {
|
|
3894
|
-
const proof = await harness.coldPreflight(rec);
|
|
3895
|
-
if (!proof.ok)
|
|
3896
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
|
|
3897
|
-
if (!proof.alreadyCold)
|
|
3898
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`);
|
|
3899
|
-
}
|
|
3900
|
-
}
|
|
3901
|
-
async function assertDiscardableWorktree(id, path, branch, kind) {
|
|
3902
|
-
if (existsSync(path)) {
|
|
3903
|
-
const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all']);
|
|
3904
|
-
if (!status.ok)
|
|
3905
|
-
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree status is unreadable`);
|
|
3906
|
-
if (status.stdout.trim())
|
|
3907
|
-
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree has dirty work`);
|
|
3908
|
-
}
|
|
3909
|
-
if (branch) {
|
|
3910
|
-
const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`]);
|
|
3911
|
-
if (resolved.ok) {
|
|
3912
|
-
const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`]);
|
|
3913
|
-
if (!count.ok)
|
|
3914
|
-
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch ancestry is unreadable`);
|
|
3915
|
-
const ahead = Number(count.stdout.trim());
|
|
3916
|
-
if (!Number.isFinite(ahead) || ahead !== 0)
|
|
3917
|
-
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`);
|
|
3918
|
-
}
|
|
3919
|
-
else if (resolved.failure !== 'exit') {
|
|
3920
|
-
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch identity is unreadable`);
|
|
3921
|
-
}
|
|
3922
|
-
else if (existsSync(path)) {
|
|
3923
|
-
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree exists but branch ${branch} is missing`);
|
|
3924
|
-
}
|
|
3925
|
-
}
|
|
3926
|
-
}
|
|
3927
4642
|
// A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
|
|
3928
4643
|
// this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
|
|
3929
4644
|
// No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
|
|
3930
|
-
async function
|
|
4645
|
+
async function assertQueuedCloseSafe(id, rec) {
|
|
3931
4646
|
if (rec.status !== 'queued' || rec.harnessSessionId)
|
|
3932
4647
|
throw new ResourceConflict(`refusing to close queued session ${id}: the record has a target thread or is no longer queued`);
|
|
3933
4648
|
if (rec.adapterRecovery || launching.has(id))
|
|
@@ -3946,14 +4661,20 @@ async function assertQueuedRetirementSafe(id, rec, path, branch) {
|
|
|
3946
4661
|
const pid = readAgentPid(pidPath);
|
|
3947
4662
|
throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`);
|
|
3948
4663
|
}
|
|
3949
|
-
|
|
4664
|
+
// close preserves the prepared tree's dirty state in refs/spex-archive/<id>; cleanliness is not a
|
|
4665
|
+
// precondition for the soft terminal transition.
|
|
3950
4666
|
}
|
|
3951
4667
|
// A launch may leave its row active before Codex publishes the native thread binding. This close path owns
|
|
3952
4668
|
// only the record's dead local launch residue; an unbound app-server peer stays unowned and untouched.
|
|
3953
|
-
async function
|
|
4669
|
+
async function assertUnboundCloseSafe(id, rec) {
|
|
3954
4670
|
if (harnessById(rec.harness || defaultHarness.id).exactNativeTargetId(rec) || rec.status === 'queued' || rec.archived)
|
|
3955
4671
|
throw new ResourceConflict(`refusing to close unbound session ${id}: it is not an unbound live-record residue`);
|
|
3956
|
-
|
|
4672
|
+
const failureStamped = /^queued launch readiness failed:/.test(rec.note || '') || rec.status === 'error' || rec.stopped;
|
|
4673
|
+
const readinessStartedAt = rec.launchReadinessStartedAt ?? rec.launchReadinessPending?.startedAt ?? null;
|
|
4674
|
+
const readinessInProgress = !failureStamped && (readinessStartedAt != null
|
|
4675
|
+
? Date.now() - readinessStartedAt < SOCKET_READY_TIMEOUT_MS
|
|
4676
|
+
: launching.has(id));
|
|
4677
|
+
if (rec.adapterRecovery || readinessInProgress)
|
|
3957
4678
|
throw new ResourceConflict(`refusing to close unbound session ${id}: launch or recovery is still in progress`);
|
|
3958
4679
|
const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
|
|
3959
4680
|
if (snap.probeFailed)
|
|
@@ -3968,55 +4689,45 @@ async function assertUnboundRetirementSafe(id, rec, path, branch) {
|
|
|
3968
4689
|
const leaf = await inspectSessionLeafIdentity(id, rec);
|
|
3969
4690
|
if (leaf.state !== 'missing' && leaf.state !== 'dead')
|
|
3970
4691
|
throw new ResourceConflict(`refusing to close unbound session ${id}: ${leaf.state === 'unknown' ? leaf.reason : 'target leaf identity is live or ambiguous'}`);
|
|
3971
|
-
await assertDiscardableWorktree(id, path, branch, 'unbound');
|
|
3972
4692
|
}
|
|
3973
|
-
async function closeOwnedSessionUnlocked(id, wt,
|
|
4693
|
+
async function closeOwnedSessionUnlocked(id, wt, _source, unboundStopped = false) {
|
|
3974
4694
|
const root = mainRoot();
|
|
3975
4695
|
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root);
|
|
3976
4696
|
if (receiptFailure)
|
|
3977
|
-
throw new ResourceConflict(`refusing
|
|
3978
|
-
const
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
if (!retired) {
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
if (
|
|
3991
|
-
|
|
3992
|
-
|
|
4697
|
+
throw new ResourceConflict(`refusing close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`);
|
|
4698
|
+
const retired = !!retirementReason(wt.rec);
|
|
4699
|
+
if (!retired && wt.rec.status === 'queued')
|
|
4700
|
+
await assertQueuedCloseSafe(id, wt.rec);
|
|
4701
|
+
if (!retired && !unboundStopped && wt.rec.status !== 'queued') {
|
|
4702
|
+
await coldStopSessionUnlocked(id);
|
|
4703
|
+
wt = (await findWorktree(id)) || wt;
|
|
4704
|
+
}
|
|
4705
|
+
// The archive ref is published before any worktree removal. A failed ref write leaves the complete
|
|
4706
|
+
// worktree and record in place for retry.
|
|
4707
|
+
if (!retired && existsSync(wt.path))
|
|
4708
|
+
archiveWorktreeState(id, wt.path);
|
|
4709
|
+
const latest = readRecord(id);
|
|
4710
|
+
if (!latest)
|
|
4711
|
+
throw new ResourceConflict(`refusing to finish close for ${id}: session record disappeared before publication`);
|
|
4712
|
+
writeRecord({
|
|
4713
|
+
...latest,
|
|
4714
|
+
archived: true,
|
|
4715
|
+
closedAt: latest.closedAt || new Date().toISOString(),
|
|
4716
|
+
stopped: true,
|
|
4717
|
+
coldProof: latest.coldProof || coldProofFor(latest),
|
|
4718
|
+
adapterRecovery: null,
|
|
4719
|
+
});
|
|
3993
4720
|
let slot = null;
|
|
3994
4721
|
try {
|
|
3995
|
-
slot = treeSlotDir(wt.path);
|
|
4722
|
+
slot = existsSync(wt.path) ? treeSlotDir(wt.path) : null;
|
|
3996
4723
|
}
|
|
3997
4724
|
catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
3998
|
-
// a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
|
|
3999
4725
|
if (existsSync(wt.path)) {
|
|
4000
|
-
const
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
}
|
|
4006
|
-
if (wt.branch) {
|
|
4007
|
-
const branchRef = `refs/heads/${wt.branch}`;
|
|
4008
|
-
const present = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
|
|
4009
|
-
if (present.ok) {
|
|
4010
|
-
const removed = await gitTry(['-C', root, 'branch', '-D', wt.branch]);
|
|
4011
|
-
if (!removed.ok)
|
|
4012
|
-
throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`);
|
|
4013
|
-
const remaining = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
|
|
4014
|
-
if (remaining.ok || remaining.failure !== 'exit')
|
|
4015
|
-
throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`);
|
|
4016
|
-
}
|
|
4017
|
-
else if (present.failure !== 'exit') {
|
|
4018
|
-
throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`);
|
|
4019
|
-
}
|
|
4726
|
+
const trashed = moveWorktreeToTrash(root, wt.path);
|
|
4727
|
+
const pruned = await gitTry(['-C', root, 'worktree', 'prune']);
|
|
4728
|
+
if (!pruned.ok)
|
|
4729
|
+
console.error(`spex: deferred worktree ${id} was renamed to ${trashed}, but git worktree prune failed: ${pruned.stderr.trim() || pruned.failure}`);
|
|
4730
|
+
queueWorktreeTrash(trashed);
|
|
4020
4731
|
}
|
|
4021
4732
|
if (slot) {
|
|
4022
4733
|
try {
|
|
@@ -4024,20 +4735,6 @@ async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false)
|
|
|
4024
4735
|
}
|
|
4025
4736
|
catch { /* best-effort GC */ }
|
|
4026
4737
|
}
|
|
4027
|
-
try {
|
|
4028
|
-
rmSync(sessionStoreDir(id), { recursive: true, force: true });
|
|
4029
|
-
}
|
|
4030
|
-
catch (error) {
|
|
4031
|
-
throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`);
|
|
4032
|
-
}
|
|
4033
|
-
if (existsSync(sessionStoreDir(id)))
|
|
4034
|
-
throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`);
|
|
4035
|
-
// The close still owns this sender's record lock. Marking after its store is gone lets any send already
|
|
4036
|
-
// admitted finish before close returns, while every later send and every retry sweep sees terminal output.
|
|
4037
|
-
revokeSenderDelivery(id);
|
|
4038
|
-
if (closesCodexBinding && wt.rec.harnessSessionId) {
|
|
4039
|
-
bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null);
|
|
4040
|
-
}
|
|
4041
4738
|
requestQueueDrain(); // a close frees a slot — start the next queued session if any
|
|
4042
4739
|
return true;
|
|
4043
4740
|
}
|
|
@@ -4053,7 +4750,7 @@ async function closeSessionUnlocked(id, source) {
|
|
|
4053
4750
|
const runtime = sessionStoreDir(id);
|
|
4054
4751
|
const evidence = quarantined
|
|
4055
4752
|
? `Original bytes were copied to ${quarantined}`
|
|
4056
|
-
: `Original bytes remain at ${join(runtime, '
|
|
4753
|
+
: `Original bytes remain at ${join(runtime, 'runtime.json')}; no quarantine copy could be made`;
|
|
4057
4754
|
let guard = 'no readable session record proves the adapter or leaf owner';
|
|
4058
4755
|
try {
|
|
4059
4756
|
await stopAgentProcess(id, null);
|
|
@@ -4061,42 +4758,29 @@ async function closeSessionUnlocked(id, source) {
|
|
|
4061
4758
|
catch (error) {
|
|
4062
4759
|
guard = error instanceof Error ? error.message : String(error);
|
|
4063
4760
|
}
|
|
4064
|
-
throw new SessionRecordUnusable('corrupt', id, `refusing
|
|
4761
|
+
throw new SessionRecordUnusable('corrupt', id, `refusing close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`);
|
|
4065
4762
|
}
|
|
4066
4763
|
if (!wt)
|
|
4067
4764
|
return false;
|
|
4068
|
-
let
|
|
4765
|
+
let unboundStopped = false;
|
|
4069
4766
|
if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
|
|
4070
4767
|
const harness = harnessById(wt.rec.harness || defaultHarness.id);
|
|
4071
4768
|
if (!harness.exactNativeTargetId(wt.rec)) {
|
|
4072
|
-
await
|
|
4769
|
+
await assertUnboundCloseSafe(id, wt.rec);
|
|
4073
4770
|
await tmuxOk(['kill-session', '-t', id]);
|
|
4074
|
-
await assertTargetTmuxAbsent(id, 'after unbound residue
|
|
4771
|
+
await assertTargetTmuxAbsent(id, 'after unbound residue close');
|
|
4075
4772
|
await harness.cleanupRuntime(wt.rec);
|
|
4076
|
-
|
|
4773
|
+
unboundStopped = true;
|
|
4077
4774
|
}
|
|
4078
4775
|
else {
|
|
4079
|
-
//
|
|
4080
|
-
//
|
|
4081
|
-
assertSessionOwnerSafe(id, harness.id);
|
|
4082
|
-
const interrupt = harness.interrupt;
|
|
4083
|
-
if (interrupt) {
|
|
4084
|
-
const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() });
|
|
4085
|
-
if (!result.ok)
|
|
4086
|
-
throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`);
|
|
4087
|
-
}
|
|
4088
|
-
const archived = await archiveSessionUnlocked(id);
|
|
4089
|
-
if (!archived)
|
|
4090
|
-
return false;
|
|
4091
|
-
wt = await findWorktree(id);
|
|
4092
|
-
if (!wt)
|
|
4093
|
-
return false;
|
|
4776
|
+
// closeOwnedSessionUnlocked performs the one exact cold-stop proof immediately before archive-ref
|
|
4777
|
+
// publication. Keeping that seam in one place prevents a second adapter mutation on retry.
|
|
4094
4778
|
}
|
|
4095
4779
|
}
|
|
4096
4780
|
const target = wt;
|
|
4097
4781
|
return target.branch
|
|
4098
|
-
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source,
|
|
4099
|
-
: closeOwnedSessionUnlocked(id, target, source,
|
|
4782
|
+
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundStopped))
|
|
4783
|
+
: closeOwnedSessionUnlocked(id, target, source, unboundStopped);
|
|
4100
4784
|
}
|
|
4101
4785
|
export const closeSession = (id, rawSource) => {
|
|
4102
4786
|
const source = normalizeCloseSource(rawSource);
|
|
@@ -4229,7 +4913,7 @@ export async function quarantineCorruptRecord(id, rawWitness) {
|
|
|
4229
4913
|
const git = await proveQuarantineGitAbsent(id, witness);
|
|
4230
4914
|
const adapter = await proveQuarantineAdapter(id, witness);
|
|
4231
4915
|
const bundle = join(quarantineRoot(id), `${observedAt.replace(/[:.]/g, '-')}-${randomUUID()}`);
|
|
4232
|
-
const stored = join(bundle, '
|
|
4916
|
+
const stored = join(bundle, 'runtime.json');
|
|
4233
4917
|
const provenance = join(bundle, 'provenance.json');
|
|
4234
4918
|
const audit = {
|
|
4235
4919
|
version: 1,
|
|
@@ -4279,10 +4963,10 @@ export async function restoreQuarantinedRecord(id) {
|
|
|
4279
4963
|
throw new ResourceConflict(`refusing to restore ${id}: no quarantine bundle exists`);
|
|
4280
4964
|
throw new ResourceConflict(`refusing to restore ${id}: quarantine bundle inventory is unreadable`);
|
|
4281
4965
|
}
|
|
4282
|
-
const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, '
|
|
4966
|
+
const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'runtime.json')) && existsSync(join(path, 'provenance.json')));
|
|
4283
4967
|
if (!bundle)
|
|
4284
4968
|
throw new ResourceConflict(`refusing to restore ${id}: no complete quarantine bundle exists`);
|
|
4285
|
-
const stored = join(bundle, '
|
|
4969
|
+
const stored = join(bundle, 'runtime.json');
|
|
4286
4970
|
let provenance;
|
|
4287
4971
|
try {
|
|
4288
4972
|
provenance = JSON.parse(readFileSync(join(bundle, 'provenance.json'), 'utf8'));
|
|
@@ -4486,6 +5170,7 @@ export function formatTable(sessions, color = true, scope = { kind: 'sessions' }
|
|
|
4486
5170
|
});
|
|
4487
5171
|
return [heading, header, ...rows, statusLegend(color)].join('\n');
|
|
4488
5172
|
}
|
|
5173
|
+
export const EMPTY_PROMPT_ERROR = 'empty prompt — nothing to dispatch';
|
|
4489
5174
|
class StrandedDeliveryError extends Error {
|
|
4490
5175
|
}
|
|
4491
5176
|
async function strandedDeliveryError(rec) {
|
|
@@ -4495,100 +5180,199 @@ async function strandedDeliveryError(rec) {
|
|
|
4495
5180
|
const transport = await h.deliveryTransport({ ...rec, runtimeDir: runtimeRoot() });
|
|
4496
5181
|
if (transport.kind !== 'unreachable' || agentAlive(rec.session) !== true)
|
|
4497
5182
|
return null;
|
|
4498
|
-
const
|
|
5183
|
+
const application = configuredSessionApplicationIfCutover();
|
|
5184
|
+
const queued = application?.readPendingMessages(rec.session).length ?? 0;
|
|
4499
5185
|
const noun = queued === 1 ? 'message is' : 'messages are';
|
|
4500
5186
|
return new StrandedDeliveryError(`session ${rec.session} is stranded: ${transport.reason} while its registered agent process is still alive; ${queued} queued ${noun} waiting with no transport to claim them. Use \`spex session send ${rec.session} --keys "<keys>"\` to steer the live tmux pane, then repair the control transport before sending text.`);
|
|
4501
5187
|
}
|
|
4502
5188
|
export async function sendText(id, text, from, opts = {}) {
|
|
4503
|
-
if (!text)
|
|
4504
|
-
return { ok: false, error:
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
text,
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
5189
|
+
if (!text.trim())
|
|
5190
|
+
return { ok: false, error: EMPTY_PROMPT_ERROR };
|
|
5191
|
+
const application = configuredSessionApplicationIfCutover();
|
|
5192
|
+
if (application) {
|
|
5193
|
+
let message;
|
|
5194
|
+
let replayed = false;
|
|
5195
|
+
try {
|
|
5196
|
+
const rec = readRecord(id);
|
|
5197
|
+
if (!rec)
|
|
5198
|
+
throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
|
|
5199
|
+
await opts.acceptGuard?.(rec);
|
|
5200
|
+
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
|
|
5201
|
+
const idempotencyKey = opts.idempotency?.requestDigest ?? (opts.deliveryKey?.trim() || null);
|
|
5202
|
+
const existing = idempotencyKey
|
|
5203
|
+
? application.readMessageHistory(id).find(message => message.idempotencyKey === idempotencyKey)
|
|
5204
|
+
: undefined;
|
|
5205
|
+
message = existing ?? application.enqueueConversationMessage(id, {
|
|
5206
|
+
kind: 'session.prompt.v1',
|
|
5207
|
+
body: Buffer.from(prompt.text, 'utf8'),
|
|
5208
|
+
senderSessionId: from ?? null,
|
|
5209
|
+
idempotencyKey,
|
|
5210
|
+
}, { text, from: from ?? null, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) });
|
|
5211
|
+
replayed = !!existing;
|
|
5212
|
+
}
|
|
5213
|
+
catch (error) {
|
|
5214
|
+
return { ok: false, error: `could not append the message to session ${id}'s application queue: ${error instanceof Error ? error.message : String(error)}` };
|
|
5215
|
+
}
|
|
5216
|
+
// Acceptance and handover are separate boundaries. A committed SQLite message remains a successful
|
|
5217
|
+
// command even when the runtime is currently unbound; binding/resume is the explicit event that makes
|
|
5218
|
+
// the durable debt drainable. Reporting the post-commit refusal as an append failure made command-box
|
|
5219
|
+
// callers show a false error despite the prompt already being safely queued.
|
|
5220
|
+
if (!opts.deferDrain) {
|
|
5221
|
+
try {
|
|
5222
|
+
await drainSession(id);
|
|
5223
|
+
}
|
|
5224
|
+
catch (error) {
|
|
5225
|
+
if (!(error instanceof ResourceConflict) || !/no bound spex-governed runtime/u.test(error.message))
|
|
5226
|
+
throw error;
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
const pending = application.readPendingMessages(id).some(candidate => candidate.messageId === message.messageId);
|
|
5230
|
+
// Queue acceptance is not runtime activity. A prompt remains owed while the adapter is unbound,
|
|
5231
|
+
// restarting, or refusing the insert; only the handoff that removes this exact message may re-enter
|
|
5232
|
+
// a waiting session as active. This keeps a queued command from painting a dead pane as working.
|
|
5233
|
+
if (!from && !pending)
|
|
5234
|
+
markHumanPromptActive(id);
|
|
5235
|
+
// @@@ a deferred drain measured NOTHING, and must not be reported as a refusal.
|
|
5236
|
+
// `pending` is read microseconds after the enqueue with no await in between, so when the drain was
|
|
5237
|
+
// deferred it is answering "did I skip the handover" — always yes — rather than "did the adapter refuse".
|
|
5238
|
+
// Reporting that as `queued` made every first Command Box send claim the transport was still owed while
|
|
5239
|
+
// the prompt was in fact in the agent's pane milliseconds later, and the claim was UNCONDITIONAL: no
|
|
5240
|
+
// transport state, harness, or runtime binding could change it. Name the deferral instead, so the one
|
|
5241
|
+
// caller that defers can say "accepted, handover in flight" and the callers that DO drain keep a
|
|
5242
|
+
// `queued` that still means what it says.
|
|
4541
5243
|
return {
|
|
4542
|
-
ok:
|
|
4543
|
-
|
|
4544
|
-
...(
|
|
5244
|
+
ok: true,
|
|
5245
|
+
delivery: opts.deferDrain ? 'deferred' : pending ? 'queued' : 'accepted',
|
|
5246
|
+
...(opts.idempotency || opts.deliveryKey ? { replayed } : {}),
|
|
4545
5247
|
};
|
|
4546
5248
|
}
|
|
4547
|
-
|
|
4548
|
-
// costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
|
|
4549
|
-
// the text in a live agent's current turn instead of up to one tick later.
|
|
4550
|
-
if (!opts.deferDrain)
|
|
4551
|
-
await drainSession(id);
|
|
4552
|
-
return { ok: true, ...(opts.idempotency ? { replayed } : {}) };
|
|
5249
|
+
throw new ResourceConflict('session application is unavailable; refusing the legacy delivery path');
|
|
4553
5250
|
}
|
|
4554
5251
|
// @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
|
|
4555
5252
|
// at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
|
|
4556
5253
|
// The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
|
|
4557
5254
|
export async function drainSession(id) {
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
5255
|
+
const application = configuredSessionApplicationIfCutover();
|
|
5256
|
+
if (application) {
|
|
5257
|
+
const rec = readRecord(id);
|
|
5258
|
+
if (!rec)
|
|
5259
|
+
return;
|
|
5260
|
+
// An empty canonical queue is a successful no-op. Do not turn a resume with no owed prompt into a
|
|
5261
|
+
// runtime-binding error; require a bound adapter only when there is a message that must be handed over.
|
|
5262
|
+
if (application.readPendingMessages(id).length === 0)
|
|
5263
|
+
return;
|
|
5264
|
+
const h = harnessById(rec.harness || defaultHarness.id);
|
|
5265
|
+
const binding = application.resolveRuntime(id, 'spex-governed');
|
|
5266
|
+
if (!binding || binding.status !== 'bound') {
|
|
5267
|
+
// Leaf adapters own their per-session controller and can deliver without a shared native identity.
|
|
5268
|
+
// Preserve the governed transport while that identity is absent, then acknowledge the same canonical
|
|
5269
|
+
// queue directly. Shared adapter runtimes (Codex) remain fail-closed until their exact binding exists.
|
|
5270
|
+
const leafWithoutNativeIdentity = !rec.harnessSessionId && (rec.harness === 'claude' || h.runtimeOwnership === 'leaf');
|
|
5271
|
+
if (leafWithoutNativeIdentity) {
|
|
5272
|
+
await withDeliveryLocks([id], async () => {
|
|
5273
|
+
for (;;) {
|
|
5274
|
+
const pending = application.readPendingMessages(id);
|
|
5275
|
+
const msg = pending[0];
|
|
5276
|
+
if (!msg)
|
|
5277
|
+
return;
|
|
5278
|
+
const text = canonicalMessageText(msg, rec);
|
|
5279
|
+
if (h.deliveryBlockedBy) {
|
|
5280
|
+
try {
|
|
5281
|
+
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
|
|
5282
|
+
return;
|
|
5283
|
+
}
|
|
5284
|
+
catch { /* no pane to consult — let the adapter decide */ }
|
|
5285
|
+
}
|
|
5286
|
+
const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.messageId }, text);
|
|
5287
|
+
if (!delivered.ok)
|
|
5288
|
+
return;
|
|
5289
|
+
const removed = application.dequeuePendingMessage(id, msg.messageId);
|
|
5290
|
+
if (!removed || removed.messageId !== msg.messageId)
|
|
5291
|
+
throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
|
|
5292
|
+
}
|
|
5293
|
+
});
|
|
5294
|
+
return;
|
|
4574
5295
|
}
|
|
4575
|
-
|
|
5296
|
+
throw new ResourceConflict(`canonical delivery for ${id} remains pending: no bound spex-governed runtime`);
|
|
4576
5297
|
}
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
5298
|
+
await withDeliveryLocks([id], async () => {
|
|
5299
|
+
for (;;) {
|
|
5300
|
+
const pending = application.readPendingMessages(id);
|
|
5301
|
+
const msg = pending[0];
|
|
5302
|
+
if (!msg)
|
|
5303
|
+
return;
|
|
5304
|
+
const text = canonicalMessageText(msg, rec);
|
|
5305
|
+
if (h.deliveryBlockedBy) {
|
|
5306
|
+
try {
|
|
5307
|
+
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
|
|
5308
|
+
return;
|
|
5309
|
+
}
|
|
5310
|
+
catch { /* no pane to consult — let the adapter decide */ }
|
|
5311
|
+
}
|
|
5312
|
+
const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.messageId }, text);
|
|
5313
|
+
if (!delivered.ok)
|
|
5314
|
+
return;
|
|
5315
|
+
const removed = application.dequeueForRuntime(id, 'spex-governed', binding.bindingGeneration, msg.messageId);
|
|
5316
|
+
if (!removed || removed.messageId !== msg.messageId)
|
|
5317
|
+
throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
|
|
5318
|
+
}
|
|
5319
|
+
});
|
|
5320
|
+
return;
|
|
5321
|
+
}
|
|
5322
|
+
throw new ResourceConflict('session application is unavailable; refusing the legacy delivery path');
|
|
5323
|
+
}
|
|
5324
|
+
// `recipient` is the session this text is delivered TO; a state message speaks about its `sessionId`, the
|
|
5325
|
+
// watched subject, and the notice must name that subject — never the reader of the notice.
|
|
5326
|
+
export function canonicalMessageText(message, recipient) {
|
|
5327
|
+
if (message.kind === 'session.prompt.v1')
|
|
5328
|
+
return Buffer.from(message.body).toString('utf8');
|
|
5329
|
+
if (message.kind === 'session.state.changed.v1') {
|
|
5330
|
+
try {
|
|
5331
|
+
const change = JSON.parse(Buffer.from(message.body).toString('utf8'));
|
|
5332
|
+
if (typeof change.sessionId !== 'string' || !change.sessionId)
|
|
5333
|
+
throw new ResourceConflict(`canonical state message delivered to ${recipient.session} names no subject session`);
|
|
5334
|
+
return watchMessage({ ...recipient, session: change.sessionId, status: (change.status ?? recipient.status), proposal: change.proposal ?? null, note: change.note ?? null, parent: change.parentSessionId ?? null });
|
|
5335
|
+
}
|
|
5336
|
+
catch (error) {
|
|
5337
|
+
if (error instanceof ResourceConflict)
|
|
5338
|
+
throw error;
|
|
5339
|
+
throw new ResourceConflict(`canonical state message for ${recipient.session} is not valid JSON`);
|
|
5340
|
+
}
|
|
5341
|
+
}
|
|
5342
|
+
throw new ResourceConflict(`canonical message kind ${message.kind} cannot be delivered as session text`);
|
|
4580
5343
|
}
|
|
4581
|
-
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness
|
|
4582
|
-
//
|
|
5344
|
+
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness with a native
|
|
5345
|
+
// primitive uses it. Without one the transport decides: a HEADLESS adapter has no keyboard, so it refuses
|
|
5346
|
+
// loudly rather than emulating an interrupt with a signal that could hit the wrong process; a PANE-BACKED
|
|
5347
|
+
// TUI has an operator's keyboard by definition, so its interrupt is the key that operator would press —
|
|
5348
|
+
// C-c into its own pane, through the raw-key channel below — and only while its lifecycle is actually
|
|
5349
|
+
// active, because the same key on an idle TUI is a second Ctrl-C away from quitting it.
|
|
4583
5350
|
export async function interruptSession(id) {
|
|
5351
|
+
// The lifecycle read and the key send share ONE record lock: a declaration that lands between them would
|
|
5352
|
+
// otherwise turn "interrupt the working turn" into "Ctrl-C an idle TUI", so there is no window.
|
|
4584
5353
|
return withRecordLock(id, async () => {
|
|
4585
5354
|
const rec = readRecord(id);
|
|
4586
5355
|
if (!rec)
|
|
4587
5356
|
return { ok: false, error: `no session record for ${id} - nothing to interrupt` };
|
|
4588
5357
|
const h = harnessById(rec.harness || defaultHarness.id);
|
|
4589
|
-
if (
|
|
5358
|
+
if (h.interrupt) {
|
|
5359
|
+
// stamped BEFORE the abort: the adapter's exit report can race the confirmation, and either order must
|
|
5360
|
+
// read "interrupted" (see the interrupt projection); a refused interrupt leaves no trace behind.
|
|
5361
|
+
stampInterrupt(id);
|
|
5362
|
+
const result = await h.interrupt({ ...rec, runtimeDir: runtimeRoot() });
|
|
5363
|
+
if (!result.ok) {
|
|
5364
|
+
clearInterruptMarker(id);
|
|
5365
|
+
return result;
|
|
5366
|
+
}
|
|
5367
|
+
projectInterruptedUnlocked(id);
|
|
5368
|
+
return result;
|
|
5369
|
+
}
|
|
5370
|
+
if (h.headless)
|
|
4590
5371
|
return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` };
|
|
4591
|
-
|
|
5372
|
+
if (rec.status !== 'active')
|
|
5373
|
+
return { ok: false, error: `session ${id} is not working (lifecycle ${rec.status}) - nothing to interrupt` };
|
|
5374
|
+
const sent = await sendRawKeysLocked(id, ['C-c']);
|
|
5375
|
+
return sent ? { ok: true } : { ok: false, error: `session ${id} has no live pane to interrupt` };
|
|
4592
5376
|
});
|
|
4593
5377
|
}
|
|
4594
5378
|
// @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
|
|
@@ -4649,19 +5433,24 @@ function rawKeyArgs(id, key) {
|
|
|
4649
5433
|
// ORDER, so they reach the pane in exactly the order they were struck. Concurrent per-key POSTs used to race
|
|
4650
5434
|
// (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
|
|
4651
5435
|
// An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
|
|
5436
|
+
// the send itself, for a caller that already holds the record lock (rawKey below; interruptSession above)
|
|
5437
|
+
async function sendRawKeysLocked(id, keys) {
|
|
5438
|
+
const list = keys.filter((k) => typeof k === 'string' && k.length > 0);
|
|
5439
|
+
if (list.length === 0 || !(await alive(id)))
|
|
5440
|
+
return false;
|
|
5441
|
+
let sent = false;
|
|
5442
|
+
for (const k of list) {
|
|
5443
|
+
const args = rawKeyArgs(id, k);
|
|
5444
|
+
if (!args)
|
|
5445
|
+
continue;
|
|
5446
|
+
await tmux(args);
|
|
5447
|
+
sent = true;
|
|
5448
|
+
}
|
|
5449
|
+
return sent;
|
|
5450
|
+
}
|
|
4652
5451
|
export async function rawKey(id, key) {
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
let sent = false;
|
|
4658
|
-
for (const k of list) {
|
|
4659
|
-
const args = rawKeyArgs(id, k);
|
|
4660
|
-
if (!args)
|
|
4661
|
-
continue;
|
|
4662
|
-
await tmux(args);
|
|
4663
|
-
sent = true;
|
|
4664
|
-
}
|
|
4665
|
-
return sent;
|
|
4666
|
-
});
|
|
5452
|
+
const sent = await withRecordLock(id, () => sendRawKeysLocked(id, Array.isArray(key) ? key : [key]));
|
|
5453
|
+
// Raw-key remote control is transport fallback, not a lifecycle event. Freshness belongs to the
|
|
5454
|
+
// harness turn hooks or a successfully handed-over durable prompt; navigation keys cannot forge working.
|
|
5455
|
+
return sent;
|
|
4667
5456
|
}
|