@gajae-code/coding-agent 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -2
- package/dist/types/append-only-mode.d.ts +1 -1
- package/dist/types/cli/args.d.ts +4 -5
- package/dist/types/cli/daemon-cli.d.ts +12 -1
- package/dist/types/cli/notify-cli.d.ts +21 -1
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +39 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/commands/notify.d.ts +28 -1
- package/dist/types/commands/sdk.d.ts +33 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
- package/dist/types/config/model-profile-activation.d.ts +35 -15
- package/dist/types/config/model-profiles.d.ts +11 -5
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +39 -6
- package/dist/types/config/model-selector-value.d.ts +17 -0
- package/dist/types/config/models-config-schema.d.ts +10 -6
- package/dist/types/config/settings-schema.d.ts +133 -6
- package/dist/types/config/settings.d.ts +63 -35
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
- package/dist/types/coordinator-mcp/server.d.ts +6 -49
- package/dist/types/daemon/builtin.d.ts +6 -5
- package/dist/types/daemon/control-types.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +12 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
- package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
- package/dist/types/extensibility/extensions/loader.d.ts +10 -1
- package/dist/types/extensibility/extensions/runner.d.ts +3 -3
- package/dist/types/extensibility/extensions/types.d.ts +141 -16
- package/dist/types/extensibility/hooks/types.d.ts +1 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
- package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
- package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
- package/dist/types/harness-control-plane/operate.d.ts +5 -5
- package/dist/types/harness-control-plane/owner.d.ts +18 -4
- package/dist/types/harness-control-plane/receipts.d.ts +0 -1
- package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
- package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
- package/dist/types/harness-control-plane/types.d.ts +3 -4
- package/dist/types/lsp/index.d.ts +1 -1
- package/dist/types/main.d.ts +32 -38
- package/dist/types/modes/acp/acp-agent.d.ts +224 -53
- package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
- package/dist/types/modes/acp/acp-mode.d.ts +6 -15
- package/dist/types/modes/acp/permission-mode.d.ts +4 -0
- package/dist/types/modes/acp/startup-options.d.ts +6 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
- package/dist/types/modes/components/pet-capability.d.ts +37 -0
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/settings-selector.d.ts +15 -4
- package/dist/types/modes/components/welcome.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
- package/dist/types/modes/index.d.ts +0 -4
- package/dist/types/modes/interactive-mode.d.ts +5 -1
- package/dist/types/modes/runtime-init.d.ts +2 -2
- package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
- package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
- package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
- package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
- package/dist/types/modes/types.d.ts +16 -1
- package/dist/types/runtime-mcp/client.d.ts +2 -0
- package/dist/types/runtime-mcp/config.d.ts +5 -1
- package/dist/types/runtime-mcp/manager.d.ts +8 -1
- package/dist/types/runtime-mcp/types.d.ts +4 -0
- package/dist/types/sdk/acp/adapter.d.ts +67 -0
- package/dist/types/sdk/acp/index.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +53 -0
- package/dist/types/sdk/broker/discovery.d.ts +30 -0
- package/dist/types/sdk/broker/ensure.d.ts +33 -0
- package/dist/types/sdk/broker/identity.d.ts +3 -0
- package/dist/types/sdk/broker/index.d.ts +8 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
- package/dist/types/sdk/broker/runtime.d.ts +33 -0
- package/dist/types/sdk/broker/session-index.d.ts +57 -0
- package/dist/types/sdk/broker/state-version.d.ts +8 -0
- package/dist/types/sdk/broker/transport.d.ts +9 -0
- package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
- package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
- package/dist/types/sdk/bus/config.d.ts +274 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
- package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
- package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
- package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
- package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
- package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
- package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
- package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
- package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
- package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
- package/dist/types/sdk/bus/session-control.d.ts +83 -0
- package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
- package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
- package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
- package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
- package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
- package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
- package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/session-cli.d.ts +19 -0
- package/dist/types/sdk/client/client.d.ts +1 -0
- package/dist/types/sdk/client/discovery.d.ts +27 -0
- package/dist/types/sdk/client/index.d.ts +2 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
- package/dist/types/sdk/host/control/index.d.ts +2 -0
- package/dist/types/sdk/host/control/operations.d.ts +65 -0
- package/dist/types/sdk/host/events.d.ts +35 -0
- package/dist/types/sdk/host/host.d.ts +35 -0
- package/dist/types/sdk/host/index.d.ts +4 -0
- package/dist/types/sdk/host/query/cursor.d.ts +62 -0
- package/dist/types/sdk/host/query/handlers.d.ts +70 -0
- package/dist/types/sdk/host/query/index.d.ts +3 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
- package/dist/types/sdk/host/types.d.ts +32 -0
- package/dist/types/sdk/index.d.ts +6 -0
- package/dist/types/sdk/lifecycle-session.d.ts +14 -0
- package/dist/types/sdk/mcp/index.d.ts +1 -0
- package/dist/types/sdk/mcp/server.d.ts +37 -0
- package/dist/types/sdk/models.d.ts +44 -0
- package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
- package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
- package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
- package/dist/types/sdk/startup-capability.d.ts +50 -0
- package/dist/types/session/agent-session.d.ts +114 -21
- package/dist/types/session/default-model-selection.d.ts +25 -0
- package/dist/types/session/fallback-chain-controller.d.ts +52 -0
- package/dist/types/session/session-manager.d.ts +58 -1
- package/dist/types/setup/credential-auto-import.d.ts +36 -6
- package/dist/types/tools/ask-answer-registry.d.ts +1 -1
- package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
- package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
- package/dist/types/tools/browser/launch.d.ts +22 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
- package/dist/types/tools/browser/profile-posture.d.ts +37 -0
- package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
- package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
- package/dist/types/tools/browser/registry.d.ts +19 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
- package/dist/types/tools/index.d.ts +8 -8
- package/package.json +34 -13
- package/scripts/build-sdk-package-smoke.ts +94 -0
- package/scripts/capture-notifications-settings-showcase.ts +435 -0
- package/scripts/compile-args.ts +4 -2
- package/scripts/g005-daemon-smoke.ts +4 -5
- package/scripts/g011-daemon-path-smoke.ts +5 -5
- package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
- package/scripts/generate-sdk-operation-inventory.ts +867 -0
- package/scripts/generate-telegram-baseline-manifest.ts +128 -0
- package/scripts/manifest-schema.ts +124 -0
- package/scripts/run-test-manifest.ts +153 -0
- package/scripts/run-threat-model-check.ts +157 -0
- package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
- package/scripts/verify-gjc-sdk-rename.ts +90 -0
- package/src/append-only-mode.ts +1 -1
- package/src/cli/args.ts +43 -12
- package/src/cli/auth-gateway-cli.ts +2 -2
- package/src/cli/config-cli.ts +2 -1
- package/src/cli/daemon-cli.ts +63 -14
- package/src/cli/list-models.ts +1 -1
- package/src/cli/notify-cli.ts +288 -286
- package/src/cli.ts +21 -2
- package/src/commands/daemon.ts +50 -6
- package/src/commands/harness.ts +93 -36
- package/src/commands/launch.ts +5 -2
- package/src/commands/mcp-serve.ts +19 -12
- package/src/commands/notify.ts +36 -3
- package/src/commands/sdk.ts +426 -0
- package/src/commit/agentic/agent.ts +1 -0
- package/src/config/atomic-yaml-patch.ts +423 -0
- package/src/config/model-profile-activation.ts +240 -89
- package/src/config/model-profiles.ts +37 -34
- package/src/config/model-registry.ts +51 -26
- package/src/config/model-resolver.ts +138 -51
- package/src/config/model-selector-value.ts +50 -0
- package/src/config/models-config-schema.ts +14 -21
- package/src/config/settings-schema.ts +128 -10
- package/src/config/settings.ts +1120 -158
- package/src/coordinator-mcp/model-preset.ts +74 -20
- package/src/coordinator-mcp/server.ts +1597 -3052
- package/src/daemon/builtin.ts +8 -5
- package/src/daemon/control-types.ts +1 -1
- package/src/defaults/gjc/skills/team/SKILL.md +4 -4
- package/src/discovery/mcp-json.ts +327 -20
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/compact-handler.ts +3 -3
- package/src/extensibility/extensions/get-commands-handler.ts +2 -2
- package/src/extensibility/extensions/loader.ts +69 -5
- package/src/extensibility/extensions/runner.ts +112 -4
- package/src/extensibility/extensions/types.ts +176 -17
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/extensibility/slash-commands.ts +3 -3
- package/src/gjc-runtime/launch-tmux.ts +170 -86
- package/src/gjc-runtime/psmux-detect.ts +82 -4
- package/src/gjc-runtime/session-state-sidecar.ts +41 -31
- package/src/gjc-runtime/team-runtime.ts +54 -35
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
- package/src/gjc-runtime/tmux-owner-isolation.ts +5 -2
- package/src/gjc-runtime/tmux-sessions.ts +19 -13
- package/src/gjc-runtime/ultragoal-guard.ts +53 -6
- package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
- package/src/gjc-runtime/windows-powershell-command.ts +58 -0
- package/src/harness-control-plane/operate.ts +9 -9
- package/src/harness-control-plane/owner.ts +283 -39
- package/src/harness-control-plane/receipts.ts +0 -1
- package/src/harness-control-plane/sdk-transport.ts +395 -0
- package/src/harness-control-plane/session-transport.ts +94 -0
- package/src/harness-control-plane/types.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +36 -37
- package/src/lsp/client.ts +42 -6
- package/src/lsp/index.ts +2 -2
- package/src/main.ts +299 -204
- package/src/modes/DESIGN.md +258 -0
- package/src/modes/acp/acp-agent.ts +1228 -2612
- package/src/modes/acp/acp-client-bridge.ts +2 -20
- package/src/modes/acp/acp-event-mapper.ts +18 -2
- package/src/modes/acp/acp-mode.ts +8 -36
- package/src/modes/acp/permission-mode.ts +25 -0
- package/src/modes/acp/startup-options.ts +6 -0
- package/src/modes/components/agent-dashboard.ts +37 -17
- package/src/modes/components/custom-editor.ts +1 -0
- package/src/modes/components/gajae-pet-widget.ts +169 -20
- package/src/modes/components/model-selector.ts +93 -35
- package/src/modes/components/notifications-settings-editor.ts +1379 -0
- package/src/modes/components/pet-capability.ts +115 -0
- package/src/modes/components/pet-selector.ts +4 -6
- package/src/modes/components/settings-selector.ts +86 -11
- package/src/modes/components/welcome.ts +4 -4
- package/src/modes/controllers/command-controller.ts +1 -2
- package/src/modes/controllers/event-controller.ts +11 -16
- package/src/modes/controllers/extension-ui-controller.ts +357 -12
- package/src/modes/controllers/input-controller.ts +43 -13
- package/src/modes/controllers/selector-controller.ts +671 -44
- package/src/modes/index.ts +0 -22
- package/src/modes/interactive-mode.ts +108 -24
- package/src/modes/runtime-init.ts +298 -6
- package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
- package/src/modes/shared/agent-wire/event-contract.ts +7 -8
- package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
- package/src/modes/shared/agent-wire/event-observation.ts +16 -7
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
- package/src/modes/types.ts +16 -1
- package/src/runtime-mcp/client.ts +94 -19
- package/src/runtime-mcp/config.ts +27 -15
- package/src/runtime-mcp/manager.ts +343 -105
- package/src/runtime-mcp/oauth-flow.ts +42 -12
- package/src/runtime-mcp/transports/http.ts +49 -19
- package/src/runtime-mcp/transports/stdio.ts +47 -28
- package/src/runtime-mcp/types.ts +10 -0
- package/src/sdk/acp/adapter.ts +418 -0
- package/src/sdk/acp/index.ts +2 -0
- package/src/sdk/broker/broker.ts +642 -0
- package/src/sdk/broker/discovery.ts +94 -0
- package/src/sdk/broker/ensure.ts +249 -0
- package/src/sdk/broker/identity.ts +42 -0
- package/src/sdk/broker/index.ts +8 -0
- package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
- package/src/sdk/broker/internal-source.bunfig.toml +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +286 -0
- package/src/sdk/broker/lifecycle.ts +2157 -0
- package/src/sdk/broker/process-incarnation.ts +159 -0
- package/src/sdk/broker/runtime.ts +164 -0
- package/src/sdk/broker/session-index.ts +318 -0
- package/src/sdk/broker/state-version.ts +23 -0
- package/src/sdk/broker/transport.ts +125 -0
- package/src/sdk/bus/chat-command-policy.ts +107 -0
- package/src/sdk/bus/chat-daemon-cli.ts +214 -0
- package/src/sdk/bus/chat-daemon-control.ts +713 -0
- package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
- package/src/sdk/bus/chat-effect-journal.ts +353 -0
- package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
- package/src/sdk/bus/config.ts +433 -0
- package/src/sdk/bus/conversation-store.ts +391 -0
- package/src/sdk/bus/discord-conversation.ts +85 -0
- package/src/sdk/bus/discord-daemon.ts +2275 -0
- package/src/sdk/bus/discord-live-provider.ts +521 -0
- package/src/sdk/bus/discord-provider.ts +56 -0
- package/src/{notifications → sdk/bus}/html-format.ts +2 -2
- package/src/sdk/bus/index.ts +3943 -0
- package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
- package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
- package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
- package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
- package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
- package/src/sdk/bus/natives-augment.d.ts +38 -0
- package/src/sdk/bus/notification-orchestration.ts +535 -0
- package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
- package/src/sdk/bus/session-control.ts +333 -0
- package/src/sdk/bus/slack-conversation.ts +74 -0
- package/src/sdk/bus/slack-daemon.ts +2144 -0
- package/src/sdk/bus/slack-live-provider.ts +380 -0
- package/src/sdk/bus/slack-provider.ts +76 -0
- package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
- package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
- package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
- package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
- package/src/sdk/bus/telegram-setup.ts +663 -0
- package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/session-cli.ts +275 -0
- package/src/sdk/client/client.ts +1 -0
- package/src/sdk/client/discovery.ts +108 -0
- package/src/sdk/client/index.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +367 -0
- package/src/sdk/host/control/index.ts +10 -0
- package/src/sdk/host/control/operations.ts +63 -0
- package/src/sdk/host/events.ts +91 -0
- package/src/sdk/host/host.ts +296 -0
- package/src/sdk/host/index.ts +4 -0
- package/src/sdk/host/query/cursor.ts +189 -0
- package/src/sdk/host/query/handlers.ts +533 -0
- package/src/sdk/host/query/index.ts +3 -0
- package/src/sdk/host/query/revision-store.ts +1126 -0
- package/src/sdk/host/reverse-leases.ts +325 -0
- package/src/sdk/host/types.ts +29 -0
- package/src/sdk/index.ts +13 -0
- package/src/sdk/lifecycle-session.ts +39 -0
- package/src/sdk/mcp/index.ts +1 -0
- package/src/sdk/mcp/server.ts +332 -0
- package/src/sdk/models.ts +168 -0
- package/src/sdk/protocol/adapter-validation.ts +48 -0
- package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
- package/src/sdk/protocol/operation-registry.ts +312 -0
- package/src/{sdk.ts → sdk/session.ts} +339 -212
- package/src/sdk/startup-capability.ts +189 -0
- package/src/session/agent-session.ts +2470 -866
- package/src/session/default-model-selection.ts +93 -0
- package/src/session/fallback-chain-controller.ts +136 -0
- package/src/session/session-manager.ts +447 -13
- package/src/session/session-storage.ts +8 -1
- package/src/setup/credential-auto-import.ts +290 -35
- package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
- package/src/slash-commands/builtin-registry.ts +33 -30
- package/src/task/executor.ts +62 -13
- package/src/tools/ask-answer-registry.ts +1 -1
- package/src/tools/ask.ts +13 -8
- package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
- package/src/tools/browser/benchmark/detector-report.ts +157 -0
- package/src/tools/browser/benchmark/run-suite.ts +39 -0
- package/src/tools/browser/launch.ts +124 -19
- package/src/tools/browser/profile-discovery.ts +63 -0
- package/src/tools/browser/profile-posture.ts +59 -0
- package/src/tools/browser/profile-reuse.ts +102 -0
- package/src/tools/browser/profile-warmup.ts +109 -0
- package/src/tools/browser/registry.ts +87 -11
- package/src/tools/browser/tab-protocol.ts +1 -0
- package/src/tools/browser/tab-supervisor.ts +1 -0
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/browser.ts +6 -0
- package/src/tools/index.ts +8 -8
- package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
- package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
- package/src/tools/telegram-send.ts +2 -2
- package/vendor/insane-search/MANIFEST.json +2 -1
- package/vendor/insane-search/engine/bias_check.py +4 -1
- package/vendor/insane-search/engine/phase0.py +26 -1
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
- package/dist/types/modes/bridge/auth.d.ts +0 -12
- package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
- package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
- package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
- package/dist/types/modes/bridge/event-stream.d.ts +0 -8
- package/dist/types/modes/rpc/host-tools.d.ts +0 -1
- package/dist/types/modes/rpc/host-uris.d.ts +0 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
- package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
- package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
- package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
- package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
- package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
- package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
- package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
- package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
- package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
- package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
- package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
- package/dist/types/notifications/config.d.ts +0 -132
- package/src/harness-control-plane/rpc-adapter.ts +0 -290
- package/src/modes/bridge/auth.ts +0 -41
- package/src/modes/bridge/bridge-client-bridge.ts +0 -47
- package/src/modes/bridge/bridge-mode.ts +0 -675
- package/src/modes/bridge/bridge-ui-context.ts +0 -200
- package/src/modes/bridge/event-stream.ts +0 -70
- package/src/modes/rpc/host-tools.ts +0 -1
- package/src/modes/rpc/host-uris.ts +0 -1
- package/src/modes/rpc/rpc-client.ts +0 -1166
- package/src/modes/rpc/rpc-mode.ts +0 -1029
- package/src/modes/rpc/rpc-socket-security.ts +0 -115
- package/src/modes/rpc/rpc-types.ts +0 -637
- package/src/modes/shared/agent-wire/command-contract.ts +0 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
- package/src/modes/shared/agent-wire/command-validation.ts +0 -181
- package/src/modes/shared/agent-wire/handshake.ts +0 -157
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
- package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
- package/src/modes/shared/agent-wire/protocol.ts +0 -46
- package/src/modes/shared/agent-wire/responses.ts +0 -17
- package/src/modes/shared/agent-wire/scopes.ts +0 -94
- package/src/modes/shared/agent-wire/session-registry.ts +0 -109
- package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
- package/src/modes/shared/agent-wire/ui-result.ts +0 -48
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
- package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
- package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
- package/src/notifications/config.ts +0 -219
- package/src/notifications/index.ts +0 -1835
- package/src/notifications/telegram-daemon-cli.ts +0 -243
- package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
- /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
- /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
- /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
- /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
- /package/src/{notifications → sdk/bus}/engine.ts +0 -0
- /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
- /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
- /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
- /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
- /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
- /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
- /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
- /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
- /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
|
@@ -0,0 +1,2157 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import * as fsSync from "node:fs";
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { getSessionsDir, resolveEquivalentPath } from "@gajae-code/utils";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ensureLaunchWorktree,
|
|
10
|
+
ensureReusableNodeModules,
|
|
11
|
+
type GjcLaunchWorktreePlan,
|
|
12
|
+
planLaunchWorktree,
|
|
13
|
+
} from "../../gjc-runtime/launch-worktree";
|
|
14
|
+
|
|
15
|
+
import { SessionManager } from "../../session/session-manager";
|
|
16
|
+
import {
|
|
17
|
+
FileSessionStorage,
|
|
18
|
+
SessionDeleteVerificationError,
|
|
19
|
+
type VerifiedSessionDeleteTarget,
|
|
20
|
+
} from "../../session/session-storage";
|
|
21
|
+
import { SdkClient, SdkClientError } from "../client/client";
|
|
22
|
+
import type { SdkStartupFailure, SdkStartupRollbackResult } from "../startup-capability";
|
|
23
|
+
|
|
24
|
+
import type { Broker, BrokerCleanupEvidence, BrokerResponse } from "./broker";
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
LifecycleCleanupProof,
|
|
28
|
+
LifecycleDurableEffectsReceipt,
|
|
29
|
+
LifecycleEffectIntent,
|
|
30
|
+
LifecycleStartupFailureReceipt,
|
|
31
|
+
LifecycleWorktreeIntent,
|
|
32
|
+
} from "./lifecycle-ledger";
|
|
33
|
+
import {
|
|
34
|
+
type ProcessIncarnationCommandRunner,
|
|
35
|
+
type ProcessIncarnationOptions,
|
|
36
|
+
parseDarwinProcessIncarnation,
|
|
37
|
+
processIncarnation,
|
|
38
|
+
} from "./process-incarnation";
|
|
39
|
+
import { resolveSdkInternalSpawnCommand, type SdkInternalSpawnCommand } from "./runtime";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
type ProcessIncarnationCommandRunner,
|
|
43
|
+
type ProcessIncarnationOptions,
|
|
44
|
+
parseDarwinProcessIncarnation,
|
|
45
|
+
processIncarnation,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
49
|
+
const MIN_READY_TIMEOUT_MS = 4_000;
|
|
50
|
+
const MAX_READY_TIMEOUT_MS = 60_000;
|
|
51
|
+
const POLL_MS = 50;
|
|
52
|
+
const CLOSE_TIMEOUT_MS = 2_000;
|
|
53
|
+
const MAX_RECEIVED_AT_SKEW_MS = 5_000;
|
|
54
|
+
|
|
55
|
+
export interface LifecycleDeadlines {
|
|
56
|
+
receivedAt: number;
|
|
57
|
+
requestedReadinessTimeoutMs: number;
|
|
58
|
+
semanticReadyDeadlineAt: number;
|
|
59
|
+
terminationStartDeadlineAt: number;
|
|
60
|
+
lifecycleCleanupDeadlineAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function deriveLifecycleDeadlines(receivedAt: number, requestedReadinessTimeoutMs: number): LifecycleDeadlines {
|
|
64
|
+
if (
|
|
65
|
+
!Number.isSafeInteger(receivedAt) ||
|
|
66
|
+
!Number.isSafeInteger(requestedReadinessTimeoutMs) ||
|
|
67
|
+
requestedReadinessTimeoutMs < MIN_READY_TIMEOUT_MS ||
|
|
68
|
+
requestedReadinessTimeoutMs > MAX_READY_TIMEOUT_MS
|
|
69
|
+
)
|
|
70
|
+
throw new Error("Lifecycle timing values must be safe integers in the approved readiness range.");
|
|
71
|
+
const phaseWindowMs = Math.min(1_000, Math.max(500, Math.floor(requestedReadinessTimeoutMs / 4)));
|
|
72
|
+
const lifecycleCleanupDeadlineAt = receivedAt + requestedReadinessTimeoutMs;
|
|
73
|
+
const semanticReadyDeadlineAt = lifecycleCleanupDeadlineAt - phaseWindowMs * 2;
|
|
74
|
+
const terminationStartDeadlineAt = lifecycleCleanupDeadlineAt - phaseWindowMs;
|
|
75
|
+
if (
|
|
76
|
+
!Number.isSafeInteger(phaseWindowMs) ||
|
|
77
|
+
!Number.isSafeInteger(lifecycleCleanupDeadlineAt) ||
|
|
78
|
+
!Number.isSafeInteger(semanticReadyDeadlineAt) ||
|
|
79
|
+
!Number.isSafeInteger(terminationStartDeadlineAt)
|
|
80
|
+
)
|
|
81
|
+
throw new Error("Lifecycle timing values overflow the safe integer range.");
|
|
82
|
+
return {
|
|
83
|
+
receivedAt,
|
|
84
|
+
requestedReadinessTimeoutMs,
|
|
85
|
+
semanticReadyDeadlineAt,
|
|
86
|
+
terminationStartDeadlineAt,
|
|
87
|
+
lifecycleCleanupDeadlineAt,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface LifecycleTiming {
|
|
92
|
+
now(): number;
|
|
93
|
+
sleep(ms: number): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const defaultLifecycleTiming: LifecycleTiming = {
|
|
97
|
+
now: Date.now,
|
|
98
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
99
|
+
};
|
|
100
|
+
const lifecycleTimingsForTest = new WeakMap<Broker, LifecycleTiming>();
|
|
101
|
+
type LifecycleCommand = SdkInternalSpawnCommand | { file: string; args: string[] };
|
|
102
|
+
type LifecycleCommandResolver = () => LifecycleCommand;
|
|
103
|
+
const lifecycleCommandResolversForTest = new WeakMap<Broker, LifecycleCommandResolver>();
|
|
104
|
+
|
|
105
|
+
export function setLifecycleCommandResolverForTest(
|
|
106
|
+
broker: Broker,
|
|
107
|
+
resolver: LifecycleCommandResolver | undefined,
|
|
108
|
+
): void {
|
|
109
|
+
if (resolver) lifecycleCommandResolversForTest.set(broker, resolver);
|
|
110
|
+
else lifecycleCommandResolversForTest.delete(broker);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function setLifecycleTimingForTest(broker: Broker, timing: LifecycleTiming | undefined): void {
|
|
114
|
+
if (timing) lifecycleTimingsForTest.set(broker, timing);
|
|
115
|
+
else lifecycleTimingsForTest.delete(broker);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function lifecycleTiming(broker: Broker): LifecycleTiming {
|
|
119
|
+
return lifecycleTimingsForTest.get(broker) ?? defaultLifecycleTiming;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function hasValidLifecycleDeadlines(value: LifecycleDeadlines, now = Date.now()): boolean {
|
|
123
|
+
const {
|
|
124
|
+
receivedAt,
|
|
125
|
+
requestedReadinessTimeoutMs,
|
|
126
|
+
semanticReadyDeadlineAt,
|
|
127
|
+
terminationStartDeadlineAt,
|
|
128
|
+
lifecycleCleanupDeadlineAt,
|
|
129
|
+
} = value;
|
|
130
|
+
if (
|
|
131
|
+
!Number.isSafeInteger(receivedAt) ||
|
|
132
|
+
!Number.isSafeInteger(requestedReadinessTimeoutMs) ||
|
|
133
|
+
!Number.isSafeInteger(semanticReadyDeadlineAt) ||
|
|
134
|
+
!Number.isSafeInteger(terminationStartDeadlineAt) ||
|
|
135
|
+
!Number.isSafeInteger(lifecycleCleanupDeadlineAt) ||
|
|
136
|
+
!Number.isSafeInteger(now) ||
|
|
137
|
+
(receivedAt > now && receivedAt - now > MAX_RECEIVED_AT_SKEW_MS)
|
|
138
|
+
)
|
|
139
|
+
return false;
|
|
140
|
+
try {
|
|
141
|
+
const expected = deriveLifecycleDeadlines(receivedAt, requestedReadinessTimeoutMs);
|
|
142
|
+
return (
|
|
143
|
+
semanticReadyDeadlineAt === expected.semanticReadyDeadlineAt &&
|
|
144
|
+
terminationStartDeadlineAt === expected.terminationStartDeadlineAt &&
|
|
145
|
+
lifecycleCleanupDeadlineAt === expected.lifecycleCleanupDeadlineAt
|
|
146
|
+
);
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
type Input = Record<string, unknown>;
|
|
152
|
+
export const isCanonicalSessionId = (value: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
|
153
|
+
const defaultStateRoot = (cwd: string) => path.join(path.resolve(cwd), ".gjc", "state");
|
|
154
|
+
const hasDefaultStateRoot = (cwd: string, root: string) => path.resolve(root) === defaultStateRoot(cwd);
|
|
155
|
+
|
|
156
|
+
export interface SessionLifecycleWorktreeTarget {
|
|
157
|
+
enabled: true;
|
|
158
|
+
name?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface SessionLifecycleWorktreeReceipt {
|
|
162
|
+
enabled: true;
|
|
163
|
+
cwd: string;
|
|
164
|
+
created: boolean;
|
|
165
|
+
reused: boolean;
|
|
166
|
+
branch?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface SessionLifecycleTranscriptIdentity {
|
|
170
|
+
dev: string;
|
|
171
|
+
ino: string;
|
|
172
|
+
size: number;
|
|
173
|
+
mtimeMs: number;
|
|
174
|
+
mtimeNs: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface SessionLifecycleLaunchRequest {
|
|
178
|
+
operation: "session.create" | "session.fork" | "session.resume";
|
|
179
|
+
sessionId: string;
|
|
180
|
+
cwd: string;
|
|
181
|
+
stateRoot: string;
|
|
182
|
+
sourceSessionId?: string;
|
|
183
|
+
sourceSessionPath?: string;
|
|
184
|
+
sourceSessionIdentity?: SessionLifecycleTranscriptIdentity;
|
|
185
|
+
sourceCwd?: string;
|
|
186
|
+
sessionPath?: string;
|
|
187
|
+
sessionIdentity?: SessionLifecycleTranscriptIdentity;
|
|
188
|
+
/** Broker-issued effect marker which the child echoes only after host readiness. */
|
|
189
|
+
effectMarker?: string;
|
|
190
|
+
modelPreset?: string;
|
|
191
|
+
worktree?: SessionLifecycleWorktreeTarget;
|
|
192
|
+
receivedAt: number;
|
|
193
|
+
requestedReadinessTimeoutMs: number;
|
|
194
|
+
semanticReadyDeadlineAt: number;
|
|
195
|
+
terminationStartDeadlineAt: number;
|
|
196
|
+
lifecycleCleanupDeadlineAt: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isSessionLifecycleTranscriptIdentity(value: unknown): value is SessionLifecycleTranscriptIdentity {
|
|
200
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
201
|
+
const identity = value as Record<string, unknown>;
|
|
202
|
+
return (
|
|
203
|
+
typeof identity.dev === "string" &&
|
|
204
|
+
/^\d+$/.test(identity.dev) &&
|
|
205
|
+
typeof identity.ino === "string" &&
|
|
206
|
+
/^\d+$/.test(identity.ino) &&
|
|
207
|
+
typeof identity.size === "number" &&
|
|
208
|
+
Number.isSafeInteger(identity.size) &&
|
|
209
|
+
identity.size >= 0 &&
|
|
210
|
+
typeof identity.mtimeMs === "number" &&
|
|
211
|
+
Number.isFinite(identity.mtimeMs) &&
|
|
212
|
+
identity.mtimeMs >= 0 &&
|
|
213
|
+
typeof identity.mtimeNs === "string" &&
|
|
214
|
+
/^\d+$/.test(identity.mtimeNs)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasValidTranscriptAuthority(path: unknown, identity: unknown): path is string {
|
|
219
|
+
return typeof path === "string" && path.length > 0 && isSessionLifecycleTranscriptIdentity(identity);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function readSessionLifecycleLaunchRequest(
|
|
223
|
+
value: string | undefined,
|
|
224
|
+
now = Date.now(),
|
|
225
|
+
): SessionLifecycleLaunchRequest {
|
|
226
|
+
if (!value) throw new Error("GJC_SDK_LIFECYCLE_REQUEST is required.");
|
|
227
|
+
const request = JSON.parse(value) as Partial<SessionLifecycleLaunchRequest>;
|
|
228
|
+
if (
|
|
229
|
+
(request.operation !== "session.create" &&
|
|
230
|
+
request.operation !== "session.fork" &&
|
|
231
|
+
request.operation !== "session.resume") ||
|
|
232
|
+
typeof request.sessionId !== "string" ||
|
|
233
|
+
!isCanonicalSessionId(request.sessionId) ||
|
|
234
|
+
typeof request.cwd !== "string" ||
|
|
235
|
+
!request.cwd ||
|
|
236
|
+
typeof request.stateRoot !== "string" ||
|
|
237
|
+
!request.stateRoot ||
|
|
238
|
+
!hasDefaultStateRoot(request.cwd, request.stateRoot) ||
|
|
239
|
+
(request.sourceSessionId !== undefined &&
|
|
240
|
+
(typeof request.sourceSessionId !== "string" || !isCanonicalSessionId(request.sourceSessionId))) ||
|
|
241
|
+
(request.sourceSessionPath !== undefined &&
|
|
242
|
+
!hasValidTranscriptAuthority(request.sourceSessionPath, request.sourceSessionIdentity)) ||
|
|
243
|
+
(request.sourceSessionIdentity !== undefined &&
|
|
244
|
+
!isSessionLifecycleTranscriptIdentity(request.sourceSessionIdentity)) ||
|
|
245
|
+
(request.sourceCwd !== undefined && (typeof request.sourceCwd !== "string" || !request.sourceCwd)) ||
|
|
246
|
+
(request.sessionPath !== undefined &&
|
|
247
|
+
!hasValidTranscriptAuthority(request.sessionPath, request.sessionIdentity)) ||
|
|
248
|
+
(request.sessionIdentity !== undefined && !isSessionLifecycleTranscriptIdentity(request.sessionIdentity)) ||
|
|
249
|
+
(request.effectMarker !== undefined &&
|
|
250
|
+
(typeof request.effectMarker !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(request.effectMarker))) ||
|
|
251
|
+
(request.modelPreset !== undefined && (typeof request.modelPreset !== "string" || !request.modelPreset)) ||
|
|
252
|
+
!hasValidLifecycleDeadlines(
|
|
253
|
+
{
|
|
254
|
+
receivedAt: request.receivedAt as number,
|
|
255
|
+
requestedReadinessTimeoutMs: request.requestedReadinessTimeoutMs as number,
|
|
256
|
+
semanticReadyDeadlineAt: request.semanticReadyDeadlineAt as number,
|
|
257
|
+
terminationStartDeadlineAt: request.terminationStartDeadlineAt as number,
|
|
258
|
+
lifecycleCleanupDeadlineAt: request.lifecycleCleanupDeadlineAt as number,
|
|
259
|
+
},
|
|
260
|
+
now,
|
|
261
|
+
) ||
|
|
262
|
+
(request.worktree !== undefined && !isLifecycleWorktreeTarget(request.worktree)) ||
|
|
263
|
+
(request.operation === "session.resume" &&
|
|
264
|
+
!hasValidTranscriptAuthority(request.sessionPath, request.sessionIdentity)) ||
|
|
265
|
+
(request.operation === "session.fork" &&
|
|
266
|
+
(!hasValidTranscriptAuthority(request.sourceSessionPath, request.sourceSessionIdentity) ||
|
|
267
|
+
request.sourceSessionId === undefined))
|
|
268
|
+
)
|
|
269
|
+
throw new Error("GJC_SDK_LIFECYCLE_REQUEST is invalid.");
|
|
270
|
+
return request as SessionLifecycleLaunchRequest;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
type SessionLaunch = {
|
|
274
|
+
id: string;
|
|
275
|
+
cwd: string;
|
|
276
|
+
root: string;
|
|
277
|
+
sourceSessionId?: string;
|
|
278
|
+
sourceSessionPath?: string;
|
|
279
|
+
sourceSessionIdentity?: SessionLifecycleTranscriptIdentity;
|
|
280
|
+
sourceCwd?: string;
|
|
281
|
+
sessionPath?: string;
|
|
282
|
+
sessionIdentity?: SessionLifecycleTranscriptIdentity;
|
|
283
|
+
modelPreset?: string;
|
|
284
|
+
worktree?: SessionLifecycleWorktreeTarget;
|
|
285
|
+
worktreePlan?: GjcLaunchWorktreePlan;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
type CleanupEvidence = BrokerCleanupEvidence;
|
|
289
|
+
|
|
290
|
+
const fail = (code: string, message: string, cleanup?: CleanupEvidence): BrokerResponse => ({
|
|
291
|
+
ok: false,
|
|
292
|
+
error: { code: code as never, message, ...(cleanup ? { cleanup } : {}) },
|
|
293
|
+
});
|
|
294
|
+
function text(value: unknown): string | undefined {
|
|
295
|
+
return typeof value === "string" && value ? value : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readinessTimeout(input: Input): number | BrokerResponse {
|
|
299
|
+
const value = input.readinessTimeoutMs;
|
|
300
|
+
if (value === undefined) return READY_TIMEOUT_MS;
|
|
301
|
+
if (
|
|
302
|
+
typeof value !== "number" ||
|
|
303
|
+
!Number.isSafeInteger(value) ||
|
|
304
|
+
value < MIN_READY_TIMEOUT_MS ||
|
|
305
|
+
value > MAX_READY_TIMEOUT_MS
|
|
306
|
+
)
|
|
307
|
+
return fail(
|
|
308
|
+
"invalid_input",
|
|
309
|
+
`readinessTimeoutMs must be an integer between ${MIN_READY_TIMEOUT_MS} and ${MAX_READY_TIMEOUT_MS}.`,
|
|
310
|
+
);
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function lifecycleDeadlines(input: Input, now: number): LifecycleDeadlines | BrokerResponse {
|
|
315
|
+
const supplied = [
|
|
316
|
+
input.receivedAt,
|
|
317
|
+
input.requestedReadinessTimeoutMs,
|
|
318
|
+
input.semanticReadyDeadlineAt,
|
|
319
|
+
input.terminationStartDeadlineAt,
|
|
320
|
+
input.lifecycleCleanupDeadlineAt,
|
|
321
|
+
];
|
|
322
|
+
if (supplied.some(value => value !== undefined)) {
|
|
323
|
+
if (!supplied.every(value => typeof value === "number" && Number.isSafeInteger(value)))
|
|
324
|
+
return fail("invalid_input", "Lifecycle deadline fields must be supplied together as safe integers.");
|
|
325
|
+
const value: LifecycleDeadlines = {
|
|
326
|
+
receivedAt: input.receivedAt as number,
|
|
327
|
+
requestedReadinessTimeoutMs: input.requestedReadinessTimeoutMs as number,
|
|
328
|
+
semanticReadyDeadlineAt: input.semanticReadyDeadlineAt as number,
|
|
329
|
+
terminationStartDeadlineAt: input.terminationStartDeadlineAt as number,
|
|
330
|
+
lifecycleCleanupDeadlineAt: input.lifecycleCleanupDeadlineAt as number,
|
|
331
|
+
};
|
|
332
|
+
return hasValidLifecycleDeadlines(value, now)
|
|
333
|
+
? value
|
|
334
|
+
: fail("invalid_input", "Lifecycle deadlines do not satisfy the exact approved timing contract.");
|
|
335
|
+
}
|
|
336
|
+
const timeout = readinessTimeout(input);
|
|
337
|
+
return typeof timeout === "number" ? deriveLifecycleDeadlines(now, timeout) : timeout;
|
|
338
|
+
}
|
|
339
|
+
function sessionId(input: Input): string | undefined {
|
|
340
|
+
return text(input.sessionId) ?? text(input.id);
|
|
341
|
+
}
|
|
342
|
+
function lifecycleCwd(input: Input): string | undefined {
|
|
343
|
+
const target = input.target as Record<string, unknown> | undefined;
|
|
344
|
+
const cwd = text(input.cwd) ?? text(input.path) ?? text(target?.path);
|
|
345
|
+
return cwd ? path.resolve(cwd) : undefined;
|
|
346
|
+
}
|
|
347
|
+
function stateRoot(input: Input, cwd: string | undefined): string | undefined {
|
|
348
|
+
const target = input.target as Record<string, unknown> | undefined;
|
|
349
|
+
const root = text(input.stateRoot) ?? text(target?.stateRoot);
|
|
350
|
+
if (root) return path.resolve(root);
|
|
351
|
+
return cwd ? path.join(cwd, ".gjc", "state") : undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isLifecycleWorktreeTarget(value: unknown): value is SessionLifecycleWorktreeTarget {
|
|
355
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
356
|
+
const target = value as Record<string, unknown>;
|
|
357
|
+
return (
|
|
358
|
+
target.enabled === true &&
|
|
359
|
+
(target.name === undefined || (typeof target.name === "string" && target.name.length > 0))
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function lifecycleWorktreeTarget(input: Input): SessionLifecycleWorktreeTarget | null | undefined {
|
|
364
|
+
const target = input.target as Record<string, unknown> | undefined;
|
|
365
|
+
const worktree = target?.worktree;
|
|
366
|
+
if (worktree === undefined) return undefined;
|
|
367
|
+
return isLifecycleWorktreeTarget(worktree) ? worktree : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type LiveResumeRecord = {
|
|
371
|
+
locator: { repo: string; stateRoot: string };
|
|
372
|
+
endpointGeneration: number;
|
|
373
|
+
pid: number;
|
|
374
|
+
endpointMtimeMs?: number;
|
|
375
|
+
live: boolean;
|
|
376
|
+
};
|
|
377
|
+
type ResumeScope = {
|
|
378
|
+
cwd: string;
|
|
379
|
+
stateRoot: string;
|
|
380
|
+
sessionPath: string;
|
|
381
|
+
sessionIdentity: {
|
|
382
|
+
dev: bigint;
|
|
383
|
+
ino: bigint;
|
|
384
|
+
size: number;
|
|
385
|
+
mtimeMs: number;
|
|
386
|
+
mtimeNs: bigint;
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
function sameResumeLocator(record: LiveResumeRecord, cwd: string, root: string): boolean {
|
|
390
|
+
return (
|
|
391
|
+
resolveEquivalentPath(record.locator.repo) === resolveEquivalentPath(cwd) &&
|
|
392
|
+
resolveEquivalentPath(record.locator.stateRoot) === resolveEquivalentPath(root)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
function sameResumeSessionIdentity(left: ResumeScope, right: ResumeScope): boolean {
|
|
396
|
+
return (
|
|
397
|
+
left.sessionPath === right.sessionPath &&
|
|
398
|
+
left.sessionIdentity.dev === right.sessionIdentity.dev &&
|
|
399
|
+
left.sessionIdentity.ino === right.sessionIdentity.ino &&
|
|
400
|
+
left.sessionIdentity.size === right.sessionIdentity.size &&
|
|
401
|
+
left.sessionIdentity.mtimeMs === right.sessionIdentity.mtimeMs &&
|
|
402
|
+
left.sessionIdentity.mtimeNs === right.sessionIdentity.mtimeNs
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
function sameLiveResumeRecord(expected: LiveResumeRecord, current: LiveResumeRecord): boolean {
|
|
406
|
+
return (
|
|
407
|
+
current.live &&
|
|
408
|
+
current.endpointGeneration === expected.endpointGeneration &&
|
|
409
|
+
current.pid === expected.pid &&
|
|
410
|
+
current.endpointMtimeMs === expected.endpointMtimeMs &&
|
|
411
|
+
sameResumeLocator(current, expected.locator.repo, expected.locator.stateRoot)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
type ValidatedTranscript = {
|
|
416
|
+
path: string;
|
|
417
|
+
id: string;
|
|
418
|
+
identity: SessionLifecycleTranscriptIdentity;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
function serializeTranscriptIdentity(identity: {
|
|
422
|
+
dev: bigint;
|
|
423
|
+
ino: bigint;
|
|
424
|
+
size: number;
|
|
425
|
+
mtimeMs: number;
|
|
426
|
+
mtimeNs: bigint;
|
|
427
|
+
}): SessionLifecycleTranscriptIdentity {
|
|
428
|
+
return {
|
|
429
|
+
dev: identity.dev.toString(),
|
|
430
|
+
ino: identity.ino.toString(),
|
|
431
|
+
size: identity.size,
|
|
432
|
+
mtimeMs: identity.mtimeMs,
|
|
433
|
+
mtimeNs: identity.mtimeNs.toString(),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function validateSavedTranscript(
|
|
438
|
+
broker: Broker,
|
|
439
|
+
cwd: string,
|
|
440
|
+
suppliedPath: string | undefined,
|
|
441
|
+
expectedSessionId: string | undefined,
|
|
442
|
+
label: "Saved" | "Source",
|
|
443
|
+
): ValidatedTranscript | BrokerResponse {
|
|
444
|
+
const inventory = SessionManager.inventorySessionsStrict(cwd, {
|
|
445
|
+
sessionDir: SessionManager.getDefaultSessionDir(cwd, broker.settings.agentDir),
|
|
446
|
+
});
|
|
447
|
+
if (inventory.kind !== "complete")
|
|
448
|
+
return fail("invalid_input", `${label} session storage could not be verified for the requested workspace.`);
|
|
449
|
+
const canonicalPath = suppliedPath ? path.resolve(suppliedPath) : undefined;
|
|
450
|
+
const matches = inventory.candidates.filter(
|
|
451
|
+
candidate =>
|
|
452
|
+
(canonicalPath === undefined || candidate.path === canonicalPath) &&
|
|
453
|
+
(expectedSessionId === undefined || candidate.id === expectedSessionId),
|
|
454
|
+
);
|
|
455
|
+
if (matches.length !== 1 || !isCanonicalSessionId(matches[0]!.id))
|
|
456
|
+
return fail("invalid_input", `${label} saved session does not match the requested workspace and session id.`);
|
|
457
|
+
const match = matches[0]!;
|
|
458
|
+
return { path: match.path, id: match.id, identity: serializeTranscriptIdentity(match.identity) };
|
|
459
|
+
}
|
|
460
|
+
async function validateLiveResumeScope(
|
|
461
|
+
broker: Broker,
|
|
462
|
+
input: Input,
|
|
463
|
+
requestedSessionId: string,
|
|
464
|
+
record: LiveResumeRecord,
|
|
465
|
+
): Promise<ResumeScope | BrokerResponse> {
|
|
466
|
+
const requestedCwd = lifecycleCwd(input);
|
|
467
|
+
if (!requestedCwd) return fail("invalid_input", "A target path is required.");
|
|
468
|
+
const suppliedRoot = stateRoot(input, requestedCwd);
|
|
469
|
+
if (!suppliedRoot || !hasDefaultStateRoot(requestedCwd, suppliedRoot))
|
|
470
|
+
return fail("invalid_input", "stateRoot must be the default .gjc/state for cwd.");
|
|
471
|
+
try {
|
|
472
|
+
if (!(await fs.stat(requestedCwd)).isDirectory())
|
|
473
|
+
return fail("invalid_input", "Lifecycle worktree must be a directory.");
|
|
474
|
+
} catch {
|
|
475
|
+
return fail("invalid_input", "Lifecycle worktree does not exist.");
|
|
476
|
+
}
|
|
477
|
+
const worktree = lifecycleWorktreeTarget(input);
|
|
478
|
+
if (worktree === null) return fail("invalid_input", "Lifecycle worktree target is invalid.");
|
|
479
|
+
let cwd = requestedCwd;
|
|
480
|
+
if (worktree) {
|
|
481
|
+
try {
|
|
482
|
+
const planned = planLaunchWorktree(
|
|
483
|
+
requestedCwd,
|
|
484
|
+
worktree.name
|
|
485
|
+
? { enabled: true, detached: false, name: worktree.name }
|
|
486
|
+
: { enabled: true, detached: true, name: null },
|
|
487
|
+
);
|
|
488
|
+
if (!planned.enabled) return fail("invalid_input", "Lifecycle worktree target is invalid.");
|
|
489
|
+
cwd = path.resolve(planned.worktreePath);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
return fail(
|
|
492
|
+
"invalid_input",
|
|
493
|
+
`Unable to validate lifecycle worktree: ${error instanceof Error ? error.message : String(error)}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const root = defaultStateRoot(cwd);
|
|
498
|
+
if (!sameResumeLocator(record, cwd, root))
|
|
499
|
+
return fail("endpoint_stale", "Live session does not match the requested resume scope.");
|
|
500
|
+
const sessionPath = text(input.sessionPath);
|
|
501
|
+
if (!sessionPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
|
|
502
|
+
const inventory = SessionManager.inventorySessionsStrict(cwd, {
|
|
503
|
+
sessionDir: SessionManager.getDefaultSessionDir(cwd, broker.settings.agentDir),
|
|
504
|
+
});
|
|
505
|
+
if (inventory.kind !== "complete")
|
|
506
|
+
return fail("endpoint_stale", "Requested saved session could not be verified for the requested workspace.");
|
|
507
|
+
const canonicalSessionPath = path.resolve(sessionPath);
|
|
508
|
+
const matches = inventory.candidates.filter(
|
|
509
|
+
candidate => candidate.id === requestedSessionId && candidate.path === canonicalSessionPath,
|
|
510
|
+
);
|
|
511
|
+
if (matches.length !== 1)
|
|
512
|
+
return fail("endpoint_stale", "Requested saved session does not match the live session scope.");
|
|
513
|
+
const session = matches[0]!;
|
|
514
|
+
return {
|
|
515
|
+
cwd,
|
|
516
|
+
stateRoot: root,
|
|
517
|
+
sessionPath: canonicalSessionPath,
|
|
518
|
+
sessionIdentity: session.identity,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
async function reconcileReadyScope(broker: Broker, id: string, scope: string | undefined): Promise<void> {
|
|
522
|
+
if (!scope) return;
|
|
523
|
+
await broker.index.refresh();
|
|
524
|
+
const record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
|
|
525
|
+
if (!record || record.locator.repo === scope) return;
|
|
526
|
+
// The host records its physical cwd, which Darwin canonicalizes from /var to
|
|
527
|
+
// /private/var. Preserve the lifecycle caller's lexical cwd for ACP's scoped
|
|
528
|
+
// listing while retaining the host-provided state root for endpoint binding.
|
|
529
|
+
await broker.index.append({
|
|
530
|
+
type: "record_reconciled",
|
|
531
|
+
sessionId: id,
|
|
532
|
+
locator: { ...record.locator, repo: scope },
|
|
533
|
+
endpointGeneration: record.endpointGeneration,
|
|
534
|
+
pid: record.pid,
|
|
535
|
+
endpointMtimeMs: record.endpointMtimeMs,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function command(broker: Broker): LifecycleCommand {
|
|
540
|
+
const configured = process.env.GJC_SDK_SESSION_COMMAND;
|
|
541
|
+
if (configured) {
|
|
542
|
+
const [file, ...args] = configured.trim().split(/\s+/);
|
|
543
|
+
if (file) return { file, args };
|
|
544
|
+
}
|
|
545
|
+
return lifecycleCommandResolversForTest.get(broker)?.() ?? resolveSdkInternalSpawnCommand("session-host-internal");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const lifecycleMarkerPath = (root: string, id: string) => path.join(root, "sdk", `${id}.lifecycle.json`);
|
|
549
|
+
const lifecycleReadyPath = (root: string, id: string) => path.join(root, "sdk", `${id}.lifecycle.ready.json`);
|
|
550
|
+
const lifecycleFailurePath = (root: string, id: string, effectMarker: string) =>
|
|
551
|
+
path.join(root, "sdk", `${id}.lifecycle.failure.${effectMarker}.json`);
|
|
552
|
+
type EffectMarker = { pid: number; effectMarker: string; incarnation: string };
|
|
553
|
+
type ReadyAuthority = {
|
|
554
|
+
endpoint: Record<string, unknown>;
|
|
555
|
+
endpointSource: string;
|
|
556
|
+
endpointMtimeMs: number;
|
|
557
|
+
endpointGeneration: number;
|
|
558
|
+
};
|
|
559
|
+
type ReadinessResult =
|
|
560
|
+
| { kind: "ready"; authority: ReadyAuthority }
|
|
561
|
+
| { kind: "startup_failed"; message: string }
|
|
562
|
+
| { kind: "child_exited" }
|
|
563
|
+
| { kind: "timeout" };
|
|
564
|
+
const processIncarnationReadersForTest = new WeakMap<Broker, (pid: number) => string | undefined>();
|
|
565
|
+
|
|
566
|
+
export function setProcessIncarnationForTest(
|
|
567
|
+
broker: Broker,
|
|
568
|
+
value: ((pid: number) => string | undefined) | undefined,
|
|
569
|
+
): void {
|
|
570
|
+
if (value) processIncarnationReadersForTest.set(broker, value);
|
|
571
|
+
else processIncarnationReadersForTest.delete(broker);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function processIncarnationForBroker(broker: Broker, pid: number): string | undefined {
|
|
575
|
+
const reader = processIncarnationReadersForTest.get(broker);
|
|
576
|
+
return reader ? reader(pid) : processIncarnation(pid);
|
|
577
|
+
}
|
|
578
|
+
function hasProcessIncarnationAuthority(): boolean {
|
|
579
|
+
return processIncarnation(process.pid) !== undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
type ProcessObservation = "alive" | "exited" | "uncertain";
|
|
583
|
+
|
|
584
|
+
/** Only ESRCH or a changed, readable incarnation proves the owned process exited. */
|
|
585
|
+
function observeProcess(
|
|
586
|
+
pid: number,
|
|
587
|
+
expectedIncarnation: string | undefined,
|
|
588
|
+
readIncarnation: (pid: number) => string | undefined = processIncarnation,
|
|
589
|
+
): ProcessObservation {
|
|
590
|
+
try {
|
|
591
|
+
process.kill(pid, 0);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "exited" : "uncertain";
|
|
594
|
+
}
|
|
595
|
+
if (!expectedIncarnation) return "uncertain";
|
|
596
|
+
const actualIncarnation = readIncarnation(pid);
|
|
597
|
+
if (!actualIncarnation) return "uncertain";
|
|
598
|
+
return actualIncarnation === expectedIncarnation ? "alive" : "exited";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function hasObservedProcessExit(pid: number): boolean {
|
|
602
|
+
try {
|
|
603
|
+
process.kill(pid, 0);
|
|
604
|
+
return false;
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function isEffectMarker(value: unknown): value is EffectMarker {
|
|
611
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
612
|
+
const marker = value as Partial<EffectMarker>;
|
|
613
|
+
return (
|
|
614
|
+
typeof marker.pid === "number" &&
|
|
615
|
+
Number.isSafeInteger(marker.pid) &&
|
|
616
|
+
marker.pid > 0 &&
|
|
617
|
+
typeof marker.effectMarker === "string" &&
|
|
618
|
+
marker.effectMarker.length > 0 &&
|
|
619
|
+
typeof marker.incarnation === "string" &&
|
|
620
|
+
marker.incarnation.length > 0
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function sameEffectMarker(left: EffectMarker, right: EffectMarker): boolean {
|
|
625
|
+
return left.pid === right.pid && left.effectMarker === right.effectMarker && left.incarnation === right.incarnation;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function readEffectMarker(file: string): Promise<EffectMarker | undefined> {
|
|
629
|
+
try {
|
|
630
|
+
const marker: unknown = JSON.parse(await fs.readFile(file, "utf8"));
|
|
631
|
+
return isEffectMarker(marker) ? marker : undefined;
|
|
632
|
+
} catch {
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function writeEffectMarker(root: string, id: string, marker: EffectMarker): Promise<void> {
|
|
638
|
+
const directory = path.join(root, "sdk");
|
|
639
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
640
|
+
const temporary = path.join(directory, `.${id}.lifecycle.${randomUUID()}.tmp`);
|
|
641
|
+
const handle = await fs.open(
|
|
642
|
+
temporary,
|
|
643
|
+
fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY,
|
|
644
|
+
0o600,
|
|
645
|
+
);
|
|
646
|
+
try {
|
|
647
|
+
await handle.writeFile(canonicalJson(marker));
|
|
648
|
+
await handle.sync();
|
|
649
|
+
} finally {
|
|
650
|
+
await handle.close();
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
await fs.rename(temporary, lifecycleMarkerPath(root, id));
|
|
654
|
+
await syncDirectory(directory);
|
|
655
|
+
} finally {
|
|
656
|
+
await fs.rm(temporary, { force: true });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The child writes this only after its endpoint and semantic ready event are both live. */
|
|
661
|
+
export async function writeSessionLifecycleReady(root: string, id: string, effectMarker: string): Promise<void> {
|
|
662
|
+
const incarnation = processIncarnation(process.pid);
|
|
663
|
+
if (!incarnation) throw new Error("Lifecycle child has no readable OS incarnation.");
|
|
664
|
+
await fs.mkdir(path.join(root, "sdk"), { recursive: true, mode: 0o700 });
|
|
665
|
+
await fs.writeFile(lifecycleReadyPath(root, id), JSON.stringify({ pid: process.pid, effectMarker, incarnation }), {
|
|
666
|
+
mode: 0o600,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface LifecycleTranscriptEvidence {
|
|
671
|
+
digest: string;
|
|
672
|
+
identity: SessionLifecycleTranscriptIdentity;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
type LifecycleFailureArtifact = EffectMarker &
|
|
676
|
+
SdkStartupFailure & {
|
|
677
|
+
rollback: SdkStartupRollbackResult;
|
|
678
|
+
transcript?: LifecycleTranscriptEvidence;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
function canonicalJson(value: unknown): string {
|
|
682
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
683
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
684
|
+
const record = value as Record<string, unknown>;
|
|
685
|
+
return `{${Object.keys(record)
|
|
686
|
+
.sort()
|
|
687
|
+
.map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
688
|
+
.join(",")}}`;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isRollbackResult(value: unknown): value is SdkStartupRollbackResult {
|
|
692
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
693
|
+
const record = value as Record<string, unknown>;
|
|
694
|
+
return (
|
|
695
|
+
Object.keys(record).length === 5 &&
|
|
696
|
+
(record.endpointGeneration === null ||
|
|
697
|
+
(typeof record.endpointGeneration === "number" &&
|
|
698
|
+
Number.isSafeInteger(record.endpointGeneration) &&
|
|
699
|
+
record.endpointGeneration > 0)) &&
|
|
700
|
+
typeof record.fenced === "boolean" &&
|
|
701
|
+
typeof record.runtimeRemoved === "boolean" &&
|
|
702
|
+
typeof record.hostStopped === "boolean" &&
|
|
703
|
+
typeof record.brokerRegistrationReleased === "boolean"
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function isLifecycleTranscriptEvidence(value: unknown): value is LifecycleTranscriptEvidence {
|
|
708
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
709
|
+
const record = value as Record<string, unknown>;
|
|
710
|
+
return (
|
|
711
|
+
Object.keys(record).length === 2 &&
|
|
712
|
+
typeof record.digest === "string" &&
|
|
713
|
+
/^[a-f0-9]{64}$/.test(record.digest) &&
|
|
714
|
+
isSessionLifecycleTranscriptIdentity(record.identity)
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function isSdkStartupFailure(value: unknown): value is SdkStartupFailure {
|
|
719
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
720
|
+
const failure = value as Record<string, unknown>;
|
|
721
|
+
return (
|
|
722
|
+
Object.keys(failure).length === 3 &&
|
|
723
|
+
(failure.phase === "registration" || failure.phase === "startup") &&
|
|
724
|
+
(failure.reason === "disabled" ||
|
|
725
|
+
failure.reason === "ineligible" ||
|
|
726
|
+
failure.reason === "factory_absent" ||
|
|
727
|
+
failure.reason === "runner_absent" ||
|
|
728
|
+
failure.reason === "pending" ||
|
|
729
|
+
failure.reason === "failed") &&
|
|
730
|
+
typeof failure.message === "string" &&
|
|
731
|
+
Buffer.byteLength(failure.message) > 0 &&
|
|
732
|
+
Buffer.byteLength(failure.message) <= 512
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isLifecycleFailureArtifact(value: unknown): value is LifecycleFailureArtifact {
|
|
737
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
738
|
+
const record = value as Record<string, unknown>;
|
|
739
|
+
if (!isEffectMarker(record)) return false;
|
|
740
|
+
const artifact = value as LifecycleFailureArtifact;
|
|
741
|
+
return (
|
|
742
|
+
Object.keys(record).length === (artifact.transcript === undefined ? 7 : 8) &&
|
|
743
|
+
isSdkStartupFailure({ phase: artifact.phase, reason: artifact.reason, message: artifact.message }) &&
|
|
744
|
+
isRollbackResult(artifact.rollback) &&
|
|
745
|
+
(artifact.transcript === undefined || isLifecycleTranscriptEvidence(artifact.transcript))
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function syncDirectory(directory: string): Promise<void> {
|
|
750
|
+
const handle = await fs.open(directory, fsSync.constants.O_RDONLY);
|
|
751
|
+
try {
|
|
752
|
+
await handle.sync();
|
|
753
|
+
} finally {
|
|
754
|
+
await handle.close();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** The child writes bounded startup diagnostics before exiting without readiness. */
|
|
759
|
+
export async function writeSessionLifecycleFailure(
|
|
760
|
+
root: string,
|
|
761
|
+
id: string,
|
|
762
|
+
effectMarker: string,
|
|
763
|
+
failure: SdkStartupFailure,
|
|
764
|
+
rollback: SdkStartupRollbackResult,
|
|
765
|
+
transcript?: LifecycleTranscriptEvidence,
|
|
766
|
+
ownerIncarnation?: string,
|
|
767
|
+
): Promise<void> {
|
|
768
|
+
if (!isSdkStartupFailure(failure))
|
|
769
|
+
throw new Error("Lifecycle startup failure does not satisfy the canonical failure contract.");
|
|
770
|
+
|
|
771
|
+
const incarnation = ownerIncarnation ?? processIncarnation(process.pid);
|
|
772
|
+
if (!incarnation) return;
|
|
773
|
+
const directory = path.join(root, "sdk");
|
|
774
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
775
|
+
await fs.chmod(directory, 0o700);
|
|
776
|
+
const artifact: LifecycleFailureArtifact = {
|
|
777
|
+
pid: process.pid,
|
|
778
|
+
effectMarker,
|
|
779
|
+
incarnation,
|
|
780
|
+
...failure,
|
|
781
|
+
rollback,
|
|
782
|
+
...(transcript ? { transcript } : {}),
|
|
783
|
+
};
|
|
784
|
+
const bytes = Buffer.from(canonicalJson(artifact), "utf8");
|
|
785
|
+
const target = lifecycleFailurePath(root, id, effectMarker);
|
|
786
|
+
const temporary = path.join(directory, `.${id}.lifecycle.failure.${effectMarker}.${randomUUID()}.tmp`);
|
|
787
|
+
let published = false;
|
|
788
|
+
try {
|
|
789
|
+
const handle = await fs.open(
|
|
790
|
+
temporary,
|
|
791
|
+
fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY,
|
|
792
|
+
0o600,
|
|
793
|
+
);
|
|
794
|
+
try {
|
|
795
|
+
await handle.writeFile(bytes);
|
|
796
|
+
await handle.sync();
|
|
797
|
+
} finally {
|
|
798
|
+
await handle.close();
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
await fs.link(temporary, target);
|
|
802
|
+
published = true;
|
|
803
|
+
} catch (writeError) {
|
|
804
|
+
if ((writeError as NodeJS.ErrnoException).code !== "EEXIST") throw writeError;
|
|
805
|
+
const existing = await readLifecycleFailureArtifact(target, artifact);
|
|
806
|
+
if (!existing?.bytes.equals(bytes)) throw new Error("Lifecycle startup failure artifact collision.");
|
|
807
|
+
}
|
|
808
|
+
} finally {
|
|
809
|
+
await fs.rm(temporary, { force: true });
|
|
810
|
+
if (published) await syncDirectory(directory);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function readLifecycleFailureArtifact(
|
|
815
|
+
file: string,
|
|
816
|
+
expected: EffectMarker,
|
|
817
|
+
): Promise<{ artifact: LifecycleFailureArtifact; bytes: Buffer; digest: string } | undefined> {
|
|
818
|
+
let handle: fs.FileHandle | undefined;
|
|
819
|
+
try {
|
|
820
|
+
handle = await fs.open(file, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
|
|
821
|
+
const stat = await handle.stat();
|
|
822
|
+
if (!stat.isFile() || stat.size > 4096) return undefined;
|
|
823
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
824
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
825
|
+
if (bytesRead > 4096) return undefined;
|
|
826
|
+
const raw = bytes.subarray(0, bytesRead);
|
|
827
|
+
const value: unknown = JSON.parse(raw.toString("utf8"));
|
|
828
|
+
if (
|
|
829
|
+
!isLifecycleFailureArtifact(value) ||
|
|
830
|
+
!sameEffectMarker(value, expected) ||
|
|
831
|
+
canonicalJson(value) !== raw.toString("utf8")
|
|
832
|
+
)
|
|
833
|
+
return undefined;
|
|
834
|
+
return { artifact: value, bytes: raw, digest: createHash("sha256").update(raw).digest("hex") };
|
|
835
|
+
} catch {
|
|
836
|
+
return undefined;
|
|
837
|
+
} finally {
|
|
838
|
+
if (handle) await handle.close();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function readSessionLifecycleFailure(
|
|
843
|
+
root: string,
|
|
844
|
+
id: string,
|
|
845
|
+
expected: EffectMarker,
|
|
846
|
+
): Promise<LifecycleFailureArtifact | undefined> {
|
|
847
|
+
return (await readLifecycleFailureArtifact(lifecycleFailurePath(root, id, expected.effectMarker), expected))
|
|
848
|
+
?.artifact;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function hasDurableProcessIdentity(
|
|
852
|
+
root: string,
|
|
853
|
+
id: string,
|
|
854
|
+
pid: number,
|
|
855
|
+
expected?: EffectMarker,
|
|
856
|
+
): Promise<boolean> {
|
|
857
|
+
const marker = await readEffectMarker(lifecycleMarkerPath(root, id));
|
|
858
|
+
if (!marker || marker.pid !== pid || (expected && !sameEffectMarker(marker, expected))) return false;
|
|
859
|
+
return marker.incarnation === processIncarnation(pid);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function hasOwnedReadinessEvidence(
|
|
863
|
+
broker: Broker,
|
|
864
|
+
root: string,
|
|
865
|
+
id: string,
|
|
866
|
+
expected: EffectMarker,
|
|
867
|
+
): Promise<boolean> {
|
|
868
|
+
if (
|
|
869
|
+
observeProcess(expected.pid, expected.incarnation, value => processIncarnationForBroker(broker, value)) !==
|
|
870
|
+
"alive"
|
|
871
|
+
)
|
|
872
|
+
return false;
|
|
873
|
+
const [effect, ready] = await Promise.all([
|
|
874
|
+
readEffectMarker(lifecycleMarkerPath(root, id)),
|
|
875
|
+
readEffectMarker(lifecycleReadyPath(root, id)),
|
|
876
|
+
]);
|
|
877
|
+
return (
|
|
878
|
+
effect !== undefined &&
|
|
879
|
+
ready !== undefined &&
|
|
880
|
+
sameEffectMarker(effect, expected) &&
|
|
881
|
+
sameEffectMarker(ready, expected)
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function removeOwnedLifecycleArtifacts(root: string, id: string, expected: EffectMarker): Promise<boolean> {
|
|
886
|
+
const marker = await readEffectMarker(lifecycleMarkerPath(root, id));
|
|
887
|
+
if (!marker || !sameEffectMarker(marker, expected)) return false;
|
|
888
|
+
const endpointPath = path.join(root, "sdk", `${id}.json`);
|
|
889
|
+
try {
|
|
890
|
+
const endpoint = JSON.parse(await fs.readFile(endpointPath, "utf8")) as { pid?: unknown };
|
|
891
|
+
if (endpoint.pid !== expected.pid || !hasObservedProcessExit(expected.pid)) return false;
|
|
892
|
+
await fs.rm(endpointPath);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
|
|
895
|
+
}
|
|
896
|
+
if (!(await endpointRemoved(root, id))) return false;
|
|
897
|
+
const currentMarker = await readEffectMarker(lifecycleMarkerPath(root, id));
|
|
898
|
+
if (!currentMarker || !sameEffectMarker(currentMarker, expected)) return false;
|
|
899
|
+
try {
|
|
900
|
+
await fs.rm(lifecycleReadyPath(root, id), { force: true });
|
|
901
|
+
} catch {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function recordTerminalUncertain(broker: Broker, id: string, root: string, pid: number): Promise<void> {
|
|
908
|
+
await broker.index.refresh();
|
|
909
|
+
const registered = broker.index.listSessions().sessions.find(session => session.sessionId === id);
|
|
910
|
+
if (registered)
|
|
911
|
+
await broker.index.append({
|
|
912
|
+
type: "lifecycle_terminal",
|
|
913
|
+
sessionId: id,
|
|
914
|
+
locator: registered.locator,
|
|
915
|
+
endpointGeneration: registered.endpointGeneration,
|
|
916
|
+
pid: registered.pid,
|
|
917
|
+
terminalUncertain: true,
|
|
918
|
+
});
|
|
919
|
+
else
|
|
920
|
+
await broker.index.append({
|
|
921
|
+
type: "lifecycle_terminal",
|
|
922
|
+
sessionId: id,
|
|
923
|
+
locator: { repo: "unknown", stateRoot: root },
|
|
924
|
+
endpointGeneration: 0,
|
|
925
|
+
pid,
|
|
926
|
+
terminalUncertain: true,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function waitUntil(timing: LifecycleTiming, deadline: number): Promise<void> {
|
|
931
|
+
while (timing.now() < deadline) await timing.sleep(Math.max(0, Math.min(POLL_MS, deadline - timing.now())));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function terminateSpawnedChild(
|
|
935
|
+
child: ChildProcess,
|
|
936
|
+
broker: Broker,
|
|
937
|
+
id: string,
|
|
938
|
+
root: string,
|
|
939
|
+
deadline: number,
|
|
940
|
+
terminationStartDeadlineAt: number,
|
|
941
|
+
expected: EffectMarker | undefined,
|
|
942
|
+
timing: LifecycleTiming,
|
|
943
|
+
): Promise<boolean> {
|
|
944
|
+
const pid = child.pid;
|
|
945
|
+
if (!pid || (expected && pid !== expected.pid)) return false;
|
|
946
|
+
const incarnation = expected?.incarnation ?? processIncarnationForBroker(broker, pid);
|
|
947
|
+
await broker.index.refresh();
|
|
948
|
+
const observe = (): ProcessObservation =>
|
|
949
|
+
child.exitCode !== null
|
|
950
|
+
? "exited"
|
|
951
|
+
: observeProcess(pid, incarnation, value => processIncarnationForBroker(broker, value));
|
|
952
|
+
const waitForExit = async (until: number): Promise<ProcessObservation> => {
|
|
953
|
+
let observation = observe();
|
|
954
|
+
while (observation !== "exited" && timing.now() < until) {
|
|
955
|
+
await timing.sleep(Math.max(0, Math.min(POLL_MS, until - timing.now())));
|
|
956
|
+
observation = observe();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return observation;
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
let observation = observe();
|
|
963
|
+
if (observation === "alive") {
|
|
964
|
+
await waitUntil(timing, terminationStartDeadlineAt);
|
|
965
|
+
observation = observe();
|
|
966
|
+
}
|
|
967
|
+
if (observation === "alive") {
|
|
968
|
+
if (!(await signalVerifiedSession({ locator: { stateRoot: root }, pid }, id, "SIGTERM", expected))) {
|
|
969
|
+
observation = observe();
|
|
970
|
+
if (observation !== "exited") {
|
|
971
|
+
await recordTerminalUncertain(broker, id, root, pid);
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
} else {
|
|
975
|
+
const remaining = Math.max(0, deadline - timing.now());
|
|
976
|
+
const gracefulDeadline = timing.now() + Math.min(CLOSE_TIMEOUT_MS, Math.floor(remaining / 2));
|
|
977
|
+
observation = await waitForExit(gracefulDeadline);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (observation === "alive") {
|
|
981
|
+
if (!(await signalVerifiedSession({ locator: { stateRoot: root }, pid }, id, "SIGKILL", expected))) {
|
|
982
|
+
observation = observe();
|
|
983
|
+
if (observation !== "exited") {
|
|
984
|
+
await recordTerminalUncertain(broker, id, root, pid);
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
} else {
|
|
988
|
+
observation = await waitForExit(deadline);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (observation !== "exited") {
|
|
992
|
+
await recordTerminalUncertain(broker, id, root, pid);
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
let rollbackGeneration: number | null | undefined;
|
|
996
|
+
if (expected) {
|
|
997
|
+
const failure = await readLifecycleFailureArtifact(
|
|
998
|
+
lifecycleFailurePath(root, id, expected.effectMarker),
|
|
999
|
+
expected,
|
|
1000
|
+
);
|
|
1001
|
+
if (
|
|
1002
|
+
!failure?.artifact.rollback.fenced ||
|
|
1003
|
+
!failure.artifact.rollback.runtimeRemoved ||
|
|
1004
|
+
!failure.artifact.rollback.hostStopped ||
|
|
1005
|
+
!failure.artifact.rollback.brokerRegistrationReleased
|
|
1006
|
+
) {
|
|
1007
|
+
await recordTerminalUncertain(broker, id, root, pid);
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
rollbackGeneration = failure.artifact.rollback.endpointGeneration;
|
|
1011
|
+
}
|
|
1012
|
+
if (expected && !(await removeOwnedLifecycleArtifacts(root, id, expected))) {
|
|
1013
|
+
await recordTerminalUncertain(broker, id, root, pid);
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
await broker.index.refresh();
|
|
1017
|
+
if (
|
|
1018
|
+
rollbackGeneration === null &&
|
|
1019
|
+
expected &&
|
|
1020
|
+
!broker.index.hasHostRegistrationForLifecycle(id, pid, expected.effectMarker)
|
|
1021
|
+
)
|
|
1022
|
+
return endpointRemoved(root, id);
|
|
1023
|
+
const registeredBeforeTermination =
|
|
1024
|
+
rollbackGeneration === undefined || rollbackGeneration === null
|
|
1025
|
+
? undefined
|
|
1026
|
+
: broker.index.findHostRegistration(id, rollbackGeneration, pid, expected?.effectMarker);
|
|
1027
|
+
const unregistered = registeredBeforeTermination
|
|
1028
|
+
? broker.index.hostUnregisteredAfter(registeredBeforeTermination)
|
|
1029
|
+
: undefined;
|
|
1030
|
+
if (!registeredBeforeTermination || !unregistered) {
|
|
1031
|
+
await recordTerminalUncertain(broker, id, root, pid);
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
return endpointRemoved(root, id);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function signalVerifiedSession(
|
|
1038
|
+
record: { locator: { stateRoot: string }; pid: number },
|
|
1039
|
+
id: string,
|
|
1040
|
+
signal: NodeJS.Signals,
|
|
1041
|
+
expected?: EffectMarker,
|
|
1042
|
+
): Promise<boolean> {
|
|
1043
|
+
if (!(await hasDurableProcessIdentity(record.locator.stateRoot, id, record.pid, expected))) return false;
|
|
1044
|
+
try {
|
|
1045
|
+
if (!(await hasDurableProcessIdentity(record.locator.stateRoot, id, record.pid, expected))) return false;
|
|
1046
|
+
process.kill(record.pid, signal);
|
|
1047
|
+
return true;
|
|
1048
|
+
} catch {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function endpointRemoved(root: string, id: string): Promise<boolean> {
|
|
1054
|
+
try {
|
|
1055
|
+
await fs.access(path.join(root, "sdk", `${id}.json`));
|
|
1056
|
+
return false;
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function waitForClose(
|
|
1063
|
+
broker: Broker,
|
|
1064
|
+
id: string,
|
|
1065
|
+
record: { locator: { stateRoot: string }; endpointGeneration: number; pid: number; lifecycleRequestId?: string },
|
|
1066
|
+
timeoutMs: number,
|
|
1067
|
+
): Promise<boolean> {
|
|
1068
|
+
const timing = lifecycleTiming(broker);
|
|
1069
|
+
const deadline = timing.now() + timeoutMs;
|
|
1070
|
+
while (timing.now() < deadline) {
|
|
1071
|
+
await broker.index.refresh();
|
|
1072
|
+
const registration = broker.index.findHostRegistration(
|
|
1073
|
+
id,
|
|
1074
|
+
record.endpointGeneration,
|
|
1075
|
+
record.pid,
|
|
1076
|
+
record.lifecycleRequestId,
|
|
1077
|
+
);
|
|
1078
|
+
if (
|
|
1079
|
+
registration &&
|
|
1080
|
+
broker.index.hostUnregisteredAfter(registration) &&
|
|
1081
|
+
(await endpointRemoved(record.locator.stateRoot, id)) &&
|
|
1082
|
+
hasObservedProcessExit(record.pid)
|
|
1083
|
+
)
|
|
1084
|
+
return true;
|
|
1085
|
+
await timing.sleep(POLL_MS);
|
|
1086
|
+
}
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function currentReadyAuthority(
|
|
1091
|
+
broker: Broker,
|
|
1092
|
+
id: string,
|
|
1093
|
+
root: string,
|
|
1094
|
+
expected: EffectMarker,
|
|
1095
|
+
): Promise<ReadyAuthority | undefined> {
|
|
1096
|
+
if (!(await hasOwnedReadinessEvidence(broker, root, id, expected))) return undefined;
|
|
1097
|
+
const endpointPath = path.join(root, "sdk", `${id}.json`);
|
|
1098
|
+
try {
|
|
1099
|
+
const [endpointSource, endpointMetadata] = await Promise.all([
|
|
1100
|
+
fs.readFile(endpointPath, "utf8"),
|
|
1101
|
+
fs.stat(endpointPath),
|
|
1102
|
+
]);
|
|
1103
|
+
const endpoint = JSON.parse(endpointSource) as {
|
|
1104
|
+
sessionId?: unknown;
|
|
1105
|
+
url?: unknown;
|
|
1106
|
+
token?: unknown;
|
|
1107
|
+
pid?: unknown;
|
|
1108
|
+
};
|
|
1109
|
+
await broker.index.refresh();
|
|
1110
|
+
const record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
|
|
1111
|
+
if (
|
|
1112
|
+
!record?.live ||
|
|
1113
|
+
record.pid !== expected.pid ||
|
|
1114
|
+
resolveEquivalentPath(record.locator.stateRoot) !== resolveEquivalentPath(root) ||
|
|
1115
|
+
record.endpointMtimeMs !== endpointMetadata.mtimeMs ||
|
|
1116
|
+
endpoint.pid !== expected.pid ||
|
|
1117
|
+
endpoint.sessionId !== id ||
|
|
1118
|
+
typeof endpoint.url !== "string" ||
|
|
1119
|
+
typeof endpoint.token !== "string"
|
|
1120
|
+
)
|
|
1121
|
+
return undefined;
|
|
1122
|
+
return {
|
|
1123
|
+
endpoint: endpoint as Record<string, unknown>,
|
|
1124
|
+
endpointSource,
|
|
1125
|
+
endpointMtimeMs: endpointMetadata.mtimeMs,
|
|
1126
|
+
endpointGeneration: record.endpointGeneration,
|
|
1127
|
+
};
|
|
1128
|
+
} catch {
|
|
1129
|
+
return undefined;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function sameReadyAuthority(left: ReadyAuthority, right: ReadyAuthority): boolean {
|
|
1134
|
+
return (
|
|
1135
|
+
left.endpointSource === right.endpointSource &&
|
|
1136
|
+
left.endpointMtimeMs === right.endpointMtimeMs &&
|
|
1137
|
+
left.endpointGeneration === right.endpointGeneration
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function waitForReady(
|
|
1142
|
+
broker: Broker,
|
|
1143
|
+
id: string,
|
|
1144
|
+
root: string,
|
|
1145
|
+
deadline: number,
|
|
1146
|
+
expected: EffectMarker,
|
|
1147
|
+
timing: LifecycleTiming,
|
|
1148
|
+
): Promise<ReadinessResult> {
|
|
1149
|
+
while (timing.now() < deadline) {
|
|
1150
|
+
const startupFailure = await readSessionLifecycleFailure(root, id, expected);
|
|
1151
|
+
if (startupFailure) return { kind: "startup_failed", message: startupFailure.message };
|
|
1152
|
+
if (
|
|
1153
|
+
observeProcess(expected.pid, expected.incarnation, value => processIncarnationForBroker(broker, value)) ===
|
|
1154
|
+
"exited"
|
|
1155
|
+
) {
|
|
1156
|
+
const finalStartupFailure = await readSessionLifecycleFailure(root, id, expected);
|
|
1157
|
+
return finalStartupFailure
|
|
1158
|
+
? { kind: "startup_failed", message: finalStartupFailure.message }
|
|
1159
|
+
: { kind: "child_exited" };
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
const authority = await currentReadyAuthority(broker, id, root, expected);
|
|
1163
|
+
if (!authority) {
|
|
1164
|
+
const remaining = deadline - timing.now();
|
|
1165
|
+
if (remaining > 0) await timing.sleep(Math.min(POLL_MS, remaining));
|
|
1166
|
+
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const connectionTimeoutMs = Math.min(2_000, deadline - timing.now());
|
|
1170
|
+
|
|
1171
|
+
if (connectionTimeoutMs <= 0) break;
|
|
1172
|
+
const endpoint = authority.endpoint as { url: string; token: string };
|
|
1173
|
+
const client = await SdkClient.connect(endpoint.url, endpoint.token, {
|
|
1174
|
+
timeoutMs: connectionTimeoutMs,
|
|
1175
|
+
deadline,
|
|
1176
|
+
reconnectAttempts: 0,
|
|
1177
|
+
});
|
|
1178
|
+
try {
|
|
1179
|
+
const requestTimeoutMs = Math.min(2_000, deadline - timing.now());
|
|
1180
|
+
|
|
1181
|
+
if (requestTimeoutMs <= 0) break;
|
|
1182
|
+
const replay = await client.request(
|
|
1183
|
+
{
|
|
1184
|
+
type: "event_replay",
|
|
1185
|
+
sinceGeneration: authority.endpointGeneration,
|
|
1186
|
+
sinceSeq: 0,
|
|
1187
|
+
},
|
|
1188
|
+
{ timeoutMs: requestTimeoutMs },
|
|
1189
|
+
);
|
|
1190
|
+
const events = (replay.events as unknown[]) ?? [];
|
|
1191
|
+
if (
|
|
1192
|
+
events.some(event => {
|
|
1193
|
+
const frame = event as Record<string, unknown>;
|
|
1194
|
+
return (
|
|
1195
|
+
frame.type === "event" &&
|
|
1196
|
+
frame.name === "session_ready" &&
|
|
1197
|
+
frame.sessionId === id &&
|
|
1198
|
+
frame.generation === authority.endpointGeneration
|
|
1199
|
+
);
|
|
1200
|
+
})
|
|
1201
|
+
) {
|
|
1202
|
+
const current = await currentReadyAuthority(broker, id, root, expected);
|
|
1203
|
+
if (current && sameReadyAuthority(authority, current)) return { kind: "ready", authority: current };
|
|
1204
|
+
}
|
|
1205
|
+
} finally {
|
|
1206
|
+
await client.close();
|
|
1207
|
+
}
|
|
1208
|
+
} catch {
|
|
1209
|
+
// A partially initialized or unauthenticated endpoint is not ready yet.
|
|
1210
|
+
}
|
|
1211
|
+
const remaining = deadline - timing.now();
|
|
1212
|
+
if (remaining > 0) await timing.sleep(Math.min(POLL_MS, remaining));
|
|
1213
|
+
}
|
|
1214
|
+
return { kind: "timeout" };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function worktreeIntent(plan: GjcLaunchWorktreePlan | undefined): LifecycleWorktreeIntent | undefined {
|
|
1218
|
+
if (!plan) return undefined;
|
|
1219
|
+
return {
|
|
1220
|
+
repoRoot: path.resolve(plan.repoRoot),
|
|
1221
|
+
worktreePath: path.resolve(plan.worktreePath),
|
|
1222
|
+
detached: plan.detached,
|
|
1223
|
+
baseRef: plan.baseRef,
|
|
1224
|
+
...(plan.branchName ? { branchName: plan.branchName } : {}),
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function preparePlannedWorktree(plan: GjcLaunchWorktreePlan): SessionLifecycleWorktreeReceipt {
|
|
1229
|
+
const prepared = ensureLaunchWorktree(plan);
|
|
1230
|
+
if (!prepared.enabled || path.resolve(prepared.worktreePath) !== path.resolve(plan.worktreePath))
|
|
1231
|
+
throw new Error("Lifecycle worktree preparation did not preserve the durable worktree identity.");
|
|
1232
|
+
return {
|
|
1233
|
+
enabled: true,
|
|
1234
|
+
cwd: path.resolve(prepared.worktreePath),
|
|
1235
|
+
created: prepared.created,
|
|
1236
|
+
reused: prepared.reused,
|
|
1237
|
+
...(prepared.branchName ? { branch: prepared.branchName } : {}),
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
async function launchInput(
|
|
1241
|
+
broker: Broker,
|
|
1242
|
+
operation: "session.create" | "session.fork" | "session.resume",
|
|
1243
|
+
input: Input,
|
|
1244
|
+
): Promise<SessionLaunch | BrokerResponse> {
|
|
1245
|
+
const requestedCwd = lifecycleCwd(input);
|
|
1246
|
+
if (!requestedCwd) return fail("invalid_input", "A target path is required.");
|
|
1247
|
+
const sourceCwd = requestedCwd;
|
|
1248
|
+
const suppliedRoot = stateRoot(input, requestedCwd);
|
|
1249
|
+
if (!suppliedRoot || !hasDefaultStateRoot(requestedCwd, suppliedRoot))
|
|
1250
|
+
return fail("invalid_input", "stateRoot must be the default .gjc/state for cwd.");
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
if (!(await fs.stat(sourceCwd)).isDirectory())
|
|
1254
|
+
return fail("invalid_input", "Lifecycle worktree must be a directory.");
|
|
1255
|
+
} catch {
|
|
1256
|
+
return fail("invalid_input", "Lifecycle worktree does not exist.");
|
|
1257
|
+
}
|
|
1258
|
+
const worktree = lifecycleWorktreeTarget(input);
|
|
1259
|
+
if (worktree === null || (worktree !== undefined && requestedCwd === undefined))
|
|
1260
|
+
return fail("invalid_input", "Lifecycle worktree target is invalid.");
|
|
1261
|
+
let cwd = sourceCwd;
|
|
1262
|
+
let worktreePlan: GjcLaunchWorktreePlan | undefined;
|
|
1263
|
+
if (worktree) {
|
|
1264
|
+
try {
|
|
1265
|
+
const planned = planLaunchWorktree(
|
|
1266
|
+
sourceCwd,
|
|
1267
|
+
worktree.name
|
|
1268
|
+
? { enabled: true, detached: false, name: worktree.name }
|
|
1269
|
+
: { enabled: true, detached: true, name: null },
|
|
1270
|
+
);
|
|
1271
|
+
if (!planned.enabled) return fail("invalid_input", "Lifecycle worktree target is invalid.");
|
|
1272
|
+
worktreePlan = planned;
|
|
1273
|
+
cwd = path.resolve(planned.worktreePath);
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
return fail(
|
|
1276
|
+
"invalid_input",
|
|
1277
|
+
`Unable to plan lifecycle worktree: ${error instanceof Error ? error.message : String(error)}`,
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
const resolvedRoot = defaultStateRoot(cwd);
|
|
1282
|
+
|
|
1283
|
+
const requested = sessionId(input);
|
|
1284
|
+
if (requested !== undefined && !isCanonicalSessionId(requested))
|
|
1285
|
+
return fail("invalid_input", "sessionId must be a canonical safe identifier.");
|
|
1286
|
+
const modelPreset = text(input.modelPreset);
|
|
1287
|
+
|
|
1288
|
+
if (operation === "session.create")
|
|
1289
|
+
return { id: randomUUID(), cwd, root: resolvedRoot, modelPreset, worktree, worktreePlan };
|
|
1290
|
+
if (operation === "session.resume") {
|
|
1291
|
+
if (!requested) return fail("invalid_input", "sessionId is required to resume a saved session.");
|
|
1292
|
+
const savedPath = text(input.sessionPath);
|
|
1293
|
+
if (!savedPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
|
|
1294
|
+
const saved = validateSavedTranscript(broker, cwd, savedPath, requested, "Saved");
|
|
1295
|
+
if ("ok" in saved) return saved;
|
|
1296
|
+
return {
|
|
1297
|
+
id: requested,
|
|
1298
|
+
cwd,
|
|
1299
|
+
root: resolvedRoot,
|
|
1300
|
+
sessionPath: saved.path,
|
|
1301
|
+
sessionIdentity: saved.identity,
|
|
1302
|
+
modelPreset,
|
|
1303
|
+
worktree,
|
|
1304
|
+
worktreePlan,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
const sourceSessionId = text(input.sourceSessionId) ?? text(input.sourceId);
|
|
1308
|
+
if (sourceSessionId !== undefined && !isCanonicalSessionId(sourceSessionId))
|
|
1309
|
+
return fail("invalid_input", "sourceSessionId must be a canonical safe identifier.");
|
|
1310
|
+
const sourceSessionPath = text(input.sourceSessionPath) ?? text(input.sourcePath) ?? text(input.sessionPath);
|
|
1311
|
+
if (!sourceSessionId && !sourceSessionPath)
|
|
1312
|
+
return fail("invalid_input", "sourceSessionId or sourceSessionPath is required to fork a session.");
|
|
1313
|
+
const source = validateSavedTranscript(broker, sourceCwd, sourceSessionPath, sourceSessionId, "Source");
|
|
1314
|
+
if ("ok" in source) return source;
|
|
1315
|
+
return {
|
|
1316
|
+
id: randomUUID(),
|
|
1317
|
+
cwd,
|
|
1318
|
+
root: resolvedRoot,
|
|
1319
|
+
sourceSessionId: source.id,
|
|
1320
|
+
sourceSessionPath: source.path,
|
|
1321
|
+
sourceSessionIdentity: source.identity,
|
|
1322
|
+
sourceCwd,
|
|
1323
|
+
modelPreset,
|
|
1324
|
+
worktree,
|
|
1325
|
+
worktreePlan,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function within(root: string, candidate: string): boolean {
|
|
1330
|
+
const relative = path.relative(root, candidate);
|
|
1331
|
+
return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
|
1332
|
+
}
|
|
1333
|
+
type ValidatedDelete = {
|
|
1334
|
+
storage: FileSessionStorage;
|
|
1335
|
+
target: VerifiedSessionDeleteTarget;
|
|
1336
|
+
metadataRoot: string;
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
async function validateDeletePath(
|
|
1340
|
+
broker: Broker,
|
|
1341
|
+
input: Input,
|
|
1342
|
+
id: string,
|
|
1343
|
+
record: { locator: { repo: string; stateRoot: string } } | undefined,
|
|
1344
|
+
): Promise<ValidatedDelete | BrokerResponse> {
|
|
1345
|
+
const sessionPath = text(input.sessionPath);
|
|
1346
|
+
const cwd = lifecycleCwd(input);
|
|
1347
|
+
if (!sessionPath || !cwd)
|
|
1348
|
+
return fail("invalid_input", "session.delete requires sessionPath and its configured cwd.");
|
|
1349
|
+
const requestedRoot = stateRoot(input, cwd);
|
|
1350
|
+
if (!requestedRoot || !hasDefaultStateRoot(cwd, requestedRoot))
|
|
1351
|
+
return fail("invalid_input", "stateRoot must be the default .gjc/state for cwd.");
|
|
1352
|
+
if (
|
|
1353
|
+
record &&
|
|
1354
|
+
(path.resolve(record.locator.repo) !== cwd || path.resolve(record.locator.stateRoot) !== requestedRoot)
|
|
1355
|
+
)
|
|
1356
|
+
return fail("invalid_input", "session.delete locator does not match the indexed session.");
|
|
1357
|
+
|
|
1358
|
+
const unresolved = path.resolve(sessionPath);
|
|
1359
|
+
const storageRoot = await fs
|
|
1360
|
+
.realpath(getSessionsDir(broker.settings.agentDir))
|
|
1361
|
+
.catch(() => path.resolve(getSessionsDir(broker.settings.agentDir)));
|
|
1362
|
+
const canonicalParent = await fs.realpath(path.dirname(unresolved)).catch(() => path.dirname(unresolved));
|
|
1363
|
+
const candidate = path.join(canonicalParent, path.basename(unresolved));
|
|
1364
|
+
if (!candidate.endsWith(".jsonl") || !within(storageRoot, candidate))
|
|
1365
|
+
return fail("invalid_input", "session.delete path is outside the configured session storage root.");
|
|
1366
|
+
try {
|
|
1367
|
+
if ((await fs.lstat(candidate)).isSymbolicLink())
|
|
1368
|
+
return fail("invalid_input", "session.delete path is a symlink.");
|
|
1369
|
+
} catch {
|
|
1370
|
+
return fail("not_found", "Requested saved session does not exist.");
|
|
1371
|
+
}
|
|
1372
|
+
let resolved: string;
|
|
1373
|
+
try {
|
|
1374
|
+
resolved = await fs.realpath(candidate);
|
|
1375
|
+
} catch {
|
|
1376
|
+
return fail("not_found", "Requested saved session does not exist.");
|
|
1377
|
+
}
|
|
1378
|
+
if (!within(storageRoot, resolved))
|
|
1379
|
+
return fail("invalid_input", "session.delete path resolves outside the configured session storage root.");
|
|
1380
|
+
|
|
1381
|
+
const storage = new FileSessionStorage();
|
|
1382
|
+
let snapshot: ReturnType<FileSessionStorage["readSnapshotSync"]>;
|
|
1383
|
+
try {
|
|
1384
|
+
snapshot = storage.readSnapshotSync(resolved);
|
|
1385
|
+
} catch {
|
|
1386
|
+
return fail("not_found", "Requested saved session does not exist or cannot be read.");
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
const newline = snapshot.bytes.indexOf(0x0a);
|
|
1390
|
+
const firstLine = Buffer.from(
|
|
1391
|
+
snapshot.bytes.subarray(0, newline === -1 ? snapshot.bytes.length : newline),
|
|
1392
|
+
).toString("utf8");
|
|
1393
|
+
const header = JSON.parse(firstLine) as { type?: unknown; id?: unknown; cwd?: unknown };
|
|
1394
|
+
if (header.type !== "session" || header.id !== id)
|
|
1395
|
+
return fail("invalid_input", "session.delete path does not contain the requested session.");
|
|
1396
|
+
if (typeof header.cwd !== "string")
|
|
1397
|
+
return fail("invalid_input", "session.delete transcript cwd does not match the configured cwd.");
|
|
1398
|
+
const transcriptCwd = header.cwd;
|
|
1399
|
+
const headerCwd = await fs.realpath(transcriptCwd).catch(() => path.resolve(transcriptCwd));
|
|
1400
|
+
const requestedCwd = await fs.realpath(cwd).catch(() => cwd);
|
|
1401
|
+
if (headerCwd !== requestedCwd)
|
|
1402
|
+
return fail("invalid_input", "session.delete transcript cwd does not match the configured cwd.");
|
|
1403
|
+
} catch {
|
|
1404
|
+
return fail("invalid_input", "Requested saved session has an invalid header.");
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
storage,
|
|
1408
|
+
target: {
|
|
1409
|
+
sessionsRoot: storageRoot,
|
|
1410
|
+
transcriptPath: resolved,
|
|
1411
|
+
sessionId: id,
|
|
1412
|
+
cwd,
|
|
1413
|
+
transcriptIdentity: { dev: snapshot.stat.dev, ino: snapshot.stat.ino },
|
|
1414
|
+
},
|
|
1415
|
+
metadataRoot: requestedRoot,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
type CloseAuthority = { endpointGeneration: number; endpointIncarnation: string };
|
|
1419
|
+
type CloseRecord = {
|
|
1420
|
+
locator: { repo: string; stateRoot: string };
|
|
1421
|
+
endpointGeneration: number;
|
|
1422
|
+
pid: number;
|
|
1423
|
+
endpointMtimeMs?: number;
|
|
1424
|
+
lifecycleRequestId?: string;
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
function endpointIncarnation(record: CloseRecord, sessionId: string): string | undefined {
|
|
1428
|
+
if (
|
|
1429
|
+
!Number.isSafeInteger(record.endpointGeneration) ||
|
|
1430
|
+
record.endpointGeneration <= 0 ||
|
|
1431
|
+
!Number.isSafeInteger(record.pid) ||
|
|
1432
|
+
record.pid <= 0 ||
|
|
1433
|
+
typeof record.endpointMtimeMs !== "number" ||
|
|
1434
|
+
!Number.isFinite(record.endpointMtimeMs) ||
|
|
1435
|
+
record.endpointMtimeMs <= 0
|
|
1436
|
+
)
|
|
1437
|
+
return undefined;
|
|
1438
|
+
return createHash("sha256")
|
|
1439
|
+
.update(
|
|
1440
|
+
JSON.stringify({
|
|
1441
|
+
endpointGeneration: record.endpointGeneration,
|
|
1442
|
+
endpointMtimeMs: record.endpointMtimeMs,
|
|
1443
|
+
pid: record.pid,
|
|
1444
|
+
sessionId,
|
|
1445
|
+
}),
|
|
1446
|
+
)
|
|
1447
|
+
.digest("hex");
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function requestedCloseAuthority(input: Input): { authority: CloseAuthority | undefined } | { error: BrokerResponse } {
|
|
1451
|
+
const endpointGeneration = input.endpointGeneration;
|
|
1452
|
+
const endpointIncarnation = input.endpointIncarnation;
|
|
1453
|
+
if (endpointGeneration === undefined && endpointIncarnation === undefined) return { authority: undefined };
|
|
1454
|
+
if (
|
|
1455
|
+
typeof endpointGeneration !== "number" ||
|
|
1456
|
+
!Number.isSafeInteger(endpointGeneration) ||
|
|
1457
|
+
endpointGeneration <= 0 ||
|
|
1458
|
+
typeof endpointIncarnation !== "string" ||
|
|
1459
|
+
!/^[a-f0-9]{64}$/.test(endpointIncarnation)
|
|
1460
|
+
)
|
|
1461
|
+
return {
|
|
1462
|
+
error: fail("invalid_input", "session.close endpoint authority is invalid"),
|
|
1463
|
+
};
|
|
1464
|
+
return { authority: { endpointGeneration, endpointIncarnation } };
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function sameCloseAuthority(authority: CloseAuthority, record: CloseRecord, sessionId: string): boolean {
|
|
1468
|
+
return (
|
|
1469
|
+
authority.endpointGeneration === record.endpointGeneration &&
|
|
1470
|
+
authority.endpointIncarnation === endpointIncarnation(record, sessionId)
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function sameCloseProcessIdentity(expected: CloseRecord, current: CloseRecord & { live: boolean }): boolean {
|
|
1475
|
+
return (
|
|
1476
|
+
current.live &&
|
|
1477
|
+
current.pid === expected.pid &&
|
|
1478
|
+
typeof expected.lifecycleRequestId === "string" &&
|
|
1479
|
+
expected.lifecycleRequestId.length > 0 &&
|
|
1480
|
+
current.lifecycleRequestId === expected.lifecycleRequestId &&
|
|
1481
|
+
path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
|
|
1482
|
+
path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function sameCloseGeneration(expected: CloseRecord, current: CloseRecord & { live: boolean }): boolean {
|
|
1487
|
+
return (
|
|
1488
|
+
current.live &&
|
|
1489
|
+
current.endpointGeneration === expected.endpointGeneration &&
|
|
1490
|
+
current.pid === expected.pid &&
|
|
1491
|
+
current.endpointMtimeMs === expected.endpointMtimeMs &&
|
|
1492
|
+
path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
|
|
1493
|
+
path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function revalidateCloseGeneration(
|
|
1498
|
+
broker: Broker,
|
|
1499
|
+
id: string,
|
|
1500
|
+
expected: CloseRecord,
|
|
1501
|
+
authority: CloseAuthority | undefined,
|
|
1502
|
+
): Promise<BrokerResponse | undefined> {
|
|
1503
|
+
await broker.index.refresh();
|
|
1504
|
+
const current = broker.index.listSessions().sessions.find(session => session.sessionId === id);
|
|
1505
|
+
return current &&
|
|
1506
|
+
sameCloseGeneration(expected, current) &&
|
|
1507
|
+
(!authority || sameCloseAuthority(authority, current, id))
|
|
1508
|
+
? undefined
|
|
1509
|
+
: fail("endpoint_stale", "session endpoint is stale");
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function isTransportFailure(error: unknown): error is SdkClientError {
|
|
1513
|
+
return (
|
|
1514
|
+
error instanceof SdkClientError &&
|
|
1515
|
+
["unavailable", "timeout", "connection_closed", "reconnect_exhausted"].includes(error.code)
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function closeEndpoint(endpoint: unknown): { url: string; token: string } | undefined {
|
|
1520
|
+
if (typeof endpoint !== "object" || endpoint === null) return undefined;
|
|
1521
|
+
const value = endpoint as { url?: unknown; token?: unknown };
|
|
1522
|
+
return typeof value.url === "string" && typeof value.token === "string"
|
|
1523
|
+
? { url: value.url, token: value.token }
|
|
1524
|
+
: undefined;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** Executes broker-owned global lifecycle effects. */
|
|
1528
|
+
async function executeLifecycleResponse(
|
|
1529
|
+
broker: Broker,
|
|
1530
|
+
operation: string,
|
|
1531
|
+
input: Input,
|
|
1532
|
+
identity: string,
|
|
1533
|
+
): Promise<BrokerResponse> {
|
|
1534
|
+
const requestedSessionId = sessionId(input);
|
|
1535
|
+
if (requestedSessionId !== undefined && !isCanonicalSessionId(requestedSessionId))
|
|
1536
|
+
return fail("invalid_input", "sessionId must be a canonical safe identifier.");
|
|
1537
|
+
const requestedSourceSessionId = text(input.sourceSessionId) ?? text(input.sourceId);
|
|
1538
|
+
if (requestedSourceSessionId !== undefined && !isCanonicalSessionId(requestedSourceSessionId))
|
|
1539
|
+
return fail("invalid_input", "sourceSessionId must be a canonical safe identifier.");
|
|
1540
|
+
if (operation === "session.create" || operation === "session.fork" || operation === "session.resume") {
|
|
1541
|
+
await broker.index.refresh();
|
|
1542
|
+
if (operation === "session.resume") {
|
|
1543
|
+
const requestedSessionId = sessionId(input);
|
|
1544
|
+
const existing = requestedSessionId
|
|
1545
|
+
? broker.index.listSessions().sessions.find(session => session.sessionId === requestedSessionId)
|
|
1546
|
+
: undefined;
|
|
1547
|
+
if (existing?.live) {
|
|
1548
|
+
const initialScope = await validateLiveResumeScope(broker, input, requestedSessionId!, existing);
|
|
1549
|
+
if ("ok" in initialScope) return initialScope;
|
|
1550
|
+
const initialIncarnation = endpointIncarnation(existing, requestedSessionId!);
|
|
1551
|
+
if (!initialIncarnation)
|
|
1552
|
+
return fail("live_session", "Session is already live but its endpoint incarnation is unavailable.");
|
|
1553
|
+
const endpoint = await broker.handleRequest("session.get_endpoint", {
|
|
1554
|
+
sessionId: requestedSessionId,
|
|
1555
|
+
endpointGeneration: existing.endpointGeneration,
|
|
1556
|
+
endpointIncarnation: initialIncarnation,
|
|
1557
|
+
});
|
|
1558
|
+
if (!endpoint.ok)
|
|
1559
|
+
return fail(
|
|
1560
|
+
"live_session",
|
|
1561
|
+
"Session is already live but its incarnation-bound endpoint is unavailable.",
|
|
1562
|
+
);
|
|
1563
|
+
await broker.index.refresh();
|
|
1564
|
+
const current = broker.index
|
|
1565
|
+
.listSessions()
|
|
1566
|
+
.sessions.find(session => session.sessionId === requestedSessionId);
|
|
1567
|
+
if (!current || !sameLiveResumeRecord(existing, current))
|
|
1568
|
+
return fail("endpoint_stale", "Live session changed while its resume authority was being verified.");
|
|
1569
|
+
const finalScope = await validateLiveResumeScope(broker, input, requestedSessionId!, current);
|
|
1570
|
+
if ("ok" in finalScope) return finalScope;
|
|
1571
|
+
if (!sameResumeSessionIdentity(initialScope, finalScope))
|
|
1572
|
+
return fail("endpoint_stale", "Saved session changed while its resume authority was being verified.");
|
|
1573
|
+
return {
|
|
1574
|
+
ok: true,
|
|
1575
|
+
result: {
|
|
1576
|
+
sessionId: requestedSessionId,
|
|
1577
|
+
cwd: finalScope.cwd,
|
|
1578
|
+
endpointGeneration: current.endpointGeneration,
|
|
1579
|
+
endpoint: endpoint.result,
|
|
1580
|
+
reused: true,
|
|
1581
|
+
},
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
const timing = lifecycleTiming(broker);
|
|
1586
|
+
|
|
1587
|
+
const deadlines = lifecycleDeadlines(input, timing.now());
|
|
1588
|
+
|
|
1589
|
+
if ("ok" in deadlines) return deadlines;
|
|
1590
|
+
const lifecycleDeadline = deadlines.lifecycleCleanupDeadlineAt;
|
|
1591
|
+
const readinessDeadline = deadlines.semanticReadyDeadlineAt;
|
|
1592
|
+
const terminationStartDeadline = deadlines.terminationStartDeadlineAt;
|
|
1593
|
+
|
|
1594
|
+
const launch = await launchInput(broker, operation, input);
|
|
1595
|
+
if ("ok" in launch) return launch;
|
|
1596
|
+
if (!hasProcessIncarnationAuthority())
|
|
1597
|
+
return fail(
|
|
1598
|
+
"incarnation_unavailable",
|
|
1599
|
+
"OS process incarnation authority is unavailable; refusing to spawn a lifecycle session.",
|
|
1600
|
+
);
|
|
1601
|
+
const effectMarker = randomUUID();
|
|
1602
|
+
const plannedWorktreeIntent = worktreeIntent(launch.worktreePlan);
|
|
1603
|
+
const effectIntent: LifecycleEffectIntent = {
|
|
1604
|
+
sessionId: launch.id,
|
|
1605
|
+
stateRoot: launch.root,
|
|
1606
|
+
childOwnershipEstablished: false,
|
|
1607
|
+
...(plannedWorktreeIntent ? { worktree: plannedWorktreeIntent } : {}),
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
1611
|
+
intendedSessionId: launch.id,
|
|
1612
|
+
effectMarker,
|
|
1613
|
+
effectIntent,
|
|
1614
|
+
});
|
|
1615
|
+
if (!hasProcessIncarnationAuthority())
|
|
1616
|
+
return fail(
|
|
1617
|
+
"incarnation_unavailable",
|
|
1618
|
+
"OS process incarnation authority is unavailable; refusing to prepare a lifecycle worktree.",
|
|
1619
|
+
);
|
|
1620
|
+
let worktreeReceipt: SessionLifecycleWorktreeReceipt | undefined;
|
|
1621
|
+
try {
|
|
1622
|
+
if (launch.worktreePlan) {
|
|
1623
|
+
worktreeReceipt = preparePlannedWorktree(launch.worktreePlan);
|
|
1624
|
+
const worktree = {
|
|
1625
|
+
cwdDigest: createHash("sha256").update(worktreeReceipt.cwd, "utf8").digest("hex"),
|
|
1626
|
+
created: worktreeReceipt.created,
|
|
1627
|
+
reused: worktreeReceipt.reused,
|
|
1628
|
+
...(worktreeReceipt.branch
|
|
1629
|
+
? { branchDigest: createHash("sha256").update(worktreeReceipt.branch, "utf8").digest("hex") }
|
|
1630
|
+
: {}),
|
|
1631
|
+
};
|
|
1632
|
+
const durableEffects: LifecycleDurableEffectsReceipt = {
|
|
1633
|
+
worktree,
|
|
1634
|
+
digest: createHash("sha256").update(canonicalJson({ worktree })).digest("hex"),
|
|
1635
|
+
};
|
|
1636
|
+
await broker.ledger.transition(identity, "effect_started", { durableEffects });
|
|
1637
|
+
ensureReusableNodeModules(launch.worktreePlan.repoRoot, launch.worktreePlan.worktreePath);
|
|
1638
|
+
}
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
return fail(
|
|
1641
|
+
"spawn_failed",
|
|
1642
|
+
`Unable to prepare lifecycle worktree: ${error instanceof Error ? error.message : String(error)}`,
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
if (timing.now() >= readinessDeadline)
|
|
1646
|
+
return fail(
|
|
1647
|
+
"readiness_timeout",
|
|
1648
|
+
"Lifecycle preparation exhausted the semantic readiness deadline before spawning.",
|
|
1649
|
+
);
|
|
1650
|
+
if (!hasProcessIncarnationAuthority())
|
|
1651
|
+
return fail(
|
|
1652
|
+
"incarnation_unavailable",
|
|
1653
|
+
"OS process incarnation authority is unavailable; refusing to spawn a lifecycle session.",
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1656
|
+
const request: SessionLifecycleLaunchRequest = {
|
|
1657
|
+
operation,
|
|
1658
|
+
sessionId: launch.id,
|
|
1659
|
+
cwd: launch.cwd,
|
|
1660
|
+
stateRoot: launch.root,
|
|
1661
|
+
effectMarker,
|
|
1662
|
+
receivedAt: deadlines.receivedAt,
|
|
1663
|
+
requestedReadinessTimeoutMs: deadlines.requestedReadinessTimeoutMs,
|
|
1664
|
+
semanticReadyDeadlineAt: deadlines.semanticReadyDeadlineAt,
|
|
1665
|
+
terminationStartDeadlineAt: deadlines.terminationStartDeadlineAt,
|
|
1666
|
+
lifecycleCleanupDeadlineAt: deadlines.lifecycleCleanupDeadlineAt,
|
|
1667
|
+
...(launch.sourceSessionId ? { sourceSessionId: launch.sourceSessionId } : {}),
|
|
1668
|
+
...(launch.sourceSessionPath ? { sourceSessionPath: launch.sourceSessionPath } : {}),
|
|
1669
|
+
...(launch.sourceSessionIdentity ? { sourceSessionIdentity: launch.sourceSessionIdentity } : {}),
|
|
1670
|
+
...(launch.sourceCwd ? { sourceCwd: launch.sourceCwd } : {}),
|
|
1671
|
+
...(launch.sessionPath ? { sessionPath: launch.sessionPath } : {}),
|
|
1672
|
+
...(launch.sessionIdentity ? { sessionIdentity: launch.sessionIdentity } : {}),
|
|
1673
|
+
...(launch.modelPreset ? { modelPreset: launch.modelPreset } : {}),
|
|
1674
|
+
...(launch.worktree ? { worktree: launch.worktree } : {}),
|
|
1675
|
+
};
|
|
1676
|
+
let child: ChildProcess | undefined;
|
|
1677
|
+
let spawnedAuthority: EffectMarker | undefined;
|
|
1678
|
+
try {
|
|
1679
|
+
const cmd = command(broker);
|
|
1680
|
+
const spawned = spawn(cmd.file, cmd.args, {
|
|
1681
|
+
cwd: launch.cwd,
|
|
1682
|
+
detached: true,
|
|
1683
|
+
stdio: "ignore",
|
|
1684
|
+
env: {
|
|
1685
|
+
...("kind" in cmd ? cmd.env : process.env),
|
|
1686
|
+
GJC_AGENT_DIR: broker.settings.agentDir,
|
|
1687
|
+
GJC_CODING_AGENT_DIR: broker.settings.agentDir,
|
|
1688
|
+
GJC_SESSION_ID: launch.id,
|
|
1689
|
+
GJC_STATE_ROOT: launch.root,
|
|
1690
|
+
GJC_LIFECYCLE_REQUEST_ID: effectMarker,
|
|
1691
|
+
GJC_SDK_LIFECYCLE_REQUEST: JSON.stringify(request),
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
child = spawned;
|
|
1695
|
+
const pid = spawned.pid;
|
|
1696
|
+
if (!pid) throw new Error("spawned session has no pid");
|
|
1697
|
+
const incarnation = processIncarnationForBroker(broker, pid);
|
|
1698
|
+
if (!incarnation) throw new Error("spawned session has no readable OS incarnation");
|
|
1699
|
+
spawnedAuthority = { pid, effectMarker, incarnation };
|
|
1700
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
1701
|
+
effectIntent: { ...effectIntent, childOwnershipEstablished: true },
|
|
1702
|
+
});
|
|
1703
|
+
await writeEffectMarker(launch.root, launch.id, spawnedAuthority);
|
|
1704
|
+
spawned.unref();
|
|
1705
|
+
} catch (error) {
|
|
1706
|
+
const terminated = child
|
|
1707
|
+
? await terminateSpawnedChild(
|
|
1708
|
+
child,
|
|
1709
|
+
broker,
|
|
1710
|
+
launch.id,
|
|
1711
|
+
launch.root,
|
|
1712
|
+
lifecycleDeadline,
|
|
1713
|
+
terminationStartDeadline,
|
|
1714
|
+
spawnedAuthority,
|
|
1715
|
+
timing,
|
|
1716
|
+
)
|
|
1717
|
+
: true;
|
|
1718
|
+
|
|
1719
|
+
return terminated
|
|
1720
|
+
? fail("spawn_failed", `Unable to spawn session: ${error instanceof Error ? error.message : String(error)}`)
|
|
1721
|
+
: fail(
|
|
1722
|
+
"terminal_uncertain",
|
|
1723
|
+
`Unable to establish spawned-session ownership and could not prove the child dead: ${error instanceof Error ? error.message : String(error)}`,
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
if (!child || !spawnedAuthority)
|
|
1727
|
+
return fail("spawn_failed", "Unable to retain the spawned session process identity.");
|
|
1728
|
+
await broker.ledger.transition(identity, "awaiting_ready", { intendedSessionId: launch.id, effectMarker });
|
|
1729
|
+
const readiness = await waitForReady(broker, launch.id, launch.root, readinessDeadline, spawnedAuthority, timing);
|
|
1730
|
+
|
|
1731
|
+
if (readiness.kind !== "ready") {
|
|
1732
|
+
const terminated = await terminateSpawnedChild(
|
|
1733
|
+
child,
|
|
1734
|
+
broker,
|
|
1735
|
+
launch.id,
|
|
1736
|
+
launch.root,
|
|
1737
|
+
lifecycleDeadline,
|
|
1738
|
+
terminationStartDeadline,
|
|
1739
|
+
spawnedAuthority,
|
|
1740
|
+
timing,
|
|
1741
|
+
);
|
|
1742
|
+
|
|
1743
|
+
if (!terminated)
|
|
1744
|
+
return fail(
|
|
1745
|
+
"terminal_uncertain",
|
|
1746
|
+
`Session ${launch.id} did not become ready and its spawned process could not be verified dead.`,
|
|
1747
|
+
);
|
|
1748
|
+
return readiness.kind === "startup_failed"
|
|
1749
|
+
? fail("spawn_failed", readiness.message)
|
|
1750
|
+
: readiness.kind === "child_exited"
|
|
1751
|
+
? fail("spawn_failed", `Session ${launch.id} exited before registering readiness.`)
|
|
1752
|
+
: fail(
|
|
1753
|
+
"readiness_timeout",
|
|
1754
|
+
`Session ${launch.id} did not register an endpoint before the readiness timeout.`,
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
await reconcileReadyScope(broker, launch.id, launch.cwd);
|
|
1758
|
+
const verified = await currentReadyAuthority(broker, launch.id, launch.root, spawnedAuthority);
|
|
1759
|
+
if (!verified || !sameReadyAuthority(readiness.authority, verified)) {
|
|
1760
|
+
const terminated = await terminateSpawnedChild(
|
|
1761
|
+
child,
|
|
1762
|
+
broker,
|
|
1763
|
+
launch.id,
|
|
1764
|
+
launch.root,
|
|
1765
|
+
lifecycleDeadline,
|
|
1766
|
+
terminationStartDeadline,
|
|
1767
|
+
spawnedAuthority,
|
|
1768
|
+
timing,
|
|
1769
|
+
);
|
|
1770
|
+
return terminated
|
|
1771
|
+
? fail("endpoint_stale", "Session endpoint changed while lifecycle readiness was being verified.")
|
|
1772
|
+
: fail(
|
|
1773
|
+
"terminal_uncertain",
|
|
1774
|
+
"Session readiness authority changed and its spawned process could not be verified dead.",
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
return {
|
|
1778
|
+
ok: true,
|
|
1779
|
+
result: {
|
|
1780
|
+
sessionId: launch.id,
|
|
1781
|
+
cwd: launch.cwd,
|
|
1782
|
+
endpoint: verified.endpoint,
|
|
1783
|
+
...(worktreeReceipt ? { worktree: worktreeReceipt } : {}),
|
|
1784
|
+
},
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const id = sessionId(input);
|
|
1789
|
+
if (!id) return fail("invalid_input", "sessionId is required.");
|
|
1790
|
+
if (!isCanonicalSessionId(id)) return fail("invalid_input", "sessionId must be a canonical safe identifier.");
|
|
1791
|
+
await broker.index.refresh();
|
|
1792
|
+
let record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
|
|
1793
|
+
if (operation === "session.close") {
|
|
1794
|
+
if (!record) return fail("not_found", "session is not indexed");
|
|
1795
|
+
if (record.terminalUncertain)
|
|
1796
|
+
return fail("terminal_uncertain", "Session ownership is uncertain and cannot be closed safely.");
|
|
1797
|
+
const requestedAuthority = requestedCloseAuthority(input);
|
|
1798
|
+
if ("error" in requestedAuthority) return requestedAuthority.error;
|
|
1799
|
+
if (requestedAuthority.authority && !sameCloseAuthority(requestedAuthority.authority, record, id))
|
|
1800
|
+
return fail("endpoint_stale", "session endpoint is stale");
|
|
1801
|
+
await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, effectMarker: randomUUID() });
|
|
1802
|
+
|
|
1803
|
+
let usedSignalFallback = false;
|
|
1804
|
+
let note: string | undefined;
|
|
1805
|
+
let endpointResult = await broker.handleRequest("session.get_endpoint", {
|
|
1806
|
+
sessionId: id,
|
|
1807
|
+
endpointGeneration: record.endpointGeneration,
|
|
1808
|
+
});
|
|
1809
|
+
if (!endpointResult.ok && endpointResult.error.code === "endpoint_stale" && !requestedAuthority.authority) {
|
|
1810
|
+
await broker.index.refresh();
|
|
1811
|
+
const refreshed = broker.index.listSessions().sessions.find(session => session.sessionId === id);
|
|
1812
|
+
if (refreshed && sameCloseProcessIdentity(record, refreshed)) {
|
|
1813
|
+
record = refreshed;
|
|
1814
|
+
endpointResult = await broker.handleRequest("session.get_endpoint", {
|
|
1815
|
+
sessionId: id,
|
|
1816
|
+
endpointGeneration: record.endpointGeneration,
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
if (!endpointResult.ok) {
|
|
1821
|
+
if (endpointResult.error.code === "endpoint_stale") return endpointResult;
|
|
1822
|
+
if (endpointResult.error.code !== "resource_gone") return endpointResult;
|
|
1823
|
+
usedSignalFallback = true;
|
|
1824
|
+
} else {
|
|
1825
|
+
const endpoint = closeEndpoint(endpointResult.result);
|
|
1826
|
+
if (!endpoint) return fail("close_refused", "Session endpoint is malformed.");
|
|
1827
|
+
let client: SdkClient | undefined;
|
|
1828
|
+
try {
|
|
1829
|
+
client = await SdkClient.connect(endpoint.url, endpoint.token, {
|
|
1830
|
+
timeoutMs: 2_000,
|
|
1831
|
+
reconnectAttempts: 0,
|
|
1832
|
+
});
|
|
1833
|
+
const refreshedEndpointResult = await broker.handleRequest("session.get_endpoint", {
|
|
1834
|
+
sessionId: id,
|
|
1835
|
+
endpointGeneration: record.endpointGeneration,
|
|
1836
|
+
});
|
|
1837
|
+
if (!refreshedEndpointResult.ok) return refreshedEndpointResult;
|
|
1838
|
+
const refreshedEndpoint = closeEndpoint(refreshedEndpointResult.result);
|
|
1839
|
+
if (
|
|
1840
|
+
!refreshedEndpoint ||
|
|
1841
|
+
refreshedEndpoint.url !== endpoint.url ||
|
|
1842
|
+
refreshedEndpoint.token !== endpoint.token
|
|
1843
|
+
)
|
|
1844
|
+
return fail("endpoint_stale", "session endpoint is stale");
|
|
1845
|
+
const stale = await revalidateCloseGeneration(broker, id, record, requestedAuthority.authority);
|
|
1846
|
+
if (stale) return stale;
|
|
1847
|
+
const response = await client.control("session.close");
|
|
1848
|
+
if ((response as { ok?: unknown }).ok !== true)
|
|
1849
|
+
return fail("close_refused", "Session endpoint rejected session.close.");
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
if (isTransportFailure(error)) usedSignalFallback = true;
|
|
1852
|
+
else if (error instanceof SdkClientError) return fail(error.code, error.message);
|
|
1853
|
+
else
|
|
1854
|
+
return fail(
|
|
1855
|
+
"close_refused",
|
|
1856
|
+
`Session endpoint close failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1857
|
+
);
|
|
1858
|
+
} finally {
|
|
1859
|
+
await client?.close();
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
if (usedSignalFallback) {
|
|
1864
|
+
const stale = await revalidateCloseGeneration(broker, id, record, requestedAuthority.authority);
|
|
1865
|
+
if (stale) return stale;
|
|
1866
|
+
if (!(await signalVerifiedSession(record, id, "SIGTERM")))
|
|
1867
|
+
return fail(
|
|
1868
|
+
"close_refused",
|
|
1869
|
+
"Session endpoint is unavailable and its durable process identity could not be verified.",
|
|
1870
|
+
);
|
|
1871
|
+
note = "Endpoint close was unreachable; sent SIGTERM to the durably identified session process.";
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
let closed = await waitForClose(broker, id, record, CLOSE_TIMEOUT_MS);
|
|
1875
|
+
if (!closed && !usedSignalFallback) {
|
|
1876
|
+
const stale = await revalidateCloseGeneration(broker, id, record, requestedAuthority.authority);
|
|
1877
|
+
if (stale) return stale;
|
|
1878
|
+
if (!(await signalVerifiedSession(record, id, "SIGTERM"))) {
|
|
1879
|
+
await recordTerminalUncertain(broker, id, record.locator.stateRoot, record.pid);
|
|
1880
|
+
return fail(
|
|
1881
|
+
"terminal_uncertain",
|
|
1882
|
+
"Session acknowledged session.close but its durable process identity could not be verified for shutdown escalation.",
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
note =
|
|
1886
|
+
"Session acknowledged session.close but graceful teardown did not complete within the bounded deadline; sent SIGTERM to the durably identified session process.";
|
|
1887
|
+
closed = await waitForClose(broker, id, record, CLOSE_TIMEOUT_MS);
|
|
1888
|
+
}
|
|
1889
|
+
if (!closed) {
|
|
1890
|
+
const stale = await revalidateCloseGeneration(broker, id, record, requestedAuthority.authority);
|
|
1891
|
+
if (stale) return stale;
|
|
1892
|
+
if (!(await signalVerifiedSession(record, id, "SIGKILL"))) {
|
|
1893
|
+
await recordTerminalUncertain(broker, id, record.locator.stateRoot, record.pid);
|
|
1894
|
+
return fail(
|
|
1895
|
+
"terminal_uncertain",
|
|
1896
|
+
"Session did not close after SIGTERM and its durable process identity could not be verified for SIGKILL.",
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
note =
|
|
1900
|
+
"Session teardown did not complete after SIGTERM within the bounded deadline; sent SIGKILL to the durably identified session process.";
|
|
1901
|
+
closed = await waitForClose(broker, id, record, CLOSE_TIMEOUT_MS);
|
|
1902
|
+
}
|
|
1903
|
+
if (!closed) {
|
|
1904
|
+
await recordTerminalUncertain(broker, id, record.locator.stateRoot, record.pid);
|
|
1905
|
+
return fail(
|
|
1906
|
+
"terminal_uncertain",
|
|
1907
|
+
"Session did not unregister, remove its endpoint, and exit after bounded shutdown escalation.",
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
return { ok: true, result: { sessionId: id, ...(note ? { note } : {}) } };
|
|
1912
|
+
}
|
|
1913
|
+
if (operation === "session.delete") {
|
|
1914
|
+
if (record?.terminalUncertain)
|
|
1915
|
+
return fail("terminal_uncertain", "Session ownership is uncertain and cannot be deleted safely.");
|
|
1916
|
+
if (record?.live) return fail("live_session", "Refusing to delete a live session; close it first.");
|
|
1917
|
+
const validated = await validateDeletePath(broker, input, id, record);
|
|
1918
|
+
if ("ok" in validated) return validated;
|
|
1919
|
+
await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, effectMarker: randomUUID() });
|
|
1920
|
+
let deleted: Awaited<ReturnType<FileSessionStorage["deleteSessionVerified"]>>;
|
|
1921
|
+
try {
|
|
1922
|
+
deleted = await validated.storage.deleteSessionVerified(validated.target);
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
if (error instanceof SessionDeleteVerificationError)
|
|
1925
|
+
return fail(
|
|
1926
|
+
"invalid_input",
|
|
1927
|
+
`Saved session deletion verification failed (${error.kind}): ${error.message}`,
|
|
1928
|
+
);
|
|
1929
|
+
return fail(
|
|
1930
|
+
"unavailable",
|
|
1931
|
+
`Unable to delete saved session artifacts: ${error instanceof Error ? error.message : String(error)}`,
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
if (deleted.kind === "cleanup_pending")
|
|
1935
|
+
return fail(
|
|
1936
|
+
"cleanup_pending",
|
|
1937
|
+
`Saved session cleanup is pending in ${deleted.phase}: ${deleted.error.message}`,
|
|
1938
|
+
{
|
|
1939
|
+
phase: deleted.phase,
|
|
1940
|
+
transcriptIdentity: {
|
|
1941
|
+
dev: deleted.transcriptIdentity.dev.toString(),
|
|
1942
|
+
ino: deleted.transcriptIdentity.ino.toString(),
|
|
1943
|
+
},
|
|
1944
|
+
...(deleted.phase === "artifacts" && deleted.artifactsIdentity
|
|
1945
|
+
? {
|
|
1946
|
+
artifactsIdentity: {
|
|
1947
|
+
dev: deleted.artifactsIdentity.dev.toString(),
|
|
1948
|
+
ino: deleted.artifactsIdentity.ino.toString(),
|
|
1949
|
+
},
|
|
1950
|
+
}
|
|
1951
|
+
: {}),
|
|
1952
|
+
},
|
|
1953
|
+
);
|
|
1954
|
+
|
|
1955
|
+
if (record)
|
|
1956
|
+
await broker.index.append({
|
|
1957
|
+
type: "session_closed",
|
|
1958
|
+
sessionId: id,
|
|
1959
|
+
locator: record.locator,
|
|
1960
|
+
endpointGeneration: record.endpointGeneration,
|
|
1961
|
+
pid: record.pid,
|
|
1962
|
+
});
|
|
1963
|
+
try {
|
|
1964
|
+
await fs.rm(lifecycleMarkerPath(validated.metadataRoot, id), { force: true });
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
return fail(
|
|
1967
|
+
"cleanup_pending",
|
|
1968
|
+
`Saved session was deleted but lifecycle metadata cleanup is pending: ${
|
|
1969
|
+
error instanceof Error ? error.message : String(error)
|
|
1970
|
+
}`,
|
|
1971
|
+
{ phase: "metadata" },
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
return { ok: true, result: { sessionId: id } };
|
|
1975
|
+
}
|
|
1976
|
+
return fail("invalid_input", "Unknown lifecycle operation.");
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
async function exactCleanupProof(
|
|
1980
|
+
broker: Broker,
|
|
1981
|
+
root: string | undefined,
|
|
1982
|
+
id: string | undefined,
|
|
1983
|
+
expected: EffectMarker | undefined,
|
|
1984
|
+
evidence: { artifact: LifecycleFailureArtifact } | undefined,
|
|
1985
|
+
): Promise<LifecycleCleanupProof | undefined> {
|
|
1986
|
+
const rollback = evidence?.artifact.rollback;
|
|
1987
|
+
if (
|
|
1988
|
+
!root ||
|
|
1989
|
+
!id ||
|
|
1990
|
+
!expected ||
|
|
1991
|
+
!rollback?.fenced ||
|
|
1992
|
+
!rollback.runtimeRemoved ||
|
|
1993
|
+
!rollback.hostStopped ||
|
|
1994
|
+
!rollback.brokerRegistrationReleased ||
|
|
1995
|
+
observeProcess(expected.pid, expected.incarnation, value => processIncarnationForBroker(broker, value)) !==
|
|
1996
|
+
"exited" ||
|
|
1997
|
+
!(await endpointRemoved(root, id))
|
|
1998
|
+
)
|
|
1999
|
+
return undefined;
|
|
2000
|
+
await broker.index.refresh();
|
|
2001
|
+
if (rollback.endpointGeneration === null) {
|
|
2002
|
+
if (broker.index.hasHostRegistrationForLifecycle(id, expected.pid, expected.effectMarker)) return undefined;
|
|
2003
|
+
return {
|
|
2004
|
+
processExited: true,
|
|
2005
|
+
endpointRemoved: true,
|
|
2006
|
+
hostUnregistered: { state: "not_registered" },
|
|
2007
|
+
rollback: {
|
|
2008
|
+
endpointGeneration: null,
|
|
2009
|
+
fenced: true,
|
|
2010
|
+
runtimeRemoved: true,
|
|
2011
|
+
hostStopped: true,
|
|
2012
|
+
brokerRegistrationReleased: true,
|
|
2013
|
+
},
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
const registration = broker.index.findHostRegistration(
|
|
2017
|
+
id,
|
|
2018
|
+
rollback.endpointGeneration,
|
|
2019
|
+
expected.pid,
|
|
2020
|
+
expected.effectMarker,
|
|
2021
|
+
);
|
|
2022
|
+
const hostUnregistered = registration ? broker.index.hostUnregisteredAfter(registration) : undefined;
|
|
2023
|
+
return hostUnregistered
|
|
2024
|
+
? {
|
|
2025
|
+
processExited: true,
|
|
2026
|
+
endpointRemoved: true,
|
|
2027
|
+
hostUnregistered: { state: "unregistered", ...hostUnregistered },
|
|
2028
|
+
rollback: {
|
|
2029
|
+
endpointGeneration: rollback.endpointGeneration,
|
|
2030
|
+
fenced: true,
|
|
2031
|
+
runtimeRemoved: true,
|
|
2032
|
+
hostStopped: true,
|
|
2033
|
+
brokerRegistrationReleased: true,
|
|
2034
|
+
},
|
|
2035
|
+
}
|
|
2036
|
+
: undefined;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
export interface LifecycleExecutionOutcome {
|
|
2040
|
+
response: BrokerResponse;
|
|
2041
|
+
durableEffects?: LifecycleDurableEffectsReceipt;
|
|
2042
|
+
startupFailure?: LifecycleStartupFailureReceipt;
|
|
2043
|
+
deferredArtifactCleanup?: () => Promise<void>;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/** Returns the response together with every durable lifecycle fact needed for truthful replay. */
|
|
2047
|
+
export async function executeLifecycle(
|
|
2048
|
+
broker: Broker,
|
|
2049
|
+
operation: string,
|
|
2050
|
+
input: Input,
|
|
2051
|
+
identity: string,
|
|
2052
|
+
): Promise<LifecycleExecutionOutcome> {
|
|
2053
|
+
const response = await executeLifecycleResponse(broker, operation, input, identity);
|
|
2054
|
+
const entry = broker.ledger.get(identity);
|
|
2055
|
+
const priorDurableEffects = entry?.durableEffects;
|
|
2056
|
+
const evidenceCwd = entry?.effectIntent?.worktree?.worktreePath ?? lifecycleCwd(input);
|
|
2057
|
+
const root = entry?.effectIntent?.stateRoot ?? stateRoot(input, evidenceCwd);
|
|
2058
|
+
const marker =
|
|
2059
|
+
entry?.effectMarker && entry.intendedSessionId && root
|
|
2060
|
+
? await readEffectMarker(lifecycleMarkerPath(root, entry.intendedSessionId))
|
|
2061
|
+
: undefined;
|
|
2062
|
+
const expected = marker && marker.effectMarker === entry?.effectMarker ? marker : undefined;
|
|
2063
|
+
const evidence =
|
|
2064
|
+
root && entry?.intendedSessionId && expected
|
|
2065
|
+
? await readLifecycleFailureArtifact(
|
|
2066
|
+
lifecycleFailurePath(root, entry.intendedSessionId, expected.effectMarker),
|
|
2067
|
+
expected,
|
|
2068
|
+
)
|
|
2069
|
+
: undefined;
|
|
2070
|
+
const cleanupProof = await exactCleanupProof(broker, root, entry?.intendedSessionId, expected, evidence);
|
|
2071
|
+
const startupFailure: LifecycleStartupFailureReceipt | undefined = evidence
|
|
2072
|
+
? {
|
|
2073
|
+
artifactDigest: evidence.digest,
|
|
2074
|
+
phase: evidence.artifact.phase,
|
|
2075
|
+
reason: evidence.artifact.reason,
|
|
2076
|
+
message: evidence.artifact.message,
|
|
2077
|
+
rollback: {
|
|
2078
|
+
endpointGeneration: evidence.artifact.rollback.endpointGeneration,
|
|
2079
|
+
fenced: evidence.artifact.rollback.fenced,
|
|
2080
|
+
runtimeRemoved: evidence.artifact.rollback.runtimeRemoved,
|
|
2081
|
+
hostStopped: evidence.artifact.rollback.hostStopped,
|
|
2082
|
+
brokerRegistrationReleased: evidence.artifact.rollback.brokerRegistrationReleased,
|
|
2083
|
+
},
|
|
2084
|
+
...(cleanupProof ? { cleanupProof } : {}),
|
|
2085
|
+
}
|
|
2086
|
+
: undefined;
|
|
2087
|
+
const durableEffectsBody: Omit<LifecycleDurableEffectsReceipt, "digest"> = {
|
|
2088
|
+
...(priorDurableEffects?.worktree ? { worktree: priorDurableEffects.worktree } : {}),
|
|
2089
|
+
...(evidence?.artifact.transcript
|
|
2090
|
+
? {
|
|
2091
|
+
transcript: {
|
|
2092
|
+
identityDigest: createHash("sha256")
|
|
2093
|
+
.update(canonicalJson(evidence.artifact.transcript.identity))
|
|
2094
|
+
.digest("hex"),
|
|
2095
|
+
contentDigest: evidence.artifact.transcript.digest,
|
|
2096
|
+
},
|
|
2097
|
+
}
|
|
2098
|
+
: {}),
|
|
2099
|
+
...(startupFailure ? { startup: startupFailure } : {}),
|
|
2100
|
+
};
|
|
2101
|
+
const durableEffects =
|
|
2102
|
+
Object.keys(durableEffectsBody).length > 0
|
|
2103
|
+
? {
|
|
2104
|
+
...durableEffectsBody,
|
|
2105
|
+
digest: createHash("sha256").update(canonicalJson(durableEffectsBody)).digest("hex"),
|
|
2106
|
+
}
|
|
2107
|
+
: undefined;
|
|
2108
|
+
const terminalResponse: BrokerResponse =
|
|
2109
|
+
!response.ok &&
|
|
2110
|
+
entry?.effectMarker &&
|
|
2111
|
+
(operation === "session.create" || operation === "session.fork" || operation === "session.resume")
|
|
2112
|
+
? entry.effectIntent?.childOwnershipEstablished === false
|
|
2113
|
+
? response
|
|
2114
|
+
: startupFailure && cleanupProof
|
|
2115
|
+
? {
|
|
2116
|
+
ok: false,
|
|
2117
|
+
error: {
|
|
2118
|
+
code: "spawn_failed",
|
|
2119
|
+
message: "No ready SDK endpoint remains available.",
|
|
2120
|
+
endpoint: "unavailable",
|
|
2121
|
+
},
|
|
2122
|
+
...(durableEffects ? { durableEffects } : {}),
|
|
2123
|
+
startupFailure,
|
|
2124
|
+
}
|
|
2125
|
+
: {
|
|
2126
|
+
ok: false,
|
|
2127
|
+
error: {
|
|
2128
|
+
code: "terminal_uncertain",
|
|
2129
|
+
message:
|
|
2130
|
+
"Lifecycle startup cleanup could not be proven; retained artifacts require reconciliation.",
|
|
2131
|
+
},
|
|
2132
|
+
...(durableEffects ? { durableEffects } : {}),
|
|
2133
|
+
...(startupFailure ? { startupFailure } : {}),
|
|
2134
|
+
}
|
|
2135
|
+
: response;
|
|
2136
|
+
return {
|
|
2137
|
+
response: terminalResponse,
|
|
2138
|
+
...(durableEffects ? { durableEffects } : {}),
|
|
2139
|
+
...(startupFailure ? { startupFailure } : {}),
|
|
2140
|
+
...(evidence && root && entry?.intendedSessionId && cleanupProof
|
|
2141
|
+
? {
|
|
2142
|
+
deferredArtifactCleanup: async () => {
|
|
2143
|
+
const current = await readLifecycleFailureArtifact(
|
|
2144
|
+
lifecycleFailurePath(root, entry.intendedSessionId!, evidence.artifact.effectMarker),
|
|
2145
|
+
evidence.artifact,
|
|
2146
|
+
);
|
|
2147
|
+
const marker = await readEffectMarker(lifecycleMarkerPath(root, entry.intendedSessionId!));
|
|
2148
|
+
if (current?.digest !== evidence.digest || !marker || !sameEffectMarker(marker, evidence.artifact))
|
|
2149
|
+
return;
|
|
2150
|
+
await fs.rm(lifecycleFailurePath(root, entry.intendedSessionId!, evidence.artifact.effectMarker));
|
|
2151
|
+
await fs.rm(lifecycleMarkerPath(root, entry.intendedSessionId!));
|
|
2152
|
+
await syncDirectory(path.join(root, "sdk"));
|
|
2153
|
+
},
|
|
2154
|
+
}
|
|
2155
|
+
: {}),
|
|
2156
|
+
};
|
|
2157
|
+
}
|