@richhardry/codor 0.10.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/LICENSE +21 -0
- package/README.md +293 -0
- package/bin/codor.mjs +7 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts +41 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.js +275 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.d.ts +2 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.js +2 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-antigravity/package.json +18 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts +94 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.js +785 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.d.ts +8 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.js +5 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts +18 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js +67 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts +3 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.js +128 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.d.ts +9 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.js +7 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts +40 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.js +505 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/package.json +18 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.d.ts +105 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.js +625 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts +39 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.js +186 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/index.d.ts +8 -0
- package/node_modules/@codor/adapter-codex/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/index.js +5 -0
- package/node_modules/@codor/adapter-codex/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts +18 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.js +87 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/peek.d.ts +3 -0
- package/node_modules/@codor/adapter-codex/dist/peek.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/peek.js +98 -0
- package/node_modules/@codor/adapter-codex/dist/peek.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/translate.d.ts +17 -0
- package/node_modules/@codor/adapter-codex/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/translate.js +286 -0
- package/node_modules/@codor/adapter-codex/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-codex/package.json +17 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts +40 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.js +190 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-copilot/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/index.js +3 -0
- package/node_modules/@codor/adapter-copilot/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.d.ts +11 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.js +184 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/package.json +18 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts +32 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.js +179 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/index.d.ts +9 -0
- package/node_modules/@codor/adapter-cursor/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/index.js +8 -0
- package/node_modules/@codor/adapter-cursor/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.d.ts +15 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.js +164 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/package.json +17 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts +36 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.js +245 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-gemini/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/index.js +3 -0
- package/node_modules/@codor/adapter-gemini/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.d.ts +12 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.js +123 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/package.json +18 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts +42 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.js +240 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-opencode/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/index.js +3 -0
- package/node_modules/@codor/adapter-opencode/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.d.ts +12 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.js +144 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/package.json +18 -0
- package/node_modules/@codor/cli/dist/artifact.d.ts +33 -0
- package/node_modules/@codor/cli/dist/artifact.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/artifact.js +183 -0
- package/node_modules/@codor/cli/dist/artifact.js.map +1 -0
- package/node_modules/@codor/cli/dist/attach.d.ts +25 -0
- package/node_modules/@codor/cli/dist/attach.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/attach.js +133 -0
- package/node_modules/@codor/cli/dist/attach.js.map +1 -0
- package/node_modules/@codor/cli/dist/connection.d.ts +21 -0
- package/node_modules/@codor/cli/dist/connection.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/connection.js +130 -0
- package/node_modules/@codor/cli/dist/connection.js.map +1 -0
- package/node_modules/@codor/cli/dist/detect.d.ts +14 -0
- package/node_modules/@codor/cli/dist/detect.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/detect.js +99 -0
- package/node_modules/@codor/cli/dist/detect.js.map +1 -0
- package/node_modules/@codor/cli/dist/index.d.ts +17 -0
- package/node_modules/@codor/cli/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/index.js +24 -0
- package/node_modules/@codor/cli/dist/index.js.map +1 -0
- package/node_modules/@codor/cli/dist/mirror.d.ts +4 -0
- package/node_modules/@codor/cli/dist/mirror.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/mirror.js +93 -0
- package/node_modules/@codor/cli/dist/mirror.js.map +1 -0
- package/node_modules/@codor/cli/dist/program.d.ts +17 -0
- package/node_modules/@codor/cli/dist/program.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/program.js +1068 -0
- package/node_modules/@codor/cli/dist/program.js.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-install.d.ts +61 -0
- package/node_modules/@codor/cli/dist/runtime-install.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-install.js +122 -0
- package/node_modules/@codor/cli/dist/runtime-install.js.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-paths.d.ts +24 -0
- package/node_modules/@codor/cli/dist/runtime-paths.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-paths.js +69 -0
- package/node_modules/@codor/cli/dist/runtime-paths.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-flow.d.ts +69 -0
- package/node_modules/@codor/cli/dist/setup-flow.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-flow.js +110 -0
- package/node_modules/@codor/cli/dist/setup-flow.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-session.d.ts +105 -0
- package/node_modules/@codor/cli/dist/setup-session.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-session.js +346 -0
- package/node_modules/@codor/cli/dist/setup-session.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-ui.d.ts +93 -0
- package/node_modules/@codor/cli/dist/setup-ui.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-ui.js +249 -0
- package/node_modules/@codor/cli/dist/setup-ui.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup.d.ts +108 -0
- package/node_modules/@codor/cli/dist/setup.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup.js +878 -0
- package/node_modules/@codor/cli/dist/setup.js.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-keys.d.ts +26 -0
- package/node_modules/@codor/cli/dist/terminal-keys.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-keys.js +70 -0
- package/node_modules/@codor/cli/dist/terminal-keys.js.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-qr.d.ts +2 -0
- package/node_modules/@codor/cli/dist/terminal-qr.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-qr.js +30 -0
- package/node_modules/@codor/cli/dist/terminal-qr.js.map +1 -0
- package/node_modules/@codor/cli/dist/up.d.ts +52 -0
- package/node_modules/@codor/cli/dist/up.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/up.js +178 -0
- package/node_modules/@codor/cli/dist/up.js.map +1 -0
- package/node_modules/@codor/cli/package.json +24 -0
- package/node_modules/@codor/cli/packaging/systemd/codor.service +21 -0
- package/node_modules/@codor/cli/runtime/web/assets/LandingPage-BOmG5kSw.js +31 -0
- package/node_modules/@codor/cli/runtime/web/assets/LedgerPage-CecuTS8-.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/NoChannels-CYiBXGQd.js +11 -0
- package/node_modules/@codor/cli/runtime/web/assets/PairingCodeInput-Cr6XDWx-.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/PairingPage-CQBWNA2L.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/SettingsPage-DGLbSXE7.js +16 -0
- package/node_modules/@codor/cli/runtime/web/assets/StartupUnavailable-DdQBHip_.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/arrow-left-BOpjMuRi.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/browser-BWQYNOsD.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/graph-toBZHlC0.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/index-Cxi54Xim.js +408 -0
- package/node_modules/@codor/cli/runtime/web/assets/index-DjwJakJC.css +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/qr-DpynCwP9.js +8 -0
- package/node_modules/@codor/cli/runtime/web/assets/react-C8w-UNLI.js +32 -0
- package/node_modules/@codor/cli/runtime/web/assets/sodium-DtsvMzX9.js +1 -0
- package/node_modules/@codor/cli/runtime/web/codor-192.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-512.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-apple-touch-180.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-favicon.svg +86 -0
- package/node_modules/@codor/cli/runtime/web/codor-mark.svg +86 -0
- package/node_modules/@codor/cli/runtime/web/codor-maskable-512.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-og.png +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/BitcountPropSingle-latin.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/Geist-Variable.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/GeistMono-Variable.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/OFL.txt +93 -0
- package/node_modules/@codor/cli/runtime/web/index.html +53 -0
- package/node_modules/@codor/cli/runtime/web/manifest.webmanifest +1 -0
- package/node_modules/@codor/cli/runtime/web/sw.js +2 -0
- package/node_modules/@codor/protocol/dist/adapter.d.ts +118 -0
- package/node_modules/@codor/protocol/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/adapter.js +15 -0
- package/node_modules/@codor/protocol/dist/adapter.js.map +1 -0
- package/node_modules/@codor/protocol/dist/body.d.ts +14 -0
- package/node_modules/@codor/protocol/dist/body.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/body.js +50 -0
- package/node_modules/@codor/protocol/dist/body.js.map +1 -0
- package/node_modules/@codor/protocol/dist/changelog.d.ts +30 -0
- package/node_modules/@codor/protocol/dist/changelog.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/changelog.js +19 -0
- package/node_modules/@codor/protocol/dist/changelog.js.map +1 -0
- package/node_modules/@codor/protocol/dist/default-recipient.d.ts +8 -0
- package/node_modules/@codor/protocol/dist/default-recipient.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/default-recipient.js +19 -0
- package/node_modules/@codor/protocol/dist/default-recipient.js.map +1 -0
- package/node_modules/@codor/protocol/dist/delivery.d.ts +42 -0
- package/node_modules/@codor/protocol/dist/delivery.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/delivery.js +44 -0
- package/node_modules/@codor/protocol/dist/delivery.js.map +1 -0
- package/node_modules/@codor/protocol/dist/events.d.ts +284 -0
- package/node_modules/@codor/protocol/dist/events.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/events.js +150 -0
- package/node_modules/@codor/protocol/dist/events.js.map +1 -0
- package/node_modules/@codor/protocol/dist/ids.d.ts +19 -0
- package/node_modules/@codor/protocol/dist/ids.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/ids.js +27 -0
- package/node_modules/@codor/protocol/dist/ids.js.map +1 -0
- package/node_modules/@codor/protocol/dist/index.d.ts +14 -0
- package/node_modules/@codor/protocol/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/index.js +14 -0
- package/node_modules/@codor/protocol/dist/index.js.map +1 -0
- package/node_modules/@codor/protocol/dist/interaction.d.ts +45 -0
- package/node_modules/@codor/protocol/dist/interaction.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/interaction.js +33 -0
- package/node_modules/@codor/protocol/dist/interaction.js.map +1 -0
- package/node_modules/@codor/protocol/dist/member.d.ts +146 -0
- package/node_modules/@codor/protocol/dist/member.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/member.js +169 -0
- package/node_modules/@codor/protocol/dist/member.js.map +1 -0
- package/node_modules/@codor/protocol/dist/message.d.ts +166 -0
- package/node_modules/@codor/protocol/dist/message.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/message.js +95 -0
- package/node_modules/@codor/protocol/dist/message.js.map +1 -0
- package/node_modules/@codor/protocol/dist/observability.d.ts +80 -0
- package/node_modules/@codor/protocol/dist/observability.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/observability.js +41 -0
- package/node_modules/@codor/protocol/dist/observability.js.map +1 -0
- package/node_modules/@codor/protocol/dist/room.d.ts +430 -0
- package/node_modules/@codor/protocol/dist/room.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/room.js +142 -0
- package/node_modules/@codor/protocol/dist/room.js.map +1 -0
- package/node_modules/@codor/protocol/dist/ws.d.ts +1348 -0
- package/node_modules/@codor/protocol/dist/ws.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/ws.js +282 -0
- package/node_modules/@codor/protocol/dist/ws.js.map +1 -0
- package/node_modules/@codor/protocol/package.json +17 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts +14 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.js +175 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/authorization.d.ts +13 -0
- package/node_modules/@codor/switchboard/dist/authorization.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/authorization.js +96 -0
- package/node_modules/@codor/switchboard/dist/authorization.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/blobs.d.ts +17 -0
- package/node_modules/@codor/switchboard/dist/blobs.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/blobs.js +54 -0
- package/node_modules/@codor/switchboard/dist/blobs.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/collaboration.d.ts +115 -0
- package/node_modules/@codor/switchboard/dist/collaboration.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/collaboration.js +123 -0
- package/node_modules/@codor/switchboard/dist/collaboration.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/continuation.d.ts +33 -0
- package/node_modules/@codor/switchboard/dist/continuation.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/continuation.js +177 -0
- package/node_modules/@codor/switchboard/dist/continuation.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts +22 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js +92 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts +37 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.js +132 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts +41 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.js +145 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts +53 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.js +182 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts +30 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js +101 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/daemon.d.ts +545 -0
- package/node_modules/@codor/switchboard/dist/daemon.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/daemon.js +3907 -0
- package/node_modules/@codor/switchboard/dist/daemon.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts +103 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.js +228 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/index.d.ts +44 -0
- package/node_modules/@codor/switchboard/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/index.js +24 -0
- package/node_modules/@codor/switchboard/dist/index.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts +12 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.js +17 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts +47 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.js +175 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts +73 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.js +232 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.d.ts +14 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.js +40 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-socket.d.ts +3 -0
- package/node_modules/@codor/switchboard/dist/local-socket.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-socket.js +16 -0
- package/node_modules/@codor/switchboard/dist/local-socket.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/pricing.d.ts +32 -0
- package/node_modules/@codor/switchboard/dist/pricing.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/pricing.js +64 -0
- package/node_modules/@codor/switchboard/dist/pricing.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.d.ts +2 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.js +9 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/producer.d.ts +54 -0
- package/node_modules/@codor/switchboard/dist/push/producer.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/producer.js +168 -0
- package/node_modules/@codor/switchboard/dist/push/producer.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts +29 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.js +83 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/redact.d.ts +11 -0
- package/node_modules/@codor/switchboard/dist/redact.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/redact.js +65 -0
- package/node_modules/@codor/switchboard/dist/redact.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/residency.d.ts +110 -0
- package/node_modules/@codor/switchboard/dist/residency.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/residency.js +587 -0
- package/node_modules/@codor/switchboard/dist/residency.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/router.d.ts +120 -0
- package/node_modules/@codor/switchboard/dist/router.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/router.js +198 -0
- package/node_modules/@codor/switchboard/dist/router.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/server.d.ts +50 -0
- package/node_modules/@codor/switchboard/dist/server.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/server.js +1425 -0
- package/node_modules/@codor/switchboard/dist/server.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/store.d.ts +534 -0
- package/node_modules/@codor/switchboard/dist/store.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/store.js +2625 -0
- package/node_modules/@codor/switchboard/dist/store.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts +59 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js +183 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.d.ts +87 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.js +316 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/working-directory.d.ts +2 -0
- package/node_modules/@codor/switchboard/dist/working-directory.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/working-directory.js +26 -0
- package/node_modules/@codor/switchboard/dist/working-directory.js.map +1 -0
- package/node_modules/@codor/switchboard/package.json +33 -0
- package/package.json +58 -0
|
@@ -0,0 +1,3907 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual, promisify } from 'node:util';
|
|
7
|
+
import { AttachmentSchema, MemberStatusResponseSchema, deriveRoomId, parseRunItemPayload, } from '@codor/protocol';
|
|
8
|
+
import { BlobStore } from './blobs.js';
|
|
9
|
+
import { validateSpawnOptions } from './adapter-registry.js';
|
|
10
|
+
import { roleAllows } from './authorization.js';
|
|
11
|
+
import { composeGroupRoundPayload, selectDeliveryBatchPrefix, } from './collaboration.js';
|
|
12
|
+
import { ContinuationWriter, projectContinuationOutputs } from './continuation.js';
|
|
13
|
+
import { localSocketPath } from './local-socket.js';
|
|
14
|
+
import { processProbeTarget } from './process-liveness.js';
|
|
15
|
+
import { estimateCostUsd } from './pricing.js';
|
|
16
|
+
import { redactValue } from './redact.js';
|
|
17
|
+
import { RemoteAttemptAmbiguousError, remoteMemberSpec, } from './residency.js';
|
|
18
|
+
import { composeDeliveryBriefing, composePayload, evaluateBrakes, parseBody, resolveRecipients, } from './router.js';
|
|
19
|
+
import { Store, } from './store.js';
|
|
20
|
+
import { normalizeWorkingDirectory } from './working-directory.js';
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
// ── Room git working-state (diff explorer) ─────────────────────────────────
|
|
23
|
+
// Every git call is read-only and runs through execFileAsync (no shell, no
|
|
24
|
+
// interpolation). The directory is always one the room already recorded — never
|
|
25
|
+
// a free path from the client. Per-file diffs are capped so a large working tree
|
|
26
|
+
// cannot balloon the response. See gitWorkingState / readGitWorkingFiles below.
|
|
27
|
+
// ── Message attachments ────────────────────────────────────────────────────
|
|
28
|
+
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
29
|
+
const MAX_ATTACHMENTS_PER_MESSAGE = 8;
|
|
30
|
+
const ORPHAN_ATTACHMENT_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
const ATTACHMENT_ID = /^[0-9a-f]{32}$/;
|
|
32
|
+
function formatBytes(size) {
|
|
33
|
+
if (size < 1024)
|
|
34
|
+
return `${String(size)} B`;
|
|
35
|
+
if (size < 1024 * 1024)
|
|
36
|
+
return `${(size / 1024).toFixed(1)} KB`;
|
|
37
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
38
|
+
}
|
|
39
|
+
// Boot-recovery requeue fence: a delivery whose turn has been started this many
|
|
40
|
+
// times without completing is left consumed instead of re-queued, so a poisonous
|
|
41
|
+
// instruction that kills the daemon on every start cannot ping-pong the service.
|
|
42
|
+
// Matches the existing give-up bound (residency retries stop at attempt_count 2).
|
|
43
|
+
export const RECOVERY_ATTEMPT_CEILING = 2;
|
|
44
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
45
|
+
const GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
46
|
+
const MAX_FILE_DIFF_CHARS = 40_000;
|
|
47
|
+
const DIFF_TRUNCATED_MARKER = '\n… diff truncated …\n';
|
|
48
|
+
function porcelainStatus(x, y) {
|
|
49
|
+
if (x === '?' && y === '?')
|
|
50
|
+
return 'untracked';
|
|
51
|
+
if (x === 'R' || y === 'R' || x === 'C' || y === 'C')
|
|
52
|
+
return 'renamed';
|
|
53
|
+
if (x === 'D' || y === 'D')
|
|
54
|
+
return 'deleted';
|
|
55
|
+
if (x === 'A' || y === 'A')
|
|
56
|
+
return 'added';
|
|
57
|
+
return 'modified';
|
|
58
|
+
}
|
|
59
|
+
/** Parse `git status --porcelain=v1 -z` into per-file entries. A rename/copy
|
|
60
|
+
* record carries a trailing original-path field, which is consumed (skipped). */
|
|
61
|
+
function parsePorcelainStatus(output) {
|
|
62
|
+
const tokens = output.split('\0');
|
|
63
|
+
const entries = [];
|
|
64
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
65
|
+
const token = tokens[i];
|
|
66
|
+
if (token === undefined || token.length < 4)
|
|
67
|
+
continue;
|
|
68
|
+
const x = token[0];
|
|
69
|
+
const y = token[1];
|
|
70
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C')
|
|
71
|
+
i++; // skip original path
|
|
72
|
+
entries.push({ path: token.slice(3), status: porcelainStatus(x, y) });
|
|
73
|
+
}
|
|
74
|
+
return entries;
|
|
75
|
+
}
|
|
76
|
+
/** Additions/deletions counted from a unified diff — the fallback for sources
|
|
77
|
+
* `--numstat` does not cover (untracked files diffed via --no-index). */
|
|
78
|
+
function countDiffLines(diff) {
|
|
79
|
+
let additions = 0;
|
|
80
|
+
let deletions = 0;
|
|
81
|
+
for (const line of diff.split('\n')) {
|
|
82
|
+
if (line.startsWith('+') && !line.startsWith('+++'))
|
|
83
|
+
additions++;
|
|
84
|
+
else if (line.startsWith('-') && !line.startsWith('---'))
|
|
85
|
+
deletions++;
|
|
86
|
+
}
|
|
87
|
+
return { additions, deletions };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Untrusted CLI stdout: only these shapes become buttons, and never many.
|
|
91
|
+
* Multi-segment ids are real (opencode reports `openrouter/anthropic/claude-…`),
|
|
92
|
+
* but a leading dash is not a model — it is a flag smuggled into an argv slot.
|
|
93
|
+
*/
|
|
94
|
+
const MODEL_ID = /^\w[\w.:-]*(?:\/[\w.:-]+)*$/;
|
|
95
|
+
const MAX_MODELS = 200;
|
|
96
|
+
// harn:assume run-cost-estimates-are-finalization-snapshots ref=run-estimate-finalization
|
|
97
|
+
function accountRunUsage(run, usage) {
|
|
98
|
+
if (usage === undefined)
|
|
99
|
+
return { costUsd: 0, uncostedTokens: 0 };
|
|
100
|
+
if (usage.cost_usd !== undefined) {
|
|
101
|
+
return { costUsd: usage.cost_usd, uncostedTokens: 0 };
|
|
102
|
+
}
|
|
103
|
+
const estimatedCostUsd = run.estimated_cost_usd ?? estimateCostUsd(run.model, usage);
|
|
104
|
+
return {
|
|
105
|
+
costUsd: 0,
|
|
106
|
+
...(estimatedCostUsd !== undefined && { estimatedCostUsd }),
|
|
107
|
+
uncostedTokens: estimatedCostUsd === undefined
|
|
108
|
+
? usage.input_tokens + usage.output_tokens
|
|
109
|
+
: 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
113
|
+
function ulid() {
|
|
114
|
+
let ts = Date.now();
|
|
115
|
+
let time = '';
|
|
116
|
+
for (let i = 0; i < 10; i++) {
|
|
117
|
+
time = ULID_ALPHABET[ts % 32] + time;
|
|
118
|
+
ts = Math.floor(ts / 32);
|
|
119
|
+
}
|
|
120
|
+
let random = '';
|
|
121
|
+
for (let i = 0; i < 16; i++)
|
|
122
|
+
random += ULID_ALPHABET[Math.floor(Math.random() * 32)];
|
|
123
|
+
return time + random;
|
|
124
|
+
}
|
|
125
|
+
function extensionSuffix(nativeId) {
|
|
126
|
+
const clean = nativeId.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
127
|
+
if (clean.length >= 6)
|
|
128
|
+
return clean.slice(0, 6);
|
|
129
|
+
let hash = 2166136261;
|
|
130
|
+
for (const char of nativeId)
|
|
131
|
+
hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
|
132
|
+
return `${clean}${(hash >>> 0).toString(36)}`.slice(0, 6).padEnd(6, '0');
|
|
133
|
+
}
|
|
134
|
+
function extensionDescription(event) {
|
|
135
|
+
if (event.item_type !== 'tool_call' || typeof event.payload !== 'object' || event.payload === null) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const payload = event.payload;
|
|
139
|
+
if (payload.tool !== 'Agent' && payload.tool !== 'Task')
|
|
140
|
+
return undefined;
|
|
141
|
+
if (typeof payload.input !== 'object' || payload.input === null)
|
|
142
|
+
return undefined;
|
|
143
|
+
const input = payload.input;
|
|
144
|
+
for (const value of [input.description, input.name, input.prompt]) {
|
|
145
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
146
|
+
return value.trim().slice(0, 160);
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/** Semantic identity of an interaction — native ids are process-lifetime only. */
|
|
151
|
+
function interactionKey(kind, card) {
|
|
152
|
+
const labels = (card.options ?? []).map((o) => o.label).join('|');
|
|
153
|
+
return `${kind}\0${card.prompt}\0${labels}\0${card.tool ?? ''}`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The switchboard daemon core: turn pump, interaction state machine,
|
|
157
|
+
* attempt-WAL reconcile, change-log fanout. Transport-free — server.ts puts
|
|
158
|
+
* WS/REST in front of it; tests drive it with a FakeAdapter.
|
|
159
|
+
*/
|
|
160
|
+
export class Daemon {
|
|
161
|
+
store;
|
|
162
|
+
blobs;
|
|
163
|
+
pushLog = [];
|
|
164
|
+
adapters = new Map();
|
|
165
|
+
modelCatalogs = new Map();
|
|
166
|
+
pendingDiscoveries = 0;
|
|
167
|
+
sessions = new Map();
|
|
168
|
+
/**
|
|
169
|
+
* Members whose settings changed and whose cached session is therefore out of date.
|
|
170
|
+
*
|
|
171
|
+
* The session is NOT dropped when it is marked: a turn in flight raised its own ask
|
|
172
|
+
* cards against that very session object, and answering one looks it up here — pull it
|
|
173
|
+
* out from under a running turn and the operator's answer lands on nothing. It is
|
|
174
|
+
* rebuilt at the START of the next turn, which is the only moment at which the old
|
|
175
|
+
* settings stop being the ones actually in use.
|
|
176
|
+
*/
|
|
177
|
+
staleSessions = new Set();
|
|
178
|
+
inflight = new Set();
|
|
179
|
+
active = new Set();
|
|
180
|
+
listeners = [];
|
|
181
|
+
pendingAttach = new Set();
|
|
182
|
+
releasedDeliveries = new Set();
|
|
183
|
+
operatorInterrupts = new Set();
|
|
184
|
+
/** Members whose in-flight turn THIS daemon's lifecycle is interrupting, so
|
|
185
|
+
* finalization can tell retryable collateral from a deliberate Stop. */
|
|
186
|
+
lifecycleInterrupts = new Set();
|
|
187
|
+
memberWaits = new Map();
|
|
188
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
|
|
189
|
+
lastUsage = new Map();
|
|
190
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
191
|
+
groupWaits = new Map();
|
|
192
|
+
attachLeaseTimeoutMs;
|
|
193
|
+
processProbe;
|
|
194
|
+
attachLeaseTimer;
|
|
195
|
+
stallTimer;
|
|
196
|
+
limitsProbeTimer;
|
|
197
|
+
probingLimits = false;
|
|
198
|
+
runActivity = new Map();
|
|
199
|
+
hostId;
|
|
200
|
+
residency;
|
|
201
|
+
ledger;
|
|
202
|
+
pushProducer;
|
|
203
|
+
onBackgroundError;
|
|
204
|
+
homeDir;
|
|
205
|
+
socketPath;
|
|
206
|
+
attachmentsRoot;
|
|
207
|
+
stopResidencyReachability;
|
|
208
|
+
closing = false;
|
|
209
|
+
closed = false;
|
|
210
|
+
constructor(options) {
|
|
211
|
+
this.store = new Store(options.dbPath);
|
|
212
|
+
this.blobs = new BlobStore(options.blobRoot);
|
|
213
|
+
this.hostId = options.hostId;
|
|
214
|
+
this.residency = options.residency;
|
|
215
|
+
this.ledger = options.ledger;
|
|
216
|
+
this.pushProducer = options.pushProducer;
|
|
217
|
+
this.onBackgroundError = options.onBackgroundError ?? (() => undefined);
|
|
218
|
+
this.homeDir = options.homeDir ?? homedir();
|
|
219
|
+
this.socketPath = options.socketPath ?? localSocketPath(dirname(options.dbPath));
|
|
220
|
+
this.attachmentsRoot = join(dirname(options.dbPath), 'attachments');
|
|
221
|
+
this.sweepOrphanAttachments(); // boot-time: drop uploads no message ever claimed
|
|
222
|
+
this.ledger?.setRoomValidator((room) => this.store.getRoom(room) !== undefined);
|
|
223
|
+
this.ledger?.setRemoteWriteAuthorizer((peerId, room, author) => {
|
|
224
|
+
const members = this.store.listMembers(room);
|
|
225
|
+
const peerBelongsToRoom = members.some((member) => member.kind === 'agent' && member.host === peerId);
|
|
226
|
+
if (!peerBelongsToRoom)
|
|
227
|
+
return false;
|
|
228
|
+
const attributed = members.find((member) => member.handle === author);
|
|
229
|
+
if (attributed?.kind === 'agent')
|
|
230
|
+
return attributed.host === peerId;
|
|
231
|
+
return attributed?.kind === 'human' && attributed.role !== undefined &&
|
|
232
|
+
roleAllows(attributed.role, 'manage_ledger');
|
|
233
|
+
});
|
|
234
|
+
this.ledger?.setChangeHandler(({ room, name, author }) => {
|
|
235
|
+
if (this.store.getRoom(room))
|
|
236
|
+
this.postSystemMessage(room, `@${author} updated [[${name}]]`);
|
|
237
|
+
});
|
|
238
|
+
for (const adapter of options.adapters)
|
|
239
|
+
this.adapters.set(adapter.id, adapter);
|
|
240
|
+
if (options.discoverModels ?? true)
|
|
241
|
+
this.discoverModels();
|
|
242
|
+
this.attachLeaseTimeoutMs = options.attachLeaseTimeoutMs ?? 5_000;
|
|
243
|
+
this.processProbe = options.processProbe ?? ((target) => {
|
|
244
|
+
try {
|
|
245
|
+
process.kill(target, 0);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
return error.code === 'EPERM';
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
this.attachLeaseTimer = setInterval(() => this.reconcileAttachLeases(), options.attachLeasePollMs ?? 1_000);
|
|
253
|
+
this.attachLeaseTimer.unref();
|
|
254
|
+
this.stallTimer = setInterval(() => this.checkStalls(), options.stallPollMs ?? 60_000);
|
|
255
|
+
this.stallTimer.unref();
|
|
256
|
+
this.track(this.probeAdapterLimits());
|
|
257
|
+
this.limitsProbeTimer = setInterval(() => this.track(this.probeAdapterLimits()), options.limitsProbeMs ?? 15 * 60_000);
|
|
258
|
+
this.limitsProbeTimer.unref();
|
|
259
|
+
this.stopResidencyReachability = this.residency?.onReachability((peerId, connected) => this.handleResidentReachability(peerId, connected));
|
|
260
|
+
}
|
|
261
|
+
// harn:assume agent-member-credentials-stay-secret ref=member-session-environment
|
|
262
|
+
issueMemberCredential(room, member, session) {
|
|
263
|
+
const token = randomBytes(32).toString('base64url');
|
|
264
|
+
const credentialHash = createHash('sha256').update(token).digest('hex');
|
|
265
|
+
this.store.setAgentCredentialHash(room, member.id, credentialHash);
|
|
266
|
+
session.env = {
|
|
267
|
+
...session.env,
|
|
268
|
+
CODOR_SOCKET: this.socketPath,
|
|
269
|
+
CODOR_CHANNEL: room,
|
|
270
|
+
CODOR_MEMBER_ID: member.id,
|
|
271
|
+
CODOR_MEMBER_TOKEN: token,
|
|
272
|
+
// harn:assume member-session-masks-operator-token ref=member-token-environment-mask
|
|
273
|
+
CODOR_TOKEN: token,
|
|
274
|
+
// harn:end member-session-masks-operator-token
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
authenticateAgentToken(token) {
|
|
278
|
+
if (token === '')
|
|
279
|
+
return undefined;
|
|
280
|
+
const credentialHash = createHash('sha256').update(token).digest('hex');
|
|
281
|
+
return this.store.findAgentByCredentialHash(credentialHash);
|
|
282
|
+
}
|
|
283
|
+
// harn:end agent-member-credentials-stay-secret
|
|
284
|
+
async close(options = {}) {
|
|
285
|
+
if (this.closed)
|
|
286
|
+
return;
|
|
287
|
+
this.closing = true;
|
|
288
|
+
clearInterval(this.attachLeaseTimer);
|
|
289
|
+
clearInterval(this.stallTimer);
|
|
290
|
+
clearInterval(this.limitsProbeTimer);
|
|
291
|
+
this.stopResidencyReachability?.();
|
|
292
|
+
if (options.force !== true) {
|
|
293
|
+
// harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
|
|
294
|
+
// A graceful stop interrupts live turns through the same adapter path the
|
|
295
|
+
// Stop button uses, so those runs finalize `interrupted` BEFORE the store
|
|
296
|
+
// closes — at boot they are indistinguishable from a deliberate Stop (#492).
|
|
297
|
+
// Mark them HERE, where the cause is provably lifecycle, so finalization
|
|
298
|
+
// takes the atomic retry-or-terminal settlement instead of committing a
|
|
299
|
+
// participant result and re-queueing behind it.
|
|
300
|
+
for (const turn of this.snapshotInFlightTurns()) {
|
|
301
|
+
this.lifecycleInterrupts.add(this.store.getMessage(turn.room, turn.runMsgId).author);
|
|
302
|
+
}
|
|
303
|
+
// harn:end lifecycle-retries-only-live-collaboration-work
|
|
304
|
+
for (const [memberId, session] of this.sessions) {
|
|
305
|
+
const member = this.store.listRooms().find((room) => this.store.getMember(room.id, memberId));
|
|
306
|
+
const persisted = member ? this.store.getMember(member.id, memberId) : undefined;
|
|
307
|
+
if (persisted?.harness)
|
|
308
|
+
this.requireAdapter(persisted.harness).interrupt(session);
|
|
309
|
+
}
|
|
310
|
+
// Each turn settled itself atomically as it finalized; there is no second
|
|
311
|
+
// healing pass to run, and nothing left half-committed if we stop here.
|
|
312
|
+
await this.settle();
|
|
313
|
+
}
|
|
314
|
+
await this.ledger?.close();
|
|
315
|
+
this.store.close();
|
|
316
|
+
this.closed = true;
|
|
317
|
+
}
|
|
318
|
+
// harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
|
|
319
|
+
/** Deliveries bound to a run the daemon's lifecycle is about to interrupt (or
|
|
320
|
+
* already has) — the set the atomic settlement decides over. */
|
|
321
|
+
boundLifecycleDeliveries(room, runMsgId) {
|
|
322
|
+
return this.store
|
|
323
|
+
.listDeliveries(room)
|
|
324
|
+
.filter((delivery) => delivery.run_msg_id === runMsgId
|
|
325
|
+
&& (delivery.state === 'consumed' || delivery.state === 'delivering'));
|
|
326
|
+
}
|
|
327
|
+
/** The in-flight turns a graceful close is about to interrupt, captured BEFORE
|
|
328
|
+
* the interrupt so the cause is provably lifecycle. Scoped to turns THIS daemon
|
|
329
|
+
* is actually running (`inflight`) — a run left `running` by an earlier crash is
|
|
330
|
+
* not ours to heal here; boot recovery owns that seam. A turn an operator stopped
|
|
331
|
+
* is excluded: its run already finalized, or its member is still marked as an
|
|
332
|
+
* operator interrupt — either way a deliberate Stop must stay stopped. */
|
|
333
|
+
snapshotInFlightTurns() {
|
|
334
|
+
const snapshot = [];
|
|
335
|
+
for (const room of this.store.listRooms()) {
|
|
336
|
+
for (const message of this.store.listMessages(room.id, { limit: Number.MAX_SAFE_INTEGER })) {
|
|
337
|
+
if (message.kind !== 'run' || message.run?.status !== 'running')
|
|
338
|
+
continue;
|
|
339
|
+
if (!this.inflight.has(message.author))
|
|
340
|
+
continue; // not a turn this daemon is running
|
|
341
|
+
if (this.operatorInterrupts.has(message.author))
|
|
342
|
+
continue; // a Stop stays stopped
|
|
343
|
+
const bound = this.boundLifecycleDeliveries(room.id, message.id);
|
|
344
|
+
if (bound.length === 0)
|
|
345
|
+
continue;
|
|
346
|
+
snapshot.push({
|
|
347
|
+
room: room.id,
|
|
348
|
+
runMsgId: message.id,
|
|
349
|
+
deliveryIds: bound.map((delivery) => delivery.id),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return snapshot;
|
|
354
|
+
}
|
|
355
|
+
// harn:end lifecycle-retries-only-live-collaboration-work
|
|
356
|
+
/** Tracks a fire-and-forget turn chain so settle() can await quiescence. */
|
|
357
|
+
track(promise) {
|
|
358
|
+
const wrapped = promise.catch((error) => {
|
|
359
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
360
|
+
try {
|
|
361
|
+
this.onBackgroundError(failure);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// A diagnostic sink must never break daemon settlement.
|
|
365
|
+
}
|
|
366
|
+
}).finally(() => this.active.delete(wrapped));
|
|
367
|
+
this.active.add(wrapped);
|
|
368
|
+
}
|
|
369
|
+
/** Resolves when no turn chains are running (blocked asks keep it waiting). */
|
|
370
|
+
async settle() {
|
|
371
|
+
while (this.active.size > 0) {
|
|
372
|
+
await Promise.all([...this.active]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// harn:assume stall-flags-never-kills ref=run-stall-watchdog
|
|
376
|
+
checkStalls(now = new Date()) {
|
|
377
|
+
for (const room of this.store.listRooms()) {
|
|
378
|
+
const timeoutMs = room.config.stall_minutes * 60_000;
|
|
379
|
+
for (const message of this.store.listMessages(room.id, { limit: Number.MAX_SAFE_INTEGER })) {
|
|
380
|
+
if (message.kind !== 'run' || message.run?.status !== 'running')
|
|
381
|
+
continue;
|
|
382
|
+
// harn:assume live-agent-waits-are-transient ref=wait-stall-exemption
|
|
383
|
+
const wait = this.memberWaits.get(message.author);
|
|
384
|
+
if (wait && Date.parse(wait.until_ts) > now.getTime())
|
|
385
|
+
continue;
|
|
386
|
+
// harn:end live-agent-waits-are-transient
|
|
387
|
+
const key = `${room.id}:${message.id}`;
|
|
388
|
+
const lastActivity = this.runActivity.get(key) ?? Date.parse(message.run.started_ts);
|
|
389
|
+
if (now.getTime() - lastActivity < timeoutMs || message.run.stalled_since !== undefined) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const stalled = this.store.updateMessage(room.id, message.id, {
|
|
393
|
+
run: { ...message.run, stalled_since: now.toISOString() },
|
|
394
|
+
});
|
|
395
|
+
this.emitMessage(room.id, stalled);
|
|
396
|
+
const member = this.store.getMember(room.id, message.author);
|
|
397
|
+
const body = `@${member?.handle ?? message.author} run #${message.id} has stalled with no events`;
|
|
398
|
+
this.pushLog.push({ room: room.id, body, ts: now.toISOString() });
|
|
399
|
+
this.queueHumanPush(room.id, message.id, 'stall', body, [this.ownerOf(room.id).id]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
noteRunActivity(room, messageId) {
|
|
404
|
+
this.runActivity.set(`${room}:${messageId}`, Date.now());
|
|
405
|
+
const message = this.store.getMessage(room, messageId);
|
|
406
|
+
if (message?.run?.status !== 'running' || message.run.stalled_since === undefined)
|
|
407
|
+
return;
|
|
408
|
+
const progressed = this.store.updateMessage(room, messageId, {
|
|
409
|
+
run: { ...message.run, stalled_since: undefined },
|
|
410
|
+
});
|
|
411
|
+
this.emitMessage(room, progressed);
|
|
412
|
+
}
|
|
413
|
+
// harn:end stall-flags-never-kills
|
|
414
|
+
onFrame(listener) {
|
|
415
|
+
this.listeners.push(listener);
|
|
416
|
+
return () => {
|
|
417
|
+
const i = this.listeners.indexOf(listener);
|
|
418
|
+
if (i >= 0)
|
|
419
|
+
this.listeners.splice(i, 1);
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// harn:assume redaction-before-fanout ref=redacted-frame-emit
|
|
423
|
+
/**
|
|
424
|
+
* ALL fanout funnels through here: frames are deep-redacted before any
|
|
425
|
+
* listener (WS/REST serializer) sees them, unless the room opted out.
|
|
426
|
+
* The store and blobs keep raw content.
|
|
427
|
+
*/
|
|
428
|
+
emit(room, frame) {
|
|
429
|
+
const projected = this.projectFrame(room, frame);
|
|
430
|
+
for (const listener of this.listeners)
|
|
431
|
+
listener(room, projected);
|
|
432
|
+
}
|
|
433
|
+
projectFrame(room, frame) {
|
|
434
|
+
// harn:assume run-item-raw-journal-only ref=live-run-item-raw-projection
|
|
435
|
+
let liveFrame = frame;
|
|
436
|
+
if (frame.type === 'run_event' &&
|
|
437
|
+
frame.event.type === 'run.item' &&
|
|
438
|
+
typeof frame.event.payload === 'object' &&
|
|
439
|
+
frame.event.payload !== null &&
|
|
440
|
+
!Array.isArray(frame.event.payload)) {
|
|
441
|
+
const { raw: _raw, ...payload } = frame.event.payload;
|
|
442
|
+
liveFrame = { ...frame, event: { ...frame.event, payload } };
|
|
443
|
+
}
|
|
444
|
+
// harn:end run-item-raw-journal-only
|
|
445
|
+
const config = this.store.getRoom(room)?.config;
|
|
446
|
+
if (config?.redaction_enabled === false)
|
|
447
|
+
return liveFrame;
|
|
448
|
+
return redactValue(liveFrame);
|
|
449
|
+
}
|
|
450
|
+
/** Redacted view of arbitrary sync/REST payloads. */
|
|
451
|
+
project(room, value) {
|
|
452
|
+
const config = this.store.getRoom(room)?.config;
|
|
453
|
+
if (config?.redaction_enabled === false)
|
|
454
|
+
return value;
|
|
455
|
+
return redactValue(value);
|
|
456
|
+
}
|
|
457
|
+
// harn:end redaction-before-fanout
|
|
458
|
+
emitMessage(room, message) {
|
|
459
|
+
this.emit(room, { type: 'message', seq: message.seq, message });
|
|
460
|
+
}
|
|
461
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
|
|
462
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-seeding
|
|
463
|
+
/**
|
|
464
|
+
* Pre-turn gauge seeding: estimate context from the harness's on-disk
|
|
465
|
+
* session artifact so operators see pressure BEFORE spending a turn.
|
|
466
|
+
* Fire-and-forget; a seed never overwrites a live (non-estimated) value.
|
|
467
|
+
*/
|
|
468
|
+
seedContextUsage(room, member) {
|
|
469
|
+
if (member.kind !== 'agent' || member.harness === undefined || member.session_ref === undefined)
|
|
470
|
+
return;
|
|
471
|
+
const adapter = this.adapters.get(member.harness);
|
|
472
|
+
if (adapter?.peekContextUsage === undefined)
|
|
473
|
+
return;
|
|
474
|
+
const existing = this.lastUsage.get(member.id);
|
|
475
|
+
if (existing !== undefined && existing.estimated !== true)
|
|
476
|
+
return;
|
|
477
|
+
const ref = member.session_ref;
|
|
478
|
+
this.track((async () => {
|
|
479
|
+
const peeked = await adapter.peekContextUsage(ref);
|
|
480
|
+
if (peeked === undefined)
|
|
481
|
+
return;
|
|
482
|
+
// The artifact scan cannot see settings-applied windows (e.g. the 1m
|
|
483
|
+
// beta), so an engine-reported window persisted on the member outranks
|
|
484
|
+
// the peek's curated guess; the used-tokens estimate stays the peek's.
|
|
485
|
+
const persisted = this.store.getMemberContextWindow(room, member.id);
|
|
486
|
+
const seeded = persisted === undefined ? peeked : { ...peeked, contextWindowMaxTokens: persisted };
|
|
487
|
+
const current = this.lastUsage.get(member.id);
|
|
488
|
+
if (current !== undefined && current.estimated !== true)
|
|
489
|
+
return; // live won meanwhile
|
|
490
|
+
if (isDeepStrictEqual(current, seeded))
|
|
491
|
+
return;
|
|
492
|
+
this.lastUsage.set(member.id, { ...seeded });
|
|
493
|
+
const fresh = this.store.getMember(room, member.id);
|
|
494
|
+
if (fresh !== undefined && fresh.removed_ts === undefined)
|
|
495
|
+
this.emitMember(room, fresh);
|
|
496
|
+
})().catch(() => undefined));
|
|
497
|
+
}
|
|
498
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
499
|
+
memberWithLastUsage(room, member) {
|
|
500
|
+
const lastUsage = this.lastUsage.get(member.id);
|
|
501
|
+
return lastUsage === undefined ? member : { ...member, lastUsage: { ...lastUsage } };
|
|
502
|
+
}
|
|
503
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
504
|
+
// harn:assume engine-reported-window-outlives-restarts ref=persisted-window-seed
|
|
505
|
+
/** Persist the engine's reported window (a stable engine fact, unlike usage)
|
|
506
|
+
* so estimated seeds after a restart present the true ceiling. */
|
|
507
|
+
landContextWindow(room, memberId, usage) {
|
|
508
|
+
const window = usage?.contextWindowMaxTokens;
|
|
509
|
+
if (usage?.estimated === true || typeof window !== 'number' || window <= 0)
|
|
510
|
+
return;
|
|
511
|
+
if (this.store.getMemberContextWindow(room, memberId) === window)
|
|
512
|
+
return;
|
|
513
|
+
this.store.setMemberContextWindow(room, memberId, window);
|
|
514
|
+
}
|
|
515
|
+
// harn:end engine-reported-window-outlives-restarts
|
|
516
|
+
emitMember(room, member) {
|
|
517
|
+
// harn:assume live-agent-waits-are-transient ref=wait-member-projection
|
|
518
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
|
|
519
|
+
const waiting = this.memberWaits.get(member.id);
|
|
520
|
+
this.emit(room, {
|
|
521
|
+
type: 'member',
|
|
522
|
+
seq: this.store.currentSeq(room),
|
|
523
|
+
member: { ...this.memberWithLastUsage(room, member), ...(waiting && { waiting }) },
|
|
524
|
+
});
|
|
525
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
526
|
+
// harn:end live-agent-waits-are-transient
|
|
527
|
+
}
|
|
528
|
+
landMemberLimits(room, memberId, limits) {
|
|
529
|
+
const member = this.store.getMember(room, memberId);
|
|
530
|
+
if (!member || member.removed_ts !== undefined)
|
|
531
|
+
return;
|
|
532
|
+
if (isDeepStrictEqual(member.limits, limits))
|
|
533
|
+
return;
|
|
534
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { limits }));
|
|
535
|
+
}
|
|
536
|
+
// harn:assume push-only-for-human-targeted-events ref=push-target-dispatch
|
|
537
|
+
emitInbox(room, delivery) {
|
|
538
|
+
this.emit(room, { type: 'inbox', seq: this.store.currentSeq(room), delivery });
|
|
539
|
+
const recipient = this.store.getMember(room, delivery.recipient);
|
|
540
|
+
if (recipient?.kind !== 'human' || delivery.state !== 'consumed' || delivery.read_ts !== undefined) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const message = this.store.getMessage(room, delivery.message_id);
|
|
544
|
+
if (!message || !['chat', 'run', 'ask', 'approval'].includes(message.kind))
|
|
545
|
+
return;
|
|
546
|
+
const kind = message.kind === 'ask' || message.kind === 'approval'
|
|
547
|
+
? message.kind
|
|
548
|
+
: 'inbox';
|
|
549
|
+
this.queueHumanPush(room, message.id, kind, message.body, [recipient.id], delivery.id);
|
|
550
|
+
}
|
|
551
|
+
queueHumanPush(room, messageId, kind, preview, targetHumanIds, deliveryId) {
|
|
552
|
+
if (!this.pushProducer || targetHumanIds.length === 0)
|
|
553
|
+
return;
|
|
554
|
+
this.track(this.pushProducer.notify({
|
|
555
|
+
room,
|
|
556
|
+
msg_id: messageId,
|
|
557
|
+
kind,
|
|
558
|
+
preview,
|
|
559
|
+
target_human_ids: targetHumanIds,
|
|
560
|
+
...(deliveryId && { delivery_id: deliveryId }),
|
|
561
|
+
}).then((results) => {
|
|
562
|
+
const failures = results.filter((result) => result.status === 'failed');
|
|
563
|
+
if (failures.length > 0) {
|
|
564
|
+
const statuses = failures.map((result) => result.http_status ?? result.error ?? 'unknown').join(',');
|
|
565
|
+
throw new Error(`push delivery failed for ${String(failures.length)} device(s): ${statuses}`);
|
|
566
|
+
}
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
// harn:end push-only-for-human-targeted-events
|
|
570
|
+
// ── room / member management ──────────────────────────────────────────
|
|
571
|
+
// harn:assume channel-creation-derived-and-seeded ref=derived-channel-creation
|
|
572
|
+
createRoom(opts) {
|
|
573
|
+
// harn:assume starting-agent-name-derives-one-valid-identity-v6 ref=starting-agent-create-validation
|
|
574
|
+
if (opts.starting_agent?.handle === opts.owner.handle) {
|
|
575
|
+
throw new Error(`starting agent handle @${opts.starting_agent.handle} is already in use by the channel owner`);
|
|
576
|
+
}
|
|
577
|
+
// harn:end starting-agent-name-derives-one-valid-identity-v6
|
|
578
|
+
const baseId = opts.id ?? deriveRoomId(opts.name);
|
|
579
|
+
let id = baseId;
|
|
580
|
+
if (opts.id === undefined) {
|
|
581
|
+
for (let suffix = 2; this.store.getRoom(id); suffix++)
|
|
582
|
+
id = `${baseId}-${String(suffix)}`;
|
|
583
|
+
}
|
|
584
|
+
// harn:assume spawn-default-cwd-is-absolute-or-empty ref=implicit-starting-agent-cwd
|
|
585
|
+
const cwd = opts.cwd !== undefined
|
|
586
|
+
? normalizeWorkingDirectory(opts.cwd, this.homeDir)
|
|
587
|
+
: opts.starting_agent !== undefined
|
|
588
|
+
? normalizeWorkingDirectory(process.cwd(), this.homeDir)
|
|
589
|
+
: undefined;
|
|
590
|
+
// harn:end spawn-default-cwd-is-absolute-or-empty
|
|
591
|
+
const created = this.store.createRoom({
|
|
592
|
+
id,
|
|
593
|
+
name: opts.name,
|
|
594
|
+
owner: opts.owner,
|
|
595
|
+
config: {
|
|
596
|
+
...(opts.color !== undefined && { color: opts.color }),
|
|
597
|
+
...(cwd !== undefined && { cwd }),
|
|
598
|
+
// harn:assume channel-starting-agent-handle-persisted ref=starting-agent-creation-record
|
|
599
|
+
...(opts.starting_agent !== undefined && {
|
|
600
|
+
starting_agent_handle: opts.starting_agent.handle,
|
|
601
|
+
}),
|
|
602
|
+
// harn:end channel-starting-agent-handle-persisted
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
if (opts.starting_agent) {
|
|
606
|
+
try {
|
|
607
|
+
this.spawnMember(id, {
|
|
608
|
+
...opts.starting_agent,
|
|
609
|
+
cwd: cwd,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
this.postSystemMessage(id, `could not spawn @${opts.starting_agent.handle}: ${error instanceof Error ? error.message : String(error)}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return created;
|
|
617
|
+
}
|
|
618
|
+
// harn:end channel-creation-derived-and-seeded
|
|
619
|
+
configureRoom(room, patch) {
|
|
620
|
+
const updated = this.store.updateRoomConfig(room, patch);
|
|
621
|
+
this.emit(room, { type: 'room', seq: this.store.currentSeq(room), room: updated });
|
|
622
|
+
return updated;
|
|
623
|
+
}
|
|
624
|
+
enableLedger(room) {
|
|
625
|
+
if (!this.store.getRoom(room))
|
|
626
|
+
throw new Error(`no such room ${room}`);
|
|
627
|
+
if (!this.ledger)
|
|
628
|
+
throw new Error('ledger is not configured');
|
|
629
|
+
this.ledger.enable(room);
|
|
630
|
+
}
|
|
631
|
+
addLedgerNote(room, write) {
|
|
632
|
+
if (!this.store.getRoom(room))
|
|
633
|
+
throw new Error(`no such room ${room}`);
|
|
634
|
+
if (!this.ledger)
|
|
635
|
+
throw new Error('ledger is not configured');
|
|
636
|
+
return this.ledger.add(room, write);
|
|
637
|
+
}
|
|
638
|
+
getLedgerNote(room, name) {
|
|
639
|
+
if (!this.store.getRoom(room))
|
|
640
|
+
throw new Error(`no such room ${room}`);
|
|
641
|
+
return this.ledger?.note(room, name);
|
|
642
|
+
}
|
|
643
|
+
ledgerSnapshot(room) {
|
|
644
|
+
if (!this.store.getRoom(room))
|
|
645
|
+
throw new Error(`no such room ${room}`);
|
|
646
|
+
return this.ledger?.snapshot(room) ?? {};
|
|
647
|
+
}
|
|
648
|
+
ledgerGraph(room) {
|
|
649
|
+
if (!this.store.getRoom(room))
|
|
650
|
+
throw new Error(`no such room ${room}`);
|
|
651
|
+
return this.ledger?.graph(room) ?? { nodes: [], edges: [] };
|
|
652
|
+
}
|
|
653
|
+
// harn:assume bridge-enable-admin-or-owner ref=bridge-daemon-ingress
|
|
654
|
+
enableBridge(room, platform, channel) {
|
|
655
|
+
if (!this.store.getRoom(room))
|
|
656
|
+
throw new Error(`no such room ${room}`);
|
|
657
|
+
const normalizedChannel = channel.trim();
|
|
658
|
+
if (normalizedChannel === '')
|
|
659
|
+
throw new Error('bridge channel is required');
|
|
660
|
+
const handle = `${platform}-bridge`;
|
|
661
|
+
const existing = this.store.getMemberByHandle(room, handle);
|
|
662
|
+
if (existing) {
|
|
663
|
+
if (existing.kind !== 'bridge')
|
|
664
|
+
throw new Error(`handle @${handle} is already in use`);
|
|
665
|
+
const expectedName = `${platform === 'slack' ? 'Slack' : 'Telegram'} · ${normalizedChannel}`;
|
|
666
|
+
if (existing.display_name !== expectedName) {
|
|
667
|
+
throw new Error(`${platform} bridge is already paired to another channel`);
|
|
668
|
+
}
|
|
669
|
+
if (this.store.getRoom(room)?.config.bridged !== true) {
|
|
670
|
+
this.configureRoom(room, { bridged: true });
|
|
671
|
+
}
|
|
672
|
+
return { member: existing, after: this.store.latestMessageId(room) };
|
|
673
|
+
}
|
|
674
|
+
const member = this.store.addMember(room, {
|
|
675
|
+
kind: 'bridge',
|
|
676
|
+
handle,
|
|
677
|
+
display_name: `${platform === 'slack' ? 'Slack' : 'Telegram'} · ${normalizedChannel}`,
|
|
678
|
+
});
|
|
679
|
+
this.emitMember(room, member);
|
|
680
|
+
this.configureRoom(room, { bridged: true });
|
|
681
|
+
return { member, after: this.store.latestMessageId(room) };
|
|
682
|
+
}
|
|
683
|
+
postBridgeMessage(room, bridgeMemberId, body, origin) {
|
|
684
|
+
const bridge = this.store.getMember(room, bridgeMemberId);
|
|
685
|
+
if (bridge?.kind !== 'bridge')
|
|
686
|
+
throw new Error(`no such bridge member: ${bridgeMemberId}`);
|
|
687
|
+
const platform = bridge.handle.endsWith('-bridge')
|
|
688
|
+
? bridge.handle.slice(0, -'-bridge'.length)
|
|
689
|
+
: '';
|
|
690
|
+
if (origin.platform !== platform)
|
|
691
|
+
throw new Error('bridge origin platform does not match member');
|
|
692
|
+
const normalizedBody = body.trim();
|
|
693
|
+
if (normalizedBody === '')
|
|
694
|
+
throw new Error('bridge message body is required');
|
|
695
|
+
const parsed = parseBody(normalizedBody, this.store.listMembers(room));
|
|
696
|
+
const result = this.store.postBridgeMessage(room, bridgeMemberId, normalizedBody, origin, parsed, (message) => {
|
|
697
|
+
const planned = this.planRoutedMessage(room, message, undefined, undefined, false, true);
|
|
698
|
+
return planned.plan;
|
|
699
|
+
});
|
|
700
|
+
if (!result.deduped) {
|
|
701
|
+
this.emitMessage(room, result.message);
|
|
702
|
+
if (result.member)
|
|
703
|
+
this.emitMember(room, result.member);
|
|
704
|
+
this.dispatchCreatedDeliveries(room, result.deliveries);
|
|
705
|
+
}
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
bridgeMessagesAfter(room, after, limit = 100) {
|
|
709
|
+
if (!this.store.getRoom(room))
|
|
710
|
+
throw new Error(`no such room ${room}`);
|
|
711
|
+
return this.store.listMessagesAfter(room, after, limit);
|
|
712
|
+
}
|
|
713
|
+
// harn:end bridge-enable-admin-or-owner
|
|
714
|
+
ownerOf(room) {
|
|
715
|
+
const owner = this.store.listMembers(room).find((m) => m.kind === 'human' && m.role === 'owner');
|
|
716
|
+
if (!owner)
|
|
717
|
+
throw new Error(`room ${room} has no owner`);
|
|
718
|
+
return owner;
|
|
719
|
+
}
|
|
720
|
+
// harn:assume adapters-own-their-model-catalog ref=adapter-model-discovery
|
|
721
|
+
/**
|
|
722
|
+
* Ask every adapter that can answer what models its harness takes. Runs once,
|
|
723
|
+
* in the background, at registration — never on a request path, because a hung
|
|
724
|
+
* CLI must not be able to wedge /api/adapters, which gates both dialogs.
|
|
725
|
+
* Any failure leaves the harness without a list: the dialog then offers the
|
|
726
|
+
* custom escape, which is a worse UI, not a broken one.
|
|
727
|
+
*/
|
|
728
|
+
discoverModels() {
|
|
729
|
+
for (const adapter of this.adapters.values()) {
|
|
730
|
+
if (!adapter.listModels)
|
|
731
|
+
continue;
|
|
732
|
+
this.pendingDiscoveries += 1;
|
|
733
|
+
void adapter.listModels().finally(() => {
|
|
734
|
+
this.pendingDiscoveries -= 1;
|
|
735
|
+
}).then((catalog) => {
|
|
736
|
+
const models = catalog.models.filter((model) => MODEL_ID.test(model)).slice(0, MAX_MODELS);
|
|
737
|
+
if (models.length > 0)
|
|
738
|
+
this.modelCatalogs.set(adapter.id, { ...catalog, models });
|
|
739
|
+
}, (error) => this.onBackgroundError(error instanceof Error ? error : new Error(`${adapter.id} model discovery failed`)));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// harn:assume model-catalogs-reach-a-browser-that-arrives-early ref=adapter-discovery-pending-signal
|
|
743
|
+
/**
|
|
744
|
+
* True while a harness that can answer still hasn't. It lets a browser tell an
|
|
745
|
+
* empty catalog apart from an unfinished one, so a page loaded during discovery
|
|
746
|
+
* asks again instead of stranding the operator with no models until reload.
|
|
747
|
+
*/
|
|
748
|
+
modelDiscoveryPending() {
|
|
749
|
+
return this.pendingDiscoveries > 0;
|
|
750
|
+
}
|
|
751
|
+
// harn:end model-catalogs-reach-a-browser-that-arrives-early
|
|
752
|
+
registeredAdapters() {
|
|
753
|
+
return [...this.adapters.values()]
|
|
754
|
+
.map((adapter) => {
|
|
755
|
+
const catalog = this.modelCatalogs.get(adapter.id);
|
|
756
|
+
return {
|
|
757
|
+
id: adapter.id,
|
|
758
|
+
capabilities: adapter.capabilities,
|
|
759
|
+
...(catalog && { models: catalog.models, models_source: catalog.source }),
|
|
760
|
+
};
|
|
761
|
+
})
|
|
762
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
763
|
+
}
|
|
764
|
+
// harn:end adapters-own-their-model-catalog
|
|
765
|
+
/** Provider limits are account-level: one probe fans out to every active
|
|
766
|
+
* agent using that harness. Missing credentials and failures preserve the
|
|
767
|
+
* last stream-reported value. */
|
|
768
|
+
async probeAdapterLimits() {
|
|
769
|
+
if (this.probingLimits || this.closing)
|
|
770
|
+
return;
|
|
771
|
+
this.probingLimits = true;
|
|
772
|
+
try {
|
|
773
|
+
const membersByHarness = new Map();
|
|
774
|
+
for (const room of this.store.listRooms()) {
|
|
775
|
+
for (const member of this.store.listMembers(room.id)) {
|
|
776
|
+
if (member.kind !== 'agent' || member.harness === undefined)
|
|
777
|
+
continue;
|
|
778
|
+
const members = membersByHarness.get(member.harness) ?? [];
|
|
779
|
+
members.push({ room: room.id, member });
|
|
780
|
+
membersByHarness.set(member.harness, members);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
for (const adapter of this.adapters.values()) {
|
|
784
|
+
const targets = membersByHarness.get(adapter.id);
|
|
785
|
+
if (!adapter.probeLimits || targets === undefined || targets.length === 0)
|
|
786
|
+
continue;
|
|
787
|
+
let limits;
|
|
788
|
+
try {
|
|
789
|
+
limits = await adapter.probeLimits();
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (this.closing || limits === undefined || limits.length === 0)
|
|
795
|
+
continue;
|
|
796
|
+
for (const target of targets) {
|
|
797
|
+
const current = this.store.getMember(target.room, target.member.id);
|
|
798
|
+
if (current?.kind !== 'agent'
|
|
799
|
+
|| current.harness !== adapter.id
|
|
800
|
+
|| current.removed_ts !== undefined)
|
|
801
|
+
continue;
|
|
802
|
+
this.landMemberLimits(target.room, current.id, limits);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
finally {
|
|
807
|
+
this.probingLimits = false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
setHumanRole(room, memberId, role) {
|
|
811
|
+
const member = this.store.getMember(room, memberId);
|
|
812
|
+
if (member?.kind !== 'human')
|
|
813
|
+
throw new Error(`no such human member: ${memberId}`);
|
|
814
|
+
if (member.role === 'owner' && role !== 'owner') {
|
|
815
|
+
const owners = this.store.listMembers(room).filter((candidate) => candidate.kind === 'human' && candidate.role === 'owner');
|
|
816
|
+
if (owners.length === 1)
|
|
817
|
+
throw new Error('a room must retain at least one owner');
|
|
818
|
+
}
|
|
819
|
+
const updated = this.store.updateMember(room, memberId, { role });
|
|
820
|
+
this.emitMember(room, updated);
|
|
821
|
+
this.postSystemMessage(room, `@${updated.handle} is now ${role}`);
|
|
822
|
+
return updated;
|
|
823
|
+
}
|
|
824
|
+
// harn:assume pins-are-durable-owner-admin-markers ref=pin-message-contract
|
|
825
|
+
/**
|
|
826
|
+
* Pin or unpin a message. Only human owners/admins may flip it — the server's
|
|
827
|
+
* capability gate enforces the role, and this refuses non-humans/underprivileged
|
|
828
|
+
* callers defensively so a direct daemon call cannot bypass the contract.
|
|
829
|
+
* Idempotent: re-flipping to the same value changes nothing and emits nothing.
|
|
830
|
+
* The flip rides the change log like any message edit; it never re-routes
|
|
831
|
+
* deliveries or touches run journals.
|
|
832
|
+
*/
|
|
833
|
+
pinMessage(room, messageId, pinned, byMemberId) {
|
|
834
|
+
const actor = this.store.getMember(room, byMemberId);
|
|
835
|
+
if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
|
|
836
|
+
throw new Error('forbidden: only owners and admins can pin messages');
|
|
837
|
+
}
|
|
838
|
+
const message = this.store.getMessage(room, messageId);
|
|
839
|
+
if (!message)
|
|
840
|
+
throw new Error(`no such message: #${messageId}`);
|
|
841
|
+
if (pinned && message.deleted === true) {
|
|
842
|
+
throw new Error('cannot pin a deleted message'); // a tombstone is not a pin target
|
|
843
|
+
}
|
|
844
|
+
if ((message.pinned === true) === pinned)
|
|
845
|
+
return message; // idempotent — emit nothing
|
|
846
|
+
const updated = this.store.setMessagePinned(room, messageId, pinned);
|
|
847
|
+
this.emitMessage(room, updated);
|
|
848
|
+
return updated;
|
|
849
|
+
}
|
|
850
|
+
// harn:end pins-are-durable-owner-admin-markers
|
|
851
|
+
// harn:assume deleted-messages-purge-rows-and-files ref=delete-message-contract
|
|
852
|
+
/**
|
|
853
|
+
* Purge a chat message, leaving a durable [deleted] tombstone. Only human
|
|
854
|
+
* owners/admins may delete (the server gate enforces the role; this refuses
|
|
855
|
+
* non-humans/underprivileged callers defensively). Only chat messages qualify
|
|
856
|
+
* — run rows are journal evidence and system rows are daemon speech, both
|
|
857
|
+
* refused. Idempotent when already deleted (emits nothing). Still-pending
|
|
858
|
+
* deliveries (queued or held) of the message are cancelled so purged content
|
|
859
|
+
* never delivers late; already-consumed deliveries keep their snapshots. Any
|
|
860
|
+
* attachment files are unlinked from disk (the store nulls their metadata).
|
|
861
|
+
* Deletion never renumbers messages or touches run journals.
|
|
862
|
+
*/
|
|
863
|
+
deleteMessage(room, messageId, byMemberId) {
|
|
864
|
+
const actor = this.store.getMember(room, byMemberId);
|
|
865
|
+
if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
|
|
866
|
+
throw new Error('forbidden: only owners and admins can delete messages');
|
|
867
|
+
}
|
|
868
|
+
const message = this.store.getMessage(room, messageId);
|
|
869
|
+
if (!message)
|
|
870
|
+
throw new Error(`no such message: #${messageId}`);
|
|
871
|
+
if (message.kind !== 'chat') {
|
|
872
|
+
throw new Error(`only chat messages can be deleted, not ${message.kind}`);
|
|
873
|
+
}
|
|
874
|
+
if (message.deleted === true)
|
|
875
|
+
return message; // idempotent — emit nothing
|
|
876
|
+
const tombstone = this.store.deleteMessage(room, messageId);
|
|
877
|
+
// The store nulled the metadata column; remove the bytes from disk too.
|
|
878
|
+
this.unlinkAttachments(room, message.attachments);
|
|
879
|
+
// Cancel still-pending deliveries so the purged body is never delivered.
|
|
880
|
+
for (const delivery of this.store.listDeliveries(room)) {
|
|
881
|
+
if (delivery.message_id !== messageId)
|
|
882
|
+
continue;
|
|
883
|
+
if (delivery.state !== 'queued' && delivery.state !== 'held')
|
|
884
|
+
continue;
|
|
885
|
+
this.emitInbox(room, this.store.updateDelivery(room, delivery.id, { state: 'consumed' }));
|
|
886
|
+
}
|
|
887
|
+
this.emitMessage(room, tombstone);
|
|
888
|
+
return tombstone;
|
|
889
|
+
}
|
|
890
|
+
// harn:end deleted-messages-purge-rows-and-files
|
|
891
|
+
memberDetails(room) {
|
|
892
|
+
const messages = this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER });
|
|
893
|
+
return this.store.listMembers(room).map((member) => {
|
|
894
|
+
const runs = messages.filter((message) => message.kind === 'run' &&
|
|
895
|
+
message.author === member.id &&
|
|
896
|
+
message.run?.status !== 'running');
|
|
897
|
+
return {
|
|
898
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
|
|
899
|
+
member: this.memberWithLastUsage(room, member),
|
|
900
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
901
|
+
queued_count: this.store.listDeliveries(room, {
|
|
902
|
+
recipient: member.id,
|
|
903
|
+
state: 'queued',
|
|
904
|
+
}).length,
|
|
905
|
+
// harn:assume estimated-cost-is-advisory-not-spend-brake-input ref=member-advisory-cost-projection
|
|
906
|
+
spend: runs.reduce((total, message) => {
|
|
907
|
+
const usage = message.run?.usage;
|
|
908
|
+
const accounting = accountRunUsage({
|
|
909
|
+
// Completed history is read only from stored run snapshots. The
|
|
910
|
+
// current member model and today's rate table never reprice it.
|
|
911
|
+
model: undefined,
|
|
912
|
+
estimated_cost_usd: usage?.cost_usd === undefined
|
|
913
|
+
? message.run?.estimated_cost_usd
|
|
914
|
+
: undefined,
|
|
915
|
+
}, usage);
|
|
916
|
+
return {
|
|
917
|
+
turns: total.turns + 1,
|
|
918
|
+
input_tokens: total.input_tokens + (usage?.input_tokens ?? 0),
|
|
919
|
+
output_tokens: total.output_tokens + (usage?.output_tokens ?? 0),
|
|
920
|
+
cost_usd: total.cost_usd + accounting.costUsd,
|
|
921
|
+
estimated_cost_usd: total.estimated_cost_usd + (accounting.estimatedCostUsd ?? 0),
|
|
922
|
+
uncosted_tokens: total.uncosted_tokens + accounting.uncostedTokens,
|
|
923
|
+
};
|
|
924
|
+
}, {
|
|
925
|
+
turns: 0,
|
|
926
|
+
input_tokens: 0,
|
|
927
|
+
output_tokens: 0,
|
|
928
|
+
cost_usd: 0,
|
|
929
|
+
estimated_cost_usd: 0,
|
|
930
|
+
uncosted_tokens: 0,
|
|
931
|
+
}),
|
|
932
|
+
// harn:end estimated-cost-is-advisory-not-spend-brake-input
|
|
933
|
+
};
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
// harn:assume roster-briefing-refreshes-on-membership ref=roster-membership-transitions
|
|
937
|
+
markRostersStale(room) {
|
|
938
|
+
this.store.markAgentRostersStale(room);
|
|
939
|
+
}
|
|
940
|
+
// harn:end roster-briefing-refreshes-on-membership
|
|
941
|
+
// harn:assume working-directories-validated-before-spawn ref=daemon-cwd-enforcement
|
|
942
|
+
spawnMember(room, opts) {
|
|
943
|
+
const cwd = normalizeWorkingDirectory(opts.cwd, this.homeDir);
|
|
944
|
+
const adapter = this.requireAdapter(opts.harness);
|
|
945
|
+
const spawnOpts = {
|
|
946
|
+
cwd,
|
|
947
|
+
policy: opts.policy,
|
|
948
|
+
model: opts.model,
|
|
949
|
+
thinking: opts.thinking,
|
|
950
|
+
};
|
|
951
|
+
// harn:assume canonical-spawn-controls-enforced ref=daemon-initial-spawn-validation
|
|
952
|
+
validateSpawnOptions(adapter, spawnOpts);
|
|
953
|
+
const session = adapter.spawn(spawnOpts);
|
|
954
|
+
// harn:end canonical-spawn-controls-enforced
|
|
955
|
+
const member = this.store.addMember(room, {
|
|
956
|
+
kind: 'agent',
|
|
957
|
+
handle: opts.handle,
|
|
958
|
+
display_name: opts.display_name ?? opts.handle,
|
|
959
|
+
purpose: opts.purpose,
|
|
960
|
+
harness: opts.harness,
|
|
961
|
+
cwd,
|
|
962
|
+
policy: opts.policy,
|
|
963
|
+
// harn:assume agent-model-and-thinking-are-durable ref=durable-agent-config-rebuild
|
|
964
|
+
// These are turn arguments, re-derived from the session on every turn. Held only
|
|
965
|
+
// in memory, they vanish on restart and the agent quietly becomes a different one.
|
|
966
|
+
model: opts.model,
|
|
967
|
+
thinking: opts.thinking,
|
|
968
|
+
// harn:end agent-model-and-thinking-are-durable
|
|
969
|
+
host: this.hostId,
|
|
970
|
+
state: 'idle',
|
|
971
|
+
custody: 'owned',
|
|
972
|
+
});
|
|
973
|
+
this.issueMemberCredential(room, member, session);
|
|
974
|
+
this.sessions.set(member.id, session);
|
|
975
|
+
this.markRostersStale(room);
|
|
976
|
+
this.emitMember(room, member);
|
|
977
|
+
return member;
|
|
978
|
+
}
|
|
979
|
+
// harn:end working-directories-validated-before-spawn
|
|
980
|
+
// harn:assume room-home-single-authority ref=remote-run-home-finalization
|
|
981
|
+
spawnRemoteMember(room, opts) {
|
|
982
|
+
if (!this.residency || !this.hostId)
|
|
983
|
+
throw new Error('remote residency is not configured');
|
|
984
|
+
if (opts.host === this.hostId)
|
|
985
|
+
throw new Error('remote member host must differ from the room home');
|
|
986
|
+
const member = this.store.addMember(room, {
|
|
987
|
+
kind: 'agent',
|
|
988
|
+
handle: opts.handle,
|
|
989
|
+
display_name: opts.display_name ?? opts.handle,
|
|
990
|
+
harness: opts.harness,
|
|
991
|
+
session_ref: opts.session_ref,
|
|
992
|
+
cwd: opts.cwd,
|
|
993
|
+
policy: opts.policy,
|
|
994
|
+
host: opts.host,
|
|
995
|
+
state: this.residency.isReachable(opts.host) ? 'idle' : 'unreachable',
|
|
996
|
+
custody: 'owned',
|
|
997
|
+
});
|
|
998
|
+
this.emitMember(room, member);
|
|
999
|
+
return member;
|
|
1000
|
+
}
|
|
1001
|
+
isRemoteMember(member) {
|
|
1002
|
+
return member.kind === 'agent' && member.host !== undefined && member.host !== this.hostId;
|
|
1003
|
+
}
|
|
1004
|
+
remoteRpcId(room, runMessageId) {
|
|
1005
|
+
if (!this.hostId)
|
|
1006
|
+
throw new Error('remote residency requires a home host id');
|
|
1007
|
+
return `${this.hostId}:${room}:${String(runMessageId)}`;
|
|
1008
|
+
}
|
|
1009
|
+
// harn:end room-home-single-authority
|
|
1010
|
+
// harn:assume remote-deliveries-queue-when-unreachable ref=remote-member-reachability
|
|
1011
|
+
handleResidentReachability(peerId, connected) {
|
|
1012
|
+
for (const room of this.store.listRooms()) {
|
|
1013
|
+
for (const member of this.store.listMembers(room.id)) {
|
|
1014
|
+
if (!this.isRemoteMember(member) || member.host !== peerId)
|
|
1015
|
+
continue;
|
|
1016
|
+
if (member.state === 'dead' || member.state === 'paused' || member.state === 'custody_uncertain') {
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (!connected) {
|
|
1020
|
+
if (member.state !== 'unreachable') {
|
|
1021
|
+
this.emitMember(room.id, this.store.updateMember(room.id, member.id, { state: 'unreachable' }));
|
|
1022
|
+
}
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const queued = this.store.listDeliveries(room.id, {
|
|
1026
|
+
recipient: member.id,
|
|
1027
|
+
state: 'queued',
|
|
1028
|
+
});
|
|
1029
|
+
const restored = this.store.updateMember(room.id, member.id, {
|
|
1030
|
+
state: queued.length > 0 ? 'queued' : 'idle',
|
|
1031
|
+
});
|
|
1032
|
+
this.emitMember(room.id, restored);
|
|
1033
|
+
this.track(this.maybeStartTurn(room.id, member.id));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (connected)
|
|
1037
|
+
this.track(this.reconcile());
|
|
1038
|
+
}
|
|
1039
|
+
// harn:end remote-deliveries-queue-when-unreachable
|
|
1040
|
+
attachedSession(member) {
|
|
1041
|
+
if (!member.harness || !member.session_ref) {
|
|
1042
|
+
throw new Error(`member @${member.handle} has no resumable session`);
|
|
1043
|
+
}
|
|
1044
|
+
const adapter = this.requireAdapter(member.harness);
|
|
1045
|
+
if (!adapter.capabilities.resume) {
|
|
1046
|
+
throw new Error(`adapter '${adapter.id}' does not support resume`);
|
|
1047
|
+
}
|
|
1048
|
+
const session = adapter.attach(member.session_ref);
|
|
1049
|
+
session.cwd = member.cwd ?? session.cwd;
|
|
1050
|
+
session.policy = member.policy;
|
|
1051
|
+
// A revived agent must be the SAME agent: same model, same thinking level.
|
|
1052
|
+
session.model = member.model;
|
|
1053
|
+
session.thinking = member.thinking;
|
|
1054
|
+
const located = this.store.listRooms().find((room) => this.store.getMember(room.id, member.id) !== undefined);
|
|
1055
|
+
if (!located)
|
|
1056
|
+
throw new Error(`no room for agent member: ${member.id}`);
|
|
1057
|
+
this.issueMemberCredential(located.id, member, session);
|
|
1058
|
+
return session;
|
|
1059
|
+
}
|
|
1060
|
+
// harn:assume revive-via-session-ref ref=revive-native-session
|
|
1061
|
+
reviveMember(room, memberId) {
|
|
1062
|
+
const existing = this.store.getMember(room, memberId);
|
|
1063
|
+
if (!existing || existing.kind !== 'agent')
|
|
1064
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1065
|
+
if (existing.state !== 'dead')
|
|
1066
|
+
throw new Error(`member @${existing.handle} is not dead`);
|
|
1067
|
+
if (this.store.getAttachLeaseForMember(memberId) || this.pendingAttach.has(memberId)) {
|
|
1068
|
+
throw new Error(`member @${existing.handle} has an active interactive attach lease`);
|
|
1069
|
+
}
|
|
1070
|
+
const session = this.attachedSession(existing);
|
|
1071
|
+
this.sessions.set(memberId, session);
|
|
1072
|
+
const member = this.store.updateMember(room, memberId, { state: 'idle', custody: 'owned' });
|
|
1073
|
+
this.emitMember(room, member);
|
|
1074
|
+
this.track(this.maybeStartTurn(room, memberId));
|
|
1075
|
+
return member;
|
|
1076
|
+
}
|
|
1077
|
+
// harn:end revive-via-session-ref
|
|
1078
|
+
// harn:assume adoption-explicit-or-sessionend ref=mirrored-adoption-transition
|
|
1079
|
+
joinMember(room, opts) {
|
|
1080
|
+
const cwd = normalizeWorkingDirectory(opts.cwd, this.homeDir);
|
|
1081
|
+
const adapter = this.requireAdapter(opts.harness);
|
|
1082
|
+
if (!adapter.capabilities.resume) {
|
|
1083
|
+
throw new Error(`adapter '${adapter.id}' cannot back a persistent mirrored member`);
|
|
1084
|
+
}
|
|
1085
|
+
const joined = this.store.findMemberBySessionRef(opts.harness, opts.session_ref);
|
|
1086
|
+
if (joined) {
|
|
1087
|
+
throw new Error(`session ${opts.session_ref} is already @${joined.member.handle} in room ${joined.room}`);
|
|
1088
|
+
}
|
|
1089
|
+
const member = this.store.addMember(room, {
|
|
1090
|
+
kind: 'agent',
|
|
1091
|
+
handle: opts.handle,
|
|
1092
|
+
display_name: opts.handle,
|
|
1093
|
+
purpose: opts.purpose,
|
|
1094
|
+
harness: opts.harness,
|
|
1095
|
+
session_ref: opts.session_ref,
|
|
1096
|
+
cwd,
|
|
1097
|
+
policy: opts.policy,
|
|
1098
|
+
state: 'idle',
|
|
1099
|
+
custody: 'mirrored',
|
|
1100
|
+
});
|
|
1101
|
+
this.markRostersStale(room);
|
|
1102
|
+
this.emitMember(room, member);
|
|
1103
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-seeding
|
|
1104
|
+
this.seedContextUsage(room, member);
|
|
1105
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
1106
|
+
this.postSystemMessage(room, `@${member.handle} joined from a live ${opts.harness} terminal`);
|
|
1107
|
+
return member;
|
|
1108
|
+
}
|
|
1109
|
+
adoptMember(room, memberId) {
|
|
1110
|
+
const existing = this.store.getMember(room, memberId);
|
|
1111
|
+
if (!existing || existing.kind !== 'agent')
|
|
1112
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1113
|
+
if (existing.custody !== 'mirrored')
|
|
1114
|
+
throw new Error(`member @${existing.handle} is not mirrored`);
|
|
1115
|
+
const lease = this.store.getAttachLeaseForMember(memberId);
|
|
1116
|
+
if (lease) {
|
|
1117
|
+
if (existing.state !== 'custody_uncertain' || this.attachChildRecorded(lease)) {
|
|
1118
|
+
throw new Error(`member @${existing.handle} has an active interactive attach lease`);
|
|
1119
|
+
}
|
|
1120
|
+
this.store.deleteAttachLease(lease.id);
|
|
1121
|
+
}
|
|
1122
|
+
return this.adoptMirroredMember(room, existing, `@${existing.handle} was adopted by the switchboard`);
|
|
1123
|
+
}
|
|
1124
|
+
adoptMirroredMember(room, existing, systemMessage) {
|
|
1125
|
+
const session = this.attachedSession(existing);
|
|
1126
|
+
this.sessions.set(existing.id, session);
|
|
1127
|
+
const member = this.store.updateMember(room, existing.id, {
|
|
1128
|
+
custody: 'owned',
|
|
1129
|
+
state: 'idle',
|
|
1130
|
+
});
|
|
1131
|
+
this.markRostersStale(room);
|
|
1132
|
+
this.emitMember(room, member);
|
|
1133
|
+
this.postSystemMessage(room, systemMessage);
|
|
1134
|
+
this.track(this.maybeStartTurn(room, member.id));
|
|
1135
|
+
return member;
|
|
1136
|
+
}
|
|
1137
|
+
mirrorSessionEnd(harness, sessionRef) {
|
|
1138
|
+
if (harness !== 'claude-code')
|
|
1139
|
+
return false;
|
|
1140
|
+
const joined = this.store.findMemberBySessionRef(harness, sessionRef);
|
|
1141
|
+
if (!joined || joined.member.custody !== 'mirrored')
|
|
1142
|
+
return false;
|
|
1143
|
+
if (this.store.getAttachLeaseForMember(joined.member.id))
|
|
1144
|
+
return false;
|
|
1145
|
+
this.adoptMember(joined.room, joined.member.id);
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
// harn:end adoption-explicit-or-sessionend
|
|
1149
|
+
// harn:assume attach-custody-lease-tracks-child-pid ref=attach-release-handshake
|
|
1150
|
+
async acquireAttachLease(room, memberId, cliPid) {
|
|
1151
|
+
const existing = this.store.getMember(room, memberId);
|
|
1152
|
+
if (!existing || existing.kind !== 'agent')
|
|
1153
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1154
|
+
if (existing.custody !== 'owned')
|
|
1155
|
+
throw new Error(`member @${existing.handle} is not switchboard-owned`);
|
|
1156
|
+
// harn:assume cli-member-recovery-is-actionable ref=attach-error-remediation
|
|
1157
|
+
if (existing.state === 'dead') {
|
|
1158
|
+
throw new Error(existing.session_ref
|
|
1159
|
+
? `member @${existing.handle} is dead; revive it to retry`
|
|
1160
|
+
: `member @${existing.handle} is dead; remove it and spawn a replacement`);
|
|
1161
|
+
}
|
|
1162
|
+
// harn:end cli-member-recovery-is-actionable
|
|
1163
|
+
if (existing.state === 'awaiting_input') {
|
|
1164
|
+
throw new Error(`member @${existing.handle} is awaiting input; answer or interrupt it before attach`);
|
|
1165
|
+
}
|
|
1166
|
+
if (!existing.session_ref)
|
|
1167
|
+
throw new Error(`member @${existing.handle} has no resumable session yet`);
|
|
1168
|
+
const adapter = this.requireAdapter(existing.harness);
|
|
1169
|
+
if (!adapter.capabilities.interactiveAttach) {
|
|
1170
|
+
throw new Error(`adapter '${adapter.id}' does not support interactive attach`);
|
|
1171
|
+
}
|
|
1172
|
+
if (this.store.getAttachLeaseForMember(memberId) || this.pendingAttach.has(memberId)) {
|
|
1173
|
+
throw new Error(`member @${existing.handle} already has an attach lease`);
|
|
1174
|
+
}
|
|
1175
|
+
this.pendingAttach.add(memberId);
|
|
1176
|
+
try {
|
|
1177
|
+
while (this.inflight.has(memberId)) {
|
|
1178
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1179
|
+
}
|
|
1180
|
+
const current = this.store.getMember(room, memberId);
|
|
1181
|
+
if (!current || current.custody !== 'owned') {
|
|
1182
|
+
throw new Error(`member @${existing.handle} changed custody while attach was waiting`);
|
|
1183
|
+
}
|
|
1184
|
+
if (current.state === 'awaiting_input') {
|
|
1185
|
+
throw new Error(`member @${existing.handle} is awaiting input; answer or interrupt it before attach`);
|
|
1186
|
+
}
|
|
1187
|
+
const lease = this.store.createAttachLease({
|
|
1188
|
+
room,
|
|
1189
|
+
member_id: memberId,
|
|
1190
|
+
cli_pid: cliPid,
|
|
1191
|
+
heartbeat_ts: Date.now(),
|
|
1192
|
+
});
|
|
1193
|
+
try {
|
|
1194
|
+
const queued = this.store.listDeliveries(room, {
|
|
1195
|
+
recipient: memberId,
|
|
1196
|
+
state: 'queued',
|
|
1197
|
+
}).length;
|
|
1198
|
+
const member = this.store.updateMember(room, memberId, {
|
|
1199
|
+
custody: 'mirrored',
|
|
1200
|
+
state: queued > 0 ? 'queued' : 'idle',
|
|
1201
|
+
});
|
|
1202
|
+
this.sessions.delete(memberId);
|
|
1203
|
+
this.emitMember(room, member);
|
|
1204
|
+
this.postSystemMessage(room, `@${member.handle} released to an interactive terminal`);
|
|
1205
|
+
return { lease, member };
|
|
1206
|
+
}
|
|
1207
|
+
catch (error) {
|
|
1208
|
+
this.store.deleteAttachLease(lease.id);
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
finally {
|
|
1213
|
+
this.pendingAttach.delete(memberId);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
reportAttachChild(leaseId, childPid, processGroupId) {
|
|
1217
|
+
const lease = this.store.setAttachLeaseChild(leaseId, childPid, processGroupId, Date.now());
|
|
1218
|
+
const member = this.store.getMember(lease.room, lease.member_id);
|
|
1219
|
+
if (!member)
|
|
1220
|
+
throw new Error(`attach lease ${leaseId} has no member`);
|
|
1221
|
+
return { lease, member };
|
|
1222
|
+
}
|
|
1223
|
+
heartbeatAttachLease(leaseId) {
|
|
1224
|
+
this.store.heartbeatAttachLease(leaseId, Date.now());
|
|
1225
|
+
}
|
|
1226
|
+
completeAttachLease(leaseId) {
|
|
1227
|
+
const lease = this.store.getAttachLease(leaseId);
|
|
1228
|
+
if (!lease)
|
|
1229
|
+
throw new Error(`no such attach lease ${leaseId}`);
|
|
1230
|
+
if (!this.attachChildRecorded(lease)) {
|
|
1231
|
+
const member = this.markCustodyUncertain(lease, 'attach completed before its native child identity was recorded; custody is uncertain');
|
|
1232
|
+
return { status: 'uncertain', lease, member };
|
|
1233
|
+
}
|
|
1234
|
+
if (this.attachChildAlive(lease)) {
|
|
1235
|
+
const member = this.markCustodyUncertain(lease);
|
|
1236
|
+
return { status: 'uncertain', lease, member };
|
|
1237
|
+
}
|
|
1238
|
+
return { status: 'completed', member: this.finishAttachLease(lease) };
|
|
1239
|
+
}
|
|
1240
|
+
// harn:end attach-custody-lease-tracks-child-pid
|
|
1241
|
+
// harn:assume custody-uncertain-never-double-writes ref=attach-lease-loss-reconcile
|
|
1242
|
+
reconcileAttachLeases(now = Date.now()) {
|
|
1243
|
+
for (const lease of this.store.listAttachLeases()) {
|
|
1244
|
+
if (now - lease.heartbeat_ts <= this.attachLeaseTimeoutMs)
|
|
1245
|
+
continue;
|
|
1246
|
+
if (!this.attachChildRecorded(lease)) {
|
|
1247
|
+
this.markCustodyUncertain(lease, 'attach heartbeat expired before its native child identity was recorded; custody is uncertain');
|
|
1248
|
+
}
|
|
1249
|
+
else if (this.attachChildAlive(lease))
|
|
1250
|
+
this.markCustodyUncertain(lease);
|
|
1251
|
+
else
|
|
1252
|
+
this.finishAttachLease(lease);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
attachChildRecorded(lease) {
|
|
1256
|
+
return lease.process_group_id !== undefined || lease.child_pid !== undefined;
|
|
1257
|
+
}
|
|
1258
|
+
attachChildAlive(lease) {
|
|
1259
|
+
const target = processProbeTarget(process.platform, lease.child_pid, lease.process_group_id);
|
|
1260
|
+
return target === undefined ? false : this.processProbe(target);
|
|
1261
|
+
}
|
|
1262
|
+
markCustodyUncertain(lease, reason = 'attach heartbeat was lost while its terminal may still be alive; custody is uncertain') {
|
|
1263
|
+
const existing = this.store.getMember(lease.room, lease.member_id);
|
|
1264
|
+
if (!existing)
|
|
1265
|
+
throw new Error(`attach lease ${lease.id} has no member`);
|
|
1266
|
+
if (existing.state === 'custody_uncertain')
|
|
1267
|
+
return existing;
|
|
1268
|
+
const member = this.store.updateMember(lease.room, lease.member_id, {
|
|
1269
|
+
custody: 'mirrored',
|
|
1270
|
+
state: 'custody_uncertain',
|
|
1271
|
+
});
|
|
1272
|
+
this.emitMember(lease.room, member);
|
|
1273
|
+
this.postSystemMessage(lease.room, `@${member.handle} ${reason}`);
|
|
1274
|
+
return member;
|
|
1275
|
+
}
|
|
1276
|
+
finishAttachLease(lease) {
|
|
1277
|
+
const existing = this.store.getMember(lease.room, lease.member_id);
|
|
1278
|
+
if (!existing || existing.kind !== 'agent')
|
|
1279
|
+
throw new Error(`attach lease ${lease.id} has no agent member`);
|
|
1280
|
+
const session = this.attachedSession(existing);
|
|
1281
|
+
this.sessions.set(existing.id, session);
|
|
1282
|
+
this.store.deleteAttachLease(lease.id);
|
|
1283
|
+
const member = this.store.updateMember(lease.room, existing.id, {
|
|
1284
|
+
custody: 'owned',
|
|
1285
|
+
state: 'idle',
|
|
1286
|
+
});
|
|
1287
|
+
this.emitMember(lease.room, member);
|
|
1288
|
+
this.postSystemMessage(lease.room, `@${existing.handle} interactive terminal exited; the switchboard re-adopted its session`);
|
|
1289
|
+
this.track(this.maybeStartTurn(lease.room, member.id));
|
|
1290
|
+
return member;
|
|
1291
|
+
}
|
|
1292
|
+
// harn:end custody-uncertain-never-double-writes
|
|
1293
|
+
// harn:assume member-config-is-changed-not-respawned ref=configure-member-daemon
|
|
1294
|
+
/**
|
|
1295
|
+
* Give a live agent new settings without losing it.
|
|
1296
|
+
*
|
|
1297
|
+
* The harness holds nothing: every turn is a fresh subprocess whose arguments are
|
|
1298
|
+
* re-derived from the session, and the conversation lives in the resume token on the
|
|
1299
|
+
* member row. So a change writes the row and DISCARDS the cached session — the next
|
|
1300
|
+
* turn rebuilds from that row and runs entirely on the new settings, while a turn
|
|
1301
|
+
* already in flight keeps the session object it started with and completes entirely
|
|
1302
|
+
* on the old ones. A turn can therefore never be assembled out of a mixture of the
|
|
1303
|
+
* two: not because we were careful, but because there is only ever one row to read.
|
|
1304
|
+
*
|
|
1305
|
+
* `undefined` leaves a setting alone; `null` clears it back to the harness default.
|
|
1306
|
+
*/
|
|
1307
|
+
configureMember(room, memberId, changes, opts = {}) {
|
|
1308
|
+
const member = this.store.getMember(room, memberId);
|
|
1309
|
+
if (!member || member.kind !== 'agent')
|
|
1310
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1311
|
+
if (member.removed_ts !== undefined)
|
|
1312
|
+
throw new Error(`member @${member.handle} was removed`);
|
|
1313
|
+
// harn:assume a-permission-change-is-never-silent ref=configure-custody-and-capability-guards
|
|
1314
|
+
// A mirrored member's session lives on another switchboard. A half-applied remote
|
|
1315
|
+
// change is worse than a refused one, so refuse it here and say where to go.
|
|
1316
|
+
if (member.custody === 'mirrored') {
|
|
1317
|
+
throw new Error(`member @${member.handle} is mirrored from another switchboard; configure it there`);
|
|
1318
|
+
}
|
|
1319
|
+
const settled = (next, current) => next === undefined ? current : (next ?? undefined);
|
|
1320
|
+
const next = {
|
|
1321
|
+
cwd: member.cwd ?? process.cwd(),
|
|
1322
|
+
model: settled(changes.model, member.model),
|
|
1323
|
+
thinking: settled(changes.thinking, member.thinking),
|
|
1324
|
+
policy: settled(changes.policy, member.policy),
|
|
1325
|
+
};
|
|
1326
|
+
// The same single gate the spawn path uses: an unknown policy, or a thinking level
|
|
1327
|
+
// this harness cannot honour, is refused rather than recorded as a preference it
|
|
1328
|
+
// would silently ignore.
|
|
1329
|
+
validateSpawnOptions(this.requireAdapter(member.harness), next);
|
|
1330
|
+
// harn:end a-permission-change-is-never-silent
|
|
1331
|
+
const updated = this.store.updateMember(room, memberId, {
|
|
1332
|
+
model: next.model,
|
|
1333
|
+
thinking: next.thinking,
|
|
1334
|
+
policy: next.policy,
|
|
1335
|
+
});
|
|
1336
|
+
// The next turn rebuilds from the row we just wrote. A turn already in flight keeps
|
|
1337
|
+
// the session it started with — including for the ask cards it has already raised.
|
|
1338
|
+
this.staleSessions.add(memberId);
|
|
1339
|
+
// harn:assume a-permission-change-is-never-silent ref=configure-audit-message
|
|
1340
|
+
// Raising what an agent may do to the operator's machine is a consequential act. A
|
|
1341
|
+
// capability change visible only as a flicker in a member frame is one nobody saw.
|
|
1342
|
+
const changed = [
|
|
1343
|
+
['policy', member.policy, updated.policy],
|
|
1344
|
+
['model', member.model, updated.model],
|
|
1345
|
+
['thinking', member.thinking, updated.thinking],
|
|
1346
|
+
]
|
|
1347
|
+
.filter(([, before, after]) => before !== after)
|
|
1348
|
+
.map(([field, before, after]) => `${field}: ${before ?? 'default'} → ${after ?? 'default'}`);
|
|
1349
|
+
if (changed.length > 0) {
|
|
1350
|
+
const actor = opts.actor === undefined ? undefined : this.store.getMember(room, opts.actor);
|
|
1351
|
+
this.postSystemMessage(room, `@${actor?.handle ?? 'someone'} changed @${updated.handle} — ${changed.join(', ')}`);
|
|
1352
|
+
}
|
|
1353
|
+
// harn:end a-permission-change-is-never-silent
|
|
1354
|
+
this.emitMember(room, updated);
|
|
1355
|
+
return updated;
|
|
1356
|
+
}
|
|
1357
|
+
// harn:end member-config-is-changed-not-respawned
|
|
1358
|
+
killMember(room, memberId) {
|
|
1359
|
+
const existing = this.store.getMember(room, memberId);
|
|
1360
|
+
if (!existing || existing.kind !== 'agent')
|
|
1361
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1362
|
+
if (this.store.getAttachLeaseForMember(memberId) || this.pendingAttach.has(memberId)) {
|
|
1363
|
+
throw new Error(`member @${existing.handle} has an active interactive attach lease`);
|
|
1364
|
+
}
|
|
1365
|
+
const session = this.sessions.get(memberId);
|
|
1366
|
+
if (existing.harness && session)
|
|
1367
|
+
this.requireAdapter(existing.harness).interrupt(session);
|
|
1368
|
+
for (const interaction of this.store.listInteractions(room)) {
|
|
1369
|
+
if (interaction.member_id === memberId &&
|
|
1370
|
+
(interaction.state === 'pending' || interaction.state === 'answered')) {
|
|
1371
|
+
this.orphanInteraction(room, interaction);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
this.memberWaits.delete(memberId);
|
|
1375
|
+
this.groupWaits.delete(memberId);
|
|
1376
|
+
const member = this.store.updateMember(room, memberId, { state: 'dead' });
|
|
1377
|
+
this.emitMember(room, member);
|
|
1378
|
+
for (const delivery of this.store.listDeliveries(room, { recipient: memberId })) {
|
|
1379
|
+
if (delivery.group_id !== undefined &&
|
|
1380
|
+
delivery.run_msg_id === undefined &&
|
|
1381
|
+
(delivery.state === 'queued' || delivery.state === 'held')) {
|
|
1382
|
+
this.skipUnavailableGroupDelivery(room, delivery);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
this.postSystemMessage(room, member.session_ref
|
|
1386
|
+
? `@${member.handle} was killed; revive to retry`
|
|
1387
|
+
: `@${member.handle} was killed; remove it and spawn a replacement`);
|
|
1388
|
+
return member;
|
|
1389
|
+
}
|
|
1390
|
+
pauseMember(room, memberId) {
|
|
1391
|
+
const existing = this.store.getMember(room, memberId);
|
|
1392
|
+
if (!existing || existing.kind !== 'agent')
|
|
1393
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1394
|
+
if (existing.state === 'dead')
|
|
1395
|
+
throw new Error(`member @${existing.handle} is dead; revive it instead`);
|
|
1396
|
+
const member = this.store.updateMember(room, memberId, { state: 'paused' });
|
|
1397
|
+
this.emitMember(room, member);
|
|
1398
|
+
return member;
|
|
1399
|
+
}
|
|
1400
|
+
unpauseMember(room, memberId) {
|
|
1401
|
+
const existing = this.store.getMember(room, memberId);
|
|
1402
|
+
if (!existing || existing.kind !== 'agent')
|
|
1403
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1404
|
+
if (existing.state !== 'paused')
|
|
1405
|
+
throw new Error(`member @${existing.handle} is not paused`);
|
|
1406
|
+
const member = this.store.updateMember(room, memberId, { state: 'idle' });
|
|
1407
|
+
this.emitMember(room, member);
|
|
1408
|
+
this.track(this.maybeStartTurn(room, memberId));
|
|
1409
|
+
return member;
|
|
1410
|
+
}
|
|
1411
|
+
interruptMember(room, memberId) {
|
|
1412
|
+
const member = this.store.getMember(room, memberId);
|
|
1413
|
+
const session = this.sessions.get(memberId);
|
|
1414
|
+
if (member?.harness && session) {
|
|
1415
|
+
if (this.inflight.has(memberId))
|
|
1416
|
+
this.operatorInterrupts.add(memberId);
|
|
1417
|
+
this.requireAdapter(member.harness).interrupt(session);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
// harn:assume manual-compaction-is-an-operator-act ref=compact-member-contract
|
|
1421
|
+
/**
|
|
1422
|
+
* Compact an agent's engine session on an operator's explicit request. Codor
|
|
1423
|
+
* never triggers compaction itself; this is the on-demand lever for a human
|
|
1424
|
+
* watching the context ring climb. Owner/admin humans only (the server gate
|
|
1425
|
+
* enforces the role, this refuses others defensively), and only for an IDLE
|
|
1426
|
+
* agent with a live session: compacting mid-turn races the engine rewriting
|
|
1427
|
+
* the same history, so an in-flight member is refused and told to stop first.
|
|
1428
|
+
* The harness does the compaction natively — a harness that cannot say so
|
|
1429
|
+
* clearly rather than silently doing nothing. When the engine reports its
|
|
1430
|
+
* re-baselined context, it lands as transient member usage exactly like live
|
|
1431
|
+
* turn telemetry, so the ring reflects the compaction immediately.
|
|
1432
|
+
*/
|
|
1433
|
+
async compactMember(room, memberId, byMemberId) {
|
|
1434
|
+
const actor = this.store.getMember(room, byMemberId);
|
|
1435
|
+
if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
|
|
1436
|
+
throw new Error('forbidden: only owners and admins can compact an agent session');
|
|
1437
|
+
}
|
|
1438
|
+
const member = this.store.getMember(room, memberId);
|
|
1439
|
+
if (member?.kind !== 'agent' || member.removed_ts !== undefined) {
|
|
1440
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1441
|
+
}
|
|
1442
|
+
if (member.harness === undefined)
|
|
1443
|
+
throw new Error(`@${member.handle} has no harness to compact`);
|
|
1444
|
+
// Idle-only, and the two refusals are distinct on purpose: a turn in flight
|
|
1445
|
+
// is a "stop it first" the operator can act on, while paused/queued/dead is
|
|
1446
|
+
// a different problem entirely. A retained session is NOT evidence of idle —
|
|
1447
|
+
// a dead or paused member can still hold one.
|
|
1448
|
+
if (this.inflight.has(memberId) || member.state === 'running') {
|
|
1449
|
+
throw new Error(`@${member.handle} is running — stop the turn before compacting`);
|
|
1450
|
+
}
|
|
1451
|
+
if (member.state !== 'idle') {
|
|
1452
|
+
throw new Error(`@${member.handle} is ${member.state} — only an idle agent can be compacted`);
|
|
1453
|
+
}
|
|
1454
|
+
const session = this.sessions.get(memberId);
|
|
1455
|
+
if (session === undefined)
|
|
1456
|
+
throw new Error(`@${member.handle} has no live session to compact`);
|
|
1457
|
+
const adapter = this.requireAdapter(member.harness);
|
|
1458
|
+
if (adapter.compactSession === undefined) {
|
|
1459
|
+
throw new Error(`harness '${member.harness}' does not support compaction`);
|
|
1460
|
+
}
|
|
1461
|
+
const usage = await adapter.compactSession(session);
|
|
1462
|
+
this.landCompactedUsage(room, memberId, usage);
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* The re-baseline takes the same transient path live turn telemetry does.
|
|
1466
|
+
* A member frame goes out on EVERY successful compaction, even when the
|
|
1467
|
+
* engine reported no usage: the UI needs a completion edge to stop showing
|
|
1468
|
+
* the operator a spinner, and silence is indistinguishable from still-working.
|
|
1469
|
+
*/
|
|
1470
|
+
landCompactedUsage(room, memberId, usage) {
|
|
1471
|
+
if (usage !== undefined) {
|
|
1472
|
+
this.lastUsage.set(memberId, { ...usage });
|
|
1473
|
+
this.landContextWindow(room, memberId, usage);
|
|
1474
|
+
}
|
|
1475
|
+
const current = this.store.getMember(room, memberId);
|
|
1476
|
+
if (current !== undefined)
|
|
1477
|
+
this.emitMember(room, current);
|
|
1478
|
+
}
|
|
1479
|
+
// harn:end manual-compaction-is-an-operator-act
|
|
1480
|
+
// harn:assume rename-preserves-mention-resolution ref=member-rename-stable-mentions
|
|
1481
|
+
renameMember(room, memberId, handle, displayName) {
|
|
1482
|
+
const before = this.store.getMember(room, memberId);
|
|
1483
|
+
if (!before || before.kind !== 'agent')
|
|
1484
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1485
|
+
const collision = this.store.getMemberByHandle(room, handle);
|
|
1486
|
+
if (collision && collision.id !== memberId) {
|
|
1487
|
+
throw new Error(`handle '@${handle}' is already in use`);
|
|
1488
|
+
}
|
|
1489
|
+
const member = this.store.updateMember(room, memberId, {
|
|
1490
|
+
handle,
|
|
1491
|
+
...(displayName !== undefined && { display_name: displayName }),
|
|
1492
|
+
});
|
|
1493
|
+
this.markRostersStale(room);
|
|
1494
|
+
this.emitMember(room, member);
|
|
1495
|
+
const body = `@${before.handle} is now @${handle}`;
|
|
1496
|
+
const secondStart = body.lastIndexOf('@');
|
|
1497
|
+
this.postSystemMessage(room, body, {
|
|
1498
|
+
mentions: [
|
|
1499
|
+
{ member_id: member.id, start: 0, end: before.handle.length + 1 },
|
|
1500
|
+
{ member_id: member.id, start: secondStart, end: secondStart + handle.length + 1 },
|
|
1501
|
+
],
|
|
1502
|
+
});
|
|
1503
|
+
return member;
|
|
1504
|
+
}
|
|
1505
|
+
// harn:end rename-preserves-mention-resolution
|
|
1506
|
+
// harn:assume removed-members-remain-attribution-tombstones ref=member-removal-daemon
|
|
1507
|
+
removeMember(room, memberId) {
|
|
1508
|
+
const existing = this.store.getMember(room, memberId);
|
|
1509
|
+
if (!existing || existing.kind !== 'agent')
|
|
1510
|
+
throw new Error(`no such agent member: ${memberId}`);
|
|
1511
|
+
// harn:assume removing-an-agent-is-one-deliberate-step ref=remove-live-member
|
|
1512
|
+
// Removing an agent is ONE act, not a ritual of kill-then-find-the-other-button. The
|
|
1513
|
+
// invariant is preserved rather than bypassed: the member is still dead before it is
|
|
1514
|
+
// tombstoned — killMember interrupts the running turn, orphans its cards, and refuses
|
|
1515
|
+
// outright if an interactive attach lease is held, so nothing is ever half-removed.
|
|
1516
|
+
if (existing.state !== 'dead')
|
|
1517
|
+
this.killMember(room, memberId);
|
|
1518
|
+
// harn:end removing-an-agent-is-one-deliberate-step
|
|
1519
|
+
const member = this.store.updateMember(room, memberId, {
|
|
1520
|
+
removed_ts: new Date().toISOString(),
|
|
1521
|
+
});
|
|
1522
|
+
this.sessions.delete(memberId);
|
|
1523
|
+
this.staleSessions.delete(memberId);
|
|
1524
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
|
|
1525
|
+
this.lastUsage.delete(memberId);
|
|
1526
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
1527
|
+
// harn:assume removing-an-agent-is-one-deliberate-step ref=remove-drains-queued-work
|
|
1528
|
+
// Work addressed to a member that no longer exists has nowhere to go. Left queued it
|
|
1529
|
+
// would wait in the pump forever for an agent that is never coming back, and count
|
|
1530
|
+
// against a member the roster no longer shows.
|
|
1531
|
+
const abandoned = this.store.listDeliveries(room, { recipient: memberId, state: 'queued' });
|
|
1532
|
+
for (const delivery of abandoned) {
|
|
1533
|
+
if (delivery.group_id !== undefined)
|
|
1534
|
+
this.skipUnavailableGroupDelivery(room, delivery);
|
|
1535
|
+
else
|
|
1536
|
+
this.store.updateDelivery(room, delivery.id, { state: 'consumed' });
|
|
1537
|
+
}
|
|
1538
|
+
// harn:end removing-an-agent-is-one-deliberate-step
|
|
1539
|
+
this.markRostersStale(room);
|
|
1540
|
+
this.emitMember(room, member);
|
|
1541
|
+
this.postSystemMessage(room, abandoned.length > 0
|
|
1542
|
+
? `@${member.handle} was removed; ${String(abandoned.length)} queued message${abandoned.length === 1 ? '' : 's'} dropped; its history remains attributed`
|
|
1543
|
+
: `@${member.handle} was removed; its history remains attributed`);
|
|
1544
|
+
return member;
|
|
1545
|
+
}
|
|
1546
|
+
// harn:end removed-members-remain-attribution-tombstones
|
|
1547
|
+
requireAdapter(id) {
|
|
1548
|
+
const adapter = this.adapters.get(id);
|
|
1549
|
+
if (!adapter)
|
|
1550
|
+
throw new Error(`no adapter registered for harness '${id}'`);
|
|
1551
|
+
return adapter;
|
|
1552
|
+
}
|
|
1553
|
+
/** Sessions are rebuilt from the persisted member row after a restart. */
|
|
1554
|
+
sessionFor(room, member) {
|
|
1555
|
+
// A configure since the last turn: discard the cached session so this turn is built
|
|
1556
|
+
// wholly from the row, and therefore wholly from the new settings.
|
|
1557
|
+
if (this.staleSessions.delete(member.id))
|
|
1558
|
+
this.sessions.delete(member.id);
|
|
1559
|
+
let session = this.sessions.get(member.id);
|
|
1560
|
+
if (!session) {
|
|
1561
|
+
const adapter = this.requireAdapter(member.harness);
|
|
1562
|
+
session =
|
|
1563
|
+
member.session_ref !== undefined
|
|
1564
|
+
? adapter.attach(member.session_ref)
|
|
1565
|
+
: (() => {
|
|
1566
|
+
const spawnOpts = {
|
|
1567
|
+
cwd: member.cwd ?? process.cwd(),
|
|
1568
|
+
policy: member.policy,
|
|
1569
|
+
model: member.model,
|
|
1570
|
+
thinking: member.thinking,
|
|
1571
|
+
};
|
|
1572
|
+
// harn:assume canonical-spawn-controls-enforced ref=daemon-session-rebuild-validation
|
|
1573
|
+
validateSpawnOptions(adapter, spawnOpts);
|
|
1574
|
+
const rebuilt = adapter.spawn(spawnOpts);
|
|
1575
|
+
// harn:end canonical-spawn-controls-enforced
|
|
1576
|
+
return rebuilt;
|
|
1577
|
+
})();
|
|
1578
|
+
session.cwd = member.cwd ?? session.cwd; // revive MUST reuse the persisted cwd
|
|
1579
|
+
session.policy = member.policy;
|
|
1580
|
+
// harn:assume agent-model-and-thinking-are-durable ref=durable-agent-config-rebuild
|
|
1581
|
+
// This is the path a restart takes. Restoring cwd and policy but not these two is
|
|
1582
|
+
// how an agent silently reverted to its harness default model, mid-conversation.
|
|
1583
|
+
session.model = member.model;
|
|
1584
|
+
session.thinking = member.thinking;
|
|
1585
|
+
// harn:end agent-model-and-thinking-are-durable
|
|
1586
|
+
this.issueMemberCredential(room, member, session);
|
|
1587
|
+
this.sessions.set(member.id, session);
|
|
1588
|
+
}
|
|
1589
|
+
return session;
|
|
1590
|
+
}
|
|
1591
|
+
// ── posting ───────────────────────────────────────────────────────────
|
|
1592
|
+
postChatMessage(room, body, authorId, replyTo, awaitingReply = false, interim = false, attachments) {
|
|
1593
|
+
const parsed = parseBody(body, this.store.listMembers(room));
|
|
1594
|
+
// harn:assume eligible-multi-agent-routing-starts-one-group ref=multi-agent-group-ingress
|
|
1595
|
+
const committed = this.store.commitRoutedMessage(room, {
|
|
1596
|
+
message: {
|
|
1597
|
+
author: authorId,
|
|
1598
|
+
kind: 'chat',
|
|
1599
|
+
body,
|
|
1600
|
+
mentions: parsed.mentions,
|
|
1601
|
+
refs: parsed.refs,
|
|
1602
|
+
ledger_refs: parsed.ledger_refs,
|
|
1603
|
+
reply_to: replyTo,
|
|
1604
|
+
...(attachments !== undefined && attachments.length > 0 && { attachments }),
|
|
1605
|
+
},
|
|
1606
|
+
plan: (message) => this.planRoutedMessage(room, message, undefined, undefined, awaitingReply, !interim).plan,
|
|
1607
|
+
});
|
|
1608
|
+
this.emitMessage(room, committed.message);
|
|
1609
|
+
if (committed.member)
|
|
1610
|
+
this.emitMember(room, committed.member);
|
|
1611
|
+
this.dispatchCreatedDeliveries(room, committed.deliveries);
|
|
1612
|
+
return committed.message;
|
|
1613
|
+
}
|
|
1614
|
+
postHumanMessage(room, body, opts = {}) {
|
|
1615
|
+
const authorId = opts.author ?? this.ownerOf(room).id;
|
|
1616
|
+
const author = this.store.getMember(room, authorId);
|
|
1617
|
+
if (author?.kind !== 'human')
|
|
1618
|
+
throw new Error(`no such human author: ${authorId}`);
|
|
1619
|
+
return this.postChatMessage(room, body, authorId, opts.reply_to, false, false, opts.attachments);
|
|
1620
|
+
}
|
|
1621
|
+
// harn:assume agent-network-authority-is-narrow ref=agent-interim-post-ingress
|
|
1622
|
+
postAgentMessage(room, memberId, body, replyTo, awaitingReply = false) {
|
|
1623
|
+
const author = this.store.getMember(room, memberId);
|
|
1624
|
+
if (!author || author.kind !== 'agent' || author.removed_ts !== undefined) {
|
|
1625
|
+
throw new Error(`no active agent author: ${memberId}`);
|
|
1626
|
+
}
|
|
1627
|
+
// harn:assume interim-agent-posts-are-nonfinal-routing ref=interim-post-classification
|
|
1628
|
+
// A latest running row makes this an interim post. It remains ordinary chat; status
|
|
1629
|
+
// derives the live-turn window from timestamps instead of changing Message kind.
|
|
1630
|
+
const currentRun = this.store.listRunMessages(room, { author: memberId, limit: 1 })[0];
|
|
1631
|
+
if (currentRun?.run?.status === 'running')
|
|
1632
|
+
this.noteRunActivity(room, currentRun.id);
|
|
1633
|
+
// harn:end interim-agent-posts-are-nonfinal-routing
|
|
1634
|
+
return this.postChatMessage(room, body, memberId, replyTo, awaitingReply, true);
|
|
1635
|
+
}
|
|
1636
|
+
// harn:end agent-network-authority-is-narrow
|
|
1637
|
+
// harn:assume live-agent-waits-are-transient ref=transient-wait-registry
|
|
1638
|
+
// harn:assume answered-approval-tools-can-register-live-waits ref=approved-tool-wait-eligibility
|
|
1639
|
+
canUseLiveWait(room, member) {
|
|
1640
|
+
if (member.state === 'running')
|
|
1641
|
+
return true;
|
|
1642
|
+
if (member.state !== 'awaiting_input')
|
|
1643
|
+
return false;
|
|
1644
|
+
const openInteractions = this.store.listInteractions(room).filter((interaction) => interaction.member_id === member.id
|
|
1645
|
+
&& (interaction.state === 'pending' || interaction.state === 'answered'));
|
|
1646
|
+
return openInteractions.length > 0
|
|
1647
|
+
&& openInteractions.every((interaction) => interaction.state === 'answered');
|
|
1648
|
+
}
|
|
1649
|
+
// harn:end answered-approval-tools-can-register-live-waits
|
|
1650
|
+
beginWait(room, memberId, input, now = new Date()) {
|
|
1651
|
+
const member = this.store.getMember(room, memberId);
|
|
1652
|
+
if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
|
|
1653
|
+
throw new Error(`no active agent member: ${memberId}`);
|
|
1654
|
+
}
|
|
1655
|
+
if (!this.canUseLiveWait(room, member)) {
|
|
1656
|
+
throw new Error(`member @${member.handle} cannot wait while ${member.state ?? 'inactive'}`);
|
|
1657
|
+
}
|
|
1658
|
+
const until = Date.parse(input.until_ts);
|
|
1659
|
+
if (!Number.isFinite(until) || until <= now.getTime()) {
|
|
1660
|
+
throw new Error('wait deadline must be in the future');
|
|
1661
|
+
}
|
|
1662
|
+
const run = this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER })
|
|
1663
|
+
.reverse()
|
|
1664
|
+
.find((message) => message.kind === 'run' && message.author === memberId && message.run?.status === 'running');
|
|
1665
|
+
if (!run)
|
|
1666
|
+
throw new Error(`member @${member.handle} has no running turn to wait in`);
|
|
1667
|
+
const peers = [...new Set(input.peers)];
|
|
1668
|
+
if (peers.length === 0 || peers.includes(memberId)) {
|
|
1669
|
+
throw new Error('wait peers must name at least one other member');
|
|
1670
|
+
}
|
|
1671
|
+
for (const peerId of peers) {
|
|
1672
|
+
const peer = this.store.getMember(room, peerId);
|
|
1673
|
+
if (!peer || peer.removed_ts !== undefined)
|
|
1674
|
+
throw new Error(`no active wait peer: ${peerId}`);
|
|
1675
|
+
}
|
|
1676
|
+
const waiting = {
|
|
1677
|
+
peers,
|
|
1678
|
+
reason: input.reason,
|
|
1679
|
+
since_ts: now.toISOString(),
|
|
1680
|
+
until_ts: input.until_ts,
|
|
1681
|
+
};
|
|
1682
|
+
this.memberWaits.set(memberId, waiting);
|
|
1683
|
+
// harn:assume same-round-terminal-peers-end-live-waits ref=collaboration-wait-context
|
|
1684
|
+
const groupedDelivery = this.store.listDeliveries(room, { recipient: memberId })
|
|
1685
|
+
.find((delivery) => delivery.run_msg_id === run.id && delivery.group_id !== undefined);
|
|
1686
|
+
if (groupedDelivery?.group_id !== undefined && groupedDelivery.group_round !== undefined) {
|
|
1687
|
+
this.groupWaits.set(memberId, {
|
|
1688
|
+
room,
|
|
1689
|
+
groupId: groupedDelivery.group_id,
|
|
1690
|
+
roundNumber: groupedDelivery.group_round,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
this.groupWaits.delete(memberId);
|
|
1695
|
+
}
|
|
1696
|
+
// harn:end same-round-terminal-peers-end-live-waits
|
|
1697
|
+
this.noteRunActivity(room, run.id);
|
|
1698
|
+
this.emitMember(room, member);
|
|
1699
|
+
const groupContext = this.groupWaits.get(memberId);
|
|
1700
|
+
if (groupContext) {
|
|
1701
|
+
this.clearSatisfiedGroupWaits(room, groupContext.groupId, groupContext.roundNumber);
|
|
1702
|
+
}
|
|
1703
|
+
return this.memberWaits.has(memberId) ? { ...member, waiting } : member;
|
|
1704
|
+
}
|
|
1705
|
+
endWait(room, memberId) {
|
|
1706
|
+
const member = this.store.getMember(room, memberId);
|
|
1707
|
+
if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
|
|
1708
|
+
throw new Error(`no active agent member: ${memberId}`);
|
|
1709
|
+
}
|
|
1710
|
+
if (!this.canUseLiveWait(room, member)) {
|
|
1711
|
+
throw new Error(`member @${member.handle} cannot end a wait while ${member.state ?? 'inactive'}`);
|
|
1712
|
+
}
|
|
1713
|
+
const run = this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER })
|
|
1714
|
+
.reverse()
|
|
1715
|
+
.find((message) => message.kind === 'run' && message.author === memberId && message.run?.status === 'running');
|
|
1716
|
+
if (!run)
|
|
1717
|
+
throw new Error(`member @${member.handle} has no running turn to end a wait in`);
|
|
1718
|
+
const changed = this.memberWaits.delete(memberId);
|
|
1719
|
+
this.groupWaits.delete(memberId);
|
|
1720
|
+
if (changed)
|
|
1721
|
+
this.emitMember(room, member);
|
|
1722
|
+
return member;
|
|
1723
|
+
}
|
|
1724
|
+
// harn:end live-agent-waits-are-transient
|
|
1725
|
+
postSystemMessage(room, body, opts = {}) {
|
|
1726
|
+
const system = this.store.listMembers(room).find((m) => m.kind === 'system');
|
|
1727
|
+
const message = this.store.postMessage(room, {
|
|
1728
|
+
author: system.id,
|
|
1729
|
+
kind: 'system',
|
|
1730
|
+
body,
|
|
1731
|
+
mentions: opts.mentions,
|
|
1732
|
+
});
|
|
1733
|
+
this.emitMessage(room, message);
|
|
1734
|
+
return message; // system messages NEVER route (eligibility gate)
|
|
1735
|
+
}
|
|
1736
|
+
// harn:assume mirror-one-message-per-native-turn ref=mirrored-turn-dedupe-route
|
|
1737
|
+
mirrorTurn(input) {
|
|
1738
|
+
const joined = this.store.findMemberBySessionRef(input.harness, input.session_ref);
|
|
1739
|
+
if (!joined)
|
|
1740
|
+
throw new Error(`no mirrored member for ${input.harness} session ${input.session_ref}`);
|
|
1741
|
+
if (joined.member.custody !== 'mirrored') {
|
|
1742
|
+
throw new Error(`member @${joined.member.handle} is not mirrored; native turn was dropped`);
|
|
1743
|
+
}
|
|
1744
|
+
const parsed = parseBody(input.body, this.store.listMembers(joined.room));
|
|
1745
|
+
const startedTs = new Date().toISOString();
|
|
1746
|
+
const committed = this.store.commitMirroredTurn(joined.room, {
|
|
1747
|
+
memberId: joined.member.id,
|
|
1748
|
+
nativeTurnId: input.native_turn_id,
|
|
1749
|
+
finalize: (placeholder) => {
|
|
1750
|
+
const eventsRef = this.blobs.ref(placeholder.id);
|
|
1751
|
+
const patch = {
|
|
1752
|
+
body: input.body,
|
|
1753
|
+
mentions: parsed.mentions,
|
|
1754
|
+
refs: parsed.refs,
|
|
1755
|
+
ledger_refs: parsed.ledger_refs,
|
|
1756
|
+
run: {
|
|
1757
|
+
status: 'completed',
|
|
1758
|
+
started_ts: startedTs,
|
|
1759
|
+
ended_ts: startedTs,
|
|
1760
|
+
tool_calls: 0,
|
|
1761
|
+
events_ref: eventsRef,
|
|
1762
|
+
final_text: input.body,
|
|
1763
|
+
output_mode: 'messages',
|
|
1764
|
+
result_message_id: placeholder.id,
|
|
1765
|
+
},
|
|
1766
|
+
};
|
|
1767
|
+
const draft = { ...placeholder, ...patch };
|
|
1768
|
+
const planned = this.planRoutedMessage(joined.room, draft, this.ownerOf(joined.room).id, undefined, false, true);
|
|
1769
|
+
return {
|
|
1770
|
+
message: patch,
|
|
1771
|
+
fanout: planned.plan.fanout,
|
|
1772
|
+
collaboration: planned.plan.collaboration,
|
|
1773
|
+
markMisaddressed: planned.result.misaddressed,
|
|
1774
|
+
};
|
|
1775
|
+
},
|
|
1776
|
+
});
|
|
1777
|
+
if (committed.deduped)
|
|
1778
|
+
return { message: committed.message, deduped: true };
|
|
1779
|
+
const eventsRef = committed.message.run.events_ref;
|
|
1780
|
+
this.blobs.append(joined.room, eventsRef, {
|
|
1781
|
+
type: 'run.item',
|
|
1782
|
+
item_type: 'reasoning_summary',
|
|
1783
|
+
payload: {
|
|
1784
|
+
source: 'mirrored-turn',
|
|
1785
|
+
native_turn_id: input.native_turn_id,
|
|
1786
|
+
transcript_path: input.transcript_path,
|
|
1787
|
+
},
|
|
1788
|
+
output_message_id: committed.message.id,
|
|
1789
|
+
});
|
|
1790
|
+
this.blobs.append(joined.room, eventsRef, {
|
|
1791
|
+
type: 'run.completed',
|
|
1792
|
+
status: 'completed',
|
|
1793
|
+
final_text: input.body,
|
|
1794
|
+
output_message_id: committed.message.id,
|
|
1795
|
+
});
|
|
1796
|
+
this.emitMessage(joined.room, committed.message);
|
|
1797
|
+
if (committed.member)
|
|
1798
|
+
this.emitMember(joined.room, committed.member);
|
|
1799
|
+
this.dispatchCreatedDeliveries(joined.room, committed.deliveries);
|
|
1800
|
+
return { message: committed.message, deduped: false };
|
|
1801
|
+
}
|
|
1802
|
+
// harn:end mirror-one-message-per-native-turn
|
|
1803
|
+
// harn:end eligible-multi-agent-routing-starts-one-group
|
|
1804
|
+
// harn:assume extension-lifecycle-from-hooks ref=switchboard-extension-lifecycle
|
|
1805
|
+
startExtension(room, parent, event, streamDescription) {
|
|
1806
|
+
const description = streamDescription ?? event.description;
|
|
1807
|
+
let extension = this.store.getExtensionByNativeId(room, parent.id, event.ext_member);
|
|
1808
|
+
if (extension) {
|
|
1809
|
+
extension = this.store.updateMember(room, extension.id, {
|
|
1810
|
+
state: 'running',
|
|
1811
|
+
...(description !== undefined && { display_name: description }),
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
else {
|
|
1815
|
+
const suffix = extensionSuffix(event.ext_member);
|
|
1816
|
+
const handle = `${parent.handle.slice(0, 20)}-ext-${suffix}`;
|
|
1817
|
+
extension = this.store.addMember(room, {
|
|
1818
|
+
kind: 'extension',
|
|
1819
|
+
handle,
|
|
1820
|
+
display_name: description ?? `${parent.display_name} extension ${suffix}`,
|
|
1821
|
+
harness: parent.harness,
|
|
1822
|
+
session_ref: event.ext_member,
|
|
1823
|
+
cwd: parent.cwd,
|
|
1824
|
+
state: 'running',
|
|
1825
|
+
parent: parent.id,
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
this.emitMember(room, extension);
|
|
1829
|
+
return {
|
|
1830
|
+
...event,
|
|
1831
|
+
parent: parent.id,
|
|
1832
|
+
ext_member: extension.id,
|
|
1833
|
+
...(description !== undefined && { description }),
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
endExtension(room, parent, event) {
|
|
1837
|
+
const existing = this.store.getExtensionByNativeId(room, parent.id, event.ext_member);
|
|
1838
|
+
if (!existing)
|
|
1839
|
+
return event;
|
|
1840
|
+
const extension = existing.state === 'dead'
|
|
1841
|
+
? existing
|
|
1842
|
+
: this.store.updateMember(room, existing.id, { state: 'dead' });
|
|
1843
|
+
if (existing.state !== 'dead')
|
|
1844
|
+
this.emitMember(room, extension);
|
|
1845
|
+
return { ...event, ext_member: extension.id };
|
|
1846
|
+
}
|
|
1847
|
+
// harn:end extension-lifecycle-from-hooks
|
|
1848
|
+
// ── routing / fanout ──────────────────────────────────────────────────
|
|
1849
|
+
latestFinalizedAgentAuthor(room) {
|
|
1850
|
+
return this.store.latestFinalizedAgentAuthor(room);
|
|
1851
|
+
}
|
|
1852
|
+
dispatchCreatedDeliveries(room, created) {
|
|
1853
|
+
for (const delivery of created) {
|
|
1854
|
+
const recipient = this.store.getMember(room, delivery.recipient);
|
|
1855
|
+
// harn:assume agent-delivery-lifecycle-streams ref=delivery-created-emit
|
|
1856
|
+
// Agent recipients stream their queued frame too — a connected client's
|
|
1857
|
+
// seen tick starts honest instead of waiting for a reconnect snapshot.
|
|
1858
|
+
if (recipient !== undefined)
|
|
1859
|
+
this.emitInbox(room, delivery);
|
|
1860
|
+
// harn:end agent-delivery-lifecycle-streams
|
|
1861
|
+
if (recipient?.kind === 'agent') {
|
|
1862
|
+
if (delivery.group_id !== undefined &&
|
|
1863
|
+
(recipient.state === 'dead' || recipient.removed_ts !== undefined)) {
|
|
1864
|
+
this.skipUnavailableGroupDelivery(room, delivery);
|
|
1865
|
+
}
|
|
1866
|
+
else {
|
|
1867
|
+
this.dispatchAgentDelivery(room, delivery, recipient);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
// harn:assume agent-chains-uninterrupted-by-default ref=delivery-hop-brake-dispatch
|
|
1873
|
+
deliveryBrakeReason(room, delivery) {
|
|
1874
|
+
const config = this.store.getRoom(room).config;
|
|
1875
|
+
const meter = this.store.getMeter(room, new Date().toISOString().slice(0, 10));
|
|
1876
|
+
const verdict = evaluateBrakes(config, {
|
|
1877
|
+
consecutiveAgentDeliveries: Math.max(0, (delivery.hop_count ?? 0) - 1),
|
|
1878
|
+
spendTodayUsd: meter?.cost_usd ?? 0,
|
|
1879
|
+
});
|
|
1880
|
+
if (!verdict.hold)
|
|
1881
|
+
return undefined;
|
|
1882
|
+
return verdict.reason === 'turn_brake'
|
|
1883
|
+
? `turn brake before hop ${delivery.hop_count ?? 0}`
|
|
1884
|
+
: `spend brake at $${(meter?.cost_usd ?? 0).toFixed(2)}`;
|
|
1885
|
+
}
|
|
1886
|
+
dispatchAgentDelivery(room, delivery, recipient) {
|
|
1887
|
+
const reason = this.deliveryBrakeReason(room, delivery);
|
|
1888
|
+
if (reason) {
|
|
1889
|
+
this.holdDelivery(room, delivery.id, reason);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
this.queueAgentDelivery(room, recipient);
|
|
1893
|
+
}
|
|
1894
|
+
// harn:end agent-chains-uninterrupted-by-default
|
|
1895
|
+
// harn:assume mirrored-deliveries-queue ref=mirrored-custody-hold
|
|
1896
|
+
queueAgentDelivery(room, recipient) {
|
|
1897
|
+
const mirrored = recipient.custody === 'mirrored';
|
|
1898
|
+
const remoteUnreachable = this.isRemoteMember(recipient) &&
|
|
1899
|
+
!this.residency?.isReachable(recipient.host);
|
|
1900
|
+
// harn:assume inflight-member-state-survives-new-delivery ref=preserve-live-state-on-queue
|
|
1901
|
+
const hasLiveTurn = this.inflight.has(recipient.id) &&
|
|
1902
|
+
(recipient.state === 'running' || recipient.state === 'awaiting_input');
|
|
1903
|
+
const preservesState = hasLiveTurn ||
|
|
1904
|
+
recipient.state === 'paused' ||
|
|
1905
|
+
recipient.state === 'dead' ||
|
|
1906
|
+
recipient.state === 'custody_uncertain';
|
|
1907
|
+
// harn:end inflight-member-state-survives-new-delivery
|
|
1908
|
+
const member = this.store.updateMember(room, recipient.id, {
|
|
1909
|
+
state: preservesState ? recipient.state : remoteUnreachable ? 'unreachable' : 'queued',
|
|
1910
|
+
});
|
|
1911
|
+
this.emitMember(room, member);
|
|
1912
|
+
if (mirrored) {
|
|
1913
|
+
const queued = this.store.listDeliveries(room, {
|
|
1914
|
+
recipient: recipient.id,
|
|
1915
|
+
state: 'queued',
|
|
1916
|
+
}).length;
|
|
1917
|
+
if (queued === 1) {
|
|
1918
|
+
this.postSystemMessage(room, `@${recipient.handle} is mirrored in an operator terminal; 1 delivery is queued`);
|
|
1919
|
+
}
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (remoteUnreachable)
|
|
1923
|
+
return;
|
|
1924
|
+
this.track(this.maybeStartTurn(room, recipient.id));
|
|
1925
|
+
}
|
|
1926
|
+
// harn:end mirrored-deliveries-queue
|
|
1927
|
+
planFanout(room, message, triggerAuthor, agentHop, awaitingReply = false) {
|
|
1928
|
+
const members = this.store.listMembers(room);
|
|
1929
|
+
const author = members.find((m) => m.id === message.author);
|
|
1930
|
+
const result = resolveRecipients(message, {
|
|
1931
|
+
members,
|
|
1932
|
+
author,
|
|
1933
|
+
repliedTo: message.reply_to !== undefined ? this.store.getMessage(room, message.reply_to) : undefined,
|
|
1934
|
+
latestFinalizedAgentAuthor: this.latestFinalizedAgentAuthor(room),
|
|
1935
|
+
roomConfig: this.store.getRoom(room).config,
|
|
1936
|
+
triggerAuthor,
|
|
1937
|
+
});
|
|
1938
|
+
const recipients = [...result.agents, ...result.humans];
|
|
1939
|
+
const fanout = [
|
|
1940
|
+
...result.humans.map((human) => ({ recipient: human.id, state: 'consumed' })),
|
|
1941
|
+
...result.agents.map((agent) => ({
|
|
1942
|
+
recipient: agent.id,
|
|
1943
|
+
state: 'queued',
|
|
1944
|
+
payload_snapshot: this.snapshotPayload(room, message, agent, recipients, awaitingReply),
|
|
1945
|
+
hop_count: agentHop ?? (author?.kind === 'agent' ? 1 : 0),
|
|
1946
|
+
})),
|
|
1947
|
+
];
|
|
1948
|
+
return { result, fanout };
|
|
1949
|
+
}
|
|
1950
|
+
planRoutedMessage(room, message, triggerAuthor, agentHop, awaitingReply = false, allowGroup = true) {
|
|
1951
|
+
const planned = this.planFanout(room, message, triggerAuthor, agentHop, awaitingReply);
|
|
1952
|
+
const base = {
|
|
1953
|
+
fanout: planned.fanout,
|
|
1954
|
+
...(planned.result.misaddressed && { markMisaddressed: true }),
|
|
1955
|
+
};
|
|
1956
|
+
if (!allowGroup || planned.result.agents.length < 2) {
|
|
1957
|
+
return { result: planned.result, plan: base };
|
|
1958
|
+
}
|
|
1959
|
+
const groupId = ulid();
|
|
1960
|
+
const context = this.groupPayloadContext(room, message, groupId, 1);
|
|
1961
|
+
const humanIds = new Set(planned.result.humans.map((member) => member.id));
|
|
1962
|
+
const agentFanout = new Map(planned.fanout
|
|
1963
|
+
.filter((delivery) => !humanIds.has(delivery.recipient))
|
|
1964
|
+
.map((delivery) => [delivery.recipient, delivery]));
|
|
1965
|
+
return {
|
|
1966
|
+
result: planned.result,
|
|
1967
|
+
plan: {
|
|
1968
|
+
...base,
|
|
1969
|
+
fanout: planned.fanout.filter((delivery) => humanIds.has(delivery.recipient)),
|
|
1970
|
+
collaboration: {
|
|
1971
|
+
groupId,
|
|
1972
|
+
participants: planned.result.agents.map((agent) => ({
|
|
1973
|
+
memberId: agent.id,
|
|
1974
|
+
payloadSnapshot: this.groupPayloadSnapshot(composeGroupRoundPayload(context, agent.handle)),
|
|
1975
|
+
state: 'queued',
|
|
1976
|
+
hopCount: agentFanout.get(agent.id)?.hop_count,
|
|
1977
|
+
})),
|
|
1978
|
+
},
|
|
1979
|
+
},
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
groupPayloadContext(room, root, groupId, roundNumber) {
|
|
1983
|
+
const author = this.store.getMember(room, root.author);
|
|
1984
|
+
if (!author)
|
|
1985
|
+
throw new Error(`group root #${root.id} has no author`);
|
|
1986
|
+
return {
|
|
1987
|
+
groupId,
|
|
1988
|
+
roundNumber,
|
|
1989
|
+
room,
|
|
1990
|
+
root: {
|
|
1991
|
+
messageId: root.id,
|
|
1992
|
+
authorHandle: author.handle,
|
|
1993
|
+
body: root.body,
|
|
1994
|
+
},
|
|
1995
|
+
refs: root.refs
|
|
1996
|
+
.map((id) => this.store.getMessage(room, id))
|
|
1997
|
+
.filter((ref) => ref !== undefined)
|
|
1998
|
+
.map((ref) => ({
|
|
1999
|
+
id: ref.id,
|
|
2000
|
+
authorHandle: this.store.getMember(room, ref.author)?.handle ?? 'unknown',
|
|
2001
|
+
ts: ref.ts,
|
|
2002
|
+
body: this.runRefBody(ref),
|
|
2003
|
+
})),
|
|
2004
|
+
ledgerRefs: this.ledger?.resolve(room, root.ledger_refs),
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
// harn:assume run-failure-evidence-is-surfaced ref=run-ref-error-evidence
|
|
2008
|
+
/**
|
|
2009
|
+
* A referenced run's quotable content: its reply text, else its failure
|
|
2010
|
+
* evidence (labeled so consumers know it is evidence, not a reply), else
|
|
2011
|
+
* its body. Keeps refs to failed/interrupted runs from resolving empty.
|
|
2012
|
+
*/
|
|
2013
|
+
runRefBody(ref) {
|
|
2014
|
+
if (ref.kind !== 'run')
|
|
2015
|
+
return ref.body;
|
|
2016
|
+
const finalText = ref.run?.final_text;
|
|
2017
|
+
if (finalText !== undefined && finalText !== '')
|
|
2018
|
+
return finalText;
|
|
2019
|
+
const error = ref.run?.error;
|
|
2020
|
+
if (error !== undefined && error !== '') {
|
|
2021
|
+
return `[run ${ref.run?.status ?? 'failed'}] ${error}`;
|
|
2022
|
+
}
|
|
2023
|
+
return ref.body;
|
|
2024
|
+
}
|
|
2025
|
+
// harn:end run-failure-evidence-is-surfaced
|
|
2026
|
+
// harn:assume collaboration-round-release-is-one-barrier ref=group-payload-snapshot-integration
|
|
2027
|
+
groupPayloadSnapshot(payload) {
|
|
2028
|
+
return JSON.stringify({ kind: 'group', payload });
|
|
2029
|
+
}
|
|
2030
|
+
// harn:end collaboration-round-release-is-one-barrier
|
|
2031
|
+
// harn:assume delivery-payload-snapshotted ref=daemon-payload-snapshot
|
|
2032
|
+
snapshotPayload(room, message, recipient, recipients, awaitingReply = false) {
|
|
2033
|
+
const author = this.store.getMember(room, message.author);
|
|
2034
|
+
const recipientIds = new Set(recipients.map((member) => member.id));
|
|
2035
|
+
const toHandles = [
|
|
2036
|
+
...new Set(message.mentions
|
|
2037
|
+
.filter((span) => recipientIds.has(span.member_id))
|
|
2038
|
+
.map((span) => this.store.getMember(room, span.member_id)?.handle)
|
|
2039
|
+
.filter((handle) => handle !== undefined)),
|
|
2040
|
+
];
|
|
2041
|
+
if (toHandles.length === 0)
|
|
2042
|
+
toHandles.push(...recipients.map((member) => member.handle));
|
|
2043
|
+
const refs = message.refs
|
|
2044
|
+
.map((id) => this.store.getMessage(room, id))
|
|
2045
|
+
.filter((ref) => ref !== undefined)
|
|
2046
|
+
.map((ref) => ({
|
|
2047
|
+
id: ref.id,
|
|
2048
|
+
author_handle: this.store.getMember(room, ref.author)?.handle ?? 'unknown',
|
|
2049
|
+
ts: ref.ts,
|
|
2050
|
+
body: this.runRefBody(ref),
|
|
2051
|
+
}));
|
|
2052
|
+
const ledgerRefs = this.ledger?.resolve(room, message.ledger_refs) ?? [];
|
|
2053
|
+
const snapshot = {
|
|
2054
|
+
context: {
|
|
2055
|
+
room,
|
|
2056
|
+
message,
|
|
2057
|
+
authorHandle: author.handle,
|
|
2058
|
+
authorKind: author.kind,
|
|
2059
|
+
toHandles,
|
|
2060
|
+
refs,
|
|
2061
|
+
ledgerRefs,
|
|
2062
|
+
// harn:assume awaiting-reply-marker-is-delivery-context ref=awaiting-reply-snapshot
|
|
2063
|
+
...(awaitingReply && { awaitingReply: true }),
|
|
2064
|
+
// harn:end awaiting-reply-marker-is-delivery-context
|
|
2065
|
+
},
|
|
2066
|
+
you: recipient.handle,
|
|
2067
|
+
};
|
|
2068
|
+
return JSON.stringify(snapshot);
|
|
2069
|
+
}
|
|
2070
|
+
// harn:end delivery-payload-snapshotted
|
|
2071
|
+
// ── the turn pump ─────────────────────────────────────────────────────
|
|
2072
|
+
// harn:assume one-inflight-turn-per-member ref=inflight-guard
|
|
2073
|
+
/**
|
|
2074
|
+
* At most ONE turn in flight per member: deliveries landing while a
|
|
2075
|
+
* member runs stay queued and are drained as one batched turn when the
|
|
2076
|
+
* member goes idle again (the pump re-runs itself after finalize).
|
|
2077
|
+
*/
|
|
2078
|
+
// harn:assume turn-start-requires-owned-custody ref=shared-turn-start-custody-gate
|
|
2079
|
+
turnStartEligibility(room, memberId) {
|
|
2080
|
+
const member = this.store.getMember(room, memberId);
|
|
2081
|
+
if (!member || member.kind !== 'agent')
|
|
2082
|
+
return { refusal: `no such agent member: ${memberId}` };
|
|
2083
|
+
if (this.closing)
|
|
2084
|
+
return { refusal: 'the daemon is closing' };
|
|
2085
|
+
if (this.inflight.has(memberId))
|
|
2086
|
+
return { refusal: `member @${member.handle} already has a turn in flight` };
|
|
2087
|
+
if (this.pendingAttach.has(memberId))
|
|
2088
|
+
return { refusal: `member @${member.handle} has an attach acquisition pending` };
|
|
2089
|
+
if (member.custody !== 'owned')
|
|
2090
|
+
return { refusal: `member @${member.handle} is not switchboard-owned` };
|
|
2091
|
+
if (this.isRemoteMember(member) && !this.residency?.isReachable(member.host)) {
|
|
2092
|
+
return { refusal: `member @${member.handle} resident switchboard is unreachable` };
|
|
2093
|
+
}
|
|
2094
|
+
if (member.state === 'paused' || member.state === 'dead' || member.state === 'custody_uncertain') {
|
|
2095
|
+
return { refusal: `member @${member.handle} is ${member.state}` };
|
|
2096
|
+
}
|
|
2097
|
+
return { member };
|
|
2098
|
+
}
|
|
2099
|
+
// harn:end turn-start-requires-owned-custody
|
|
2100
|
+
// harn:assume brakes-rechecked-at-turn-start ref=turn-start-brake-recheck
|
|
2101
|
+
applyTurnStartBrakes(room, batch, atomic) {
|
|
2102
|
+
const braked = batch
|
|
2103
|
+
.filter((delivery) => (delivery.hop_count ?? 0) > 0 && !this.releasedDeliveries.has(delivery.id))
|
|
2104
|
+
.map((delivery) => ({ delivery, reason: this.deliveryBrakeReason(room, delivery) }))
|
|
2105
|
+
.filter((item) => item.reason !== undefined);
|
|
2106
|
+
if (braked.length === 0) {
|
|
2107
|
+
for (const delivery of batch)
|
|
2108
|
+
this.releasedDeliveries.delete(delivery.id);
|
|
2109
|
+
return batch;
|
|
2110
|
+
}
|
|
2111
|
+
if (atomic) {
|
|
2112
|
+
const reason = braked[0].reason;
|
|
2113
|
+
for (const delivery of batch)
|
|
2114
|
+
this.holdDelivery(room, delivery.id, reason);
|
|
2115
|
+
return [];
|
|
2116
|
+
}
|
|
2117
|
+
const heldIds = new Set(braked.map(({ delivery }) => delivery.id));
|
|
2118
|
+
for (const { delivery, reason } of braked)
|
|
2119
|
+
this.holdDelivery(room, delivery.id, reason);
|
|
2120
|
+
const runnable = batch.filter((delivery) => !heldIds.has(delivery.id));
|
|
2121
|
+
for (const delivery of runnable)
|
|
2122
|
+
this.releasedDeliveries.delete(delivery.id);
|
|
2123
|
+
return runnable;
|
|
2124
|
+
}
|
|
2125
|
+
async maybeStartTurn(room, memberId) {
|
|
2126
|
+
const eligible = this.turnStartEligibility(room, memberId);
|
|
2127
|
+
if (!eligible.member)
|
|
2128
|
+
return; // holds its queue; the room shows the backlog
|
|
2129
|
+
const member = eligible.member;
|
|
2130
|
+
const queued = this.store.listDeliveries(room, { recipient: memberId, state: 'queued' });
|
|
2131
|
+
if (queued.length === 0)
|
|
2132
|
+
return;
|
|
2133
|
+
// harn:assume grouped-deliveries-have-an-isolated-batch-class ref=group-batch-pump-integration
|
|
2134
|
+
const selected = selectDeliveryBatchPrefix(queued);
|
|
2135
|
+
const batch = this.applyTurnStartBrakes(room, selected, selected[0]?.group_id !== undefined);
|
|
2136
|
+
// harn:end grouped-deliveries-have-an-isolated-batch-class
|
|
2137
|
+
if (batch.length === 0) {
|
|
2138
|
+
const current = this.store.getMember(room, memberId);
|
|
2139
|
+
if (current?.state === 'queued')
|
|
2140
|
+
this.emitMember(room, this.store.updateMember(room, memberId, { state: 'idle' }));
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
this.inflight.add(memberId);
|
|
2144
|
+
try {
|
|
2145
|
+
await this.runTurn(room, member, batch);
|
|
2146
|
+
}
|
|
2147
|
+
finally {
|
|
2148
|
+
this.inflight.delete(memberId);
|
|
2149
|
+
}
|
|
2150
|
+
await this.maybeStartTurn(room, memberId); // drain anything queued meanwhile
|
|
2151
|
+
}
|
|
2152
|
+
// harn:end brakes-rechecked-at-turn-start
|
|
2153
|
+
// harn:end one-inflight-turn-per-member
|
|
2154
|
+
async runTurn(room, member, batch, reuseRunMsg) {
|
|
2155
|
+
// harn:assume turns-reuse-one-root-and-append-output-messages ref=run-message-lifecycle
|
|
2156
|
+
// Exactly one lifecycle root owns the turn, its input custody, and journal.
|
|
2157
|
+
// A crash retry reuses it; only later visible stretches may append rows.
|
|
2158
|
+
const originalStates = new Map(batch.map((delivery) => [delivery.id, delivery.state]));
|
|
2159
|
+
const started = this.store.beginTurn(room, {
|
|
2160
|
+
memberId: member.id,
|
|
2161
|
+
deliveryIds: batch.map((delivery) => delivery.id),
|
|
2162
|
+
startedTs: new Date().toISOString(),
|
|
2163
|
+
model: member.model,
|
|
2164
|
+
eventsRef: (messageId) => this.blobs.ref(messageId),
|
|
2165
|
+
reuseRunMsgId: reuseRunMsg?.id,
|
|
2166
|
+
});
|
|
2167
|
+
// harn:assume only-an-admissible-delivery-becomes-delivering ref=turn-start-with-nothing-admissible
|
|
2168
|
+
// Everything in the batch was consumed between selection and admission — the member
|
|
2169
|
+
// was removed, or the work was taken by something else. There is nothing to say, and
|
|
2170
|
+
// an empty run message would be a defect of its own. Idle the member and stop.
|
|
2171
|
+
if (!started) {
|
|
2172
|
+
const current = this.store.getMember(room, member.id);
|
|
2173
|
+
if (current !== undefined && current.state === 'queued') {
|
|
2174
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { state: 'idle' }));
|
|
2175
|
+
}
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
// harn:end only-an-admissible-delivery-becomes-delivering
|
|
2179
|
+
const runMsg = started.runMessage;
|
|
2180
|
+
this.emitMessage(room, runMsg);
|
|
2181
|
+
this.noteRunActivity(room, runMsg.id);
|
|
2182
|
+
// harn:end turns-reuse-one-root-and-append-output-messages
|
|
2183
|
+
// harn:assume run-events-merge-by-journal-index ref=daemon-journal-index-stamp
|
|
2184
|
+
// The journal position of the NEXT appended event. A reconciled retry
|
|
2185
|
+
// reuses the run message, so its blob may already carry lines.
|
|
2186
|
+
const existingJournal = reuseRunMsg !== undefined
|
|
2187
|
+
? this.blobs.read(room, runMsg.run.events_ref)
|
|
2188
|
+
: [];
|
|
2189
|
+
let journalIndex = existingJournal.length;
|
|
2190
|
+
const continuation = new ContinuationWriter(runMsg.id, existingJournal);
|
|
2191
|
+
// harn:end run-events-merge-by-journal-index
|
|
2192
|
+
// harn:assume delivery-attempt-wal-reconcile ref=wal-bind-before-spawn
|
|
2193
|
+
// Attempt WAL: bind every batched delivery to the run message and count
|
|
2194
|
+
// the attempt BEFORE the adapter spawns — consumption happens only when
|
|
2195
|
+
// run.completed lands, so a crash leaves reconcilable evidence.
|
|
2196
|
+
const bound = started.deliveries;
|
|
2197
|
+
// harn:end delivery-attempt-wal-reconcile
|
|
2198
|
+
// harn:assume agent-delivery-lifecycle-streams ref=delivery-bound-emit
|
|
2199
|
+
// Every bound delivery whose state moved (queued/held -> delivering)
|
|
2200
|
+
// streams its transition — formerly only releases out of held did.
|
|
2201
|
+
for (const delivery of bound) {
|
|
2202
|
+
if (originalStates.get(delivery.id) !== delivery.state)
|
|
2203
|
+
this.emitInbox(room, delivery);
|
|
2204
|
+
}
|
|
2205
|
+
// harn:end agent-delivery-lifecycle-streams
|
|
2206
|
+
const payload = this.composeBatchPayload(room, member, bound);
|
|
2207
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { state: 'running' }));
|
|
2208
|
+
const remote = this.isRemoteMember(member);
|
|
2209
|
+
const adapter = remote ? undefined : this.requireAdapter(member.harness);
|
|
2210
|
+
const session = remote ? undefined : this.sessionFor(room, member);
|
|
2211
|
+
let completion;
|
|
2212
|
+
let toolCalls = 0;
|
|
2213
|
+
const pendingExtensionDescriptions = [];
|
|
2214
|
+
try {
|
|
2215
|
+
const triggerMsg = bound.at(-1)?.message_id ?? runMsg.id;
|
|
2216
|
+
const events = remote
|
|
2217
|
+
? this.residency.deliver(member.host, {
|
|
2218
|
+
rpc_id: this.remoteRpcId(room, runMsg.id),
|
|
2219
|
+
room,
|
|
2220
|
+
member: remoteMemberSpec(member),
|
|
2221
|
+
payload,
|
|
2222
|
+
trigger_msg: triggerMsg,
|
|
2223
|
+
}, {
|
|
2224
|
+
lastEventIndex: this.blobs.read(room, runMsg.run.events_ref).length - 1,
|
|
2225
|
+
onSessionRef: (sessionRef) => {
|
|
2226
|
+
const persisted = this.store.getMember(room, member.id);
|
|
2227
|
+
if (persisted?.session_ref === sessionRef)
|
|
2228
|
+
return;
|
|
2229
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { session_ref: sessionRef }));
|
|
2230
|
+
},
|
|
2231
|
+
})
|
|
2232
|
+
: adapter.deliver(session, payload, {
|
|
2233
|
+
// harn:assume attempt-start-evidence-persisted ref=attempt-start-evidence
|
|
2234
|
+
onStarted: (process) => {
|
|
2235
|
+
this.noteRunActivity(room, runMsg.id);
|
|
2236
|
+
this.store.setDeliveryAttemptProcess(room, bound.map((delivery) => delivery.id), process);
|
|
2237
|
+
const startedEvent = {
|
|
2238
|
+
type: 'run.started',
|
|
2239
|
+
member: member.id,
|
|
2240
|
+
trigger_msg: triggerMsg,
|
|
2241
|
+
};
|
|
2242
|
+
this.blobs.append(room, runMsg.run.events_ref, startedEvent);
|
|
2243
|
+
// harn:assume run-events-merge-by-journal-index ref=daemon-journal-index-stamp
|
|
2244
|
+
this.emit(room, {
|
|
2245
|
+
type: 'run_event',
|
|
2246
|
+
room,
|
|
2247
|
+
message_id: runMsg.id,
|
|
2248
|
+
event: startedEvent,
|
|
2249
|
+
index: journalIndex++,
|
|
2250
|
+
});
|
|
2251
|
+
// harn:end run-events-merge-by-journal-index
|
|
2252
|
+
},
|
|
2253
|
+
onSessionRef: (sessionRef) => {
|
|
2254
|
+
const persisted = this.store.getMember(room, member.id);
|
|
2255
|
+
if (persisted?.session_ref === sessionRef)
|
|
2256
|
+
return;
|
|
2257
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { session_ref: sessionRef }));
|
|
2258
|
+
},
|
|
2259
|
+
// harn:end attempt-start-evidence-persisted
|
|
2260
|
+
});
|
|
2261
|
+
for await (const event of events) {
|
|
2262
|
+
this.noteRunActivity(room, runMsg.id);
|
|
2263
|
+
let journalEvent = event;
|
|
2264
|
+
if (event.type === 'run.item') {
|
|
2265
|
+
// harn:assume member-status-is-bounded-and-identity-safe ref=run-item-journal-timestamp
|
|
2266
|
+
// Every run item is journaled with a wall-clock ts — prose (text_delta,
|
|
2267
|
+
// reasoning) included — so transcript blocks can be ordered by true time,
|
|
2268
|
+
// not only tool events. One path, no per-type branching.
|
|
2269
|
+
journalEvent = { ...event, ts: new Date().toISOString() };
|
|
2270
|
+
// harn:end member-status-is-bounded-and-identity-safe
|
|
2271
|
+
if (event.item_type === 'tool_call')
|
|
2272
|
+
toolCalls++;
|
|
2273
|
+
const description = extensionDescription(event);
|
|
2274
|
+
if (description !== undefined)
|
|
2275
|
+
pendingExtensionDescriptions.push(description);
|
|
2276
|
+
}
|
|
2277
|
+
else if (event.type === 'extension.started') {
|
|
2278
|
+
journalEvent = this.startExtension(room, member, event, pendingExtensionDescriptions.shift());
|
|
2279
|
+
}
|
|
2280
|
+
else if (event.type === 'extension.ended') {
|
|
2281
|
+
journalEvent = this.endExtension(room, member, event);
|
|
2282
|
+
}
|
|
2283
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
|
|
2284
|
+
// Live usage is member runtime state: broadcast it, but do not append it
|
|
2285
|
+
// to the durable run journal or change log.
|
|
2286
|
+
if (event.type === 'usage_updated') {
|
|
2287
|
+
this.landContextWindow(room, member.id, event.usage);
|
|
2288
|
+
// Keyed by bare member id like every sibling per-member map (ULIDs
|
|
2289
|
+
// never repeat, so no cross-room collision). Skip the re-broadcast
|
|
2290
|
+
// when the snapshot is unchanged — a full member frame per identical
|
|
2291
|
+
// usage report is pure fanout waste.
|
|
2292
|
+
if (!isDeepStrictEqual(this.lastUsage.get(member.id), event.usage)) {
|
|
2293
|
+
this.lastUsage.set(member.id, { ...event.usage });
|
|
2294
|
+
const current = this.store.getMember(room, member.id);
|
|
2295
|
+
if (current !== undefined)
|
|
2296
|
+
this.emitMember(room, current);
|
|
2297
|
+
}
|
|
2298
|
+
continue;
|
|
2299
|
+
}
|
|
2300
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
2301
|
+
// harn:assume agent-usage-limits-reported-not-guessed ref=member-limits-persisted
|
|
2302
|
+
// Limits are member status, not run content: land the harness's report
|
|
2303
|
+
// on the member row and stream the member frame — nothing is journaled.
|
|
2304
|
+
if (event.type === 'run.limits') {
|
|
2305
|
+
this.landMemberLimits(room, member.id, event.limits);
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
// harn:end agent-usage-limits-reported-not-guessed
|
|
2309
|
+
// harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-writer-engine
|
|
2310
|
+
// Allocate+insert before the id becomes journal truth. The empty row is
|
|
2311
|
+
// streamed before its first targeted event, so readers never observe an
|
|
2312
|
+
// event whose permanent message does not exist yet.
|
|
2313
|
+
let allocated;
|
|
2314
|
+
const assigned = continuation.assign(journalEvent, this.store.latestMessageId(room), () => {
|
|
2315
|
+
allocated = this.store.createRunContinuation(room, runMsg.id);
|
|
2316
|
+
return allocated.id;
|
|
2317
|
+
});
|
|
2318
|
+
journalEvent = assigned.event;
|
|
2319
|
+
if (allocated !== undefined)
|
|
2320
|
+
this.emitMessage(room, allocated);
|
|
2321
|
+
this.blobs.append(room, runMsg.run.events_ref, journalEvent);
|
|
2322
|
+
// harn:end continuation-writer-follows-journaled-output-ownership
|
|
2323
|
+
// harn:assume run-events-merge-by-journal-index ref=daemon-journal-index-stamp
|
|
2324
|
+
// Stamp the frame with the position this event just took in the
|
|
2325
|
+
// journal, so a viewer who joined mid-run merges exactly.
|
|
2326
|
+
const stampedIndex = journalIndex++;
|
|
2327
|
+
// harn:assume compaction-timeline-items-are-durable-run-evidence ref=compaction-journal-fanout
|
|
2328
|
+
if (journalEvent.type === 'run.started' ||
|
|
2329
|
+
journalEvent.type === 'run.item' ||
|
|
2330
|
+
journalEvent.type === 'timeline' ||
|
|
2331
|
+
journalEvent.type === 'extension.started' ||
|
|
2332
|
+
journalEvent.type === 'extension.ended') {
|
|
2333
|
+
this.emit(room, {
|
|
2334
|
+
type: 'run_event',
|
|
2335
|
+
room,
|
|
2336
|
+
message_id: runMsg.id,
|
|
2337
|
+
event: journalEvent,
|
|
2338
|
+
index: stampedIndex,
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
// harn:end compaction-timeline-items-are-durable-run-evidence
|
|
2342
|
+
// harn:end run-events-merge-by-journal-index
|
|
2343
|
+
if (event.type === 'ask.raised' || event.type === 'approval.raised') {
|
|
2344
|
+
this.handleInteractionRaised(room, member, event.card, event.type === 'ask.raised' ? 'ask' : 'approval');
|
|
2345
|
+
}
|
|
2346
|
+
else if (journalEvent.type === 'run.completed') {
|
|
2347
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
|
|
2348
|
+
if (journalEvent.agent_usage !== undefined) {
|
|
2349
|
+
this.lastUsage.set(member.id, { ...journalEvent.agent_usage });
|
|
2350
|
+
this.landContextWindow(room, member.id, journalEvent.agent_usage);
|
|
2351
|
+
}
|
|
2352
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
2353
|
+
// harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
|
|
2354
|
+
completion = {
|
|
2355
|
+
status: journalEvent.status,
|
|
2356
|
+
final_text: journalEvent.final_text,
|
|
2357
|
+
error: journalEvent.error,
|
|
2358
|
+
usage: journalEvent.usage,
|
|
2359
|
+
};
|
|
2360
|
+
// harn:end failed-run-details-never-route-as-replies
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
catch (error) {
|
|
2365
|
+
if (error instanceof RemoteAttemptAmbiguousError) {
|
|
2366
|
+
this.holdAmbiguousTurn(room, member, bound, runMsg.id, 'resident reported ambiguous');
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
// harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
|
|
2370
|
+
completion = completion ?? {
|
|
2371
|
+
status: 'failed',
|
|
2372
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2373
|
+
};
|
|
2374
|
+
// harn:end failed-run-details-never-route-as-replies
|
|
2375
|
+
}
|
|
2376
|
+
if (session?.session_ref !== undefined &&
|
|
2377
|
+
session.session_ref !== this.store.getMember(room, member.id)?.session_ref) {
|
|
2378
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { session_ref: session.session_ref }));
|
|
2379
|
+
}
|
|
2380
|
+
// harn:assume operator-interrupt-not-failure ref=interrupt-failure-classification
|
|
2381
|
+
const operatorInterrupted = this.operatorInterrupts.delete(member.id);
|
|
2382
|
+
if (operatorInterrupted && completion?.status === 'failed') {
|
|
2383
|
+
completion = { ...completion, status: 'interrupted' };
|
|
2384
|
+
}
|
|
2385
|
+
// harn:end operator-interrupt-not-failure
|
|
2386
|
+
// harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
|
|
2387
|
+
// Some harnesses report a SIGINT as a generic failure. Once close() has
|
|
2388
|
+
// captured lifecycle cause, classify that native exit as interrupted so it
|
|
2389
|
+
// reaches the atomic retry-or-terminal settlement instead of killing the
|
|
2390
|
+
// member and consuming the instruction as an operator result.
|
|
2391
|
+
if (this.lifecycleInterrupts.has(member.id) && completion?.status === 'failed') {
|
|
2392
|
+
completion = { ...completion, status: 'interrupted' };
|
|
2393
|
+
}
|
|
2394
|
+
// harn:end lifecycle-retries-only-live-collaboration-work
|
|
2395
|
+
// harn:assume failed-finalization-reconciles-at-runtime ref=runtime-finalization-reconcile
|
|
2396
|
+
// The engine's journal is already terminal here. If normal finalization
|
|
2397
|
+
// cannot commit — a lifecycle retry rebound to a delivery whose participant
|
|
2398
|
+
// already holds a result — the rollback used to leave the row running and
|
|
2399
|
+
// the delivery delivering while the in-memory guard cleared, so nothing
|
|
2400
|
+
// noticed until a human went looking. Reconcile immediately instead.
|
|
2401
|
+
const finalCompletion = completion ?? { status: 'interrupted' };
|
|
2402
|
+
const lifecycleSettlement = finalCompletion.status === 'interrupted'
|
|
2403
|
+
&& this.lifecycleInterrupts.has(member.id);
|
|
2404
|
+
if (!lifecycleSettlement && this.normalFinalizationIsIllegal(room, bound)) {
|
|
2405
|
+
// Known-settled work never attempts normal completion: completeTurn would
|
|
2406
|
+
// roll back anyway, and for a closed group it might not even refuse.
|
|
2407
|
+
this.reconcileFailedFinalization(room, member.id, runMsg.id, bound, finalCompletion, new Error('its collaboration work was already settled'));
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
try {
|
|
2411
|
+
this.finalizeTurn(room, member.id, runMsg.id, finalCompletion, bound, toolCalls);
|
|
2412
|
+
}
|
|
2413
|
+
catch (error) {
|
|
2414
|
+
this.reconcileFailedFinalization(room, member.id, runMsg.id, bound, finalCompletion, error);
|
|
2415
|
+
}
|
|
2416
|
+
// harn:end failed-finalization-reconciles-at-runtime
|
|
2417
|
+
}
|
|
2418
|
+
// harn:assume failed-finalization-reconciles-at-runtime ref=runtime-finalization-reconcile
|
|
2419
|
+
/**
|
|
2420
|
+
* The one repair both runtime and boot use. It never routes and never
|
|
2421
|
+
* replaces a participant result: it makes the durable state honest, streams
|
|
2422
|
+
* the repaired rows, and says so once. A second pass finds nothing to do.
|
|
2423
|
+
*/
|
|
2424
|
+
reconcileFailedFinalization(room, memberId, runMsgId, batch, completion, error) {
|
|
2425
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2426
|
+
const runMsg = this.store.getMessage(room, runMsgId);
|
|
2427
|
+
if (runMsg?.run === undefined)
|
|
2428
|
+
throw new Error(`turn #${runMsgId} has no lifecycle root`);
|
|
2429
|
+
const projection = projectContinuationOutputs(runMsgId, this.blobs.read(room, runMsg.run.events_ref));
|
|
2430
|
+
const accounting = accountRunUsage(runMsg.run, completion?.usage);
|
|
2431
|
+
const repaired = this.store.repairFailedFinalization(room, {
|
|
2432
|
+
runMsgId,
|
|
2433
|
+
memberId,
|
|
2434
|
+
deliveryIds: batch.map((delivery) => delivery.id),
|
|
2435
|
+
outputs: this.outputPatches(room, runMsg, projection, false, false),
|
|
2436
|
+
error: `finalization could not commit: ${detail}`,
|
|
2437
|
+
endedTs: new Date().toISOString(),
|
|
2438
|
+
usage: completion?.usage,
|
|
2439
|
+
estimatedCostUsd: accounting.estimatedCostUsd,
|
|
2440
|
+
meterDay: new Date().toISOString().slice(0, 10),
|
|
2441
|
+
meterDelta: {
|
|
2442
|
+
turns: 1,
|
|
2443
|
+
cost_usd: accounting.costUsd,
|
|
2444
|
+
estimated_cost_usd: accounting.estimatedCostUsd ?? 0,
|
|
2445
|
+
input_tokens: completion?.usage?.input_tokens ?? 0,
|
|
2446
|
+
output_tokens: completion?.usage?.output_tokens ?? 0,
|
|
2447
|
+
uncosted_tokens: accounting.uncostedTokens,
|
|
2448
|
+
},
|
|
2449
|
+
});
|
|
2450
|
+
if (!repaired.repaired) {
|
|
2451
|
+
// The run is already terminal, so the Store transaction DID commit and
|
|
2452
|
+
// something after it threw — an emit or barrier step. That is a real
|
|
2453
|
+
// failure with nothing to repair, and swallowing it here would hide it
|
|
2454
|
+
// from background error reporting and from later reconciliation.
|
|
2455
|
+
throw error instanceof Error ? error : new Error(detail);
|
|
2456
|
+
}
|
|
2457
|
+
for (const output of repaired.outputMessages ?? [])
|
|
2458
|
+
this.emitMessage(room, output);
|
|
2459
|
+
if (repaired.member !== undefined)
|
|
2460
|
+
this.emitMember(room, repaired.member);
|
|
2461
|
+
if (repaired.meter !== undefined) {
|
|
2462
|
+
this.emit(room, { type: 'meter', seq: this.store.currentSeq(room), meter: repaired.meter });
|
|
2463
|
+
}
|
|
2464
|
+
for (const delivery of repaired.deliveries)
|
|
2465
|
+
this.emitInbox(room, delivery);
|
|
2466
|
+
if (repaired.notice !== undefined)
|
|
2467
|
+
this.emitMessage(room, repaired.notice);
|
|
2468
|
+
this.memberWaits.delete(memberId);
|
|
2469
|
+
this.groupWaits.delete(memberId);
|
|
2470
|
+
this.retireTerminalRunRuntime(room, memberId, runMsgId);
|
|
2471
|
+
}
|
|
2472
|
+
// harn:assume collaboration-lifecycle-interruption-is-nonterminal ref=lifecycle-collaboration-finalization
|
|
2473
|
+
/**
|
|
2474
|
+
* Commit the one lifecycle settlement and stream what it changed. Nothing is
|
|
2475
|
+
* routed: an interrupted attempt produced no answer to deliver, and its retry
|
|
2476
|
+
* (if admitted) will produce the one result the round is still waiting for.
|
|
2477
|
+
*/
|
|
2478
|
+
settleLifecycleInterruptedTurn(room, memberId, runMsgId, batch, messagePatch, endedTs, preserveOtherActiveTurn = false) {
|
|
2479
|
+
const current = this.store.getMember(room, memberId);
|
|
2480
|
+
const usage = messagePatch.run?.usage;
|
|
2481
|
+
const accounting = accountRunUsage(messagePatch.run ?? {}, usage);
|
|
2482
|
+
const runMsg = this.store.getMessage(room, runMsgId);
|
|
2483
|
+
if (runMsg?.run === undefined)
|
|
2484
|
+
throw new Error(`turn #${runMsgId} has no lifecycle root`);
|
|
2485
|
+
const projection = projectContinuationOutputs(runMsgId, this.blobs.read(room, runMsg.run.events_ref));
|
|
2486
|
+
const outputs = this.outputPatches(room, runMsg, projection, false, false);
|
|
2487
|
+
const settlement = this.store.settleLifecycleInterruption(room, {
|
|
2488
|
+
runMsgId,
|
|
2489
|
+
memberId,
|
|
2490
|
+
deliveryIds: batch.map((delivery) => delivery.id),
|
|
2491
|
+
message: messagePatch,
|
|
2492
|
+
outputs,
|
|
2493
|
+
memberPatch: {
|
|
2494
|
+
state: preserveOtherActiveTurn
|
|
2495
|
+
? (current?.state ?? 'running')
|
|
2496
|
+
: current?.state === 'dead' || current?.state === 'paused'
|
|
2497
|
+
? current.state
|
|
2498
|
+
: 'idle',
|
|
2499
|
+
},
|
|
2500
|
+
endedTs,
|
|
2501
|
+
attemptCeiling: RECOVERY_ATTEMPT_CEILING,
|
|
2502
|
+
meterDay: endedTs.slice(0, 10),
|
|
2503
|
+
meterDelta: {
|
|
2504
|
+
turns: 1,
|
|
2505
|
+
cost_usd: accounting.costUsd,
|
|
2506
|
+
estimated_cost_usd: accounting.estimatedCostUsd ?? 0,
|
|
2507
|
+
input_tokens: usage?.input_tokens ?? 0,
|
|
2508
|
+
output_tokens: usage?.output_tokens ?? 0,
|
|
2509
|
+
uncosted_tokens: accounting.uncostedTokens,
|
|
2510
|
+
},
|
|
2511
|
+
});
|
|
2512
|
+
if (!preserveOtherActiveTurn) {
|
|
2513
|
+
this.lifecycleInterrupts.delete(memberId);
|
|
2514
|
+
this.memberWaits.delete(memberId);
|
|
2515
|
+
this.groupWaits.delete(memberId);
|
|
2516
|
+
}
|
|
2517
|
+
for (const output of settlement.outputMessages)
|
|
2518
|
+
this.emitMessage(room, output);
|
|
2519
|
+
this.emitMember(room, settlement.member);
|
|
2520
|
+
this.emit(room, { type: 'meter', seq: this.store.currentSeq(room), meter: settlement.meter });
|
|
2521
|
+
for (const delivery of [...settlement.requeued, ...settlement.settled]) {
|
|
2522
|
+
this.emitInbox(room, delivery);
|
|
2523
|
+
}
|
|
2524
|
+
if (settlement.notice !== undefined)
|
|
2525
|
+
this.emitMessage(room, settlement.notice);
|
|
2526
|
+
const affectedRounds = new Map();
|
|
2527
|
+
for (const delivery of settlement.settled) {
|
|
2528
|
+
if (delivery.group_id === undefined || delivery.group_round === undefined)
|
|
2529
|
+
continue;
|
|
2530
|
+
affectedRounds.set(`${delivery.group_id}:${String(delivery.group_round)}`, {
|
|
2531
|
+
groupId: delivery.group_id,
|
|
2532
|
+
roundNumber: delivery.group_round,
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
for (const { groupId, roundNumber } of affectedRounds.values()) {
|
|
2536
|
+
this.clearSatisfiedGroupWaits(room, groupId, roundNumber);
|
|
2537
|
+
this.advanceCollaborationRound(room, groupId, roundNumber);
|
|
2538
|
+
}
|
|
2539
|
+
if (preserveOtherActiveTurn) {
|
|
2540
|
+
this.runActivity.delete(`${room}:${runMsgId}`);
|
|
2541
|
+
}
|
|
2542
|
+
else {
|
|
2543
|
+
this.retireTerminalRunRuntime(room, memberId, runMsgId);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
// harn:end collaboration-lifecycle-interruption-is-nonterminal
|
|
2547
|
+
/**
|
|
2548
|
+
* True when this batch cannot take the normal finalization path, because its
|
|
2549
|
+
* collaboration work is already settled: a terminal participant, a closed
|
|
2550
|
+
* group, or a closed round. It asks the Store the SAME question repair asks —
|
|
2551
|
+
* a narrower check here would let a closed group accept a fresh result, since
|
|
2552
|
+
* `recordCollaborationParticipantTerminal` guards the participant alone.
|
|
2553
|
+
*
|
|
2554
|
+
* Checked proactively before normal completion at both boot and runtime; the
|
|
2555
|
+
* runtime catch stays for transaction failures this cannot foresee.
|
|
2556
|
+
*/
|
|
2557
|
+
normalFinalizationIsIllegal(room, batch) {
|
|
2558
|
+
return batch.some((delivery) => this.store.collaborationWorkIsSettled(room, delivery.id));
|
|
2559
|
+
}
|
|
2560
|
+
// harn:end failed-finalization-reconciles-at-runtime
|
|
2561
|
+
/** Clear transient evidence that must not outlive any terminalized attempt. */
|
|
2562
|
+
retireTerminalRunRuntime(room, memberId, runMsgId) {
|
|
2563
|
+
this.runActivity.delete(`${room}:${runMsgId}`);
|
|
2564
|
+
for (const extension of this.store.listMembers(room)) {
|
|
2565
|
+
if (extension.kind !== 'extension' || extension.parent !== memberId || extension.state !== 'running')
|
|
2566
|
+
continue;
|
|
2567
|
+
this.emitMember(room, this.store.updateMember(room, extension.id, { state: 'dead' }));
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
composeBatchPayload(room, recipient, batch) {
|
|
2571
|
+
const payloads = [];
|
|
2572
|
+
for (const delivery of batch) {
|
|
2573
|
+
const encoded = this.store.getDeliveryPayloadSnapshot(room, delivery.id);
|
|
2574
|
+
const fresh = this.store.getMember(room, recipient.id);
|
|
2575
|
+
const needsConventions = !fresh.conventions_sent || fresh.misaddressed;
|
|
2576
|
+
const needsRoster = fresh.roster_stale;
|
|
2577
|
+
// harn:assume grouped-deliveries-retain-agent-briefings ref=grouped-delivery-briefing
|
|
2578
|
+
const roster = needsRoster
|
|
2579
|
+
? this.store.listMembers(room).map((member) => ({
|
|
2580
|
+
handle: member.handle,
|
|
2581
|
+
kind: member.kind,
|
|
2582
|
+
...(member.purpose !== undefined && { purpose: member.purpose }),
|
|
2583
|
+
}))
|
|
2584
|
+
: undefined;
|
|
2585
|
+
const conventions = needsConventions
|
|
2586
|
+
? {
|
|
2587
|
+
ledger: this.ledger?.isEnabled(room) ?? false,
|
|
2588
|
+
// harn:assume collaboration-briefing-is-capability-aware ref=collaboration-capability-context
|
|
2589
|
+
liveInbox: fresh.harness !== undefined &&
|
|
2590
|
+
this.adapters.get(fresh.harness)?.capabilities.live_inbox === true,
|
|
2591
|
+
// harn:end collaboration-briefing-is-capability-aware
|
|
2592
|
+
}
|
|
2593
|
+
: undefined;
|
|
2594
|
+
if (encoded !== undefined) {
|
|
2595
|
+
const candidate = JSON.parse(encoded);
|
|
2596
|
+
if ('kind' in candidate && candidate.kind === 'group') {
|
|
2597
|
+
payloads.push(candidate.payload + composeDeliveryBriefing({ roster, conventions }));
|
|
2598
|
+
if (needsRoster)
|
|
2599
|
+
this.store.clearAgentRosterStale(room, recipient.id);
|
|
2600
|
+
if (needsConventions) {
|
|
2601
|
+
this.emitMember(room, this.store.updateMember(room, recipient.id, {
|
|
2602
|
+
conventions_sent: true,
|
|
2603
|
+
misaddressed: false,
|
|
2604
|
+
}));
|
|
2605
|
+
}
|
|
2606
|
+
// harn:end grouped-deliveries-retain-agent-briefings
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
const snapshot = encoded
|
|
2611
|
+
? JSON.parse(encoded)
|
|
2612
|
+
: JSON.parse(this.snapshotPayload(room, this.store.getMessage(room, delivery.message_id), recipient, [recipient]));
|
|
2613
|
+
const ctx = {
|
|
2614
|
+
...snapshot.context,
|
|
2615
|
+
roster,
|
|
2616
|
+
conventions: needsConventions
|
|
2617
|
+
? {
|
|
2618
|
+
...conventions,
|
|
2619
|
+
untaggedGoesTo: snapshot.context.authorHandle,
|
|
2620
|
+
}
|
|
2621
|
+
: undefined,
|
|
2622
|
+
};
|
|
2623
|
+
payloads.push(composePayload(ctx, snapshot.you));
|
|
2624
|
+
if (needsRoster)
|
|
2625
|
+
this.store.clearAgentRosterStale(room, recipient.id);
|
|
2626
|
+
if (needsConventions) {
|
|
2627
|
+
this.emitMember(room, this.store.updateMember(room, recipient.id, { conventions_sent: true, misaddressed: false }));
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
// Attachment path lines ride each delivery's text (outside the briefing
|
|
2631
|
+
// anchors), recomputed from the live message so they vanish on delete.
|
|
2632
|
+
return payloads
|
|
2633
|
+
.map((payload, index) => payload + this.attachmentPayloadLines(room, batch[index].message_id))
|
|
2634
|
+
.join('\n');
|
|
2635
|
+
}
|
|
2636
|
+
outputPatches(room, root, projection, ack, retainRootBody = true) {
|
|
2637
|
+
const members = this.store.listMembers(room);
|
|
2638
|
+
return [...projection.referencedMessageIds]
|
|
2639
|
+
.sort((left, right) => left - right)
|
|
2640
|
+
.map((id) => {
|
|
2641
|
+
const message = this.store.getMessage(room, id);
|
|
2642
|
+
if (message === undefined
|
|
2643
|
+
|| (id !== root.id && message.run_parent_id !== root.id)) {
|
|
2644
|
+
throw new Error(`turn #${root.id} journal targets invalid output #${id}`);
|
|
2645
|
+
}
|
|
2646
|
+
const body = id === root.id && !retainRootBody
|
|
2647
|
+
? ''
|
|
2648
|
+
: (projection.bodies.get(id) ?? '');
|
|
2649
|
+
const rowAck = ack && id === projection.resultMessageId;
|
|
2650
|
+
const parsed = rowAck
|
|
2651
|
+
? { mentions: [], refs: [], ledger_refs: [] }
|
|
2652
|
+
: parseBody(body, members);
|
|
2653
|
+
return {
|
|
2654
|
+
id,
|
|
2655
|
+
body,
|
|
2656
|
+
mentions: parsed.mentions,
|
|
2657
|
+
refs: parsed.refs,
|
|
2658
|
+
ledger_refs: parsed.ledger_refs,
|
|
2659
|
+
...(rowAck && { ack: true }),
|
|
2660
|
+
substantive: !ack && projection.substantiveMessageIds.has(id),
|
|
2661
|
+
};
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
// harn:assume finalized-turn-routes-aggregate-from-terminal-output ref=finalize-and-route
|
|
2665
|
+
/**
|
|
2666
|
+
* Finalize every journal-owned output row while routing one complete aggregate
|
|
2667
|
+
* under the permanent terminal output id. The root alone owns lifecycle truth;
|
|
2668
|
+
* persisted continuation bodies remain their own chronological stretches.
|
|
2669
|
+
*/
|
|
2670
|
+
finalizeTurn(room, memberId, runMsgId, completion, batch, toolCalls) {
|
|
2671
|
+
const runMsg = this.store.getMessage(room, runMsgId);
|
|
2672
|
+
// harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
|
|
2673
|
+
// harn:assume run-failure-evidence-is-surfaced ref=interrupted-error-evidence
|
|
2674
|
+
const failed = completion.status === 'failed';
|
|
2675
|
+
// The `?? completion.final_text` arm is LOAD-BEARING: codex/gemini/opencode/
|
|
2676
|
+
// copilot report failure detail in final_text, only claude uses error.
|
|
2677
|
+
// An operator interrupt can reclassify failed->interrupted after the
|
|
2678
|
+
// adapter already produced error detail — persist it there too, or the
|
|
2679
|
+
// "why" of the interrupt vanishes from every surface.
|
|
2680
|
+
const rawFailure = failed
|
|
2681
|
+
? (completion.error ?? completion.final_text)
|
|
2682
|
+
: completion.status === 'interrupted'
|
|
2683
|
+
? completion.error
|
|
2684
|
+
: undefined;
|
|
2685
|
+
const failure = rawFailure?.trim() === '' ? undefined : rawFailure;
|
|
2686
|
+
const journal = this.blobs.read(room, runMsg.run.events_ref);
|
|
2687
|
+
const projection = projectContinuationOutputs(runMsgId, journal);
|
|
2688
|
+
const projectedAggregate = [...projection.bodies]
|
|
2689
|
+
.sort(([left], [right]) => left - right)
|
|
2690
|
+
.map(([, text]) => text)
|
|
2691
|
+
.join('');
|
|
2692
|
+
// The journal projection is the one complete, normalized answer. Some
|
|
2693
|
+
// adapters report the whole response in final_text; others report only the
|
|
2694
|
+
// final suffix after streaming interim narration. Persisting the native
|
|
2695
|
+
// field directly would silently truncate the lifecycle aggregate in the
|
|
2696
|
+
// latter case even though every chronological row rendered correctly.
|
|
2697
|
+
const body = failed ? '' : projectedAggregate;
|
|
2698
|
+
// harn:end run-failure-evidence-is-surfaced
|
|
2699
|
+
// harn:end failed-run-details-never-route-as-replies
|
|
2700
|
+
// harn:assume substantive-routing-excludes-acknowledgements ref=exact-ack-finalization
|
|
2701
|
+
const ack = completion.status === 'completed' && body.trim() === '<ACK_OK>';
|
|
2702
|
+
const parsed = failed || ack
|
|
2703
|
+
? { mentions: [], refs: [], ledger_refs: [], unresolved: [] }
|
|
2704
|
+
: parseBody(body, this.store.listMembers(room));
|
|
2705
|
+
const endedTs = new Date().toISOString();
|
|
2706
|
+
const accounting = accountRunUsage(runMsg.run, completion.usage);
|
|
2707
|
+
const outputPatches = this.outputPatches(room, runMsg, projection, ack, !failed);
|
|
2708
|
+
const resultMessageId = projection.resultMessageId;
|
|
2709
|
+
const messagePatch = {
|
|
2710
|
+
body: projection.bodies.get(runMsgId) ?? '',
|
|
2711
|
+
...(ack && { ack: true }),
|
|
2712
|
+
mentions: parsed.mentions,
|
|
2713
|
+
refs: parsed.refs,
|
|
2714
|
+
ledger_refs: parsed.ledger_refs,
|
|
2715
|
+
run: {
|
|
2716
|
+
...runMsg.run,
|
|
2717
|
+
status: completion.status,
|
|
2718
|
+
ended_ts: endedTs,
|
|
2719
|
+
stalled_since: undefined,
|
|
2720
|
+
tool_calls: toolCalls,
|
|
2721
|
+
usage: completion.usage,
|
|
2722
|
+
estimated_cost_usd: accounting.estimatedCostUsd,
|
|
2723
|
+
// harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
|
|
2724
|
+
final_text: failed ? undefined : body,
|
|
2725
|
+
output_mode: 'messages',
|
|
2726
|
+
result_message_id: resultMessageId,
|
|
2727
|
+
error: failure,
|
|
2728
|
+
// harn:end failed-run-details-never-route-as-replies
|
|
2729
|
+
},
|
|
2730
|
+
};
|
|
2731
|
+
// harn:end substantive-routing-excludes-acknowledgements
|
|
2732
|
+
// harn:assume collaboration-lifecycle-interruption-is-nonterminal ref=lifecycle-collaboration-finalization
|
|
2733
|
+
// A turn THIS daemon's lifecycle interrupted is retryable work, not a
|
|
2734
|
+
// result. Settle the run evidence, the delivery's retry decision, and the
|
|
2735
|
+
// participant's barrier state as one fact — committing terminality first
|
|
2736
|
+
// and re-queueing afterwards is exactly what resurrected #598 into #599.
|
|
2737
|
+
// Operator Stop never reaches here: it is not marked as lifecycle.
|
|
2738
|
+
if (completion.status === 'interrupted' && this.lifecycleInterrupts.has(memberId)) {
|
|
2739
|
+
this.settleLifecycleInterruptedTurn(room, memberId, runMsgId, batch, messagePatch, endedTs);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
// harn:end collaboration-lifecycle-interruption-is-nonterminal
|
|
2743
|
+
const resultRow = this.store.getMessage(room, resultMessageId);
|
|
2744
|
+
if (resultRow === undefined)
|
|
2745
|
+
throw new Error(`turn #${runMsgId} result #${resultMessageId} is missing`);
|
|
2746
|
+
const finalizedDraft = {
|
|
2747
|
+
...resultRow,
|
|
2748
|
+
body,
|
|
2749
|
+
mentions: parsed.mentions,
|
|
2750
|
+
refs: parsed.refs,
|
|
2751
|
+
ledger_refs: parsed.ledger_refs,
|
|
2752
|
+
...(ack && { ack: true }),
|
|
2753
|
+
run: messagePatch.run,
|
|
2754
|
+
};
|
|
2755
|
+
const lastDelivery = batch.at(-1);
|
|
2756
|
+
const triggerAuthor = lastDelivery
|
|
2757
|
+
? this.store.getMessage(room, lastDelivery.message_id)?.author
|
|
2758
|
+
: undefined;
|
|
2759
|
+
// harn:assume batched-human-resets-hop-count ref=batched-onward-hop-reset
|
|
2760
|
+
const onwardHopCount = batch.length === 0
|
|
2761
|
+
? 1
|
|
2762
|
+
: Math.min(...batch.map((delivery) => delivery.hop_count ?? 0)) + 1;
|
|
2763
|
+
// harn:end batched-human-resets-hop-count
|
|
2764
|
+
// harn:assume group-participant-terminality-commits-with-the-turn ref=collaboration-finalization-engine
|
|
2765
|
+
const groupedDelivery = batch.find((delivery) => delivery.group_id !== undefined);
|
|
2766
|
+
const planned = this.planRoutedMessage(room, finalizedDraft, triggerAuthor, onwardHopCount, false, groupedDelivery === undefined);
|
|
2767
|
+
const humanIds = new Set(planned.result.humans.map((human) => human.id));
|
|
2768
|
+
const fanout = groupedDelivery === undefined
|
|
2769
|
+
? planned.plan.fanout
|
|
2770
|
+
: planned.plan.fanout.filter((delivery) => humanIds.has(delivery.recipient));
|
|
2771
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
2772
|
+
const completed = this.store.completeTurn(room, {
|
|
2773
|
+
runMsgId,
|
|
2774
|
+
message: messagePatch,
|
|
2775
|
+
outputs: outputPatches,
|
|
2776
|
+
resultMessageId,
|
|
2777
|
+
inputDeliveryIds: batch.map((delivery) => delivery.id),
|
|
2778
|
+
memberId,
|
|
2779
|
+
memberPatch: {
|
|
2780
|
+
state: completion.status === 'failed'
|
|
2781
|
+
? 'dead'
|
|
2782
|
+
: this.store.getMember(room, memberId)?.state === 'dead'
|
|
2783
|
+
? 'dead'
|
|
2784
|
+
: this.store.getMember(room, memberId)?.state === 'paused'
|
|
2785
|
+
? 'paused'
|
|
2786
|
+
: 'idle',
|
|
2787
|
+
...(planned.result.misaddressed && { misaddressed: true }),
|
|
2788
|
+
},
|
|
2789
|
+
meterDay: day,
|
|
2790
|
+
meterDelta: {
|
|
2791
|
+
turns: 1,
|
|
2792
|
+
cost_usd: accounting.costUsd,
|
|
2793
|
+
estimated_cost_usd: accounting.estimatedCostUsd ?? 0,
|
|
2794
|
+
input_tokens: completion.usage?.input_tokens ?? 0,
|
|
2795
|
+
output_tokens: completion.usage?.output_tokens ?? 0,
|
|
2796
|
+
uncosted_tokens: accounting.uncostedTokens,
|
|
2797
|
+
},
|
|
2798
|
+
fanout,
|
|
2799
|
+
...(groupedDelivery !== undefined && {
|
|
2800
|
+
participantTerminal: {
|
|
2801
|
+
deliveryId: groupedDelivery.id,
|
|
2802
|
+
status: completion.status,
|
|
2803
|
+
completedTs: endedTs,
|
|
2804
|
+
},
|
|
2805
|
+
}),
|
|
2806
|
+
...(groupedDelivery === undefined && planned.plan.collaboration !== undefined && {
|
|
2807
|
+
collaboration: planned.plan.collaboration,
|
|
2808
|
+
}),
|
|
2809
|
+
});
|
|
2810
|
+
// harn:end group-participant-terminality-commits-with-the-turn
|
|
2811
|
+
// harn:assume live-agent-waits-are-transient ref=wait-clears-on-turn-end
|
|
2812
|
+
this.memberWaits.delete(memberId);
|
|
2813
|
+
this.groupWaits.delete(memberId);
|
|
2814
|
+
// harn:end live-agent-waits-are-transient
|
|
2815
|
+
for (const output of completed.outputMessages)
|
|
2816
|
+
this.emitMessage(room, output);
|
|
2817
|
+
this.emitMember(room, completed.member);
|
|
2818
|
+
// harn:assume agent-delivery-lifecycle-streams ref=delivery-consumed-emit
|
|
2819
|
+
// The turn just consumed its inputs — stream the settled rows so seen
|
|
2820
|
+
// ticks flip without a reconnect.
|
|
2821
|
+
for (const input of batch) {
|
|
2822
|
+
const settled = this.store.getDelivery(room, input.id);
|
|
2823
|
+
if (settled !== undefined)
|
|
2824
|
+
this.emitInbox(room, settled);
|
|
2825
|
+
}
|
|
2826
|
+
// harn:end agent-delivery-lifecycle-streams
|
|
2827
|
+
// harn:assume extensions-retire-with-parent-run ref=parent-finalization-extension-sweep
|
|
2828
|
+
for (const extension of this.store.listMembers(room)) {
|
|
2829
|
+
if (extension.kind !== 'extension' || extension.parent !== memberId || extension.state !== 'running')
|
|
2830
|
+
continue;
|
|
2831
|
+
this.emitMember(room, this.store.updateMember(room, extension.id, { state: 'dead' }));
|
|
2832
|
+
}
|
|
2833
|
+
// harn:end extensions-retire-with-parent-run
|
|
2834
|
+
this.emit(room, { type: 'meter', seq: this.store.currentSeq(room), meter: completed.meter });
|
|
2835
|
+
this.dispatchCreatedDeliveries(room, completed.deliveries);
|
|
2836
|
+
if (groupedDelivery?.group_id !== undefined && groupedDelivery.group_round !== undefined) {
|
|
2837
|
+
this.clearSatisfiedGroupWaits(room, groupedDelivery.group_id, groupedDelivery.group_round);
|
|
2838
|
+
this.advanceCollaborationRound(room, groupedDelivery.group_id, groupedDelivery.group_round);
|
|
2839
|
+
}
|
|
2840
|
+
this.runActivity.delete(`${room}:${runMsgId}`);
|
|
2841
|
+
if (completion.status === 'failed') {
|
|
2842
|
+
this.postSystemMessage(room, completed.member.session_ref
|
|
2843
|
+
? `@${completed.member.handle} died mid-run (turn #${runMsgId} failed); revive to retry`
|
|
2844
|
+
: `@${completed.member.handle} died mid-run (turn #${runMsgId} failed); remove it and spawn a replacement`);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
// harn:end finalized-turn-routes-aggregate-from-terminal-output
|
|
2848
|
+
// harn:assume collaboration-round-release-is-one-barrier ref=collaboration-barrier-engine
|
|
2849
|
+
advanceCollaborationRound(room, groupId, roundNumber) {
|
|
2850
|
+
const projection = this.store.getCollaborationRoundProjection(room, groupId, roundNumber);
|
|
2851
|
+
if (!projection || projection.round.state !== 'collecting')
|
|
2852
|
+
return;
|
|
2853
|
+
if (projection.participants.some((participant) => participant.terminal_status === undefined))
|
|
2854
|
+
return;
|
|
2855
|
+
const root = this.store.getMessage(room, projection.group.root_message_id);
|
|
2856
|
+
if (!root)
|
|
2857
|
+
throw new Error(`collaboration group ${groupId} has no root message`);
|
|
2858
|
+
const results = [];
|
|
2859
|
+
const nextMembers = [];
|
|
2860
|
+
const seen = new Set();
|
|
2861
|
+
for (const participant of projection.participants) {
|
|
2862
|
+
const member = this.store.getMember(room, participant.member_id);
|
|
2863
|
+
const result = participant.result_message_id === undefined
|
|
2864
|
+
? undefined
|
|
2865
|
+
: this.store.getMessage(room, participant.result_message_id);
|
|
2866
|
+
const resultRoot = result === undefined ? undefined : this.store.getRunRoot(room, result);
|
|
2867
|
+
const resultAck = result?.ack === true || resultRoot?.ack === true;
|
|
2868
|
+
const aggregateBody = resultRoot?.run?.final_text ?? result?.body ?? '';
|
|
2869
|
+
const aggregateParsed = parseBody(aggregateBody, this.store.listMembers(room));
|
|
2870
|
+
const status = participant.terminal_status === 'completed' && resultAck
|
|
2871
|
+
? 'acknowledged'
|
|
2872
|
+
: participant.terminal_status;
|
|
2873
|
+
results.push({
|
|
2874
|
+
ordinal: participant.ordinal,
|
|
2875
|
+
memberHandle: member?.handle ?? participant.member_id,
|
|
2876
|
+
status,
|
|
2877
|
+
...(result !== undefined && !resultAck && {
|
|
2878
|
+
messageId: result.id,
|
|
2879
|
+
// harn:assume run-failure-evidence-is-surfaced ref=round-result-error-evidence
|
|
2880
|
+
// A failed participant's body is empty by design; surface its run
|
|
2881
|
+
// error so peers see why the round member stopped.
|
|
2882
|
+
body: participant.terminal_status === 'completed'
|
|
2883
|
+
? aggregateBody
|
|
2884
|
+
: this.runRefBody(resultRoot ?? result),
|
|
2885
|
+
// harn:end run-failure-evidence-is-surfaced
|
|
2886
|
+
}),
|
|
2887
|
+
});
|
|
2888
|
+
if (participant.terminal_status !== 'completed' || resultAck)
|
|
2889
|
+
continue;
|
|
2890
|
+
for (const mention of aggregateParsed.mentions) {
|
|
2891
|
+
if (mention.member_id === participant.member_id || seen.has(mention.member_id))
|
|
2892
|
+
continue;
|
|
2893
|
+
const recipient = this.store.getMember(room, mention.member_id);
|
|
2894
|
+
if (recipient?.kind !== 'agent' ||
|
|
2895
|
+
recipient.removed_ts !== undefined)
|
|
2896
|
+
continue;
|
|
2897
|
+
seen.add(recipient.id);
|
|
2898
|
+
nextMembers.push(recipient);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
const context = {
|
|
2902
|
+
...this.groupPayloadContext(room, root, groupId, roundNumber + 1),
|
|
2903
|
+
priorRoundNumber: roundNumber,
|
|
2904
|
+
results,
|
|
2905
|
+
};
|
|
2906
|
+
const nextHop = Math.min(...projection.deliveries.map((delivery) => delivery.hop_count ?? 0)) + 1;
|
|
2907
|
+
const release = this.store.releaseCollaborationRound(room, {
|
|
2908
|
+
groupId,
|
|
2909
|
+
roundNumber,
|
|
2910
|
+
releasedTs: new Date().toISOString(),
|
|
2911
|
+
nextParticipants: nextMembers.map((member) => ({
|
|
2912
|
+
memberId: member.id,
|
|
2913
|
+
payloadSnapshot: this.groupPayloadSnapshot(composeGroupRoundPayload(context, member.handle)),
|
|
2914
|
+
state: 'queued',
|
|
2915
|
+
hopCount: nextHop,
|
|
2916
|
+
})),
|
|
2917
|
+
});
|
|
2918
|
+
if (release.status === 'released') {
|
|
2919
|
+
this.dispatchCreatedDeliveries(room, release.deliveries);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
// harn:end collaboration-round-release-is-one-barrier
|
|
2923
|
+
clearSatisfiedGroupWaits(room, groupId, roundNumber) {
|
|
2924
|
+
const participants = this.store.listCollaborationParticipants(room, groupId, roundNumber);
|
|
2925
|
+
const terminal = new Set(participants
|
|
2926
|
+
.filter((participant) => participant.terminal_status !== undefined)
|
|
2927
|
+
.map((participant) => participant.member_id));
|
|
2928
|
+
const participantIds = new Set(participants.map((participant) => participant.member_id));
|
|
2929
|
+
for (const [memberId, context] of this.groupWaits) {
|
|
2930
|
+
if (context.room !== room ||
|
|
2931
|
+
context.groupId !== groupId ||
|
|
2932
|
+
context.roundNumber !== roundNumber)
|
|
2933
|
+
continue;
|
|
2934
|
+
const waiting = this.memberWaits.get(memberId);
|
|
2935
|
+
if (!waiting ||
|
|
2936
|
+
!waiting.peers.every((peerId) => participantIds.has(peerId) && terminal.has(peerId)))
|
|
2937
|
+
continue;
|
|
2938
|
+
this.memberWaits.delete(memberId);
|
|
2939
|
+
this.groupWaits.delete(memberId);
|
|
2940
|
+
const member = this.store.getMember(room, memberId);
|
|
2941
|
+
if (member)
|
|
2942
|
+
this.emitMember(room, member);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
// harn:assume open-collaboration-groups-reconcile-without-resurrection ref=collaboration-member-skip-engine
|
|
2946
|
+
skipUnavailableGroupDelivery(room, delivery) {
|
|
2947
|
+
if (delivery.group_id === undefined || delivery.group_round === undefined)
|
|
2948
|
+
return;
|
|
2949
|
+
const skipped = this.store.skipCollaborationParticipant(room, delivery.id, new Date().toISOString());
|
|
2950
|
+
if (delivery.state === 'held')
|
|
2951
|
+
this.emitInbox(room, skipped.delivery);
|
|
2952
|
+
this.clearSatisfiedGroupWaits(room, delivery.group_id, delivery.group_round);
|
|
2953
|
+
this.advanceCollaborationRound(room, delivery.group_id, delivery.group_round);
|
|
2954
|
+
}
|
|
2955
|
+
// harn:end open-collaboration-groups-reconcile-without-resurrection
|
|
2956
|
+
// ── interactions (PROTOCOL §2 state machine) ──────────────────────────
|
|
2957
|
+
// harn:assume approval-deliveries-project-resolution-separately ref=approval-resolution-orphan-daemon
|
|
2958
|
+
orphanInteraction(room, interaction) {
|
|
2959
|
+
const orphaned = this.store.orphanInteraction(room, interaction.id, new Date().toISOString());
|
|
2960
|
+
for (const delivery of orphaned.deliveries)
|
|
2961
|
+
this.emitInbox(room, delivery);
|
|
2962
|
+
return orphaned.interaction;
|
|
2963
|
+
}
|
|
2964
|
+
// harn:end approval-deliveries-project-resolution-separately
|
|
2965
|
+
handleInteractionRaised(room, member, card, kind) {
|
|
2966
|
+
const key = interactionKey(kind, card);
|
|
2967
|
+
const open = this.store
|
|
2968
|
+
.listInteractions(room)
|
|
2969
|
+
.filter((i) => i.member_id === member.id && (i.state === 'pending' || i.state === 'answered'));
|
|
2970
|
+
const match = open.find((i) => {
|
|
2971
|
+
const cardMsg = this.store.getMessage(room, i.message_id);
|
|
2972
|
+
return cardMsg?.ask !== undefined && interactionKey(i.kind, cardMsg.ask) === key;
|
|
2973
|
+
});
|
|
2974
|
+
if (match) {
|
|
2975
|
+
// Re-correlation after a crash: same semantic card, FRESH native id.
|
|
2976
|
+
const updated = this.store.upsertInteraction({ ...match, native_id: card.interaction_id });
|
|
2977
|
+
if (updated.state === 'answered') {
|
|
2978
|
+
if (updated.kind === 'ask') {
|
|
2979
|
+
// Idempotent replay of the persisted answer (P0.2 fixtures).
|
|
2980
|
+
void this.deliverAnswer(room, updated).catch(() => undefined);
|
|
2981
|
+
}
|
|
2982
|
+
else {
|
|
2983
|
+
// NEVER auto-resend an approval: orphan it and raise a fresh card.
|
|
2984
|
+
this.orphanInteraction(room, updated);
|
|
2985
|
+
this.postSystemMessage(room, `approval card #${updated.message_id} expired (answered before a restart; approvals are never auto-resent)`);
|
|
2986
|
+
this.createInteraction(room, member, card, kind);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
this.createInteraction(room, member, card, kind);
|
|
2992
|
+
}
|
|
2993
|
+
createInteraction(room, member, card, kind) {
|
|
2994
|
+
const cardMsg = this.store.postMessage(room, {
|
|
2995
|
+
author: member.id,
|
|
2996
|
+
kind,
|
|
2997
|
+
body: card.prompt,
|
|
2998
|
+
ask: card,
|
|
2999
|
+
});
|
|
3000
|
+
this.emitMessage(room, cardMsg);
|
|
3001
|
+
const targets = this.store
|
|
3002
|
+
.listMembers(room)
|
|
3003
|
+
.filter((m) => m.kind === 'human' && (m.role === 'owner' || m.role === 'admin' || m.role === 'member'))
|
|
3004
|
+
.map((m) => m.id);
|
|
3005
|
+
this.store.upsertInteraction({
|
|
3006
|
+
id: ulid(),
|
|
3007
|
+
room,
|
|
3008
|
+
member_id: member.id,
|
|
3009
|
+
message_id: cardMsg.id,
|
|
3010
|
+
native_id: card.interaction_id,
|
|
3011
|
+
kind,
|
|
3012
|
+
targets,
|
|
3013
|
+
state: 'pending',
|
|
3014
|
+
});
|
|
3015
|
+
for (const target of targets) {
|
|
3016
|
+
const delivery = this.store.createDelivery(room, {
|
|
3017
|
+
message_id: cardMsg.id,
|
|
3018
|
+
recipient: target,
|
|
3019
|
+
state: 'consumed',
|
|
3020
|
+
});
|
|
3021
|
+
this.emitInbox(room, delivery);
|
|
3022
|
+
}
|
|
3023
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { state: 'awaiting_input' }));
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Resolves the client-supplied interaction handle: the store id, the
|
|
3027
|
+
* harness-native id, or the CARD MESSAGE id (what surfaces have — stable
|
|
3028
|
+
* across re-raises while native ids rotate).
|
|
3029
|
+
*/
|
|
3030
|
+
resolveInteraction(room, handle) {
|
|
3031
|
+
const direct = this.store.getInteraction(handle);
|
|
3032
|
+
if (direct && direct.room === room)
|
|
3033
|
+
return direct;
|
|
3034
|
+
return this.store
|
|
3035
|
+
.listInteractions(room)
|
|
3036
|
+
.find((i) => i.native_id === handle || String(i.message_id) === handle);
|
|
3037
|
+
}
|
|
3038
|
+
/** The answer_interaction act: answered → respondInteraction ack → acked. */
|
|
3039
|
+
async answerInteraction(room, interactionId, answer, byMemberId) {
|
|
3040
|
+
const interaction = this.resolveInteraction(room, interactionId);
|
|
3041
|
+
if (!interaction)
|
|
3042
|
+
throw new Error(`no such interaction ${interactionId}`);
|
|
3043
|
+
if (interaction.state !== 'pending')
|
|
3044
|
+
throw new Error(`interaction ${interactionId} is ${interaction.state}`);
|
|
3045
|
+
const by = byMemberId ?? this.ownerOf(room).id;
|
|
3046
|
+
if (!interaction.targets.includes(by)) {
|
|
3047
|
+
throw new Error(`interaction ${interactionId} is not addressed to member ${by}`);
|
|
3048
|
+
}
|
|
3049
|
+
// harn:assume approval-answer-is-atomic-and-chatless ref=approval-answer-daemon
|
|
3050
|
+
let answered;
|
|
3051
|
+
if (interaction.kind === 'approval') {
|
|
3052
|
+
const committed = this.store.answerApproval(room, interaction.id, answer, by, new Date().toISOString());
|
|
3053
|
+
answered = committed.interaction;
|
|
3054
|
+
for (const delivery of committed.deliveries)
|
|
3055
|
+
this.emitInbox(room, delivery);
|
|
3056
|
+
}
|
|
3057
|
+
else {
|
|
3058
|
+
answered = this.store.upsertInteraction({
|
|
3059
|
+
...interaction,
|
|
3060
|
+
state: 'answered',
|
|
3061
|
+
answer,
|
|
3062
|
+
answered_by: by,
|
|
3063
|
+
answered_ts: new Date().toISOString(),
|
|
3064
|
+
});
|
|
3065
|
+
// Question answers remain visible history. A reply to a card never routes.
|
|
3066
|
+
const audit = this.store.postMessage(room, {
|
|
3067
|
+
author: by,
|
|
3068
|
+
kind: 'chat',
|
|
3069
|
+
body: typeof answer === 'string' ? answer : JSON.stringify(answer),
|
|
3070
|
+
reply_to: interaction.message_id,
|
|
3071
|
+
});
|
|
3072
|
+
this.emitMessage(room, audit);
|
|
3073
|
+
}
|
|
3074
|
+
// harn:end approval-answer-is-atomic-and-chatless
|
|
3075
|
+
await this.deliverAnswer(room, answered);
|
|
3076
|
+
}
|
|
3077
|
+
async deliverAnswer(room, interaction) {
|
|
3078
|
+
const member = this.store.getMember(room, interaction.member_id);
|
|
3079
|
+
const session = member ? this.sessions.get(member.id) : undefined;
|
|
3080
|
+
if (!member || !session) {
|
|
3081
|
+
throw new Error('interaction answer persisted but its adapter turn is not in flight');
|
|
3082
|
+
}
|
|
3083
|
+
await this.requireAdapter(member.harness).respondInteraction(session, interaction.native_id, interaction.answer);
|
|
3084
|
+
this.store.upsertInteraction({ ...interaction, state: 'acked' });
|
|
3085
|
+
// harn:assume interaction-ack-preserves-finalized-member-state ref=interaction-ack-member-transition
|
|
3086
|
+
const current = this.store.getMember(room, member.id);
|
|
3087
|
+
if (current?.state === 'awaiting_input') {
|
|
3088
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { state: 'running' }));
|
|
3089
|
+
}
|
|
3090
|
+
// harn:end interaction-ack-preserves-finalized-member-state
|
|
3091
|
+
}
|
|
3092
|
+
// ── boot reconcile ────────────────────────────────────────────────────
|
|
3093
|
+
// harn:assume delivery-attempt-wal-reconcile ref=boot-reconcile
|
|
3094
|
+
/**
|
|
3095
|
+
* Crash recovery, exactly-once-or-held: every `delivering` delivery is
|
|
3096
|
+
* reconciled against its run blob —
|
|
3097
|
+
* blob shows run.completed → finalize that turn from the journal;
|
|
3098
|
+
* blob empty on the first attempt → provably never started → retry ONCE
|
|
3099
|
+
* (the retry REUSES the same run message);
|
|
3100
|
+
* anything else (events but no completion, or a second failure) → HELD
|
|
3101
|
+
* with a system message; the operator releases or redelivers.
|
|
3102
|
+
* Interactions left pending/answered ride the retry: the re-raised card
|
|
3103
|
+
* re-correlates semantically (fresh native ids), answered asks replay
|
|
3104
|
+
* idempotently, answered approvals orphan (never auto-resent). If the
|
|
3105
|
+
* turn never re-raises, finalize orphans the leftover interaction.
|
|
3106
|
+
*/
|
|
3107
|
+
async reconcile() {
|
|
3108
|
+
for (const room of this.store.listRooms()) {
|
|
3109
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-seeding
|
|
3110
|
+
for (const member of this.store.listMembers(room.id)) {
|
|
3111
|
+
this.seedContextUsage(room.id, member);
|
|
3112
|
+
}
|
|
3113
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
3114
|
+
const delivering = this.store.listDeliveries(room.id, { state: 'delivering' });
|
|
3115
|
+
const byRunMsg = new Map();
|
|
3116
|
+
for (const delivery of delivering) {
|
|
3117
|
+
if (delivery.run_msg_id === undefined)
|
|
3118
|
+
continue;
|
|
3119
|
+
const group = byRunMsg.get(delivery.run_msg_id) ?? [];
|
|
3120
|
+
group.push(delivery);
|
|
3121
|
+
byRunMsg.set(delivery.run_msg_id, group);
|
|
3122
|
+
}
|
|
3123
|
+
for (const [runMsgId, group] of byRunMsg) {
|
|
3124
|
+
const runMsg = this.store.getMessage(room.id, runMsgId);
|
|
3125
|
+
if (!runMsg?.run)
|
|
3126
|
+
continue;
|
|
3127
|
+
if (runMsg.run.status !== 'running') {
|
|
3128
|
+
// already finalized — just consume
|
|
3129
|
+
for (const d of group)
|
|
3130
|
+
this.store.updateDelivery(room.id, d.id, { state: 'consumed' });
|
|
3131
|
+
continue;
|
|
3132
|
+
}
|
|
3133
|
+
const events = this.blobs.read(room.id, runMsg.run.events_ref);
|
|
3134
|
+
const completed = events.find((e) => e.type === 'run.completed');
|
|
3135
|
+
const member = this.store.getMember(room.id, runMsg.author);
|
|
3136
|
+
if (this.isRemoteMember(member)) {
|
|
3137
|
+
if (!this.residency?.isReachable(member.host)) {
|
|
3138
|
+
if (member.state !== 'unreachable') {
|
|
3139
|
+
this.emitMember(room.id, this.store.updateMember(room.id, member.id, { state: 'unreachable' }));
|
|
3140
|
+
}
|
|
3141
|
+
continue;
|
|
3142
|
+
}
|
|
3143
|
+
if (!this.inflight.has(member.id)) {
|
|
3144
|
+
this.inflight.add(member.id);
|
|
3145
|
+
this.track(this.runTurn(room.id, member, group, runMsg)
|
|
3146
|
+
.finally(() => this.inflight.delete(member.id)));
|
|
3147
|
+
}
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
const blockedInteractions = this.store
|
|
3151
|
+
.listInteractions(room.id)
|
|
3152
|
+
.filter((i) => i.member_id === member.id && (i.state === 'pending' || i.state === 'answered'));
|
|
3153
|
+
const hasProcessEvidence = group.some((delivery) => this.store.getDeliveryAttemptProcess(room.id, delivery.id) !== undefined);
|
|
3154
|
+
const processAlive = group.some((delivery) => {
|
|
3155
|
+
const process = this.store.getDeliveryAttemptProcess(room.id, delivery.id);
|
|
3156
|
+
return process !== undefined && this.processAlive(process);
|
|
3157
|
+
});
|
|
3158
|
+
if (completed) {
|
|
3159
|
+
// Provably completed → finalize from the journal, never re-run.
|
|
3160
|
+
const toolCalls = events.filter((e) => e.type === 'run.item' && e.item_type === 'tool_call').length;
|
|
3161
|
+
// A row like #599 cannot finalize normally: its participant already
|
|
3162
|
+
// holds a terminal result, so completeTurn would refuse and roll the
|
|
3163
|
+
// whole transaction back, leaving the row running exactly as it was
|
|
3164
|
+
// found. Boot uses the SAME repair the runtime seam does instead.
|
|
3165
|
+
if (this.normalFinalizationIsIllegal(room.id, group)) {
|
|
3166
|
+
this.reconcileFailedFinalization(room.id, member.id, runMsgId, group, { status: completed.status, usage: completed.usage }, new Error('its collaboration work was already settled'));
|
|
3167
|
+
this.orphanLeftoverInteractions(room.id, member.id);
|
|
3168
|
+
continue;
|
|
3169
|
+
}
|
|
3170
|
+
const completion = {
|
|
3171
|
+
status: completed.status,
|
|
3172
|
+
final_text: completed.final_text,
|
|
3173
|
+
error: completed.error,
|
|
3174
|
+
usage: completed.usage,
|
|
3175
|
+
};
|
|
3176
|
+
try {
|
|
3177
|
+
this.finalizeTurn(room.id, member.id, runMsgId,
|
|
3178
|
+
// harn:assume failed-run-details-never-route-as-replies ref=failed-run-recovery
|
|
3179
|
+
completion,
|
|
3180
|
+
// harn:end failed-run-details-never-route-as-replies
|
|
3181
|
+
group, toolCalls);
|
|
3182
|
+
}
|
|
3183
|
+
catch (error) {
|
|
3184
|
+
this.reconcileFailedFinalization(room.id, member.id, runMsgId, group, completion, error);
|
|
3185
|
+
}
|
|
3186
|
+
this.orphanLeftoverInteractions(room.id, member.id);
|
|
3187
|
+
}
|
|
3188
|
+
else if (processAlive) {
|
|
3189
|
+
this.holdAmbiguousTurn(room.id, member, group, runMsgId, 'its adapter process may still be alive');
|
|
3190
|
+
}
|
|
3191
|
+
else if (blockedInteractions.length > 0 && group.every((d) => d.attempt_count <= 2)) {
|
|
3192
|
+
// Crashed while BLOCKED on an ask/approval: re-deliver so the
|
|
3193
|
+
// session can re-raise — the raise handler re-correlates the card
|
|
3194
|
+
// semantically (fresh native ids), replays answered asks, and
|
|
3195
|
+
// orphans answered approvals. The retried turn may block again on
|
|
3196
|
+
// a human, so it is TRACKED, never awaited; whatever never
|
|
3197
|
+
// re-raised is orphaned once the turn finalizes.
|
|
3198
|
+
const refusal = this.retryTurn(room.id, member, group, runMsg, true);
|
|
3199
|
+
if (refusal && !refusal.alreadyHeld) {
|
|
3200
|
+
this.holdAmbiguousTurn(room.id, member, group, runMsgId, refusal.reason);
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
else if (events.length === 0 &&
|
|
3204
|
+
!hasProcessEvidence &&
|
|
3205
|
+
group.every((d) => d.attempt_count <= 1)) {
|
|
3206
|
+
// Provably never started → retry once, REUSING the run message.
|
|
3207
|
+
const refusal = this.retryTurn(room.id, member, group, runMsg, false);
|
|
3208
|
+
if (refusal && !refusal.alreadyHeld) {
|
|
3209
|
+
this.holdAmbiguousTurn(room.id, member, group, runMsgId, refusal.reason);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
else {
|
|
3213
|
+
// Ambiguous → held + system message; operator decides.
|
|
3214
|
+
this.holdAmbiguousTurn(room.id, member, group, runMsgId);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
// harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
|
|
3218
|
+
// A run still `running` here was stranded by a CRASH mid-turn — a graceful
|
|
3219
|
+
// stop settles its own turns in close(), before the store closes (#492), so
|
|
3220
|
+
// anything left running got no chance to. It takes the SAME atomic
|
|
3221
|
+
// retry-or-terminal settlement, so a crash cannot resurrect closed work
|
|
3222
|
+
// that a graceful stop would have refused.
|
|
3223
|
+
for (const runMsg of this.store.listMessages(room.id, { limit: Number.MAX_SAFE_INTEGER })) {
|
|
3224
|
+
if (runMsg.kind !== 'run' || runMsg.run?.status !== 'running')
|
|
3225
|
+
continue;
|
|
3226
|
+
// Exact run, not author-wide: another turn by the same author being
|
|
3227
|
+
// reconciled says nothing about whether THIS run is still owned.
|
|
3228
|
+
if (byRunMsg.has(runMsg.id))
|
|
3229
|
+
continue; // this pass already reconciled it
|
|
3230
|
+
const bound = this.boundLifecycleDeliveries(room.id, runMsg.id);
|
|
3231
|
+
if (bound.length === 0)
|
|
3232
|
+
continue;
|
|
3233
|
+
const endedTs = new Date().toISOString();
|
|
3234
|
+
this.runActivity.delete(`${room.id}:${runMsg.id}`);
|
|
3235
|
+
this.settleLifecycleInterruptedTurn(room.id, runMsg.author, runMsg.id, bound, {
|
|
3236
|
+
body: '',
|
|
3237
|
+
mentions: [],
|
|
3238
|
+
refs: [],
|
|
3239
|
+
ledger_refs: [],
|
|
3240
|
+
run: {
|
|
3241
|
+
...runMsg.run,
|
|
3242
|
+
status: 'interrupted',
|
|
3243
|
+
ended_ts: endedTs,
|
|
3244
|
+
stalled_since: undefined,
|
|
3245
|
+
final_text: undefined,
|
|
3246
|
+
},
|
|
3247
|
+
}, endedTs, this.inflight.has(runMsg.author));
|
|
3248
|
+
}
|
|
3249
|
+
// harn:end lifecycle-retries-only-live-collaboration-work
|
|
3250
|
+
this.reconcileCollaborationGroups(room.id);
|
|
3251
|
+
// drain anything still queued (tracked — a turn may block on an ask)
|
|
3252
|
+
for (const member of this.store.listMembers(room.id)) {
|
|
3253
|
+
if (member.kind === 'agent')
|
|
3254
|
+
this.track(this.maybeStartTurn(room.id, member.id));
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
// harn:end delivery-attempt-wal-reconcile
|
|
3259
|
+
// harn:assume open-collaboration-groups-reconcile-without-resurrection ref=collaboration-reconciliation-engine
|
|
3260
|
+
reconcileCollaborationGroups(room) {
|
|
3261
|
+
for (const group of this.store.listCollaborationGroups(room, 'open')) {
|
|
3262
|
+
for (const round of this.store.listCollaborationRounds(room, group.id)) {
|
|
3263
|
+
if (round.state !== 'collecting')
|
|
3264
|
+
continue;
|
|
3265
|
+
for (const participant of this.store.listCollaborationParticipants(room, group.id, round.round_number)) {
|
|
3266
|
+
if (participant.terminal_status !== undefined)
|
|
3267
|
+
continue;
|
|
3268
|
+
const delivery = this.store.getDelivery(room, participant.delivery_id);
|
|
3269
|
+
const member = this.store.getMember(room, participant.member_id);
|
|
3270
|
+
const result = delivery?.run_msg_id === undefined
|
|
3271
|
+
? undefined
|
|
3272
|
+
: this.store.getMessage(room, delivery.run_msg_id);
|
|
3273
|
+
if (result?.run && result.run.status !== 'running') {
|
|
3274
|
+
this.store.recoverCollaborationParticipantTerminal(room, {
|
|
3275
|
+
deliveryId: participant.delivery_id,
|
|
3276
|
+
status: result.run.status,
|
|
3277
|
+
resultMessageId: result.id,
|
|
3278
|
+
completedTs: result.run.ended_ts ?? result.ts,
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
else if (delivery !== undefined &&
|
|
3282
|
+
delivery.run_msg_id === undefined &&
|
|
3283
|
+
(member?.state === 'dead' || member?.removed_ts !== undefined)) {
|
|
3284
|
+
this.store.skipCollaborationParticipant(room, delivery.id, new Date().toISOString());
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
this.advanceCollaborationRound(room, group.id, round.round_number);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
// harn:end open-collaboration-groups-reconcile-without-resurrection
|
|
3292
|
+
processAlive(attempt) {
|
|
3293
|
+
const target = processProbeTarget(process.platform, attempt.pid, attempt.process_group_id);
|
|
3294
|
+
if (target === undefined)
|
|
3295
|
+
return false;
|
|
3296
|
+
return this.processProbe(target);
|
|
3297
|
+
}
|
|
3298
|
+
holdAmbiguousTurn(room, member, group, runMsgId, detail) {
|
|
3299
|
+
for (const delivery of group)
|
|
3300
|
+
this.store.updateDelivery(room, delivery.id, { state: 'held' });
|
|
3301
|
+
this.postSystemMessage(room, `delivery to @${member.handle} held after an ambiguous crash (turn #${runMsgId}${detail ? `; ${detail}` : ''}) — release_hold to retry or redeliver`);
|
|
3302
|
+
const current = this.store.getMember(room, member.id);
|
|
3303
|
+
// harn:assume live-agent-waits-are-transient ref=wait-clears-on-turn-end
|
|
3304
|
+
this.memberWaits.delete(member.id);
|
|
3305
|
+
this.groupWaits.delete(member.id);
|
|
3306
|
+
// harn:end live-agent-waits-are-transient
|
|
3307
|
+
if (current?.custody === 'owned' &&
|
|
3308
|
+
current.state !== 'paused' &&
|
|
3309
|
+
current.state !== 'dead' &&
|
|
3310
|
+
current.state !== 'custody_uncertain') {
|
|
3311
|
+
this.emitMember(room, this.store.updateMember(room, member.id, { state: 'idle' }));
|
|
3312
|
+
}
|
|
3313
|
+
this.orphanLeftoverInteractions(room, member.id);
|
|
3314
|
+
}
|
|
3315
|
+
/** Reconcile retry: re-runs the SAME turn without blocking reconcile. */
|
|
3316
|
+
retryTurn(room, member, group, runMsg, orphanAfter) {
|
|
3317
|
+
const eligible = this.turnStartEligibility(room, member.id);
|
|
3318
|
+
if (!eligible.member) {
|
|
3319
|
+
return {
|
|
3320
|
+
reason: eligible.refusal ?? `member @${member.handle} cannot start a turn`,
|
|
3321
|
+
alreadyHeld: false,
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
const runnable = this.applyTurnStartBrakes(room, group, true);
|
|
3325
|
+
if (runnable.length === 0) {
|
|
3326
|
+
return { reason: 'delivery batch was held by current room brakes', alreadyHeld: true };
|
|
3327
|
+
}
|
|
3328
|
+
this.inflight.add(eligible.member.id);
|
|
3329
|
+
const turn = this.runTurn(room, eligible.member, runnable, runMsg)
|
|
3330
|
+
.finally(() => this.inflight.delete(eligible.member.id))
|
|
3331
|
+
.then(() => {
|
|
3332
|
+
if (orphanAfter)
|
|
3333
|
+
this.orphanLeftoverInteractions(room, eligible.member.id);
|
|
3334
|
+
});
|
|
3335
|
+
this.track(turn);
|
|
3336
|
+
return undefined;
|
|
3337
|
+
}
|
|
3338
|
+
/** Pending/answered interactions whose run never re-raised them → orphaned. */
|
|
3339
|
+
orphanLeftoverInteractions(room, memberId) {
|
|
3340
|
+
for (const interaction of this.store.listInteractions(room)) {
|
|
3341
|
+
if (interaction.member_id !== memberId)
|
|
3342
|
+
continue;
|
|
3343
|
+
if (interaction.state !== 'pending' && interaction.state !== 'answered')
|
|
3344
|
+
continue;
|
|
3345
|
+
this.orphanInteraction(room, interaction);
|
|
3346
|
+
this.postSystemMessage(room, `${interaction.kind} card #${interaction.message_id} expired (could not be re-correlated after restart) — redeliver to retry`);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
// ── operator acts ─────────────────────────────────────────────────────
|
|
3350
|
+
// harn:assume redeliver-interrupts-stranded-run ref=redeliver-run-retirement
|
|
3351
|
+
redeliver(room, deliveryId) {
|
|
3352
|
+
const delivery = this.store.getDelivery(room, deliveryId);
|
|
3353
|
+
if (!delivery)
|
|
3354
|
+
throw new Error(`no such delivery ${deliveryId}`);
|
|
3355
|
+
const abandonedRunId = delivery.run_msg_id;
|
|
3356
|
+
this.releasedDeliveries.delete(deliveryId);
|
|
3357
|
+
const updated = this.store.updateDelivery(room, deliveryId, {
|
|
3358
|
+
state: 'queued',
|
|
3359
|
+
run_msg_id: undefined,
|
|
3360
|
+
attempt_count: 0,
|
|
3361
|
+
});
|
|
3362
|
+
this.store.setDeliveryAttemptProcess(room, [deliveryId], undefined);
|
|
3363
|
+
if (abandonedRunId !== undefined) {
|
|
3364
|
+
const stillBound = this.store
|
|
3365
|
+
.listDeliveries(room)
|
|
3366
|
+
.some((candidate) => candidate.id !== deliveryId && candidate.run_msg_id === abandonedRunId);
|
|
3367
|
+
const abandoned = this.store.getMessage(room, abandonedRunId);
|
|
3368
|
+
if (!stillBound && abandoned?.run?.status === 'running') {
|
|
3369
|
+
const interrupted = this.store.updateMessage(room, abandoned.id, {
|
|
3370
|
+
body: '',
|
|
3371
|
+
mentions: [],
|
|
3372
|
+
refs: [],
|
|
3373
|
+
ledger_refs: [],
|
|
3374
|
+
run: {
|
|
3375
|
+
...abandoned.run,
|
|
3376
|
+
status: 'interrupted',
|
|
3377
|
+
ended_ts: new Date().toISOString(),
|
|
3378
|
+
stalled_since: undefined,
|
|
3379
|
+
final_text: undefined,
|
|
3380
|
+
},
|
|
3381
|
+
});
|
|
3382
|
+
this.runActivity.delete(`${room}:${abandoned.id}`);
|
|
3383
|
+
this.emitMessage(room, interrupted);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
this.emitInbox(room, updated);
|
|
3387
|
+
this.track(this.maybeStartTurn(room, delivery.recipient));
|
|
3388
|
+
}
|
|
3389
|
+
// harn:end redeliver-interrupts-stranded-run
|
|
3390
|
+
// harn:assume retried-runs-revive-and-redeliver ref=retry-run-contract
|
|
3391
|
+
/**
|
|
3392
|
+
* Retry a failed or interrupted run: re-deliver the instructions it fed so the
|
|
3393
|
+
* agent takes them again as a fresh turn producing a NEW run; the original run
|
|
3394
|
+
* stays in history untouched. Only human owners/admins may retry (the server
|
|
3395
|
+
* gate enforces the role; this refuses others defensively). Deliveries whose
|
|
3396
|
+
* trigger message was since deleted are skipped — a purge must not resurrect —
|
|
3397
|
+
* and a run with none surviving is refused. A failed run leaves its agent dead,
|
|
3398
|
+
* so it is revived first; reuses redeliver unmodified for the re-queue.
|
|
3399
|
+
*/
|
|
3400
|
+
retryRun(room, messageId, byMemberId) {
|
|
3401
|
+
const actor = this.store.getMember(room, byMemberId);
|
|
3402
|
+
if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
|
|
3403
|
+
throw new Error('forbidden: only owners and admins can retry runs');
|
|
3404
|
+
}
|
|
3405
|
+
const message = this.store.getMessage(room, messageId);
|
|
3406
|
+
if (!message)
|
|
3407
|
+
throw new Error(`no such message: #${messageId}`);
|
|
3408
|
+
if (message.kind !== 'run' || message.run === undefined) {
|
|
3409
|
+
throw new Error(`only run messages can be retried, not ${message.kind}`);
|
|
3410
|
+
}
|
|
3411
|
+
const status = message.run.status;
|
|
3412
|
+
if (status !== 'failed' && status !== 'interrupted') {
|
|
3413
|
+
throw new Error(`only failed or interrupted runs can be retried, not ${status}`);
|
|
3414
|
+
}
|
|
3415
|
+
// A deleted trigger stays purged: skip its snapshotted delivery.
|
|
3416
|
+
const survivors = this.store
|
|
3417
|
+
.listDeliveries(room)
|
|
3418
|
+
.filter((delivery) => delivery.run_msg_id === messageId)
|
|
3419
|
+
.filter((delivery) => this.store.getMessage(room, delivery.message_id)?.deleted !== true);
|
|
3420
|
+
if (survivors.length === 0) {
|
|
3421
|
+
throw new Error('nothing to retry: the run has no surviving instructions to re-deliver');
|
|
3422
|
+
}
|
|
3423
|
+
// A failed run killed its agent; bring it back so the re-queue can run.
|
|
3424
|
+
const agent = this.store.getMember(room, message.author);
|
|
3425
|
+
if (agent?.kind === 'agent' && agent.state === 'dead')
|
|
3426
|
+
this.reviveMember(room, agent.id);
|
|
3427
|
+
for (const delivery of survivors)
|
|
3428
|
+
this.redeliver(room, delivery.id);
|
|
3429
|
+
}
|
|
3430
|
+
// harn:end retried-runs-revive-and-redeliver
|
|
3431
|
+
releaseHold(room, deliveryId) {
|
|
3432
|
+
const delivery = this.store.getDelivery(room, deliveryId);
|
|
3433
|
+
if (!delivery || delivery.state !== 'held')
|
|
3434
|
+
throw new Error(`delivery ${deliveryId} is not held`);
|
|
3435
|
+
const attemptProcess = this.store.getDeliveryAttemptProcess(room, deliveryId);
|
|
3436
|
+
if (attemptProcess && this.processAlive(attemptProcess)) {
|
|
3437
|
+
throw new Error(`delivery ${deliveryId} cannot be released while its adapter process is alive`);
|
|
3438
|
+
}
|
|
3439
|
+
if (delivery.run_msg_id !== undefined) {
|
|
3440
|
+
const runMsg = this.store.getMessage(room, delivery.run_msg_id);
|
|
3441
|
+
const member = this.store.getMember(room, delivery.recipient);
|
|
3442
|
+
if (runMsg?.run?.status === 'running' && member?.kind === 'agent') {
|
|
3443
|
+
const group = this.store
|
|
3444
|
+
.listDeliveries(room, { recipient: member.id, state: 'held' })
|
|
3445
|
+
.filter((candidate) => candidate.run_msg_id === runMsg.id);
|
|
3446
|
+
for (const candidate of group)
|
|
3447
|
+
this.releasedDeliveries.add(candidate.id);
|
|
3448
|
+
const refusal = this.retryTurn(room, member, group, runMsg, false);
|
|
3449
|
+
if (refusal) {
|
|
3450
|
+
for (const candidate of group)
|
|
3451
|
+
this.releasedDeliveries.delete(candidate.id);
|
|
3452
|
+
throw new Error(`delivery ${deliveryId} cannot be released: ${refusal.reason}`);
|
|
3453
|
+
}
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
this.releasedDeliveries.add(deliveryId);
|
|
3458
|
+
const updated = this.store.updateDelivery(room, deliveryId, { state: 'queued' });
|
|
3459
|
+
this.emitInbox(room, updated);
|
|
3460
|
+
this.track(this.maybeStartTurn(room, delivery.recipient));
|
|
3461
|
+
}
|
|
3462
|
+
/** Operator hold: parks a queued delivery until release_hold (also the brake hook). */
|
|
3463
|
+
holdDelivery(room, deliveryId, reason) {
|
|
3464
|
+
const delivery = this.store.getDelivery(room, deliveryId);
|
|
3465
|
+
if (!delivery)
|
|
3466
|
+
throw new Error(`no such delivery ${deliveryId}`);
|
|
3467
|
+
this.releasedDeliveries.delete(deliveryId);
|
|
3468
|
+
const updated = this.store.updateDelivery(room, deliveryId, { state: 'held' });
|
|
3469
|
+
this.emitInbox(room, updated);
|
|
3470
|
+
const recipient = this.store.getMember(room, delivery.recipient);
|
|
3471
|
+
const body = `delivery to @${recipient?.handle ?? delivery.recipient} held (${reason}) — release_hold to run it`;
|
|
3472
|
+
this.postSystemMessage(room, body);
|
|
3473
|
+
this.pushLog.push({ room, body, ts: new Date().toISOString() });
|
|
3474
|
+
if (reason.startsWith('turn brake') || reason.startsWith('spend brake')) {
|
|
3475
|
+
this.queueHumanPush(room, delivery.message_id, 'hold', body, [this.ownerOf(room).id], delivery.id);
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
markRead(room, deliveryId, byMemberId) {
|
|
3479
|
+
const delivery = this.store.getDelivery(room, deliveryId);
|
|
3480
|
+
if (!delivery)
|
|
3481
|
+
throw new Error(`no such delivery ${deliveryId}`);
|
|
3482
|
+
const by = byMemberId ?? this.ownerOf(room).id;
|
|
3483
|
+
if (delivery.recipient !== by)
|
|
3484
|
+
throw new Error(`delivery ${deliveryId} does not belong to member ${by}`);
|
|
3485
|
+
const updated = this.store.updateDelivery(room, deliveryId, { read_ts: new Date().toISOString() });
|
|
3486
|
+
this.emitInbox(room, updated);
|
|
3487
|
+
return updated;
|
|
3488
|
+
}
|
|
3489
|
+
// harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
|
|
3490
|
+
markRoomRead(room, throughSeq, byMemberId) {
|
|
3491
|
+
const by = byMemberId ?? this.ownerOf(room).id;
|
|
3492
|
+
const result = this.store.markRoomRead(room, by, throughSeq);
|
|
3493
|
+
for (const delivery of result.deliveries)
|
|
3494
|
+
this.emitInbox(room, delivery);
|
|
3495
|
+
return this.roomSupport(room, by);
|
|
3496
|
+
}
|
|
3497
|
+
// harn:end human-room-read-cursors-are-durable-and-monotonic
|
|
3498
|
+
// harn:assume live-delivery-consumption-is-idempotent ref=consume-delivery-daemon
|
|
3499
|
+
consumeDelivery(room, deliveryId, byMemberId) {
|
|
3500
|
+
return this.project(room, this.store.consumeQueuedDelivery(room, deliveryId, byMemberId));
|
|
3501
|
+
}
|
|
3502
|
+
// harn:end live-delivery-consumption-is-idempotent
|
|
3503
|
+
// harn:assume member-status-is-bounded-and-identity-safe ref=status-aggregation
|
|
3504
|
+
memberStatus(room, memberId, now = new Date()) {
|
|
3505
|
+
const member = this.store.getMember(room, memberId);
|
|
3506
|
+
if (!member || member.removed_ts !== undefined)
|
|
3507
|
+
throw new Error(`no such member: ${memberId}`);
|
|
3508
|
+
const latestRun = this.store.listRunMessages(room, { author: memberId, limit: 1 })[0];
|
|
3509
|
+
const currentRun = latestRun?.run?.status === 'running' ? latestRun : undefined;
|
|
3510
|
+
const resultByCall = new Map();
|
|
3511
|
+
const events = latestRun ? this.readRunBlob(room, latestRun.id) : [];
|
|
3512
|
+
for (const event of events) {
|
|
3513
|
+
if (event.type !== 'run.item' || event.item_type !== 'tool_result')
|
|
3514
|
+
continue;
|
|
3515
|
+
const parsed = parseRunItemPayload('tool_result', event.payload);
|
|
3516
|
+
if (!parsed.success)
|
|
3517
|
+
continue;
|
|
3518
|
+
resultByCall.set(parsed.data.call_id, {
|
|
3519
|
+
status: parsed.data.status,
|
|
3520
|
+
...(parsed.data.duration_ms !== undefined && { duration_ms: parsed.data.duration_ms }),
|
|
3521
|
+
});
|
|
3522
|
+
}
|
|
3523
|
+
const recent = [];
|
|
3524
|
+
let observedToolCalls = 0;
|
|
3525
|
+
if (latestRun?.run) {
|
|
3526
|
+
for (const event of events) {
|
|
3527
|
+
if (event.type !== 'run.item' || event.item_type !== 'tool_call')
|
|
3528
|
+
continue;
|
|
3529
|
+
const parsed = parseRunItemPayload('tool_call', event.payload);
|
|
3530
|
+
if (!parsed.success)
|
|
3531
|
+
continue;
|
|
3532
|
+
observedToolCalls++;
|
|
3533
|
+
const result = resultByCall.get(parsed.data.call_id);
|
|
3534
|
+
recent.push({
|
|
3535
|
+
kind: 'tool',
|
|
3536
|
+
title: parsed.data.title.slice(0, 500),
|
|
3537
|
+
...(result?.status !== undefined && { status: result.status }),
|
|
3538
|
+
...(result?.duration_ms !== undefined && { duration_ms: result.duration_ms }),
|
|
3539
|
+
ts: event.ts ?? latestRun.run.started_ts,
|
|
3540
|
+
});
|
|
3541
|
+
}
|
|
3542
|
+
for (const post of this.store.listChatMessagesByAuthorWithin(room, memberId, latestRun.run.started_ts, latestRun.run.ended_ts, 5)) {
|
|
3543
|
+
recent.push({ kind: 'post', title: post.body.slice(0, 500), ts: post.ts });
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
recent.sort((left, right) => Date.parse(right.ts) - Date.parse(left.ts));
|
|
3547
|
+
const waiting = this.memberWaits.get(memberId);
|
|
3548
|
+
const response = {
|
|
3549
|
+
member: {
|
|
3550
|
+
handle: member.handle,
|
|
3551
|
+
state: member.state ?? 'idle',
|
|
3552
|
+
...(waiting && {
|
|
3553
|
+
waiting: {
|
|
3554
|
+
peers: waiting.peers
|
|
3555
|
+
.map((peerId) => this.store.getMember(room, peerId)?.handle)
|
|
3556
|
+
.filter((handle) => handle !== undefined),
|
|
3557
|
+
reason: waiting.reason,
|
|
3558
|
+
since_ts: waiting.since_ts,
|
|
3559
|
+
until_ts: waiting.until_ts,
|
|
3560
|
+
},
|
|
3561
|
+
}),
|
|
3562
|
+
},
|
|
3563
|
+
...(currentRun?.run && {
|
|
3564
|
+
current_run: {
|
|
3565
|
+
message_id: currentRun.id,
|
|
3566
|
+
started_ts: currentRun.run.started_ts,
|
|
3567
|
+
elapsed_ms: Math.max(0, now.getTime() - Date.parse(currentRun.run.started_ts)),
|
|
3568
|
+
tool_calls: Math.max(currentRun.run.tool_calls, observedToolCalls),
|
|
3569
|
+
},
|
|
3570
|
+
}),
|
|
3571
|
+
recent: recent.slice(0, 5),
|
|
3572
|
+
};
|
|
3573
|
+
return MemberStatusResponseSchema.parse(this.project(room, response));
|
|
3574
|
+
}
|
|
3575
|
+
// harn:end member-status-is-bounded-and-identity-safe
|
|
3576
|
+
// harn:assume run-evidence-search-is-bounded-and-redacted ref=bounded-run-evidence-scan
|
|
3577
|
+
searchRunEvidence(room, query, scanLimit = 50) {
|
|
3578
|
+
if (!Number.isSafeInteger(scanLimit) || scanLimit < 1 || scanLimit > 200) {
|
|
3579
|
+
throw new Error('run search limit must be an integer from 1 to 200');
|
|
3580
|
+
}
|
|
3581
|
+
const needle = query.toLowerCase();
|
|
3582
|
+
const hits = [];
|
|
3583
|
+
const excerpt = (value) => {
|
|
3584
|
+
const text = value.replace(/\s+/g, ' ').trim();
|
|
3585
|
+
const match = text.toLowerCase().indexOf(needle);
|
|
3586
|
+
const start = Math.max(0, match - 80);
|
|
3587
|
+
return text.slice(start, start + 240);
|
|
3588
|
+
};
|
|
3589
|
+
for (const run of this.store.listRunMessages(room, { limit: scanLimit })) {
|
|
3590
|
+
const events = this.readRunBlob(room, run.id);
|
|
3591
|
+
for (let itemIndex = 0; itemIndex < events.length; itemIndex++) {
|
|
3592
|
+
const event = events[itemIndex];
|
|
3593
|
+
if (event.type !== 'run.item')
|
|
3594
|
+
continue;
|
|
3595
|
+
let value;
|
|
3596
|
+
if (event.item_type === 'tool_call') {
|
|
3597
|
+
const parsed = parseRunItemPayload('tool_call', event.payload);
|
|
3598
|
+
if (parsed.success)
|
|
3599
|
+
value = parsed.data.title;
|
|
3600
|
+
}
|
|
3601
|
+
else if (event.item_type === 'tool_result') {
|
|
3602
|
+
const parsed = parseRunItemPayload('tool_result', event.payload);
|
|
3603
|
+
if (parsed.success)
|
|
3604
|
+
value = parsed.data.output_text;
|
|
3605
|
+
}
|
|
3606
|
+
if (value === undefined || !value.toLowerCase().includes(needle))
|
|
3607
|
+
continue;
|
|
3608
|
+
hits.push({
|
|
3609
|
+
message_id: run.id,
|
|
3610
|
+
item_index: itemIndex,
|
|
3611
|
+
kind: event.item_type,
|
|
3612
|
+
excerpt: excerpt(value),
|
|
3613
|
+
});
|
|
3614
|
+
if (hits.length === scanLimit)
|
|
3615
|
+
return hits;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
return hits;
|
|
3619
|
+
}
|
|
3620
|
+
// harn:end run-evidence-search-is-bounded-and-redacted
|
|
3621
|
+
unreadCount(room, memberId) {
|
|
3622
|
+
return this.store
|
|
3623
|
+
.listDeliveries(room, { recipient: memberId })
|
|
3624
|
+
.filter((d) => d.read_ts === undefined && d.state === 'consumed').length;
|
|
3625
|
+
}
|
|
3626
|
+
// harn:assume room-support-is-bounded-recipient-scoped-state ref=room-support-projection
|
|
3627
|
+
roomSupport(room, memberId) {
|
|
3628
|
+
return this.project(room, this.store.roomSupport(room, memberId));
|
|
3629
|
+
}
|
|
3630
|
+
// harn:end addressed-cold-hydration-is-strict-and-legacy-safe
|
|
3631
|
+
// harn:assume live-agent-waits-are-transient ref=wait-member-projection
|
|
3632
|
+
sync(room, sinceSeq, opts = {}) {
|
|
3633
|
+
const sync = this.store.sync(room, sinceSeq, opts);
|
|
3634
|
+
const members = new Map(sync.members.map((member) => [member.id, member]));
|
|
3635
|
+
for (const member of this.store.listMembers(room))
|
|
3636
|
+
members.set(member.id, member);
|
|
3637
|
+
return this.project(room, {
|
|
3638
|
+
...sync,
|
|
3639
|
+
// Transient waits have no change-log row, so every hydration gets the
|
|
3640
|
+
// authoritative active roster plus any removed-member delta from Store.
|
|
3641
|
+
members: [...members.values()].map((member) => {
|
|
3642
|
+
// harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
|
|
3643
|
+
const waiting = this.memberWaits.get(member.id);
|
|
3644
|
+
return {
|
|
3645
|
+
...this.memberWithLastUsage(room, member),
|
|
3646
|
+
...(waiting && { waiting }),
|
|
3647
|
+
};
|
|
3648
|
+
// harn:end last-agent-usage-is-transient-and-seeded
|
|
3649
|
+
}),
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
// harn:end live-agent-waits-are-transient
|
|
3653
|
+
readRunBlob(room, msgId) {
|
|
3654
|
+
const message = this.store.getMessage(room, msgId);
|
|
3655
|
+
if (!message?.run)
|
|
3656
|
+
return [];
|
|
3657
|
+
return this.project(room, this.blobs.read(room, message.run.events_ref));
|
|
3658
|
+
}
|
|
3659
|
+
// harn:assume room-git-state-read-only-from-known-cwds ref=room-git-state-contract
|
|
3660
|
+
/**
|
|
3661
|
+
* The diff explorer's live git working-state for one of the room's known
|
|
3662
|
+
* directories. `requestedCwd` is a SELECTOR into the room's recorded cwd set,
|
|
3663
|
+
* never a free path: a value outside the set is refused before any git runs.
|
|
3664
|
+
* A non-git or clean directory yields an empty, clean state.
|
|
3665
|
+
*/
|
|
3666
|
+
async gitWorkingState(room, requestedCwd) {
|
|
3667
|
+
const cwds = this.roomKnownCwds(room);
|
|
3668
|
+
if (requestedCwd !== undefined && !cwds.includes(requestedCwd)) {
|
|
3669
|
+
throw new Error("cwd is not one of the room's known directories");
|
|
3670
|
+
}
|
|
3671
|
+
const selected = requestedCwd ?? cwds[0] ?? null;
|
|
3672
|
+
if (selected === null)
|
|
3673
|
+
return { cwds, selected, clean: true, files: [] };
|
|
3674
|
+
const files = await this.readGitWorkingFiles(selected);
|
|
3675
|
+
return { cwds, selected, clean: files.length === 0, files };
|
|
3676
|
+
}
|
|
3677
|
+
/** The room's known directories: distinct existing cwds of its agent members
|
|
3678
|
+
* plus the room's recorded folder, canonicalized. Missing dirs are dropped so
|
|
3679
|
+
* a stale cwd is never offered and never reaches git. */
|
|
3680
|
+
roomKnownCwds(room) {
|
|
3681
|
+
const raw = [];
|
|
3682
|
+
for (const member of this.store.listMembers(room)) {
|
|
3683
|
+
if (member.kind === 'agent' && member.cwd !== undefined)
|
|
3684
|
+
raw.push(member.cwd);
|
|
3685
|
+
}
|
|
3686
|
+
const roomCwd = this.store.getRoom(room)?.config.cwd;
|
|
3687
|
+
if (roomCwd !== undefined)
|
|
3688
|
+
raw.push(roomCwd);
|
|
3689
|
+
const seen = new Set();
|
|
3690
|
+
const out = [];
|
|
3691
|
+
for (const value of raw) {
|
|
3692
|
+
let normalized;
|
|
3693
|
+
try {
|
|
3694
|
+
normalized = normalizeWorkingDirectory(value, this.homeDir);
|
|
3695
|
+
}
|
|
3696
|
+
catch {
|
|
3697
|
+
continue; // a removed/invalid directory is simply not offered
|
|
3698
|
+
}
|
|
3699
|
+
if (!seen.has(normalized)) {
|
|
3700
|
+
seen.add(normalized);
|
|
3701
|
+
out.push(normalized);
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
return out;
|
|
3705
|
+
}
|
|
3706
|
+
/** Read-only git working-tree read for one already-validated directory. Only
|
|
3707
|
+
* read-only subcommands run, each via execFile with a timeout — no shell, no
|
|
3708
|
+
* mutation. A non-git or clean directory returns no files. */
|
|
3709
|
+
async readGitWorkingFiles(cwd) {
|
|
3710
|
+
const git = async (args, tolerateDiff = false) => {
|
|
3711
|
+
try {
|
|
3712
|
+
const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
|
|
3713
|
+
timeout: GIT_TIMEOUT_MS,
|
|
3714
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
3715
|
+
windowsHide: true,
|
|
3716
|
+
});
|
|
3717
|
+
return stdout;
|
|
3718
|
+
}
|
|
3719
|
+
catch (error) {
|
|
3720
|
+
// `diff --no-index` exits 1 when the files differ — expected, not failure.
|
|
3721
|
+
const code = error.code;
|
|
3722
|
+
const stdout = error.stdout;
|
|
3723
|
+
if (tolerateDiff && code === 1 && typeof stdout === 'string')
|
|
3724
|
+
return stdout;
|
|
3725
|
+
throw error;
|
|
3726
|
+
}
|
|
3727
|
+
};
|
|
3728
|
+
try {
|
|
3729
|
+
if ((await git(['rev-parse', '--is-inside-work-tree'])).trim() !== 'true')
|
|
3730
|
+
return [];
|
|
3731
|
+
}
|
|
3732
|
+
catch {
|
|
3733
|
+
return []; // not a git repository (or git unavailable)
|
|
3734
|
+
}
|
|
3735
|
+
let statusOut;
|
|
3736
|
+
try {
|
|
3737
|
+
statusOut = await git(['status', '--porcelain=v1', '-z']);
|
|
3738
|
+
}
|
|
3739
|
+
catch {
|
|
3740
|
+
return [];
|
|
3741
|
+
}
|
|
3742
|
+
const entries = parsePorcelainStatus(statusOut);
|
|
3743
|
+
if (entries.length === 0)
|
|
3744
|
+
return [];
|
|
3745
|
+
const numstat = new Map();
|
|
3746
|
+
try {
|
|
3747
|
+
const out = await git(['diff', 'HEAD', '--numstat']);
|
|
3748
|
+
for (const line of out.split('\n')) {
|
|
3749
|
+
const match = /^(\d+|-)\t(\d+|-)\t(.*)$/.exec(line);
|
|
3750
|
+
if (!match)
|
|
3751
|
+
continue;
|
|
3752
|
+
const raw = match[3];
|
|
3753
|
+
const path = raw.includes(' => ') ? raw.replace(/^.* => /, '') : raw;
|
|
3754
|
+
numstat.set(path, {
|
|
3755
|
+
additions: match[1] === '-' ? 0 : Number(match[1]),
|
|
3756
|
+
deletions: match[2] === '-' ? 0 : Number(match[2]),
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
catch {
|
|
3761
|
+
// No HEAD yet (empty repo): counts fall back to per-file line counting.
|
|
3762
|
+
}
|
|
3763
|
+
const files = [];
|
|
3764
|
+
for (const entry of entries) {
|
|
3765
|
+
let diff = '';
|
|
3766
|
+
try {
|
|
3767
|
+
diff = entry.status === 'untracked'
|
|
3768
|
+
? await git(['diff', '--no-index', '--', '/dev/null', entry.path], true)
|
|
3769
|
+
: await git(['diff', 'HEAD', '--', entry.path]);
|
|
3770
|
+
}
|
|
3771
|
+
catch {
|
|
3772
|
+
diff = '';
|
|
3773
|
+
}
|
|
3774
|
+
let truncated = false;
|
|
3775
|
+
if (diff.length > MAX_FILE_DIFF_CHARS) {
|
|
3776
|
+
diff = diff.slice(0, MAX_FILE_DIFF_CHARS) + DIFF_TRUNCATED_MARKER;
|
|
3777
|
+
truncated = true;
|
|
3778
|
+
}
|
|
3779
|
+
const counts = numstat.get(entry.path) ?? countDiffLines(diff);
|
|
3780
|
+
files.push({
|
|
3781
|
+
path: entry.path,
|
|
3782
|
+
status: entry.status,
|
|
3783
|
+
additions: counts.additions,
|
|
3784
|
+
deletions: counts.deletions,
|
|
3785
|
+
diff,
|
|
3786
|
+
truncated,
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
return files;
|
|
3790
|
+
}
|
|
3791
|
+
// harn:end room-git-state-read-only-from-known-cwds
|
|
3792
|
+
// harn:assume attachments-are-capped-files-served-inert ref=attachment-contract
|
|
3793
|
+
// The attachment contract: files live under the data dir keyed by a server-issued
|
|
3794
|
+
// hex id (never a client path), staged by upload, validated on post, delivered to
|
|
3795
|
+
// agents as absolute path lines, unlinked on delete, and swept when orphaned.
|
|
3796
|
+
/** A fresh, path-safe attachment id — also the on-disk file name. */
|
|
3797
|
+
newAttachmentId() {
|
|
3798
|
+
return randomBytes(16).toString('hex');
|
|
3799
|
+
}
|
|
3800
|
+
/** Absolute path to an attachment's bytes; throws unless id is a valid handle,
|
|
3801
|
+
* so a client-supplied id can never escape the room's attachment directory. */
|
|
3802
|
+
attachmentPath(room, id) {
|
|
3803
|
+
if (!ATTACHMENT_ID.test(id))
|
|
3804
|
+
throw new Error('invalid attachment id');
|
|
3805
|
+
return join(this.attachmentsRoot, room, id);
|
|
3806
|
+
}
|
|
3807
|
+
attachmentMetaPath(room, id) {
|
|
3808
|
+
return `${this.attachmentPath(room, id)}.json`;
|
|
3809
|
+
}
|
|
3810
|
+
/** Ensure a room's attachment directory exists before an upload writes into it. */
|
|
3811
|
+
ensureAttachmentDir(room) {
|
|
3812
|
+
const dir = join(this.attachmentsRoot, room);
|
|
3813
|
+
mkdirSync(dir, { recursive: true });
|
|
3814
|
+
return dir;
|
|
3815
|
+
}
|
|
3816
|
+
/** Persist an uploaded file's metadata sidecar (name/mime/size by id). */
|
|
3817
|
+
recordAttachment(room, meta) {
|
|
3818
|
+
writeFileSync(this.attachmentMetaPath(room, meta.id), JSON.stringify(meta));
|
|
3819
|
+
}
|
|
3820
|
+
/** An attachment's stored metadata, or undefined if it was never staged here. */
|
|
3821
|
+
getAttachmentMeta(room, id) {
|
|
3822
|
+
if (!ATTACHMENT_ID.test(id))
|
|
3823
|
+
return undefined;
|
|
3824
|
+
try {
|
|
3825
|
+
return AttachmentSchema.parse(JSON.parse(readFileSync(this.attachmentMetaPath(room, id), 'utf8')));
|
|
3826
|
+
}
|
|
3827
|
+
catch {
|
|
3828
|
+
return undefined;
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
/** Validate post-time attachment ids against this room's staging and return
|
|
3832
|
+
* their metadata to stamp onto the message. Throws on any unknown id (which
|
|
3833
|
+
* also refuses another room's ids) or over the per-message cap. */
|
|
3834
|
+
resolveAttachmentsForPost(room, ids) {
|
|
3835
|
+
if (ids === undefined || ids.length === 0)
|
|
3836
|
+
return [];
|
|
3837
|
+
if (ids.length > MAX_ATTACHMENTS_PER_MESSAGE) {
|
|
3838
|
+
throw new Error(`at most ${String(MAX_ATTACHMENTS_PER_MESSAGE)} attachments per message`);
|
|
3839
|
+
}
|
|
3840
|
+
return ids.map((id) => {
|
|
3841
|
+
const meta = this.getAttachmentMeta(room, id);
|
|
3842
|
+
if (meta === undefined)
|
|
3843
|
+
throw new Error(`unknown attachment ${id}`);
|
|
3844
|
+
return meta;
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
/** Unlink a deleted message's attachment files (bytes + sidecar) from disk. */
|
|
3848
|
+
unlinkAttachments(room, attachments) {
|
|
3849
|
+
for (const attachment of attachments ?? []) {
|
|
3850
|
+
if (!ATTACHMENT_ID.test(attachment.id))
|
|
3851
|
+
continue;
|
|
3852
|
+
rmSync(this.attachmentPath(room, attachment.id), { force: true });
|
|
3853
|
+
rmSync(this.attachmentMetaPath(room, attachment.id), { force: true });
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
/** Boot-time sweep: unlink staged files that no message referenced within the
|
|
3857
|
+
* orphan window. Referenced files stay until their message is deleted. */
|
|
3858
|
+
sweepOrphanAttachments(now = Date.now()) {
|
|
3859
|
+
let rooms;
|
|
3860
|
+
try {
|
|
3861
|
+
rooms = readdirSync(this.attachmentsRoot);
|
|
3862
|
+
}
|
|
3863
|
+
catch {
|
|
3864
|
+
return; // nothing staged yet
|
|
3865
|
+
}
|
|
3866
|
+
for (const room of rooms) {
|
|
3867
|
+
const dir = join(this.attachmentsRoot, room);
|
|
3868
|
+
const referenced = new Set();
|
|
3869
|
+
if (this.store.getRoom(room) !== undefined) {
|
|
3870
|
+
for (const message of this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER })) {
|
|
3871
|
+
for (const attachment of message.attachments ?? [])
|
|
3872
|
+
referenced.add(attachment.id);
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
let entries;
|
|
3876
|
+
try {
|
|
3877
|
+
entries = readdirSync(dir);
|
|
3878
|
+
}
|
|
3879
|
+
catch {
|
|
3880
|
+
continue;
|
|
3881
|
+
}
|
|
3882
|
+
for (const entry of entries) {
|
|
3883
|
+
const id = entry.endsWith('.json') ? entry.slice(0, -'.json'.length) : entry;
|
|
3884
|
+
if (!ATTACHMENT_ID.test(id) || referenced.has(id))
|
|
3885
|
+
continue;
|
|
3886
|
+
const path = join(dir, entry);
|
|
3887
|
+
try {
|
|
3888
|
+
if (now - statSync(path).mtimeMs > ORPHAN_ATTACHMENT_MS)
|
|
3889
|
+
rmSync(path, { force: true });
|
|
3890
|
+
}
|
|
3891
|
+
catch {
|
|
3892
|
+
// raced away — nothing to unlink
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
/** Absolute-path lines appended to an agent's delivered text for a message's
|
|
3898
|
+
* files — agents share this machine, so a path is the honest delivery form. */
|
|
3899
|
+
attachmentPayloadLines(room, messageId) {
|
|
3900
|
+
const attachments = this.store.getMessage(room, messageId)?.attachments ?? [];
|
|
3901
|
+
if (attachments.length === 0)
|
|
3902
|
+
return '';
|
|
3903
|
+
const lines = attachments.map((attachment) => `- ${this.attachmentPath(room, attachment.id)} (${attachment.name}, ${formatBytes(attachment.size)})`);
|
|
3904
|
+
return `\n\nAttachments:\n${lines.join('\n')}`;
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
//# sourceMappingURL=daemon.js.map
|