botmux 3.10.0 → 3.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/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/herdr-backend.d.ts +4 -3
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +11 -8
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/pty-backend.d.ts +1 -1
- package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
- package/dist/adapters/backend/pty-backend.js +4 -1
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/riff-backend.d.ts +29 -5
- package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
- package/dist/adapters/backend/riff-backend.js +302 -31
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/sandbox.d.ts +6 -0
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +7 -0
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +4 -1
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +37 -3
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +9 -3
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.js +12 -7
- package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +11 -2
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +35 -2
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +88 -7
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/runner-input.d.ts +15 -14
- package/dist/adapters/cli/runner-input.d.ts.map +1 -1
- package/dist/adapters/cli/runner-input.js +64 -26
- package/dist/adapters/cli/runner-input.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +20 -5
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +24 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +110 -5
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/fleet-shutdown.d.ts +47 -0
- package/dist/cli/fleet-shutdown.d.ts.map +1 -0
- package/dist/cli/fleet-shutdown.js +387 -0
- package/dist/cli/fleet-shutdown.js.map +1 -0
- package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
- package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
- package/dist/cli/pm2-descriptor-guard.js +122 -0
- package/dist/cli/pm2-descriptor-guard.js.map +1 -0
- package/dist/cli/pm2-exact-start.d.ts +16 -0
- package/dist/cli/pm2-exact-start.d.ts.map +1 -0
- package/dist/cli/pm2-exact-start.js +63 -0
- package/dist/cli/pm2-exact-start.js.map +1 -0
- package/dist/cli/pm2-god-admission.d.ts +8 -0
- package/dist/cli/pm2-god-admission.d.ts.map +1 -0
- package/dist/cli/pm2-god-admission.js +25 -0
- package/dist/cli/pm2-god-admission.js.map +1 -0
- package/dist/cli/pm2-jlist.d.ts +13 -0
- package/dist/cli/pm2-jlist.d.ts.map +1 -0
- package/dist/cli/pm2-jlist.js +108 -0
- package/dist/cli/pm2-jlist.js.map +1 -0
- package/dist/cli/pm2-preflight.d.ts +10 -0
- package/dist/cli/pm2-preflight.d.ts.map +1 -0
- package/dist/cli/pm2-preflight.js +23 -0
- package/dist/cli/pm2-preflight.js.map +1 -0
- package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
- package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
- package/dist/cli/pm2-shutdown-capability.js +161 -0
- package/dist/cli/pm2-shutdown-capability.js.map +1 -0
- package/dist/cli/pm2-start-transaction.d.ts +55 -0
- package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
- package/dist/cli/pm2-start-transaction.js +220 -0
- package/dist/cli/pm2-start-transaction.js.map +1 -0
- package/dist/cli/send-dispatch.d.ts +12 -0
- package/dist/cli/send-dispatch.d.ts.map +1 -1
- package/dist/cli/send-dispatch.js +31 -4
- package/dist/cli/send-dispatch.js.map +1 -1
- package/dist/cli/session-list-liveness.d.ts +1 -0
- package/dist/cli/session-list-liveness.d.ts.map +1 -1
- package/dist/cli/session-list-liveness.js +6 -0
- package/dist/cli/session-list-liveness.js.map +1 -1
- package/dist/cli/session-picker-layout.d.ts +26 -0
- package/dist/cli/session-picker-layout.d.ts.map +1 -0
- package/dist/cli/session-picker-layout.js +123 -0
- package/dist/cli/session-picker-layout.js.map +1 -0
- package/dist/cli/session-picker-viewport.d.ts +49 -0
- package/dist/cli/session-picker-viewport.d.ts.map +1 -0
- package/dist/cli/session-picker-viewport.js +46 -0
- package/dist/cli/session-picker-viewport.js.map +1 -0
- package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
- package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
- package/dist/cli/supervisor-shutdown-client.js +146 -0
- package/dist/cli/supervisor-shutdown-client.js.map +1 -0
- package/dist/cli/terminal-width.d.ts +48 -0
- package/dist/cli/terminal-width.d.ts.map +1 -0
- package/dist/cli/terminal-width.js +119 -0
- package/dist/cli/terminal-width.js.map +1 -0
- package/dist/cli/vc-agent.d.ts.map +1 -1
- package/dist/cli/vc-agent.js +1 -1
- package/dist/cli/vc-agent.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2269 -716
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +1630 -125
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/core/ask-api.d.ts +8 -1
- package/dist/core/ask-api.d.ts.map +1 -1
- package/dist/core/ask-api.js +18 -0
- package/dist/core/ask-api.js.map +1 -1
- package/dist/core/ask-broker.d.ts +20 -0
- package/dist/core/ask-broker.d.ts.map +1 -1
- package/dist/core/ask-broker.js +467 -47
- package/dist/core/ask-broker.js.map +1 -1
- package/dist/core/ask-persist-store.d.ts +126 -0
- package/dist/core/ask-persist-store.d.ts.map +1 -0
- package/dist/core/ask-persist-store.js +188 -0
- package/dist/core/ask-persist-store.js.map +1 -0
- package/dist/core/ask-types.d.ts +80 -0
- package/dist/core/ask-types.d.ts.map +1 -1
- package/dist/core/ask-types.js +62 -0
- package/dist/core/ask-types.js.map +1 -1
- package/dist/core/cli-session-id-publisher.d.ts +20 -0
- package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
- package/dist/core/cli-session-id-publisher.js +21 -0
- package/dist/core/cli-session-id-publisher.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +427 -255
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +13 -0
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +385 -26
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +9 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +842 -288
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
- package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
- package/dist/core/explicit-session-backing-cleanup.js +76 -0
- package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
- package/dist/core/idle-worker-sweeper.d.ts +20 -0
- package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
- package/dist/core/idle-worker-sweeper.js +18 -0
- package/dist/core/idle-worker-sweeper.js.map +1 -1
- package/dist/core/inflight-input-tracker.d.ts +10 -0
- package/dist/core/inflight-input-tracker.d.ts.map +1 -1
- package/dist/core/inflight-input-tracker.js +7 -0
- package/dist/core/inflight-input-tracker.js.map +1 -1
- package/dist/core/managed-origin-attestation.d.ts +51 -0
- package/dist/core/managed-origin-attestation.d.ts.map +1 -0
- package/dist/core/managed-origin-attestation.js +208 -0
- package/dist/core/managed-origin-attestation.js.map +1 -0
- package/dist/core/managed-origin-capability.d.ts +62 -3
- package/dist/core/managed-origin-capability.d.ts.map +1 -1
- package/dist/core/managed-origin-capability.js +419 -9
- package/dist/core/managed-origin-capability.js.map +1 -1
- package/dist/core/pending-repo-journal.d.ts +6 -0
- package/dist/core/pending-repo-journal.d.ts.map +1 -0
- package/dist/core/pending-repo-journal.js +85 -0
- package/dist/core/pending-repo-journal.js.map +1 -0
- package/dist/core/persistent-backend.d.ts +6 -1
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +12 -6
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/process-start-identity.d.ts +3 -0
- package/dist/core/process-start-identity.d.ts.map +1 -0
- package/dist/core/process-start-identity.js +62 -0
- package/dist/core/process-start-identity.js.map +1 -0
- package/dist/core/raw-command-writer.d.ts +34 -0
- package/dist/core/raw-command-writer.d.ts.map +1 -0
- package/dist/core/raw-command-writer.js +48 -0
- package/dist/core/raw-command-writer.js.map +1 -0
- package/dist/core/reply-target.d.ts +85 -22
- package/dist/core/reply-target.d.ts.map +1 -1
- package/dist/core/reply-target.js +267 -32
- package/dist/core/reply-target.js.map +1 -1
- package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
- package/dist/core/resource-monitor/runtime.js +4 -1
- package/dist/core/resource-monitor/runtime.js.map +1 -1
- package/dist/core/restart-report.d.ts +6 -1
- package/dist/core/restart-report.d.ts.map +1 -1
- package/dist/core/restart-report.js +22 -4
- package/dist/core/restart-report.js.map +1 -1
- package/dist/core/riff-shutdown-detach.d.ts +164 -0
- package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
- package/dist/core/riff-shutdown-detach.js +787 -0
- package/dist/core/riff-shutdown-detach.js.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.js +21 -0
- package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
- package/dist/core/session-manager.d.ts +6 -6
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +1227 -594
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-marker.d.ts +1 -1
- package/dist/core/session-marker.d.ts.map +1 -1
- package/dist/core/session-marker.js +2 -2
- package/dist/core/session-marker.js.map +1 -1
- package/dist/core/session-mutation-guard.d.ts +17 -0
- package/dist/core/session-mutation-guard.d.ts.map +1 -0
- package/dist/core/session-mutation-guard.js +33 -0
- package/dist/core/session-mutation-guard.js.map +1 -0
- package/dist/core/shutdown-budgets.d.ts +27 -0
- package/dist/core/shutdown-budgets.d.ts.map +1 -0
- package/dist/core/shutdown-budgets.js +47 -0
- package/dist/core/shutdown-budgets.js.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.js +10 -0
- package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.js +16 -0
- package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
- package/dist/core/trigger-final-suppression.d.ts +4 -0
- package/dist/core/trigger-final-suppression.d.ts.map +1 -1
- package/dist/core/trigger-final-suppression.js +6 -11
- package/dist/core/trigger-final-suppression.js.map +1 -1
- package/dist/core/trigger-session.d.ts +70 -0
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +923 -145
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +94 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +194 -13
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2597 -547
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +76 -2
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2255 -773
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +20 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +24 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/feed-groups.d.ts +17 -0
- package/dist/dashboard/feed-groups.d.ts.map +1 -0
- package/dist/dashboard/feed-groups.js +75 -0
- package/dist/dashboard/feed-groups.js.map +1 -0
- package/dist/dashboard/session-card-model.d.ts +1 -1
- package/dist/dashboard/session-card-model.d.ts.map +1 -1
- package/dist/dashboard/session-card-model.js +8 -5
- package/dist/dashboard/session-card-model.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +139 -46
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +40 -3
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +95 -2
- package/dist/dashboard/web/groups-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +86 -40
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/kanban-model.js +1 -1
- package/dist/dashboard/web/kanban-model.js.map +1 -1
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js +3 -2
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/overview.d.ts +3 -0
- package/dist/dashboard/web/overview.d.ts.map +1 -1
- package/dist/dashboard/web/overview.js +2 -0
- package/dist/dashboard/web/overview.js.map +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-kanban.js +2 -0
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +103 -2
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +3 -2
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts +8 -0
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +15 -0
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
- package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
- package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
- package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
- package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
- package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
- package/dist/dashboard-web/style.css +255 -0
- package/dist/dashboard.js +172 -7
- package/dist/dashboard.js.map +1 -1
- package/dist/desktop/renderer/app.js +21 -0
- package/dist/desktop/renderer/app.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +35 -9
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +35 -9
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/ask-card.d.ts +23 -0
- package/dist/im/lark/ask-card.d.ts.map +1 -1
- package/dist/im/lark/ask-card.js +94 -4
- package/dist/im/lark/ask-card.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +6 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +112 -15
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +496 -310
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +7 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +39 -26
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/doc-comment.d.ts +7 -4
- package/dist/im/lark/doc-comment.d.ts.map +1 -1
- package/dist/im/lark/doc-comment.js +19 -6
- package/dist/im/lark/doc-comment.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +16 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +88 -23
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
- package/dist/im/lark/forward-followup-content.js +9 -1
- package/dist/im/lark/forward-followup-content.js.map +1 -1
- package/dist/im/lark/grant-command.d.ts.map +1 -1
- package/dist/im/lark/grant-command.js +7 -4
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/md-card.d.ts +11 -0
- package/dist/im/lark/md-card.d.ts.map +1 -1
- package/dist/im/lark/md-card.js +46 -0
- package/dist/im/lark/md-card.js.map +1 -1
- package/dist/im/lark/message-parser.d.ts +23 -2
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +58 -3
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/overview-card.d.ts.map +1 -1
- package/dist/im/lark/overview-card.js +2 -1
- package/dist/im/lark/overview-card.js.map +1 -1
- package/dist/services/async-trigger-state.d.ts +6 -1
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +21 -0
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +23 -1
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +96 -13
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +40 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +124 -24
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/codex-app-runner-protocol.d.ts +8 -0
- package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
- package/dist/services/codex-app-runner-protocol.js +9 -2
- package/dist/services/codex-app-runner-protocol.js.map +1 -1
- package/dist/services/codex-bridge-queue.d.ts +1 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -1
- package/dist/services/codex-bridge-queue.js +1 -0
- package/dist/services/codex-bridge-queue.js.map +1 -1
- package/dist/services/codex-service-tier.d.ts +2 -0
- package/dist/services/codex-service-tier.d.ts.map +1 -1
- package/dist/services/codex-service-tier.js +1 -0
- package/dist/services/codex-service-tier.js.map +1 -1
- package/dist/services/codex-transcript.d.ts +27 -0
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +340 -2
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/grant-policy.d.ts +3 -0
- package/dist/services/grant-policy.d.ts.map +1 -1
- package/dist/services/grant-policy.js +5 -0
- package/dist/services/grant-policy.js.map +1 -1
- package/dist/services/grant-prefs-store.d.ts +6 -3
- package/dist/services/grant-prefs-store.d.ts.map +1 -1
- package/dist/services/grant-prefs-store.js +42 -12
- package/dist/services/grant-prefs-store.js.map +1 -1
- package/dist/services/hook-runner.d.ts +20 -1
- package/dist/services/hook-runner.d.ts.map +1 -1
- package/dist/services/hook-runner.js +17 -13
- package/dist/services/hook-runner.js.map +1 -1
- package/dist/services/idempotency-store.d.ts +134 -0
- package/dist/services/idempotency-store.d.ts.map +1 -0
- package/dist/services/idempotency-store.js +299 -0
- package/dist/services/idempotency-store.js.map +1 -0
- package/dist/services/jsonl-cursor.d.ts +7 -3
- package/dist/services/jsonl-cursor.d.ts.map +1 -1
- package/dist/services/jsonl-cursor.js +55 -31
- package/dist/services/jsonl-cursor.js.map +1 -1
- package/dist/services/open-platform-rename.d.ts.map +1 -1
- package/dist/services/open-platform-rename.js +1 -108
- package/dist/services/open-platform-rename.js.map +1 -1
- package/dist/services/restart-intent-store.d.ts +22 -2
- package/dist/services/restart-intent-store.d.ts.map +1 -1
- package/dist/services/restart-intent-store.js +160 -18
- package/dist/services/restart-intent-store.js.map +1 -1
- package/dist/services/sandbox-store.d.ts +22 -0
- package/dist/services/sandbox-store.d.ts.map +1 -1
- package/dist/services/sandbox-store.js +37 -8
- package/dist/services/sandbox-store.js.map +1 -1
- package/dist/services/send-policy.d.ts +41 -33
- package/dist/services/send-policy.d.ts.map +1 -1
- package/dist/services/send-policy.js +39 -40
- package/dist/services/send-policy.js.map +1 -1
- package/dist/services/session-store.d.ts +66 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +403 -67
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/submit-notification.d.ts +3 -0
- package/dist/services/submit-notification.d.ts.map +1 -0
- package/dist/services/submit-notification.js +15 -0
- package/dist/services/submit-notification.js.map +1 -0
- package/dist/services/traex-transcript.d.ts +25 -2
- package/dist/services/traex-transcript.d.ts.map +1 -1
- package/dist/services/traex-transcript.js +133 -1
- package/dist/services/traex-transcript.js.map +1 -1
- package/dist/services/trigger-types.d.ts +13 -1
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +39 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/services/voice/index.d.ts +3 -2
- package/dist/services/voice/index.d.ts.map +1 -1
- package/dist/services/voice/index.js +5 -5
- package/dist/services/voice/index.js.map +1 -1
- package/dist/services/voice/openai.d.ts +2 -1
- package/dist/services/voice/openai.d.ts.map +1 -1
- package/dist/services/voice/openai.js +2 -1
- package/dist/services/voice/openai.js.map +1 -1
- package/dist/services/voice/sami.d.ts +5 -2
- package/dist/services/voice/sami.d.ts.map +1 -1
- package/dist/services/voice/sami.js +19 -4
- package/dist/services/voice/sami.js.map +1 -1
- package/dist/setup/bots-store.d.ts.map +1 -1
- package/dist/setup/bots-store.js +10 -4
- package/dist/setup/bots-store.js.map +1 -1
- package/dist/setup/lark-scopes.json +2 -0
- package/dist/setup/open-platform-automation.d.ts +9 -1
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +44 -15
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/open-platform-visibility.d.ts +42 -0
- package/dist/setup/open-platform-visibility.d.ts.map +1 -0
- package/dist/setup/open-platform-visibility.js +133 -0
- package/dist/setup/open-platform-visibility.js.map +1 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -1
- package/dist/setup/verify-permissions.js +6 -0
- package/dist/setup/verify-permissions.js.map +1 -1
- package/dist/skills/definitions.js +2 -2
- package/dist/types.d.ts +290 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +1 -0
- package/dist/utils/bot-routing.d.ts.map +1 -1
- package/dist/utils/bot-routing.js +1 -1
- package/dist/utils/bot-routing.js.map +1 -1
- package/dist/utils/child-env.d.ts +1 -1
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +9 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/codex-app-control.d.ts +438 -0
- package/dist/utils/codex-app-control.d.ts.map +1 -0
- package/dist/utils/codex-app-control.js +2336 -0
- package/dist/utils/codex-app-control.js.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.js +149 -0
- package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.js +141 -0
- package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
- package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
- package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-liveness.js +301 -0
- package/dist/utils/codex-app-turn-liveness.js.map +1 -0
- package/dist/utils/idle-detector.d.ts +12 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +19 -0
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/input-gate.d.ts +87 -0
- package/dist/utils/input-gate.d.ts.map +1 -1
- package/dist/utils/input-gate.js +86 -0
- package/dist/utils/input-gate.js.map +1 -1
- package/dist/utils/lark-upload.d.ts.map +1 -1
- package/dist/utils/lark-upload.js +52 -1
- package/dist/utils/lark-upload.js.map +1 -1
- package/dist/utils/pending-input-queue.d.ts +15 -0
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js +5 -1
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/utils/user-token.d.ts.map +1 -1
- package/dist/utils/user-token.js +53 -2
- package/dist/utils/user-token.js.map +1 -1
- package/dist/worker-ipc-preload.d.ts +12 -0
- package/dist/worker-ipc-preload.d.ts.map +1 -0
- package/dist/worker-ipc-preload.js +44 -0
- package/dist/worker-ipc-preload.js.map +1 -0
- package/dist/worker.js +2604 -325
- package/dist/worker.js.map +1 -1
- package/dist/workflows/v3/session-relay-client.d.ts +1 -0
- package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
- package/dist/workflows/v3/session-relay-client.js +12 -3
- package/dist/workflows/v3/session-relay-client.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
- package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
- package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
- package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
- package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
package/dist/core/worker-pool.js
CHANGED
|
@@ -7,7 +7,7 @@ import { join, dirname } from 'node:path';
|
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { readFileSync, readdirSync, mkdirSync, existsSync, realpathSync, unlinkSync } from 'node:fs';
|
|
9
9
|
import { atomicWriteFileSync } from '../utils/atomic-write.js';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
11
|
import { ensureSkills, ensureAskSkill, ensurePluginSkills, ensureWhiteboardSkill, removeGlobalBotmuxSkills } from '../skills/installer.js';
|
|
12
12
|
import { shouldInstallGlobalSkills } from '../skills/injection-mode.js';
|
|
13
13
|
import { whiteboardEnabled } from '../services/whiteboard-store.js';
|
|
@@ -20,12 +20,12 @@ import * as sessionStore from '../services/session-store.js';
|
|
|
20
20
|
import * as asyncTriggerStore from '../services/async-trigger-store.js';
|
|
21
21
|
import { markMessageListenerRunPreviewFailed, markMessageListenerRunPreviewReplied, markMessageListenerRunPreviewRunning, } from '../services/message-listener-run-preview-store.js';
|
|
22
22
|
import { persistStreamCardState, rememberLastCliInput } from './session-manager.js';
|
|
23
|
-
import { fallbackTurnId, isSubstituteTurn } from './reply-target.js';
|
|
23
|
+
import { fallbackTurnId, frozenReplyContextForTurn, isSubstituteTurn } from './reply-target.js';
|
|
24
24
|
import { updateMessage, deleteMessage, sendEphemeralCard, sendUserMessage, addReaction, removeReaction, getMessageChatId, MessageWithdrawnError } from '../im/lark/client.js';
|
|
25
25
|
import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildTuiPromptFailedCard, buildRelayedFrozenCard, getCliDisplayName } from '../im/lark/card-builder.js';
|
|
26
26
|
import { codexServiceTierBadge } from '../services/codex-service-tier.js';
|
|
27
27
|
import { loadFrozenCards, saveFrozenCards } from '../services/frozen-card-store.js';
|
|
28
|
-
import { hashUrlForLog
|
|
28
|
+
import { hashUrlForLog } from '../adapters/backend/riff-backend.js';
|
|
29
29
|
import { logger } from '../utils/logger.js';
|
|
30
30
|
import { createCliAdapterSync } from '../adapters/cli/registry.js';
|
|
31
31
|
import { resolveCliRuntime, runtimeInstallationKey, runtimePathOverride, snapshotCliRuntime, } from '../adapters/cli/runtime.js';
|
|
@@ -44,7 +44,8 @@ import { ZmxBackend } from '../adapters/backend/zmx-backend.js';
|
|
|
44
44
|
import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js';
|
|
45
45
|
import { sandboxEnabled } from '../adapters/backend/sandbox.js';
|
|
46
46
|
import { isStrongManagedHerdrAgentName, managedHerdrAgentName, } from '../adapters/backend/session-backend-selector.js';
|
|
47
|
-
import { isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js';
|
|
47
|
+
import { isRiffBackendSession, isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js';
|
|
48
|
+
import { withBotTurnMutation } from './bot-turn-mutation-gate.js';
|
|
48
49
|
import { getBot, getAllBots, resolveBrandLabel, getLoadedConfigPath, resolveUsageDisplay } from '../bot-registry.js';
|
|
49
50
|
import { RestartCoordinator } from './restart-coordinator.js';
|
|
50
51
|
import { runtimeBuildIdentity } from '../utils/runtime-build-id.js';
|
|
@@ -55,6 +56,11 @@ const DAEMON_BOOT_ID = randomUUID();
|
|
|
55
56
|
const restartCoordinator = new RestartCoordinator();
|
|
56
57
|
const lifecycleRetiringWorkers = new WeakMap();
|
|
57
58
|
const transferRetiringWorkers = new WeakSet();
|
|
59
|
+
/** 在完整 Worker 模块加载前接住首条 IPC,避免冷启动耗时被误判为投递失败。 */
|
|
60
|
+
function workerForkExecArgv() {
|
|
61
|
+
const preloadPath = join(__dirname, '..', 'worker-ipc-preload.js');
|
|
62
|
+
return [...process.execArgv, '--import', pathToFileURL(preloadPath).href];
|
|
63
|
+
}
|
|
58
64
|
function trackLifecycleRetirement(ds, worker) {
|
|
59
65
|
let workers = lifecycleRetiringWorkers.get(ds);
|
|
60
66
|
if (!workers) {
|
|
@@ -163,15 +169,37 @@ export function getDaemonReplyCardUsageSnapshot(ds, effectiveCliId) {
|
|
|
163
169
|
* boundaries (turn end / idle); intra-turn ticks leave it false to ride the
|
|
164
170
|
* reader's reparse throttle and stay off the disk. */
|
|
165
171
|
export function getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, opts) {
|
|
172
|
+
// Runtime identity is derived from in-memory fields only, so it is available
|
|
173
|
+
// without reading the transcript. The disk read (getDaemonSessionUsageSnapshot)
|
|
174
|
+
// is deferred until we know usage will actually render — a footer/off bot must
|
|
175
|
+
// not pay a per-tick transcript parse just to throw the tokens away.
|
|
176
|
+
const runtimeModel = ds.activeModel?.trim() || ds.session.model?.trim();
|
|
177
|
+
const reasoningEffort = ds.activeReasoningEffort?.trim()
|
|
178
|
+
|| ds.session.reasoningEffort?.trim();
|
|
166
179
|
try {
|
|
167
180
|
if (resolveUsageDisplay(ds.larkAppId) !== 'streaming') {
|
|
168
|
-
return {
|
|
181
|
+
return {
|
|
182
|
+
context: null,
|
|
183
|
+
tokens: null,
|
|
184
|
+
...(runtimeModel ? { model: runtimeModel } : {}),
|
|
185
|
+
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
186
|
+
};
|
|
169
187
|
}
|
|
170
188
|
}
|
|
171
189
|
catch {
|
|
172
190
|
// Missing runtime config → default 'streaming' → show usage (best-effort).
|
|
173
191
|
}
|
|
174
|
-
|
|
192
|
+
const snapshot = getDaemonSessionUsageSnapshot(ds, effectiveCliId, { fresh: opts?.fresh ?? false });
|
|
193
|
+
// Model comes only from an explicitly-wired executor runtime (TRAE/Codex set
|
|
194
|
+
// ds.activeModel from their rollout settings) or the user-configured launch
|
|
195
|
+
// model — never snapshot.tokens.model. That field is the RAW transcript model
|
|
196
|
+
// and for relay-style CLIs is an internal routing code (e.g. `ark/relay-code`)
|
|
197
|
+
// that must not surface on a user card.
|
|
198
|
+
return {
|
|
199
|
+
...snapshot,
|
|
200
|
+
...(runtimeModel ? { model: runtimeModel } : {}),
|
|
201
|
+
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
202
|
+
};
|
|
175
203
|
}
|
|
176
204
|
import { normalizeBrand } from '../im/lark/lark-hosts.js';
|
|
177
205
|
import { dashboardEventBus } from './dashboard-events.js';
|
|
@@ -182,7 +210,9 @@ import { emitSessionLifecycleHook, emitSessionStateTransitionHook } from '../ser
|
|
|
182
210
|
import { anchorUsageForDaemonSession, recordOwnershipForDaemonSession, recordUsageForDaemonSession, reconcileUsageForDaemonSession } from '../services/usage-ledger.js';
|
|
183
211
|
import { isStructuredBridgeAdoptCli } from '../services/structured-bridge-clis.js';
|
|
184
212
|
import { ensureGatewayEntry } from './plugins/mcp/gateway-installer.js';
|
|
185
|
-
import {
|
|
213
|
+
import { appendAcceptedCodexAppDispatch, cancelCodexAppDispatch, committedCodexAppSequence, hasUnsettledCodexAppDispatch, prepareCodexAppDispatch, retryPreparedCodexAppDispatch, retainFreshCodexAppGeneration, settleCodexAppDispatch, } from '../utils/codex-app-dispatch-ledger.js';
|
|
214
|
+
import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, isDocNativeSession, larkTransportEnabled, riffRetirementAdmissionPhase, } from './types.js';
|
|
215
|
+
import { hasProtectedSessionMutationOwnership } from './session-mutation-guard.js';
|
|
186
216
|
import { DONE_REACTION_EMOJI_TYPE } from './pending-response.js';
|
|
187
217
|
import { buildTerminalUrl } from './terminal-url.js';
|
|
188
218
|
import { prependBotmuxBin, resolveBotmuxWrapperBinDir } from './botmux-wrapper.js';
|
|
@@ -203,6 +233,9 @@ import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitleP
|
|
|
203
233
|
import { acknowledgeSessionReady } from './session-ready-handshake.js';
|
|
204
234
|
import { recordDispatchInputCommit } from './dispatch.js';
|
|
205
235
|
import { sendWorkerIpc } from './worker-ipc.js';
|
|
236
|
+
import { cleanupExplicitSessionBacking } from './explicit-session-backing-cleanup.js';
|
|
237
|
+
import { RIFF_ADMISSION_RESTORE_TIMEOUT_MS } from './shutdown-budgets.js';
|
|
238
|
+
import { managedOriginCapabilityPath, replaceManagedOriginCapabilityFile, } from './managed-origin-capability.js';
|
|
206
239
|
const __filename = fileURLToPath(import.meta.url);
|
|
207
240
|
const __dirname = dirname(__filename);
|
|
208
241
|
const WORKER_SIGTERM_BACKSTOP_MS = 2_000;
|
|
@@ -273,6 +306,7 @@ function requireCallbacks() {
|
|
|
273
306
|
// daemon registers its Map here at boot. Helpers below return a snapshot or
|
|
274
307
|
// linear-scan by sessionId.
|
|
275
308
|
let activeSessionsRegistry;
|
|
309
|
+
const pendingRiffWorkerCloses = new Map();
|
|
276
310
|
export function setActiveSessionsRegistry(m) {
|
|
277
311
|
activeSessionsRegistry = m;
|
|
278
312
|
}
|
|
@@ -449,6 +483,38 @@ function flushPendingLocalCliOpenReadinessPatch(ds) {
|
|
|
449
483
|
ds.pendingLocalCliButtonRefresh = undefined;
|
|
450
484
|
scheduleLocalCliOpenReadinessPatch(ds);
|
|
451
485
|
}
|
|
486
|
+
/** PATCH the live card when the executor reports a different active runtime.
|
|
487
|
+
* Runtime identity stays attached to the streaming usage line. */
|
|
488
|
+
function scheduleActiveRuntimePatch(ds) {
|
|
489
|
+
if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds) || ds.suppressRecoveryCard) {
|
|
490
|
+
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (ds.streamCardNonce && ds.parkedStreamCardNonce === ds.streamCardNonce) {
|
|
494
|
+
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (ds.streamCardId === CARD_POSTING_SENTINEL) {
|
|
498
|
+
ds.pendingActiveRuntimeCardRefresh = true;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!ds.streamCardId || !workerHasInitialized(ds)) {
|
|
502
|
+
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
506
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
507
|
+
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
508
|
+
const status = ds.usageLimit ? 'limited' : (ds.lastScreenStatus ?? 'starting');
|
|
509
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readableTerminalUrlFor(ds), ds.currentTurnTitle || ds.session.title || sessionCliDisplayName(ds, botCfg), ds.lastScreenContent ?? '', status, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), status === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), sessionRuntimeDisplayName(ds, botCfg), codexServiceTierBadge(effectiveCliId, ds.codexServiceTier));
|
|
510
|
+
scheduleCardPatch(ds, cardJson);
|
|
511
|
+
}
|
|
512
|
+
function flushPendingActiveRuntimePatch(ds) {
|
|
513
|
+
if (!ds.pendingActiveRuntimeCardRefresh)
|
|
514
|
+
return;
|
|
515
|
+
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
516
|
+
scheduleActiveRuntimePatch(ds);
|
|
517
|
+
}
|
|
452
518
|
/** PATCH a live card when rollout settings change, even if the PTY is static. */
|
|
453
519
|
function scheduleCodexServiceTierPatch(ds) {
|
|
454
520
|
if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds) || ds.suppressRecoveryCard) {
|
|
@@ -977,6 +1043,7 @@ export async function postFreshStreamingCard(ds, sessionReply) {
|
|
|
977
1043
|
recallFrozenCards(ds);
|
|
978
1044
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
979
1045
|
flushPendingRiffUrlPatch(ds);
|
|
1046
|
+
flushPendingActiveRuntimePatch(ds);
|
|
980
1047
|
flushPendingCodexServiceTierPatch(ds);
|
|
981
1048
|
// Manual /card during a working turn lands a live card whose subsequent
|
|
982
1049
|
// screen_updates are working→working (no status edge) — arm the periodic
|
|
@@ -991,6 +1058,7 @@ export async function postFreshStreamingCard(ds, sessionReply) {
|
|
|
991
1058
|
ds.streamCardPending = prevPending;
|
|
992
1059
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
993
1060
|
flushPendingRiffUrlPatch(ds);
|
|
1061
|
+
flushPendingActiveRuntimePatch(ds);
|
|
994
1062
|
flushPendingCodexServiceTierPatch(ds);
|
|
995
1063
|
// Rolled back to the prior card identity — re-sync so a restored still-live
|
|
996
1064
|
// working card keeps (or resumes) its refresh rather than losing the timer.
|
|
@@ -1335,6 +1403,31 @@ function flushCardPatch(ds) {
|
|
|
1335
1403
|
}
|
|
1336
1404
|
});
|
|
1337
1405
|
}
|
|
1406
|
+
/** Root withdrawal is not an explicit abandon boundary. Preserve a Codex App
|
|
1407
|
+
* owner (including its live worker) while any durable FIFO entry is unsettled;
|
|
1408
|
+
* only ledger-empty sessions retain the historical stale-root auto-close. */
|
|
1409
|
+
async function closeWithdrawnSessionIfLedgerEmpty(ds, context) {
|
|
1410
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
1411
|
+
logger.warn(`[${tag(ds)}] ${context}; preserving session because Codex App dispatch is unsettled`);
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
logger.warn(`[${tag(ds)}] ${context}; closing ledger-empty stale session`);
|
|
1415
|
+
// The callback routes through the authoritative async closeSession helper;
|
|
1416
|
+
// never pre-kill a worker before that helper settles its backend boundary.
|
|
1417
|
+
try {
|
|
1418
|
+
const closed = await requireCallbacks().closeSession(ds);
|
|
1419
|
+
if (closed === false) {
|
|
1420
|
+
logger.warn(`[${tag(ds)}] ${context}; authoritative close failed, session retained for retry`);
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
catch (err) {
|
|
1426
|
+
logger.warn(`[${tag(ds)}] ${context}; authoritative close threw, session retained for retry: `
|
|
1427
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1428
|
+
return false;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1338
1431
|
// ─── Restart rate-limiting ──────────────────────────────────────────────────
|
|
1339
1432
|
export const restartCounts = new Map();
|
|
1340
1433
|
// ─── Skills installation ────────────────────────────────────────────────────
|
|
@@ -1519,7 +1612,18 @@ export function ensureClaudeFolderTrust(workingDir, stateJsonPath = join(homedir
|
|
|
1519
1612
|
}
|
|
1520
1613
|
}
|
|
1521
1614
|
// ─── Kill worker ────────────────────────────────────────────────────────────
|
|
1522
|
-
export function killWorker(ds) {
|
|
1615
|
+
export function killWorker(ds, opts = {}) {
|
|
1616
|
+
const closeFrozenType = ds.initConfig?.backendType ?? ds.session.backendType;
|
|
1617
|
+
if (ds.worker && !ds.worker.killed
|
|
1618
|
+
&& closeFrozenType === 'riff'
|
|
1619
|
+
&& !opts.riffCloseCommitRequestId) {
|
|
1620
|
+
// A generic synchronous retirement cannot safely detach Riff. An accepted
|
|
1621
|
+
// create/follow-up may still be waiting for the task id that becomes the
|
|
1622
|
+
// only durable lineage anchor.
|
|
1623
|
+
logger.error(`[${tag(ds)}] Refused unprepared live Riff worker retirement; `
|
|
1624
|
+
+ 'preserving worker and remote-task lineage');
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1523
1627
|
restartCoordinator.cancelSession(ds.session.sessionId);
|
|
1524
1628
|
clearUsageLimitState(ds);
|
|
1525
1629
|
ds.workerReady = false;
|
|
@@ -1543,12 +1647,28 @@ export function killWorker(ds) {
|
|
|
1543
1647
|
// session would leave an orphaned CLI running in tmux that still replies.
|
|
1544
1648
|
// Destroy the backing session directly here so /close always terminates it.
|
|
1545
1649
|
destroyOrphanedBackingSession(ds);
|
|
1650
|
+
if (opts.riffCloseCommitRequestId
|
|
1651
|
+
&& ds.riffCloseState?.requestId === opts.riffCloseCommitRequestId) {
|
|
1652
|
+
ds.riffCloseState = undefined;
|
|
1653
|
+
}
|
|
1546
1654
|
return;
|
|
1547
1655
|
}
|
|
1548
1656
|
try {
|
|
1549
|
-
|
|
1657
|
+
if (opts.riffCloseCommitRequestId) {
|
|
1658
|
+
ds.worker.send({
|
|
1659
|
+
type: 'close_commit',
|
|
1660
|
+
requestId: opts.riffCloseCommitRequestId,
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
else {
|
|
1664
|
+
ds.worker.send({ type: 'close' });
|
|
1665
|
+
}
|
|
1550
1666
|
}
|
|
1551
1667
|
catch { /* IPC already closed */ }
|
|
1668
|
+
if (opts.riffCloseCommitRequestId
|
|
1669
|
+
&& ds.riffCloseState?.requestId === opts.riffCloseCommitRequestId) {
|
|
1670
|
+
ds.riffCloseState = undefined;
|
|
1671
|
+
}
|
|
1552
1672
|
const w = ds.worker;
|
|
1553
1673
|
trackLifecycleRetirement(ds, w);
|
|
1554
1674
|
armCloseFence(ds, w);
|
|
@@ -1556,7 +1676,6 @@ export function killWorker(ds) {
|
|
|
1556
1676
|
// 外层 race 8s)。默认 2s SIGTERM backstop 会在取消发出前掐死进程,已关闭话题
|
|
1557
1677
|
// 的远端任务照跑——冻结为 riff 的会话放宽到 24s(层级:destroy 20s < worker 22s
|
|
1558
1678
|
// < SIGTERM 24s < SIGKILL 29s;正常路径 worker 自行 exit,不会等满)。
|
|
1559
|
-
const closeFrozenType = ds.initConfig?.backendType ?? ds.session.backendType;
|
|
1560
1679
|
armWorkerKillBackstop(w, tag(ds), closeFrozenType === 'riff' ? 24_000 : WORKER_SIGTERM_BACKSTOP_MS);
|
|
1561
1680
|
ds.worker = null;
|
|
1562
1681
|
ds.workerPort = null;
|
|
@@ -1840,24 +1959,15 @@ function destroyOrphanedBackingSession(ds) {
|
|
|
1840
1959
|
if (ds.initConfig?.adoptMode || ds.adoptedFrom)
|
|
1841
1960
|
return;
|
|
1842
1961
|
reclaimParkedCrashDiagnostic(ds);
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1962
|
+
// Riff cancellation is asynchronous and cannot be made safe from this
|
|
1963
|
+
// synchronous best-effort helper. The authoritative closeSession path awaits
|
|
1964
|
+
// cancellation before publishing the closed row and retains lineage on
|
|
1965
|
+
// failure.
|
|
1845
1966
|
const frozenType = ds.initConfig?.backendType ?? ds.session.backendType;
|
|
1846
1967
|
if (frozenType === 'riff') {
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
const riffCfg = getBot(ds.larkAppId).config.riff;
|
|
1851
|
-
if (riffCfg?.baseUrl) {
|
|
1852
|
-
void cancelRiffTaskById(riffCfg, taskId).then((ok) => {
|
|
1853
|
-
if (ok)
|
|
1854
|
-
logger.info(`[${tag(ds)}] killWorker: orphan riff task ${taskId} cancelled`);
|
|
1855
|
-
});
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
catch { /* bot deregistered — nothing to cancel with */ }
|
|
1859
|
-
ds.session.riffParentTaskId = undefined;
|
|
1860
|
-
sessionStore.updateSession(ds.session);
|
|
1968
|
+
if (ds.session.riffParentTaskId) {
|
|
1969
|
+
logger.warn(`[${tag(ds)}] worker-less Riff teardown requires awaited explicit close; `
|
|
1970
|
+
+ `retaining task ${ds.session.riffParentTaskId} for retry`);
|
|
1861
1971
|
}
|
|
1862
1972
|
return;
|
|
1863
1973
|
}
|
|
@@ -1872,6 +1982,266 @@ function destroyOrphanedBackingSession(ds) {
|
|
|
1872
1982
|
logger.warn(`[${tag(ds)}] killWorker: failed to destroy orphaned ${backendType} backing session: ${err}`);
|
|
1873
1983
|
}
|
|
1874
1984
|
}
|
|
1985
|
+
async function abortLiveRiffWorkerClose(ds, requestId, opts = {}) {
|
|
1986
|
+
if (ds.riffCloseState?.requestId !== requestId)
|
|
1987
|
+
return false;
|
|
1988
|
+
const worker = ds.worker;
|
|
1989
|
+
if (!worker || worker.killed) {
|
|
1990
|
+
if (opts.allowAbsentAfterProvenRestore) {
|
|
1991
|
+
ds.riffCloseState = undefined;
|
|
1992
|
+
return true;
|
|
1993
|
+
}
|
|
1994
|
+
ds.riffCloseState = { ...ds.riffCloseState, phase: 'uncertain' };
|
|
1995
|
+
return false;
|
|
1996
|
+
}
|
|
1997
|
+
const restored = await new Promise(resolve => {
|
|
1998
|
+
let settled = false;
|
|
1999
|
+
const finish = (result) => {
|
|
2000
|
+
if (settled)
|
|
2001
|
+
return;
|
|
2002
|
+
settled = true;
|
|
2003
|
+
clearTimeout(timer);
|
|
2004
|
+
worker.removeListener?.('message', onMessage);
|
|
2005
|
+
worker.removeListener?.('exit', onExit);
|
|
2006
|
+
resolve(result);
|
|
2007
|
+
};
|
|
2008
|
+
const onMessage = (raw) => {
|
|
2009
|
+
const msg = raw;
|
|
2010
|
+
if (msg?.type !== 'close_abort_result' || msg.requestId !== requestId)
|
|
2011
|
+
return;
|
|
2012
|
+
if (ds.worker !== worker) {
|
|
2013
|
+
finish({ ok: false, error: 'stale_worker_generation' });
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
finish({ ok: msg.ok, ...(msg.error ? { error: msg.error } : {}) });
|
|
2017
|
+
};
|
|
2018
|
+
const onExit = () => finish({
|
|
2019
|
+
ok: false,
|
|
2020
|
+
error: 'worker_exited_before_close_abort_result',
|
|
2021
|
+
});
|
|
2022
|
+
const timer = setTimeout(() => finish({ ok: false, error: 'close_abort_result_timeout' }), RIFF_ADMISSION_RESTORE_TIMEOUT_MS);
|
|
2023
|
+
timer.unref?.();
|
|
2024
|
+
worker.on?.('message', onMessage);
|
|
2025
|
+
worker.once?.('exit', onExit);
|
|
2026
|
+
try {
|
|
2027
|
+
worker.send({ type: 'close_abort', requestId });
|
|
2028
|
+
}
|
|
2029
|
+
catch (err) {
|
|
2030
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
if (restored.ok && ds.riffCloseState?.requestId === requestId) {
|
|
2034
|
+
ds.riffCloseState = undefined;
|
|
2035
|
+
return true;
|
|
2036
|
+
}
|
|
2037
|
+
if (opts.allowAbsentAfterProvenRestore
|
|
2038
|
+
&& ds.riffCloseState?.requestId === requestId) {
|
|
2039
|
+
ds.riffCloseState = undefined;
|
|
2040
|
+
return true;
|
|
2041
|
+
}
|
|
2042
|
+
if (ds.riffCloseState?.requestId === requestId) {
|
|
2043
|
+
ds.riffCloseState = { ...ds.riffCloseState, phase: 'uncertain' };
|
|
2044
|
+
}
|
|
2045
|
+
logger.warn(`[${tag(ds)}] Riff close abort was not acknowledged (${restored.error ?? 'unknown'}); `
|
|
2046
|
+
+ 'retaining admission fence pending explicit lineage reconciliation');
|
|
2047
|
+
return false;
|
|
2048
|
+
}
|
|
2049
|
+
async function prepareLiveRiffWorkerClose(ds) {
|
|
2050
|
+
const worker = ds.worker;
|
|
2051
|
+
if (!worker || worker.killed) {
|
|
2052
|
+
return { ok: false, error: 'riff_worker_close_failed', retryable: true };
|
|
2053
|
+
}
|
|
2054
|
+
if (ds.riffCloseState || ds.riffShutdownState) {
|
|
2055
|
+
return {
|
|
2056
|
+
ok: false,
|
|
2057
|
+
error: 'riff_worker_close_failed',
|
|
2058
|
+
retryable: true,
|
|
2059
|
+
...((ds.riffCloseState?.taskId ?? ds.riffShutdownState?.taskId)
|
|
2060
|
+
? { taskId: (ds.riffCloseState?.taskId ?? ds.riffShutdownState?.taskId) }
|
|
2061
|
+
: {}),
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
const requestId = randomUUID();
|
|
2065
|
+
ds.riffCloseState = {
|
|
2066
|
+
phase: 'preparing',
|
|
2067
|
+
requestId,
|
|
2068
|
+
...(ds.session.riffParentTaskId ? { taskId: ds.session.riffParentTaskId } : {}),
|
|
2069
|
+
};
|
|
2070
|
+
let matchedCloseResult = false;
|
|
2071
|
+
const result = await new Promise((resolve) => {
|
|
2072
|
+
let settled = false;
|
|
2073
|
+
const finish = (value) => {
|
|
2074
|
+
if (settled)
|
|
2075
|
+
return;
|
|
2076
|
+
settled = true;
|
|
2077
|
+
clearTimeout(timer);
|
|
2078
|
+
worker.removeListener?.('exit', onExit);
|
|
2079
|
+
pendingRiffWorkerCloses.delete(requestId);
|
|
2080
|
+
resolve(value);
|
|
2081
|
+
};
|
|
2082
|
+
const onExit = () => finish({
|
|
2083
|
+
ok: false,
|
|
2084
|
+
error: 'worker_exited_before_close_result',
|
|
2085
|
+
});
|
|
2086
|
+
const timer = setTimeout(() => finish({ ok: false, error: 'worker_close_result_timeout' }), 23_000);
|
|
2087
|
+
timer.unref?.();
|
|
2088
|
+
worker.once?.('exit', onExit);
|
|
2089
|
+
pendingRiffWorkerCloses.set(requestId, {
|
|
2090
|
+
sessionId: ds.session.sessionId,
|
|
2091
|
+
worker,
|
|
2092
|
+
resolve: value => {
|
|
2093
|
+
matchedCloseResult = true;
|
|
2094
|
+
finish(value);
|
|
2095
|
+
},
|
|
2096
|
+
});
|
|
2097
|
+
try {
|
|
2098
|
+
worker.send({ type: 'close', requestId });
|
|
2099
|
+
}
|
|
2100
|
+
catch (err) {
|
|
2101
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
const taskId = result.taskId ?? ds.session.riffParentTaskId;
|
|
2105
|
+
if (result.taskId) {
|
|
2106
|
+
ds.session.riffParentTaskId = result.taskId;
|
|
2107
|
+
try {
|
|
2108
|
+
sessionStore.updateSession(ds.session);
|
|
2109
|
+
}
|
|
2110
|
+
catch (err) {
|
|
2111
|
+
await abortLiveRiffWorkerClose(ds, requestId);
|
|
2112
|
+
logger.error(`[${tag(ds)}] Riff close lineage persistence failed; close aborted: `
|
|
2113
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2114
|
+
return {
|
|
2115
|
+
ok: false,
|
|
2116
|
+
error: 'riff_durable_close_failed',
|
|
2117
|
+
retryable: true,
|
|
2118
|
+
taskId: result.taskId,
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
if (!result.ok) {
|
|
2123
|
+
await abortLiveRiffWorkerClose(ds, requestId, {
|
|
2124
|
+
allowAbsentAfterProvenRestore: matchedCloseResult,
|
|
2125
|
+
});
|
|
2126
|
+
logger.warn(`[${tag(ds)}] Riff worker close prepare failed: ${result.error ?? 'unknown'}; `
|
|
2127
|
+
+ `session remains active${taskId ? ` (task ${taskId})` : ''}`);
|
|
2128
|
+
return {
|
|
2129
|
+
ok: false,
|
|
2130
|
+
error: 'riff_worker_close_failed',
|
|
2131
|
+
retryable: true,
|
|
2132
|
+
...(taskId ? { taskId } : {}),
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
ds.riffCloseState = {
|
|
2136
|
+
phase: 'prepared',
|
|
2137
|
+
requestId,
|
|
2138
|
+
...(taskId ? { taskId } : {}),
|
|
2139
|
+
};
|
|
2140
|
+
logger.info(`[${tag(ds)}] Riff worker close prepared and remote task cancellation confirmed`);
|
|
2141
|
+
return { ok: true, ...(taskId ? { taskId } : {}) };
|
|
2142
|
+
}
|
|
2143
|
+
/** Await remote cancellation for any Riff owner before its durable row is
|
|
2144
|
+
* closed. Live workers use prepare/commit; worker-less rows cancel their exact
|
|
2145
|
+
* persisted task through current authoritative bot config. */
|
|
2146
|
+
async function prepareRiffExplicitClose(ds, stored) {
|
|
2147
|
+
const session = ds?.session ?? stored;
|
|
2148
|
+
if (!session)
|
|
2149
|
+
return { ok: true };
|
|
2150
|
+
const backendType = ds?.initConfig?.backendType ?? session.backendType;
|
|
2151
|
+
const taskId = session.riffParentTaskId;
|
|
2152
|
+
if (ds?.riffShutdownState) {
|
|
2153
|
+
const fencedTaskId = Object.prototype.hasOwnProperty.call(ds.riffShutdownState, 'taskId')
|
|
2154
|
+
? ds.riffShutdownState.taskId
|
|
2155
|
+
: taskId;
|
|
2156
|
+
return {
|
|
2157
|
+
ok: false,
|
|
2158
|
+
error: 'riff_shutdown_fence_in_progress',
|
|
2159
|
+
retryable: true,
|
|
2160
|
+
...(typeof fencedTaskId === 'string' && fencedTaskId ? { taskId: fencedTaskId } : {}),
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
if (ds?.riffCloseState) {
|
|
2164
|
+
return {
|
|
2165
|
+
ok: false,
|
|
2166
|
+
error: 'riff_close_reconciliation_required',
|
|
2167
|
+
retryable: true,
|
|
2168
|
+
...(ds.riffCloseState.taskId ? { taskId: ds.riffCloseState.taskId } : {}),
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
if (backendType !== 'riff')
|
|
2172
|
+
return { ok: true };
|
|
2173
|
+
if (ds?.initConfig?.adoptMode || ds?.adoptedFrom || session.adoptedFrom)
|
|
2174
|
+
return { ok: true };
|
|
2175
|
+
if (ds?.worker && !ds.worker.killed) {
|
|
2176
|
+
if (!stored || stored.status !== 'active') {
|
|
2177
|
+
return {
|
|
2178
|
+
ok: false,
|
|
2179
|
+
error: 'riff_row_inconsistent',
|
|
2180
|
+
retryable: true,
|
|
2181
|
+
...(taskId ? { taskId } : {}),
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
return prepareLiveRiffWorkerClose(ds);
|
|
2185
|
+
}
|
|
2186
|
+
const durableBefore = sessionStore.getSession(session.sessionId);
|
|
2187
|
+
if (ds && durableBefore
|
|
2188
|
+
&& ds.session.riffParentTaskId !== durableBefore.riffParentTaskId) {
|
|
2189
|
+
const authoritativeTaskId = durableBefore.riffParentTaskId ?? ds.session.riffParentTaskId;
|
|
2190
|
+
logger.warn(`[${tag(ds)}] explicit close refused before cancellation: runtime/durable Riff lineage differs `
|
|
2191
|
+
+ `(${ds.session.riffParentTaskId ?? 'none'}/${durableBefore.riffParentTaskId ?? 'none'})`);
|
|
2192
|
+
return {
|
|
2193
|
+
ok: false,
|
|
2194
|
+
error: 'riff_task_changed',
|
|
2195
|
+
retryable: true,
|
|
2196
|
+
...(authoritativeTaskId ? { taskId: authoritativeTaskId } : {}),
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
if (!taskId)
|
|
2200
|
+
return { ok: true };
|
|
2201
|
+
let riffConfig;
|
|
2202
|
+
const larkAppId = ds?.larkAppId ?? session.larkAppId;
|
|
2203
|
+
try {
|
|
2204
|
+
if (larkAppId)
|
|
2205
|
+
riffConfig = getBot(larkAppId).config.riff;
|
|
2206
|
+
}
|
|
2207
|
+
catch { /* bot removed */ }
|
|
2208
|
+
const cleanup = await cleanupExplicitSessionBacking({
|
|
2209
|
+
sessionId: session.sessionId,
|
|
2210
|
+
backendType,
|
|
2211
|
+
riffParentTaskId: taskId,
|
|
2212
|
+
riffConfig,
|
|
2213
|
+
});
|
|
2214
|
+
const closeLabel = ds ? tag(ds) : session.sessionId.slice(0, 8);
|
|
2215
|
+
if (!cleanup.ok) {
|
|
2216
|
+
logger.warn(`[${closeLabel}] explicit close refused: ${cleanup.kind}; `
|
|
2217
|
+
+ `Riff task ${taskId} remains active and retryable`);
|
|
2218
|
+
return {
|
|
2219
|
+
ok: false,
|
|
2220
|
+
error: cleanup.kind === 'riff_config_missing' ? 'riff_config_missing' : 'riff_cancel_failed',
|
|
2221
|
+
retryable: true,
|
|
2222
|
+
taskId,
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
if (cleanup.kind !== 'cancelled_riff')
|
|
2226
|
+
return { ok: true };
|
|
2227
|
+
const latest = sessionStore.getSession(session.sessionId);
|
|
2228
|
+
const latestTaskId = latest?.riffParentTaskId;
|
|
2229
|
+
const runtimeTaskId = ds?.session.riffParentTaskId;
|
|
2230
|
+
if (!latest || latest.status !== 'active' || latestTaskId !== cleanup.taskId
|
|
2231
|
+
|| (ds && runtimeTaskId !== cleanup.taskId)) {
|
|
2232
|
+
logger.warn(`[${closeLabel}] explicit close cancelled stale Riff task ${cleanup.taskId}, `
|
|
2233
|
+
+ `but runtime/durable lineage or status changed to `
|
|
2234
|
+
+ `${runtimeTaskId ?? 'none'}/${latestTaskId ?? 'none'}/${latest?.status ?? 'missing'}; retry required`);
|
|
2235
|
+
return {
|
|
2236
|
+
ok: false,
|
|
2237
|
+
error: 'riff_task_changed',
|
|
2238
|
+
retryable: true,
|
|
2239
|
+
taskId: latestTaskId ?? runtimeTaskId ?? cleanup.taskId,
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
logger.info(`[${closeLabel}] Riff task ${cleanup.taskId} cancellation prepared for explicit close`);
|
|
2243
|
+
return { ok: true, taskId: cleanup.taskId };
|
|
2244
|
+
}
|
|
1875
2245
|
/**
|
|
1876
2246
|
* Reclaim a session's parked crash-diagnostic shell (`bmx-diag-<sid>`) and its
|
|
1877
2247
|
* captured `.ansi` file. The worker normally tears these down itself (killCli /
|
|
@@ -1893,6 +2263,10 @@ function reclaimParkedCrashDiagnostic(ds) {
|
|
|
1893
2263
|
catch { /* absent — benign */ }
|
|
1894
2264
|
}
|
|
1895
2265
|
export function suspendWorker(ds, reason = 'suspended_idle') {
|
|
2266
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
2267
|
+
logger.warn(`[${tag(ds)}] Refused worker suspend (${reason}) while Codex App dispatch ownership is non-empty`);
|
|
2268
|
+
return false;
|
|
2269
|
+
}
|
|
1896
2270
|
if (isSessionTransferring(ds)) {
|
|
1897
2271
|
logger.warn(`[${tag(ds)}] Suspend refused while routing transfer is in progress`);
|
|
1898
2272
|
return false;
|
|
@@ -2154,14 +2528,6 @@ function teardownAuthoritativePersistentBackingBeforeCloseImpl(target, closeWins
|
|
|
2154
2528
|
export function teardownAuthoritativePersistentBackingBeforeClose(target) {
|
|
2155
2529
|
teardownAuthoritativePersistentBackingBeforeCloseImpl(target, false);
|
|
2156
2530
|
}
|
|
2157
|
-
/**
|
|
2158
|
-
* Idempotent close: kill worker if alive, mark Session status='closed' + closedAt,
|
|
2159
|
-
* publish session.exited (if a live worker was killed) and session.update
|
|
2160
|
-
* (if the persistence row transitioned to closed).
|
|
2161
|
-
*
|
|
2162
|
-
* Calling this on an unknown sessionId, an already-closed session, or a session
|
|
2163
|
-
* whose worker died asynchronously must still resolve with `{ ok: true }`.
|
|
2164
|
-
*/
|
|
2165
2531
|
export async function closeSession(sessionId) {
|
|
2166
2532
|
const ds = findActiveBySessionId(sessionId);
|
|
2167
2533
|
const stored = sessionStore.getOwnedSession(sessionId);
|
|
@@ -2171,10 +2537,11 @@ export async function closeSession(sessionId) {
|
|
|
2171
2537
|
if (teardownTarget) {
|
|
2172
2538
|
teardownAuthoritativePersistentBackingBeforeCloseImpl(teardownTarget, true);
|
|
2173
2539
|
}
|
|
2540
|
+
const isOwnedRiffClose = !ds?.initConfig?.adoptMode
|
|
2541
|
+
&& !ds?.adoptedFrom
|
|
2542
|
+
&& !stored?.adoptedFrom
|
|
2543
|
+
&& (ds?.initConfig?.backendType ?? ds?.session.backendType ?? stored?.backendType) === 'riff';
|
|
2174
2544
|
let killedLive = false;
|
|
2175
|
-
// 会话关闭即可回收其崩溃重启计数;否则每个曾崩溃过的 session 会在 daemon
|
|
2176
|
-
// 生命周期内永久占位(restartCounts 此前无任何 delete)。
|
|
2177
|
-
restartCounts.delete(sessionId);
|
|
2178
2545
|
const hadLiveWorker = !!ds?.worker && !ds.worker.killed;
|
|
2179
2546
|
const closeWorkerGeneration = ds ? closeFenceGeneration(ds) : undefined;
|
|
2180
2547
|
// Snapshot ownership + transition state before mutating the live object:
|
|
@@ -2183,6 +2550,26 @@ export async function closeSession(sessionId) {
|
|
|
2183
2550
|
const wasOpen = !!stored && stored.status !== 'closed';
|
|
2184
2551
|
const storedHadDocCommentTargets = Object.keys(stored?.docCommentTargets ?? {}).length > 0;
|
|
2185
2552
|
const docReactionTargets = collectDocCommentReactionTargets(ds, stored);
|
|
2553
|
+
if (ds) {
|
|
2554
|
+
// Usage ledger: flush the final delta before the worker goes away (a
|
|
2555
|
+
// crash/limited turn may never have reached an idle edge).
|
|
2556
|
+
recordUsageForDaemonSession(ds);
|
|
2557
|
+
}
|
|
2558
|
+
// Riff owns a remote credential-bearing process. Prove cancellation before
|
|
2559
|
+
// mutating routing, transient capabilities, or the durable row. Non-Riff
|
|
2560
|
+
// closes retain master's synchronous state-transition path.
|
|
2561
|
+
const prepared = isOwnedRiffClose
|
|
2562
|
+
? await prepareRiffExplicitClose(ds, stored)
|
|
2563
|
+
: { ok: true };
|
|
2564
|
+
if (!prepared.ok) {
|
|
2565
|
+
return { ...prepared, alreadyClosed: false };
|
|
2566
|
+
}
|
|
2567
|
+
const preparedRiffRequestId = ds?.riffCloseState?.phase === 'prepared'
|
|
2568
|
+
? ds.riffCloseState.requestId
|
|
2569
|
+
: undefined;
|
|
2570
|
+
// 会话关闭即可回收其崩溃重启计数;否则每个曾崩溃过的 session 会在 daemon
|
|
2571
|
+
// 生命周期内永久占位(restartCounts 此前无任何 delete)。
|
|
2572
|
+
restartCounts.delete(sessionId);
|
|
2186
2573
|
// Per-turn comment routes are transient capabilities. Clear them from both
|
|
2187
2574
|
// live and owner-scoped persisted objects inside the synchronous close
|
|
2188
2575
|
// critical section, before any best-effort Lark cleanup can yield.
|
|
@@ -2192,30 +2579,33 @@ export async function closeSession(sessionId) {
|
|
|
2192
2579
|
if (stored && !wasOpen && storedHadDocCommentTargets) {
|
|
2193
2580
|
sessionStore.updateSession(stored);
|
|
2194
2581
|
}
|
|
2195
|
-
if (ds) {
|
|
2196
|
-
// Usage ledger: flush the final delta before the worker goes away (a
|
|
2197
|
-
// crash/limited turn may never have reached an idle edge).
|
|
2198
|
-
recordUsageForDaemonSession(ds);
|
|
2199
|
-
killWorker(ds);
|
|
2200
|
-
const activeKey = activeSessionKey(ds);
|
|
2201
|
-
if (activeSessionsRegistry?.get(activeKey) === ds) {
|
|
2202
|
-
activeSessionsRegistry.delete(activeKey);
|
|
2203
|
-
}
|
|
2204
|
-
// Mark the captured object too. Async message/card paths may already hold a
|
|
2205
|
-
// reference to `ds`; deleting only the registry entry would not stop one of
|
|
2206
|
-
// those continuations from re-forking the closed session after its await.
|
|
2207
|
-
ds.session.status = 'closed';
|
|
2208
|
-
ds.session.closedAt ??= new Date().toISOString();
|
|
2209
|
-
killedLive = true;
|
|
2210
|
-
}
|
|
2211
2582
|
// Mutations are bot-owner scoped. getSession() has a read-only cross-file
|
|
2212
2583
|
// fallback for agent CLI discovery, so it must not authorize close.
|
|
2213
2584
|
if (wasOpen) {
|
|
2214
|
-
if (!ds && stored)
|
|
2585
|
+
if (!ds && stored && !isOwnedRiffClose)
|
|
2215
2586
|
destroyUnregisteredPersistentBacking(stored);
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2587
|
+
try {
|
|
2588
|
+
sessionStore.closeSession(sessionId, {
|
|
2589
|
+
cleanupBridgeMarkers: !hadLiveWorker,
|
|
2590
|
+
...(isOwnedRiffClose ? { clearRiffParentTaskId: true } : {}),
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
catch (err) {
|
|
2594
|
+
if (!isOwnedRiffClose)
|
|
2595
|
+
throw err;
|
|
2596
|
+
if (ds && preparedRiffRequestId) {
|
|
2597
|
+
await abortLiveRiffWorkerClose(ds, preparedRiffRequestId);
|
|
2598
|
+
}
|
|
2599
|
+
logger.error(`[${sessionId.slice(0, 8)}] Durable session close failed after Riff prepare; `
|
|
2600
|
+
+ `worker admission restored: ${err instanceof Error ? err.message : String(err)}`);
|
|
2601
|
+
return {
|
|
2602
|
+
ok: false,
|
|
2603
|
+
alreadyClosed: false,
|
|
2604
|
+
error: 'riff_durable_close_failed',
|
|
2605
|
+
retryable: true,
|
|
2606
|
+
...(prepared.taskId ? { taskId: prepared.taskId } : {}),
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2219
2609
|
const after = sessionStore.getOwnedSession(sessionId);
|
|
2220
2610
|
if (ds) {
|
|
2221
2611
|
ds.session.status = 'closed';
|
|
@@ -2223,6 +2613,23 @@ export async function closeSession(sessionId) {
|
|
|
2223
2613
|
}
|
|
2224
2614
|
}
|
|
2225
2615
|
if (ds) {
|
|
2616
|
+
killWorker(ds, {
|
|
2617
|
+
...(preparedRiffRequestId ? { riffCloseCommitRequestId: preparedRiffRequestId } : {}),
|
|
2618
|
+
});
|
|
2619
|
+
// A transferred/restored exact object may remain under more than one alias.
|
|
2620
|
+
// Remove only identity matches, never a same-key successor.
|
|
2621
|
+
if (activeSessionsRegistry) {
|
|
2622
|
+
for (const [registeredKey, candidate] of activeSessionsRegistry) {
|
|
2623
|
+
if (candidate === ds)
|
|
2624
|
+
activeSessionsRegistry.delete(registeredKey);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
// Mark the captured object too. Async message/card paths may already hold a
|
|
2628
|
+
// reference to `ds`; deleting only the registry entry would not stop one of
|
|
2629
|
+
// those continuations from re-forking the closed session after its await.
|
|
2630
|
+
ds.session.status = 'closed';
|
|
2631
|
+
ds.session.closedAt ??= new Date().toISOString();
|
|
2632
|
+
killedLive = true;
|
|
2226
2633
|
if (!ds.exitEventEmitted) {
|
|
2227
2634
|
ds.exitEventEmitted = true;
|
|
2228
2635
|
dashboardEventBus.publish({
|
|
@@ -2327,23 +2734,62 @@ export function destroyUnregisteredPersistentBacking(session, kill = killPersist
|
|
|
2327
2734
|
return false;
|
|
2328
2735
|
}
|
|
2329
2736
|
}
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2737
|
+
const activeSessionKeyLocks = new WeakMap();
|
|
2738
|
+
/** Serialize asynchronous ownership decisions for one registry key. Ordinary
|
|
2739
|
+
* bot turn admissions are intentionally concurrent, so a read/await/set helper
|
|
2740
|
+
* is not a CAS unless every contender shares this lock. */
|
|
2741
|
+
export async function withActiveSessionKeyLock(map, key, action) {
|
|
2742
|
+
let locks = activeSessionKeyLocks.get(map);
|
|
2743
|
+
if (!locks) {
|
|
2744
|
+
locks = new Map();
|
|
2745
|
+
activeSessionKeyLocks.set(map, locks);
|
|
2746
|
+
}
|
|
2747
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
2748
|
+
let release;
|
|
2749
|
+
const hold = new Promise(resolve => { release = resolve; });
|
|
2750
|
+
const tail = previous.catch(() => { }).then(() => hold);
|
|
2751
|
+
locks.set(key, tail);
|
|
2752
|
+
await previous.catch(() => { });
|
|
2753
|
+
try {
|
|
2754
|
+
return await action();
|
|
2755
|
+
}
|
|
2756
|
+
finally {
|
|
2757
|
+
release();
|
|
2758
|
+
if (locks.get(key) === tail)
|
|
2759
|
+
locks.delete(key);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
async function closeUnregisteredCollisionLoser(map, loser) {
|
|
2763
|
+
// Operate on exact object identity in the caller's registry. Looking up by
|
|
2764
|
+
// session id through the daemon-global registry can conflate a non-global
|
|
2765
|
+
// restore map (or a stale duplicate object) with an unrelated canonical
|
|
2766
|
+
// owner and close the wrong generation.
|
|
2767
|
+
const sameIdDifferentOwner = [...map.values()].some(candidate => candidate !== loser && candidate.session.sessionId === loser.session.sessionId)
|
|
2768
|
+
|| (!!activeSessionsRegistry && activeSessionsRegistry !== map
|
|
2769
|
+
&& [...activeSessionsRegistry.values()].some(candidate => candidate !== loser && candidate.session.sessionId === loser.session.sessionId));
|
|
2770
|
+
if (sameIdDifferentOwner) {
|
|
2771
|
+
logger.error(`[setActiveSessionSafe] refusing persistence close for ambiguous session id `
|
|
2772
|
+
+ `${loser.session.sessionId}; preserving every owner`);
|
|
2773
|
+
return { ok: false, error: 'ambiguous_session_id' };
|
|
2774
|
+
}
|
|
2775
|
+
const stored = sessionStore.getSession(loser.session.sessionId);
|
|
2776
|
+
try {
|
|
2777
|
+
if (stored?.status !== 'closed') {
|
|
2778
|
+
sessionStore.closeSession(loser.session.sessionId);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
catch (err) {
|
|
2782
|
+
logger.error(`[setActiveSessionSafe] durable loser close failed for ${loser.session.sessionId}: `
|
|
2783
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2784
|
+
return { ok: false, error: 'durable_close_failed' };
|
|
2785
|
+
}
|
|
2786
|
+
killWorker(loser);
|
|
2787
|
+
for (const [registeredKey, candidate] of map) {
|
|
2788
|
+
if (candidate === loser)
|
|
2789
|
+
map.delete(registeredKey);
|
|
2790
|
+
}
|
|
2791
|
+
return { ok: true };
|
|
2792
|
+
}
|
|
2347
2793
|
function removeInactiveRegistration(map, key, ds) {
|
|
2348
2794
|
if (ds.session.status === 'active')
|
|
2349
2795
|
return false;
|
|
@@ -2385,6 +2831,22 @@ export function findQuarantinedRoutingConflict(key, larkAppId, candidateSessionI
|
|
|
2385
2831
|
return undefined;
|
|
2386
2832
|
}
|
|
2387
2833
|
}
|
|
2834
|
+
/** Clear a durable quarantine marker once a live owner has claimed the route.
|
|
2835
|
+
* Runtime ownership (the Map + lock) is authoritative for this process even if
|
|
2836
|
+
* the persistence write fails; a later update or restore attempt converges the
|
|
2837
|
+
* stale on-disk marker. */
|
|
2838
|
+
function clearRestoreQuarantineMarker(ds) {
|
|
2839
|
+
if (!ds.session.restoreQuarantinedAt)
|
|
2840
|
+
return;
|
|
2841
|
+
delete ds.session.restoreQuarantinedAt;
|
|
2842
|
+
try {
|
|
2843
|
+
sessionStore.updateSession(ds.session);
|
|
2844
|
+
}
|
|
2845
|
+
catch (err) {
|
|
2846
|
+
logger.warn(`[${tag(ds)}] Registered quarantined session but could not clear its persisted marker: `
|
|
2847
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2388
2850
|
/**
|
|
2389
2851
|
* Synchronous registration gate for creation paths that previously used a
|
|
2390
2852
|
* bare Map.set(). A daemon close can update the shared Session object while
|
|
@@ -2407,23 +2869,115 @@ export function setActiveSessionIfActive(map, key, ds) {
|
|
|
2407
2869
|
return false;
|
|
2408
2870
|
}
|
|
2409
2871
|
map.set(key, ds);
|
|
2410
|
-
|
|
2411
|
-
delete ds.session.restoreQuarantinedAt;
|
|
2412
|
-
try {
|
|
2413
|
-
sessionStore.updateSession(ds.session);
|
|
2414
|
-
}
|
|
2415
|
-
catch (err) {
|
|
2416
|
-
// Runtime ownership is now authoritative and the Map prevents duplicates
|
|
2417
|
-
// for this process. Keep running; a later persistence update (or the next
|
|
2418
|
-
// restore attempt) will converge the stale on-disk marker.
|
|
2419
|
-
logger.warn(`[${tag(ds)}] Registered quarantined session but could not clear its persisted marker: `
|
|
2420
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2421
|
-
}
|
|
2422
|
-
}
|
|
2872
|
+
clearRestoreQuarantineMarker(ds);
|
|
2423
2873
|
return true;
|
|
2424
2874
|
}
|
|
2425
2875
|
export async function setActiveSessionSafe(map, key, ds) {
|
|
2426
|
-
|
|
2876
|
+
const canonicalKey = activeSessionKey(ds);
|
|
2877
|
+
if (canonicalKey !== key) {
|
|
2878
|
+
throw new Error(`refusing noncanonical active-session registration: requested=${key} canonical=${canonicalKey}`);
|
|
2879
|
+
}
|
|
2880
|
+
return withActiveSessionKeyLock(map, key, async () => {
|
|
2881
|
+
// Reboot-resume guard (master): a daemon close can flip the shared Session
|
|
2882
|
+
// to a non-active status while an async creator/restore awaited Lark/project
|
|
2883
|
+
// metadata. Never publish that now-inactive row back into the live map; drop
|
|
2884
|
+
// only our exact stale object so a newer owner of the same key is untouched.
|
|
2885
|
+
if (ds.session.status !== 'active') {
|
|
2886
|
+
if (map.get(key) === ds)
|
|
2887
|
+
map.delete(key);
|
|
2888
|
+
logger.warn(`[setActiveSessionSafe] refusing to register inactive session `
|
|
2889
|
+
+ `${ds.session.sessionId.substring(0, 8)} (status=${ds.session.status})`);
|
|
2890
|
+
return {
|
|
2891
|
+
accepted: false,
|
|
2892
|
+
reason: 'inactive_incoming',
|
|
2893
|
+
keptSessionId: ds.session.sessionId,
|
|
2894
|
+
preservedIncomingSessionId: ds.session.sessionId,
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
const prev = map.get(key);
|
|
2898
|
+
if (prev && prev !== ds) {
|
|
2899
|
+
if (prev.session.sessionId === ds.session.sessionId) {
|
|
2900
|
+
logger.error(`[setActiveSessionSafe] refusing collision between distinct owners of session id `
|
|
2901
|
+
+ `${prev.session.sessionId}; preserving both objects and the durable row`);
|
|
2902
|
+
return {
|
|
2903
|
+
accepted: false,
|
|
2904
|
+
reason: 'cleanup_failed',
|
|
2905
|
+
keptSessionId: prev.session.sessionId,
|
|
2906
|
+
preservedIncomingSessionId: ds.session.sessionId,
|
|
2907
|
+
cleanupSessionId: prev.session.sessionId,
|
|
2908
|
+
error: 'ambiguous_session_id',
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
const prevPending = hasProtectedSessionMutationOwnership(prev);
|
|
2912
|
+
const incomingPending = hasProtectedSessionMutationOwnership(ds);
|
|
2913
|
+
if (prevPending && incomingPending) {
|
|
2914
|
+
logger.error(`[setActiveSessionSafe] refusing collision between two unsettled Codex App owners `
|
|
2915
|
+
+ `${prev.session.sessionId} and ${ds.session.sessionId}; preserving both rows/panes`);
|
|
2916
|
+
return {
|
|
2917
|
+
accepted: false,
|
|
2918
|
+
reason: 'both_pending',
|
|
2919
|
+
keptSessionId: prev.session.sessionId,
|
|
2920
|
+
preservedIncomingSessionId: ds.session.sessionId,
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
if (prevPending) {
|
|
2924
|
+
logger.warn(`[setActiveSessionSafe] keeping unsettled owner ${prev.session.sessionId.substring(0, 8)}; `
|
|
2925
|
+
+ `closing ledger-empty incoming ${ds.session.sessionId.substring(0, 8)}`);
|
|
2926
|
+
const cleanup = await closeUnregisteredCollisionLoser(map, ds);
|
|
2927
|
+
if (!cleanup.ok) {
|
|
2928
|
+
return {
|
|
2929
|
+
accepted: false,
|
|
2930
|
+
reason: 'cleanup_failed',
|
|
2931
|
+
keptSessionId: prev.session.sessionId,
|
|
2932
|
+
preservedIncomingSessionId: ds.session.sessionId,
|
|
2933
|
+
cleanupSessionId: ds.session.sessionId,
|
|
2934
|
+
error: cleanup.error,
|
|
2935
|
+
};
|
|
2936
|
+
}
|
|
2937
|
+
return {
|
|
2938
|
+
accepted: false,
|
|
2939
|
+
reason: 'kept_pending_owner',
|
|
2940
|
+
keptSessionId: prev.session.sessionId,
|
|
2941
|
+
closedIncomingSessionId: ds.session.sessionId,
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
logger.warn(`[setActiveSessionSafe] key already occupied by ${prev.session.sessionId.substring(0, 8)} ` +
|
|
2945
|
+
`(worker=${prev.worker ? 'live' : 'null'}); closing it before set`);
|
|
2946
|
+
const cleanup = await closeUnregisteredCollisionLoser(map, prev);
|
|
2947
|
+
if (!cleanup.ok) {
|
|
2948
|
+
return {
|
|
2949
|
+
accepted: false,
|
|
2950
|
+
reason: 'cleanup_failed',
|
|
2951
|
+
keptSessionId: prev.session.sessionId,
|
|
2952
|
+
preservedIncomingSessionId: ds.session.sessionId,
|
|
2953
|
+
cleanupSessionId: prev.session.sessionId,
|
|
2954
|
+
error: cleanup.error,
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
// Reboot-resume guard (master): a durable quarantine reservation for this
|
|
2959
|
+
// route (an inconclusive exact-backend teardown) must block a second runtime
|
|
2960
|
+
// beside a possibly-live persistent target, even after the in-memory prev
|
|
2961
|
+
// occupant above was closed. Closed rows never match, so an operator close
|
|
2962
|
+
// immediately releases the route.
|
|
2963
|
+
const quarantined = findQuarantinedRoutingConflict(key, ds.larkAppId, ds.session.sessionId);
|
|
2964
|
+
if (quarantined) {
|
|
2965
|
+
logger.warn(`[setActiveSessionSafe] refusing to occupy routing key reserved by quarantined session `
|
|
2966
|
+
+ `${quarantined.sessionId.substring(0, 8)}`);
|
|
2967
|
+
return {
|
|
2968
|
+
accepted: false,
|
|
2969
|
+
reason: 'quarantine_reserved',
|
|
2970
|
+
keptSessionId: quarantined.sessionId,
|
|
2971
|
+
preservedIncomingSessionId: ds.session.sessionId,
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
2974
|
+
map.set(key, ds);
|
|
2975
|
+
clearRestoreQuarantineMarker(ds);
|
|
2976
|
+
return {
|
|
2977
|
+
accepted: true,
|
|
2978
|
+
...(prev && prev !== ds ? { closedSessionId: prev.session.sessionId } : {}),
|
|
2979
|
+
};
|
|
2980
|
+
});
|
|
2427
2981
|
}
|
|
2428
2982
|
/**
|
|
2429
2983
|
* Roll back a freshly-created row rejected by the registration CAS, then read
|
|
@@ -2857,8 +3411,18 @@ targetScope, opts) {
|
|
|
2857
3411
|
if (targetChatType !== 'group' && targetChatType !== 'p2p') {
|
|
2858
3412
|
return { ok: false, error: 'target_chat_type_unsupported' };
|
|
2859
3413
|
}
|
|
3414
|
+
const initial = findActiveBySessionId(sessionId);
|
|
3415
|
+
if (!initial)
|
|
3416
|
+
return { ok: false, error: 'session_not_active' };
|
|
3417
|
+
if (!opts?.mutationHeld) {
|
|
3418
|
+
// Transfer rewrites both the source and target routing identities and may
|
|
3419
|
+
// await scratch cleanup. Drain every admitted turn for this bot first so
|
|
3420
|
+
// no source prompt can become durable mid-transfer and no target creator
|
|
3421
|
+
// can slip into the cleanup window.
|
|
3422
|
+
return withBotTurnMutation(initial.larkAppId, () => transferSession(sessionId, targetChatId, targetRootMessageId, targetChatType, targetScope, { ...opts, mutationHeld: true }));
|
|
3423
|
+
}
|
|
2860
3424
|
const ds = findActiveBySessionId(sessionId);
|
|
2861
|
-
if (!ds)
|
|
3425
|
+
if (!ds || ds.session.status !== 'active')
|
|
2862
3426
|
return { ok: false, error: 'session_not_active' };
|
|
2863
3427
|
const sourceSession = ds.session;
|
|
2864
3428
|
const sourceLifecycleIdentity = JSON.stringify({
|
|
@@ -2875,6 +3439,9 @@ targetScope, opts) {
|
|
|
2875
3439
|
});
|
|
2876
3440
|
if (ds.session.vcMeetingReceiver)
|
|
2877
3441
|
return { ok: false, error: 'vc_receiver_not_relayable' };
|
|
3442
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
3443
|
+
return { ok: false, error: 'codex_app_dispatch_pending' };
|
|
3444
|
+
}
|
|
2878
3445
|
// Anchor-based identity. A thread-scope session in the SAME chat (different
|
|
2879
3446
|
// root) is a legitimate cross-topic move, so we refuse only when the target
|
|
2880
3447
|
// anchor equals the source anchor (relaying a session onto itself). Replaces
|
|
@@ -3002,7 +3569,11 @@ targetScope, opts) {
|
|
|
3002
3569
|
continue;
|
|
3003
3570
|
if (sessionAnchorId(existing) !== targetAnchor)
|
|
3004
3571
|
continue;
|
|
3005
|
-
|
|
3572
|
+
// A durable dispatch ledger / activation journal makes the occupant a
|
|
3573
|
+
// real conflict even when it currently has no worker; only a proven
|
|
3574
|
+
// ledger-empty command scratch may be retired.
|
|
3575
|
+
if (isDisposableCommandScratch(existing)
|
|
3576
|
+
&& !hasProtectedSessionMutationOwnership(existing)) {
|
|
3006
3577
|
scratchesToClose.push(existing.session.sessionId);
|
|
3007
3578
|
continue;
|
|
3008
3579
|
}
|
|
@@ -3012,12 +3583,43 @@ targetScope, opts) {
|
|
|
3012
3583
|
for (const sid of scratchesToClose) {
|
|
3013
3584
|
await closeSession(sid);
|
|
3014
3585
|
}
|
|
3586
|
+
// A partially hydrated daemon can have an active target row absent from the
|
|
3587
|
+
// runtime map. Durable ownership makes that row a real conflict even when it
|
|
3588
|
+
// has worker:null and no legacy CLI markers; only proven ledger-empty
|
|
3589
|
+
// scratch rows may be retired.
|
|
3590
|
+
const runtimeIds = new Set(activeSessionsRegistry
|
|
3591
|
+
? [...activeSessionsRegistry.values()].map(item => item.session.sessionId)
|
|
3592
|
+
: []);
|
|
3593
|
+
const persistedTargetConflicts = sessionStore.listSessions().filter(item => item.sessionId !== ds.session.sessionId
|
|
3594
|
+
&& item.status === 'active'
|
|
3595
|
+
&& (!item.larkAppId || item.larkAppId === ds.larkAppId)
|
|
3596
|
+
&& !item.vcMeetingReceiver
|
|
3597
|
+
&& (item.scope === 'chat' ? item.chatId : item.rootMessageId) === targetAnchor
|
|
3598
|
+
&& !runtimeIds.has(item.sessionId));
|
|
3599
|
+
if (persistedTargetConflicts.some(item => hasProtectedSessionMutationOwnership(item)
|
|
3600
|
+
|| !!item.cliId
|
|
3601
|
+
|| !!item.lastCliInput
|
|
3602
|
+
|| item.queued === true)) {
|
|
3603
|
+
return { ok: false, error: 'target_chat_has_session' };
|
|
3604
|
+
}
|
|
3605
|
+
for (const scratch of persistedTargetConflicts) {
|
|
3606
|
+
await closeSession(scratch.sessionId);
|
|
3607
|
+
}
|
|
3015
3608
|
const postScratchStateError = validateAfterAwait();
|
|
3016
3609
|
if (postScratchStateError)
|
|
3017
3610
|
return postScratchStateError;
|
|
3018
3611
|
const tagPrefix = sessionId.substring(0, 8);
|
|
3019
3612
|
const oldAnchor = sessionAnchorId(ds);
|
|
3020
3613
|
const oldChatId = ds.chatId;
|
|
3614
|
+
const oldStreamCardId = ds.streamCardId;
|
|
3615
|
+
const oldCurrentImageKey = ds.currentImageKey;
|
|
3616
|
+
// Scratch/store cleanup above awaited. A fresh source turn may have been
|
|
3617
|
+
// admitted during that window; recheck the durable dispatch ledger in the
|
|
3618
|
+
// same synchronous section that precedes the destructive detach so transfer
|
|
3619
|
+
// never abandons newly durable work.
|
|
3620
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
3621
|
+
return { ok: false, error: 'codex_app_dispatch_pending' };
|
|
3622
|
+
}
|
|
3021
3623
|
// Detach only the worker/observer. Persistent backends and Riff keep the
|
|
3022
3624
|
// owned CLI/task alive so the replacement can reattach below. PTY cannot
|
|
3023
3625
|
// survive a worker exit and therefore retains its historical cold-resume.
|
|
@@ -3420,6 +4022,28 @@ function resolvesToHome(p) {
|
|
|
3420
4022
|
return p === homedir();
|
|
3421
4023
|
}
|
|
3422
4024
|
}
|
|
4025
|
+
export function codexAppCleanInputAcceptedForSession(ds) {
|
|
4026
|
+
try {
|
|
4027
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
4028
|
+
const effectiveCliId = ds.session.cliId ?? botCfg.cliId;
|
|
4029
|
+
return effectiveCliId === 'codex-app'
|
|
4030
|
+
&& botCfg.codexAppCleanInput === true
|
|
4031
|
+
&& !ds.adoptedFrom;
|
|
4032
|
+
}
|
|
4033
|
+
catch {
|
|
4034
|
+
// Admission metadata is optional for every other CLI. If bot config is
|
|
4035
|
+
// temporarily unavailable, fail closed instead of leaking a clean sidecar.
|
|
4036
|
+
return false;
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
function cloneFrozenCodexAppInput(input, turnId) {
|
|
4040
|
+
if (!input)
|
|
4041
|
+
return undefined;
|
|
4042
|
+
const cloned = structuredClone(input);
|
|
4043
|
+
if (turnId && !cloned.clientUserMessageId)
|
|
4044
|
+
cloned.clientUserMessageId = turnId;
|
|
4045
|
+
return cloned;
|
|
4046
|
+
}
|
|
3423
4047
|
function canForkRegisteredSession(ds) {
|
|
3424
4048
|
const key = activeSessionKey(ds);
|
|
3425
4049
|
const registered = activeSessionsRegistry ? activeSessionsRegistry.get(key) : ds;
|
|
@@ -3435,60 +4059,647 @@ function canForkRegisteredSession(ds) {
|
|
|
3435
4059
|
function codexAppInputForSession(ds, input, turnId) {
|
|
3436
4060
|
if (!input)
|
|
3437
4061
|
return undefined;
|
|
4062
|
+
if (!codexAppCleanInputAcceptedForSession(ds))
|
|
4063
|
+
return undefined;
|
|
4064
|
+
return cloneFrozenCodexAppInput(input, turnId);
|
|
4065
|
+
}
|
|
4066
|
+
function codexAppDeliverySinkForTurn(ds, turnId, dispatchAttempt) {
|
|
4067
|
+
const armedThrough = ds.suppressedFinalOutputTurns?.get(turnId);
|
|
4068
|
+
if (dispatchAttempt !== undefined
|
|
4069
|
+
&& armedThrough !== undefined
|
|
4070
|
+
&& dispatchAttempt <= armedThrough)
|
|
4071
|
+
return 'suppressed';
|
|
4072
|
+
if (ds.session.docCommentTargets?.[turnId] || ds.docCommentTurns?.has(turnId)) {
|
|
4073
|
+
return 'doc_comment';
|
|
4074
|
+
}
|
|
4075
|
+
if (ds.pendingWaitPromises?.has(turnId))
|
|
4076
|
+
return 'http_wait';
|
|
4077
|
+
if (ds.asyncTriggerResults?.has(turnId))
|
|
4078
|
+
return 'http_async';
|
|
4079
|
+
return 'lark';
|
|
4080
|
+
}
|
|
4081
|
+
/** A recovered transient/non-IM sink has no safe provider to replay into.
|
|
4082
|
+
* Treat it as consumed so the runner can advance, but never fall through to a
|
|
4083
|
+
* Lark card. Doc-comment replay also fails closed: chunk posting has no durable
|
|
4084
|
+
* per-chunk effect journal yet, so blind crash replay could duplicate comments. */
|
|
4085
|
+
function codexAppDeliveryMustFailClosed(ds, entry) {
|
|
4086
|
+
const sink = entry.deliverySink
|
|
4087
|
+
?? (ds.session.docCommentTargets?.[entry.turnId]
|
|
4088
|
+
? 'doc_comment'
|
|
4089
|
+
: ds.chatId.startsWith('http_wait_')
|
|
4090
|
+
? 'http_wait'
|
|
4091
|
+
: ds.chatId.startsWith('http_async_')
|
|
4092
|
+
? 'http_async'
|
|
4093
|
+
: 'lark');
|
|
4094
|
+
if (sink === 'suppressed')
|
|
4095
|
+
return true;
|
|
4096
|
+
if (sink === 'doc_comment')
|
|
4097
|
+
return !ds.docCommentTurns?.has(entry.turnId);
|
|
4098
|
+
if (sink === 'http_wait')
|
|
4099
|
+
return !ds.pendingWaitPromises?.has(entry.turnId);
|
|
4100
|
+
if (sink === 'http_async')
|
|
4101
|
+
return !ds.asyncTriggerResults?.has(entry.turnId);
|
|
4102
|
+
return false;
|
|
4103
|
+
}
|
|
4104
|
+
function acceptCodexAppDispatch(ds, payload, turnId, dispatchAttempt, vcMeetingImTurnOrigin, opts = {}) {
|
|
3438
4105
|
const botCfg = getBot(ds.larkAppId).config;
|
|
3439
4106
|
const effectiveCliId = ds.session.cliId ?? botCfg.cliId;
|
|
3440
|
-
if (effectiveCliId !== 'codex-app' ||
|
|
4107
|
+
if (effectiveCliId !== 'codex-app' || !turnId)
|
|
3441
4108
|
return undefined;
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
4109
|
+
const dispatchId = randomUUID();
|
|
4110
|
+
const priorLedger = ds.session.codexAppDispatchLedger;
|
|
4111
|
+
ds.session.codexAppDispatchLedger = appendAcceptedCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], {
|
|
4112
|
+
dispatchId,
|
|
4113
|
+
turnId,
|
|
4114
|
+
...(payload.queuedActivationToken
|
|
4115
|
+
? { queuedActivationToken: payload.queuedActivationToken }
|
|
4116
|
+
: {}),
|
|
4117
|
+
...(payload.replyTurnId ? { replyTurnId: payload.replyTurnId } : {}),
|
|
4118
|
+
...(payload.replyTarget ? { replyTarget: payload.replyTarget } : {}),
|
|
4119
|
+
...(payload.quoteTargetId ? { quoteTargetId: payload.quoteTargetId } : {}),
|
|
4120
|
+
...(payload.replyTargetSenderOpenId
|
|
4121
|
+
? { replyTargetSenderOpenId: payload.replyTargetSenderOpenId }
|
|
4122
|
+
: {}),
|
|
4123
|
+
...(payload.replyTargetSenderIsBot !== undefined
|
|
4124
|
+
? { replyTargetSenderIsBot: payload.replyTargetSenderIsBot }
|
|
4125
|
+
: {}),
|
|
4126
|
+
deliverySink: codexAppDeliverySinkForTurn(ds, turnId, dispatchAttempt),
|
|
4127
|
+
...(payload.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
4128
|
+
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
4129
|
+
content: payload.content,
|
|
4130
|
+
...(payload.codexAppInput ? { codexAppInput: payload.codexAppInput } : {}),
|
|
4131
|
+
...(vcMeetingImTurnOrigin ? { vcMeetingImTurnOrigin } : {}),
|
|
4132
|
+
});
|
|
4133
|
+
if (opts.persist !== false) {
|
|
4134
|
+
try {
|
|
4135
|
+
sessionStore.updateSession(ds.session);
|
|
4136
|
+
}
|
|
4137
|
+
catch (err) {
|
|
4138
|
+
ds.session.codexAppDispatchLedger = priorLedger;
|
|
4139
|
+
throw err;
|
|
3472
4140
|
}
|
|
3473
4141
|
}
|
|
3474
|
-
|
|
4142
|
+
return dispatchId;
|
|
4143
|
+
}
|
|
4144
|
+
function rollbackAcceptedCodexAppDispatch(ds, dispatchId, turnId, dispatchAttempt) {
|
|
4145
|
+
if (!dispatchId || !turnId)
|
|
4146
|
+
return;
|
|
4147
|
+
const cancelled = cancelCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], {
|
|
4148
|
+
dispatchId,
|
|
4149
|
+
turnId,
|
|
4150
|
+
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
4151
|
+
});
|
|
4152
|
+
if (!cancelled.ok)
|
|
4153
|
+
return;
|
|
4154
|
+
const priorLedger = ds.session.codexAppDispatchLedger;
|
|
4155
|
+
ds.session.codexAppDispatchLedger = cancelled.ledger;
|
|
4156
|
+
try {
|
|
4157
|
+
sessionStore.updateSession(ds.session);
|
|
4158
|
+
if (!hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
|
|
4159
|
+
void Promise.resolve(callbacks?.onCodexAppLedgerDrained?.(ds)).catch(err => {
|
|
4160
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] post-rollback ledger-drain cleanup failed: `
|
|
4161
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4162
|
+
});
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
catch {
|
|
4166
|
+
ds.session.codexAppDispatchLedger = priorLedger;
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
function snapshotQueuedActivationJournal(session) {
|
|
4170
|
+
return {
|
|
4171
|
+
queuedActivationPending: session.queuedActivationPending,
|
|
4172
|
+
queuedActivationToken: session.queuedActivationToken,
|
|
4173
|
+
queuedActivationInput: session.queuedActivationInput,
|
|
4174
|
+
queuedActivationTurnId: session.queuedActivationTurnId,
|
|
4175
|
+
queuedActivationDispatchAttempt: session.queuedActivationDispatchAttempt,
|
|
4176
|
+
queuedActivationResume: session.queuedActivationResume,
|
|
4177
|
+
queuedPrompt: session.queuedPrompt,
|
|
4178
|
+
queuedCodexAppText: session.queuedCodexAppText,
|
|
4179
|
+
queuedCodexAppMessageContext: session.queuedCodexAppMessageContext,
|
|
4180
|
+
pendingRepoSetup: session.pendingRepoSetup
|
|
4181
|
+
? structuredClone(session.pendingRepoSetup)
|
|
4182
|
+
: undefined,
|
|
4183
|
+
};
|
|
4184
|
+
}
|
|
4185
|
+
function restoreQueuedActivationJournal(session, snapshot) {
|
|
4186
|
+
Object.assign(session, snapshot);
|
|
4187
|
+
}
|
|
4188
|
+
function clearQueuedActivationJournal(session) {
|
|
4189
|
+
session.queuedActivationPending = undefined;
|
|
4190
|
+
session.queuedActivationToken = undefined;
|
|
4191
|
+
session.queuedActivationInput = undefined;
|
|
4192
|
+
session.queuedActivationTurnId = undefined;
|
|
4193
|
+
session.queuedActivationDispatchAttempt = undefined;
|
|
4194
|
+
session.queuedActivationResume = undefined;
|
|
4195
|
+
session.queuedPrompt = undefined;
|
|
4196
|
+
session.queuedCodexAppText = undefined;
|
|
4197
|
+
session.queuedCodexAppMessageContext = undefined;
|
|
4198
|
+
session.pendingRepoSetup = undefined;
|
|
4199
|
+
}
|
|
4200
|
+
/** A worker disappeared before the exact activation ACK. Re-park non-Codex
|
|
4201
|
+
* work from the retained exact input; ACK loss may duplicate, but N can never
|
|
4202
|
+
* be silently replaced by a later inbound turn in the same daemon lifetime. */
|
|
4203
|
+
function reparkUnsubmittedQueuedActivation(ds, reason) {
|
|
4204
|
+
if (!ds.session.queuedActivationPending
|
|
4205
|
+
|| !ds.session.queuedActivationToken
|
|
4206
|
+
|| ds.session.cliId === 'codex-app')
|
|
4207
|
+
return false;
|
|
4208
|
+
ds.session.queued = true;
|
|
4209
|
+
ds.session.queuedActivationPending = undefined;
|
|
4210
|
+
ds.session.queuedActivationToken = undefined;
|
|
4211
|
+
ds.initialStartPending = false;
|
|
4212
|
+
ds.initialStartClaimToken = undefined;
|
|
4213
|
+
ds.pendingPrompt ??= ds.session.queuedPrompt;
|
|
4214
|
+
try {
|
|
4215
|
+
sessionStore.updateSession(ds.session);
|
|
4216
|
+
}
|
|
4217
|
+
catch (err) {
|
|
4218
|
+
// Memory remains explicitly parked while the old durable marker still
|
|
4219
|
+
// provides restart recovery on disk.
|
|
4220
|
+
logger.error(`[${tag(ds)}] Failed to persist queued activation re-park after ${reason}: `
|
|
4221
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4222
|
+
}
|
|
4223
|
+
logger.warn(`[${tag(ds)}] Re-parked unacknowledged queued activation after ${reason}`);
|
|
4224
|
+
return true;
|
|
4225
|
+
}
|
|
4226
|
+
/** The opening activation was ACKed, but one of the turns held behind its
|
|
4227
|
+
* runtime reservation was not accepted before that worker exited. Promote the
|
|
4228
|
+
* exact FIFO head into a new durable queued activation so the next inbound or
|
|
4229
|
+
* Dashboard activation reforks it before every remaining tail item. */
|
|
4230
|
+
function reparkQueuedActivationFollowUpTail(ds, reason) {
|
|
4231
|
+
if (!ds.initialStartPending || ds.session.queuedActivationPending)
|
|
4232
|
+
return false;
|
|
4233
|
+
const next = ds.pendingQueuedActivationFollowUps?.[0];
|
|
4234
|
+
if (!next)
|
|
4235
|
+
return false;
|
|
4236
|
+
const matchingCodexEntries = ds.session.cliId === 'codex-app'
|
|
4237
|
+
? (ds.session.codexAppDispatchLedger ?? []).filter(entry => (entry.state === 'accepted' || entry.state === 'prepared')
|
|
4238
|
+
&& entry.turnId === next.turnId
|
|
4239
|
+
&& entry.dispatchAttempt === next.dispatchAttempt)
|
|
4240
|
+
: [];
|
|
4241
|
+
if (matchingCodexEntries.length > 1) {
|
|
4242
|
+
logger.error(`[${tag(ds)}] Cannot re-park queued follow-up after ${reason}: `
|
|
4243
|
+
+ `${matchingCodexEntries.length} Codex entries match turn ${next.turnId}`);
|
|
4244
|
+
return false;
|
|
4245
|
+
}
|
|
4246
|
+
const retainedCodexEntry = matchingCodexEntries[0];
|
|
4247
|
+
const retainedCodexToken = retainedCodexEntry
|
|
4248
|
+
? (retainedCodexEntry.queuedActivationToken ?? randomUUID())
|
|
4249
|
+
: undefined;
|
|
4250
|
+
// A failed daemon→worker IPC normally rolls its newly accepted Codex entry
|
|
4251
|
+
// back. If that rollback persistence itself failed, the durable FIFO remains
|
|
4252
|
+
// authoritative: recover it through a tokened ACK journal instead of
|
|
4253
|
+
// creating an invalid queued+unsettled hybrid.
|
|
4254
|
+
ds.session.queued = !retainedCodexEntry;
|
|
4255
|
+
ds.session.queuedPrompt = next.cliInput.content;
|
|
4256
|
+
ds.session.queuedCodexAppText = next.cliInput.codexAppInput?.text;
|
|
4257
|
+
ds.session.queuedCodexAppMessageContext = undefined;
|
|
4258
|
+
ds.session.queuedActivationInput = next.cliInput;
|
|
4259
|
+
ds.session.queuedActivationTurnId = next.turnId;
|
|
4260
|
+
ds.session.queuedActivationDispatchAttempt = next.dispatchAttempt;
|
|
4261
|
+
ds.session.queuedActivationPending = retainedCodexEntry ? true : undefined;
|
|
4262
|
+
ds.session.queuedActivationToken = retainedCodexToken;
|
|
4263
|
+
if (retainedCodexEntry && retainedCodexToken) {
|
|
4264
|
+
retainedCodexEntry.queuedActivationToken = retainedCodexToken;
|
|
4265
|
+
}
|
|
4266
|
+
ds.pendingQueuedActivationFollowUps.shift();
|
|
4267
|
+
if (ds.pendingQueuedActivationFollowUps.length === 0) {
|
|
4268
|
+
ds.pendingQueuedActivationFollowUps = undefined;
|
|
4269
|
+
}
|
|
4270
|
+
ds.pendingPrompt = next.cliInput.content;
|
|
4271
|
+
ds.initialStartPending = false;
|
|
4272
|
+
ds.initialStartClaimToken = undefined;
|
|
4273
|
+
try {
|
|
4274
|
+
sessionStore.updateSession(ds.session);
|
|
4275
|
+
}
|
|
4276
|
+
catch (err) {
|
|
4277
|
+
// Keep the exact head parked in memory. The caller has already fenced the
|
|
4278
|
+
// dead worker, so a later inbound can safely retry this owner even if the
|
|
4279
|
+
// durable projection is temporarily unavailable.
|
|
4280
|
+
logger.error(`[${tag(ds)}] Failed to persist queued follow-up re-park after ${reason}: `
|
|
4281
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4282
|
+
}
|
|
4283
|
+
logger.warn(`[${tag(ds)}] Re-parked unaccepted queued activation follow-up after ${reason}`);
|
|
4284
|
+
return true;
|
|
4285
|
+
}
|
|
4286
|
+
export const __testOnly_reparkQueuedActivationFollowUpTail = reparkQueuedActivationFollowUpTail;
|
|
4287
|
+
/** Compensate only the durable mutations made before a worker accepts its init
|
|
4288
|
+
* IPC. This is one synchronous call stack, so removing the exact dispatch that
|
|
4289
|
+
* this fork appended cannot race a later worker transition or damage the FIFO
|
|
4290
|
+
* that existed before the attempt. Persist the queued payload and ledger in one
|
|
4291
|
+
* write so a daemon crash cannot observe only half of the compensation. */
|
|
4292
|
+
function rollbackWorkerForkPreInit(ds, queuedSnapshot, acceptedDispatch) {
|
|
4293
|
+
const exactActivationInput = ds.session.queuedActivationInput;
|
|
4294
|
+
const exactActivationTurnId = ds.session.queuedActivationTurnId;
|
|
4295
|
+
const exactActivationDispatchAttempt = ds.session.queuedActivationDispatchAttempt;
|
|
4296
|
+
const exactActivationResume = ds.session.queuedActivationResume;
|
|
4297
|
+
let changed = false;
|
|
4298
|
+
if (acceptedDispatch) {
|
|
4299
|
+
const cancelled = cancelCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], acceptedDispatch);
|
|
4300
|
+
if (!cancelled.ok) {
|
|
4301
|
+
throw new Error(`failed to cancel pre-init Codex App dispatch: ${cancelled.error}`);
|
|
4302
|
+
}
|
|
4303
|
+
ds.session.codexAppDispatchLedger = cancelled.ledger;
|
|
4304
|
+
changed = true;
|
|
4305
|
+
}
|
|
4306
|
+
if (queuedSnapshot) {
|
|
4307
|
+
ds.session.queued = queuedSnapshot.queued;
|
|
4308
|
+
restoreQueuedActivationJournal(ds.session, queuedSnapshot);
|
|
4309
|
+
// The activation may have folded a triggering group reply into the final
|
|
4310
|
+
// init payload. Retain that exact retry body even though the in-flight
|
|
4311
|
+
// marker/token are rolled back with the fenced child.
|
|
4312
|
+
ds.session.queuedActivationPending = undefined;
|
|
4313
|
+
ds.session.queuedActivationToken = undefined;
|
|
4314
|
+
ds.session.queuedActivationInput = exactActivationInput;
|
|
4315
|
+
ds.session.queuedActivationTurnId = exactActivationTurnId;
|
|
4316
|
+
ds.session.queuedActivationDispatchAttempt = exactActivationDispatchAttempt;
|
|
4317
|
+
ds.session.queuedActivationResume = exactActivationResume;
|
|
4318
|
+
ds.initialStartPending = queuedSnapshot.initialStartPending;
|
|
4319
|
+
changed = true;
|
|
4320
|
+
}
|
|
4321
|
+
if (changed)
|
|
4322
|
+
sessionStore.updateSession(ds.session);
|
|
4323
|
+
}
|
|
4324
|
+
/** Send one normal (non-raw) worker turn while applying the per-bot Codex App
|
|
4325
|
+
* clean-input gate at message acceptance time. This freezes the sidecar onto
|
|
4326
|
+
* the IPC item, so later config flips do not mutate an already queued turn. */
|
|
4327
|
+
export function sendWorkerInput(ds, payload, turnId, opts = {}) {
|
|
4328
|
+
const riffRetirementPhase = riffRetirementAdmissionPhase(ds);
|
|
4329
|
+
if (riffRetirementPhase) {
|
|
4330
|
+
logger.warn(`[${tag(ds)}] Rejected turn ${turnId ?? '?'} while Riff retirement fence is ${riffRetirementPhase}`);
|
|
4331
|
+
void callbacks?.sessionReply(sessionAnchorId(ds), tr('worker.riff_close_in_progress', undefined, localeForBot(ds.larkAppId)), 'text', ds.larkAppId, turnId).catch(err => {
|
|
4332
|
+
logger.warn(`[${tag(ds)}] Failed to notify rejected Riff close-race turn: `
|
|
4333
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4334
|
+
});
|
|
4335
|
+
return false;
|
|
4336
|
+
}
|
|
4337
|
+
const transferGate = transferInputGates.get(ds);
|
|
4338
|
+
if ((!ds.worker || ds.worker.killed) && !transferGate)
|
|
4339
|
+
return false;
|
|
4340
|
+
const normalized = typeof payload === 'string' ? { content: payload } : payload;
|
|
4341
|
+
const effectiveCliId = ds.session.cliId ?? getBot(ds.larkAppId).config.cliId;
|
|
4342
|
+
const effectiveTurnId = turnId ?? (effectiveCliId === 'codex-app'
|
|
4343
|
+
? `codex-app-dispatch-${randomUUID()}`
|
|
4344
|
+
: undefined);
|
|
4345
|
+
const replyTurnId = turnId ? undefined : ds.currentReplyTarget?.turnId;
|
|
4346
|
+
const routingTurnId = turnId ?? replyTurnId;
|
|
4347
|
+
const replyContext = frozenReplyContextForTurn(ds, routingTurnId);
|
|
4348
|
+
const vcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, routingTurnId);
|
|
4349
|
+
let nativeSessionTitlePrompt;
|
|
4350
|
+
let nativeSessionTitle;
|
|
4351
|
+
if (ds.session.nativeSessionTitleAwaitingContent && !ds.session.nativeSessionTitleUserDefined && !ds.adoptedFrom) {
|
|
4352
|
+
const bot = getBot(ds.larkAppId);
|
|
4353
|
+
if (effectiveCliId === 'codex') {
|
|
4354
|
+
nativeSessionTitlePrompt = extractBotmuxLarkNativeSessionTitlePrompt(normalized.codexAppInput?.text ?? normalized.content, bot.botName ? [{ name: bot.botName }] : undefined);
|
|
4355
|
+
if (nativeSessionTitlePrompt) {
|
|
4356
|
+
nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(nativeSessionTitlePrompt);
|
|
4357
|
+
ds.session.nativeSessionTitle = nativeSessionTitle;
|
|
4358
|
+
ds.session.nativeSessionTitleAwaitingContent = undefined;
|
|
4359
|
+
if (ds.initConfig) {
|
|
4360
|
+
ds.initConfig.nativeSessionTitle = nativeSessionTitle;
|
|
4361
|
+
ds.initConfig.nativeSessionTitlePrompt = nativeSessionTitlePrompt;
|
|
4362
|
+
}
|
|
4363
|
+
sessionStore.updateSession(ds.session);
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
if (hasQueuedActivationAdmissionGate(ds)) {
|
|
4368
|
+
const queuedTurnId = effectiveTurnId
|
|
4369
|
+
?? routingTurnId
|
|
4370
|
+
?? `queued-activation-followup-${randomUUID()}`;
|
|
4371
|
+
try {
|
|
4372
|
+
admitQueuedActivationTail(ds, {
|
|
4373
|
+
userPrompt: normalized.content,
|
|
4374
|
+
cliInput: {
|
|
4375
|
+
content: normalized.content,
|
|
4376
|
+
...(normalized.codexAppInput
|
|
4377
|
+
? { codexAppInput: normalized.codexAppInput }
|
|
4378
|
+
: {}),
|
|
4379
|
+
// R4-B1: freeze the admission-time steer authorization into the queued
|
|
4380
|
+
// tail's frozen payload so promote/repark/restore COPY it verbatim
|
|
4381
|
+
// (admission computed once; never re-inferred downstream).
|
|
4382
|
+
...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
4383
|
+
},
|
|
4384
|
+
turnId: queuedTurnId,
|
|
4385
|
+
...(opts.dispatchAttempt !== undefined
|
|
4386
|
+
? { dispatchAttempt: opts.dispatchAttempt }
|
|
4387
|
+
: {}),
|
|
4388
|
+
});
|
|
4389
|
+
logger.info(`[${tag(ds)}] Staged turn ${queuedTurnId} behind queued activation ACK`);
|
|
4390
|
+
return true;
|
|
4391
|
+
}
|
|
4392
|
+
catch (err) {
|
|
4393
|
+
logger.error(`[${tag(ds)}] Failed to durably stage turn ${queuedTurnId} behind activation: `
|
|
4394
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4395
|
+
return false;
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
const codexAppInput = codexAppInputForSession(ds, normalized.codexAppInput, effectiveTurnId);
|
|
4399
|
+
const codexAppDispatchId = acceptCodexAppDispatch(ds, {
|
|
4400
|
+
content: normalized.content,
|
|
4401
|
+
...(codexAppInput ? { codexAppInput } : {}),
|
|
4402
|
+
...(replyTurnId ? { replyTurnId } : {}),
|
|
4403
|
+
replyTarget: replyContext.target,
|
|
4404
|
+
...(replyContext.quoteTargetId ? { quoteTargetId: replyContext.quoteTargetId } : {}),
|
|
4405
|
+
...(replyContext.replyTargetSenderOpenId
|
|
4406
|
+
? { replyTargetSenderOpenId: replyContext.replyTargetSenderOpenId }
|
|
4407
|
+
: {}),
|
|
4408
|
+
...(replyContext.replyTargetSenderIsBot !== undefined
|
|
4409
|
+
? { replyTargetSenderIsBot: replyContext.replyTargetSenderIsBot }
|
|
4410
|
+
: {}),
|
|
4411
|
+
...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
4412
|
+
}, effectiveTurnId, opts.dispatchAttempt, vcMeetingImTurnOrigin);
|
|
4413
|
+
// Route through sendWorkerSessionInput so an in-flight routing transfer
|
|
4414
|
+
// buffers this turn instead of dropping it (master's transfer input gate),
|
|
4415
|
+
// while preserving PR #597's accepted-dispatch ledger + reply-context wiring.
|
|
3475
4416
|
const message = {
|
|
3476
4417
|
type: 'message',
|
|
3477
4418
|
content: normalized.content,
|
|
3478
4419
|
...(codexAppInput ? { codexAppInput } : {}),
|
|
3479
4420
|
...(nativeSessionTitle ? { nativeSessionTitle } : {}),
|
|
3480
4421
|
...(nativeSessionTitlePrompt ? { nativeSessionTitlePrompt } : {}),
|
|
3481
|
-
...(
|
|
4422
|
+
...(effectiveTurnId ? { turnId: effectiveTurnId } : {}),
|
|
4423
|
+
...(replyTurnId ? { replyTurnId } : {}),
|
|
3482
4424
|
...(opts.dispatchAttempt !== undefined ? { dispatchAttempt: opts.dispatchAttempt } : {}),
|
|
4425
|
+
...(codexAppDispatchId ? { codexAppDispatchId } : {}),
|
|
4426
|
+
...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
3483
4427
|
...(vcMeetingImTurnOrigin
|
|
3484
4428
|
? { vcMeetingImTurnOrigin }
|
|
3485
4429
|
: {}),
|
|
3486
4430
|
};
|
|
3487
|
-
|
|
3488
|
-
|
|
4431
|
+
// #597: the accept-ledger entry is already appended above; if the IPC send
|
|
4432
|
+
// fails (returns false or throws) we MUST roll it back or the dispatch ledger
|
|
4433
|
+
// keeps a phantom accepted turn. master's ordinary-IM-delivery tracking runs
|
|
4434
|
+
// inside the same protected send so a tracked delivery still rolls back.
|
|
4435
|
+
const performSend = () => {
|
|
4436
|
+
if (!transferGate && shouldTrackOrdinaryImDelivery(ds, message)) {
|
|
4437
|
+
return sendOrdinaryImDeliveryTracked(ds, message);
|
|
4438
|
+
}
|
|
4439
|
+
return sendWorkerSessionInput(ds, message);
|
|
4440
|
+
};
|
|
4441
|
+
try {
|
|
4442
|
+
if (!performSend()) {
|
|
4443
|
+
rollbackAcceptedCodexAppDispatch(ds, codexAppDispatchId, effectiveTurnId, opts.dispatchAttempt);
|
|
4444
|
+
return false;
|
|
4445
|
+
}
|
|
3489
4446
|
}
|
|
3490
|
-
|
|
4447
|
+
catch {
|
|
4448
|
+
rollbackAcceptedCodexAppDispatch(ds, codexAppDispatchId, effectiveTurnId, opts.dispatchAttempt);
|
|
4449
|
+
return false;
|
|
4450
|
+
}
|
|
4451
|
+
return true;
|
|
3491
4452
|
}
|
|
4453
|
+
/** Promote the oldest durable activation successor into a fresh tokened
|
|
4454
|
+
* journal and (for Codex App) its single accepted-ledger owner in one store
|
|
4455
|
+
* update, then hand it to the current worker. The tail is never shifted merely
|
|
4456
|
+
* because ChildProcess.send returned: the promoted journal survives until the
|
|
4457
|
+
* adapter ACK carrying this token arrives. */
|
|
4458
|
+
export function promoteQueuedActivationTail(ds, opts = {}) {
|
|
4459
|
+
if (ds.session.queuedActivationPending)
|
|
4460
|
+
return true;
|
|
4461
|
+
if (opts.send !== false
|
|
4462
|
+
&& (!ds.worker || ds.worker.killed))
|
|
4463
|
+
return false;
|
|
4464
|
+
const ordered = [...(ds.session.queuedActivationTail ?? [])]
|
|
4465
|
+
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
4466
|
+
const head = ordered[0];
|
|
4467
|
+
if (!head)
|
|
4468
|
+
return false;
|
|
4469
|
+
const priorJournal = snapshotQueuedActivationJournal(ds.session);
|
|
4470
|
+
const priorTail = ds.session.queuedActivationTail?.map(entry => ({
|
|
4471
|
+
...entry,
|
|
4472
|
+
cliInput: {
|
|
4473
|
+
...entry.cliInput,
|
|
4474
|
+
...(entry.cliInput.codexAppInput
|
|
4475
|
+
? { codexAppInput: structuredClone(entry.cliInput.codexAppInput) }
|
|
4476
|
+
: {}),
|
|
4477
|
+
},
|
|
4478
|
+
}));
|
|
4479
|
+
const priorLedger = ds.session.codexAppDispatchLedger?.map(entry => ({ ...entry }));
|
|
4480
|
+
const priorPendingPrompt = ds.pendingPrompt;
|
|
4481
|
+
const token = randomUUID();
|
|
4482
|
+
const replyContext = frozenReplyContextForTurn(ds, head.turnId);
|
|
4483
|
+
// The tail entry crossed its admission boundary with the clean-input gate
|
|
4484
|
+
// already frozen. Do not re-run that immediate bot-config gate at promotion:
|
|
4485
|
+
// a true→false toggle between N+1 admission and N's ACK would otherwise
|
|
4486
|
+
// discard N+1's exact sidecar/hidden context. Only fill the deterministic id
|
|
4487
|
+
// for legacy entries persisted before admission began stamping it.
|
|
4488
|
+
const codexAppInput = cloneFrozenCodexAppInput(head.cliInput.codexAppInput, head.turnId);
|
|
4489
|
+
const exactInput = {
|
|
4490
|
+
content: head.cliInput.content,
|
|
4491
|
+
...(codexAppInput ? { codexAppInput } : {}),
|
|
4492
|
+
// R5-B1-2: preserve the frozen steer authorization onto the promoted
|
|
4493
|
+
// queuedActivationInput so the ensuing fork/accept-ledger COPY (below) sees
|
|
4494
|
+
// it. Only `=== true`; a missing/false tail head stays forced-serial.
|
|
4495
|
+
...(head.cliInput.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
|
|
4496
|
+
};
|
|
4497
|
+
const vcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, head.turnId);
|
|
4498
|
+
ds.session.queued = false;
|
|
4499
|
+
ds.session.queuedActivationPending = true;
|
|
4500
|
+
ds.session.queuedActivationToken = token;
|
|
4501
|
+
ds.session.queuedActivationInput = exactInput;
|
|
4502
|
+
ds.session.queuedActivationTurnId = head.turnId;
|
|
4503
|
+
ds.session.queuedActivationDispatchAttempt = head.dispatchAttempt;
|
|
4504
|
+
ds.session.queuedActivationResume = ds.hasHistory;
|
|
4505
|
+
ds.session.queuedActivationTail = ordered.slice(1);
|
|
4506
|
+
if (ds.session.queuedActivationTail.length === 0) {
|
|
4507
|
+
ds.session.queuedActivationTail = undefined;
|
|
4508
|
+
}
|
|
4509
|
+
ds.pendingPrompt = exactInput.content;
|
|
4510
|
+
ds.initialStartPending = true;
|
|
4511
|
+
let codexAppDispatchId;
|
|
4512
|
+
try {
|
|
4513
|
+
codexAppDispatchId = acceptCodexAppDispatch(ds, {
|
|
4514
|
+
content: exactInput.content,
|
|
4515
|
+
...(codexAppInput ? { codexAppInput } : {}),
|
|
4516
|
+
queuedActivationToken: token,
|
|
4517
|
+
replyTarget: replyContext.target,
|
|
4518
|
+
...(replyContext.quoteTargetId ? { quoteTargetId: replyContext.quoteTargetId } : {}),
|
|
4519
|
+
...(replyContext.replyTargetSenderOpenId
|
|
4520
|
+
? { replyTargetSenderOpenId: replyContext.replyTargetSenderOpenId }
|
|
4521
|
+
: {}),
|
|
4522
|
+
...(replyContext.replyTargetSenderIsBot !== undefined
|
|
4523
|
+
? { replyTargetSenderIsBot: replyContext.replyTargetSenderIsBot }
|
|
4524
|
+
: {}),
|
|
4525
|
+
// R4-B1: COPY the frozen steer authorization from the promoted tail
|
|
4526
|
+
// entry's payload onto the accept-ledger (admission computed once; never
|
|
4527
|
+
// re-inferred at promote time).
|
|
4528
|
+
...(exactInput.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
4529
|
+
}, head.turnId, head.dispatchAttempt, vcMeetingImTurnOrigin, { persist: false });
|
|
4530
|
+
sessionStore.updateSession(ds.session);
|
|
4531
|
+
}
|
|
4532
|
+
catch (err) {
|
|
4533
|
+
restoreQueuedActivationJournal(ds.session, priorJournal);
|
|
4534
|
+
ds.session.queuedActivationTail = priorTail;
|
|
4535
|
+
ds.session.codexAppDispatchLedger = priorLedger;
|
|
4536
|
+
ds.pendingPrompt = priorPendingPrompt;
|
|
4537
|
+
logger.error(`[${tag(ds)}] Failed to atomically promote queued activation tail ${head.id}: `
|
|
4538
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4539
|
+
return false;
|
|
4540
|
+
}
|
|
4541
|
+
if (opts.send === false)
|
|
4542
|
+
return true;
|
|
4543
|
+
try {
|
|
4544
|
+
ds.worker.send({
|
|
4545
|
+
type: 'message',
|
|
4546
|
+
content: exactInput.content,
|
|
4547
|
+
...(codexAppInput ? { codexAppInput } : {}),
|
|
4548
|
+
turnId: head.turnId,
|
|
4549
|
+
...(head.dispatchAttempt !== undefined
|
|
4550
|
+
? { dispatchAttempt: head.dispatchAttempt }
|
|
4551
|
+
: {}),
|
|
4552
|
+
...(codexAppDispatchId ? { codexAppDispatchId } : {}),
|
|
4553
|
+
// R6-B2: COPY the frozen steer authorization onto the live-worker IPC — the
|
|
4554
|
+
// last hop. Without it a promoted N+1 reaches a live worker with the daemon
|
|
4555
|
+
// ledger head steerable=true but the worker reservation false, so a
|
|
4556
|
+
// legitimate superseded settlement would be wrongly rejected.
|
|
4557
|
+
...(exactInput.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
|
|
4558
|
+
queuedActivationToken: token,
|
|
4559
|
+
...(vcMeetingImTurnOrigin ? { vcMeetingImTurnOrigin } : {}),
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
catch (err) {
|
|
4563
|
+
// Durable ownership already moved to the journal (and Codex ledger). Never
|
|
4564
|
+
// append another owner on retry; fence this IPC generation and let recovery
|
|
4565
|
+
// replay the exact tokened head.
|
|
4566
|
+
logger.error(`[${tag(ds)}] Worker IPC rejected promoted activation ${head.id}; retaining journal: `
|
|
4567
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4568
|
+
try {
|
|
4569
|
+
ds.worker.kill();
|
|
4570
|
+
}
|
|
4571
|
+
catch { /* exit/error path will fence runtime */ }
|
|
4572
|
+
}
|
|
4573
|
+
return true;
|
|
4574
|
+
}
|
|
4575
|
+
/** Reserve arrival order synchronously, before any caller-specific prompt
|
|
4576
|
+
* rendering may await. Gaps are harmless; reusing an order after a failed
|
|
4577
|
+
* durable admission would not be. */
|
|
4578
|
+
export function reserveQueuedActivationTailAdmission(ds) {
|
|
4579
|
+
const order = (ds.session.queuedActivationTailNextOrder ?? 0) + 1;
|
|
4580
|
+
ds.session.queuedActivationTailNextOrder = order;
|
|
4581
|
+
return {
|
|
4582
|
+
id: randomUUID(),
|
|
4583
|
+
order,
|
|
4584
|
+
codexAppInputAccepted: codexAppCleanInputAcceptedForSession(ds),
|
|
4585
|
+
};
|
|
4586
|
+
}
|
|
4587
|
+
/** Admit one exact successor behind a queued activation. The response boundary
|
|
4588
|
+
* is the session-store write: callers must not report acceptance or use live
|
|
4589
|
+
* worker IPC unless this returns. `reservation` lets routes reserve FIFO order
|
|
4590
|
+
* before asynchronous prompt construction without duplicating persistence
|
|
4591
|
+
* logic. */
|
|
4592
|
+
export function admitQueuedActivationTail(ds, entry, reservation = reserveQueuedActivationTailAdmission(ds), opts = {}) {
|
|
4593
|
+
const acceptedCodexAppInput = opts.codexAppInputGateFrozen === true
|
|
4594
|
+
? cloneFrozenCodexAppInput(entry.cliInput.codexAppInput, entry.turnId)
|
|
4595
|
+
: reservation.codexAppInputAccepted === true
|
|
4596
|
+
? cloneFrozenCodexAppInput(entry.cliInput.codexAppInput, entry.turnId)
|
|
4597
|
+
: undefined;
|
|
4598
|
+
const admitted = {
|
|
4599
|
+
id: reservation.id,
|
|
4600
|
+
order: reservation.order,
|
|
4601
|
+
...entry,
|
|
4602
|
+
cliInput: {
|
|
4603
|
+
content: entry.cliInput.content,
|
|
4604
|
+
...(acceptedCodexAppInput ? { codexAppInput: acceptedCodexAppInput } : {}),
|
|
4605
|
+
// R5-B1-2: preserve the frozen steer authorization across the tail rebuild
|
|
4606
|
+
// (only `=== true`, never truthy). Dropping it here silently un-authorized
|
|
4607
|
+
// every queued/opening turn — the strip point that defeated the R4 fix.
|
|
4608
|
+
...(entry.cliInput.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
|
|
4609
|
+
},
|
|
4610
|
+
};
|
|
4611
|
+
const priorTail = ds.session.queuedActivationTail;
|
|
4612
|
+
const next = [...(priorTail ?? [])];
|
|
4613
|
+
if (!next.some(candidate => candidate.id === admitted.id))
|
|
4614
|
+
next.push(admitted);
|
|
4615
|
+
next.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
4616
|
+
ds.session.queuedActivationTail = next;
|
|
4617
|
+
try {
|
|
4618
|
+
sessionStore.updateSession(ds.session);
|
|
4619
|
+
}
|
|
4620
|
+
catch (err) {
|
|
4621
|
+
ds.session.queuedActivationTail = priorTail;
|
|
4622
|
+
throw err;
|
|
4623
|
+
}
|
|
4624
|
+
return admitted;
|
|
4625
|
+
}
|
|
4626
|
+
/** True while a live worker's opening activation still owns submission order.
|
|
4627
|
+
* Every ingress that sees this state must use admitQueuedActivationTail rather
|
|
4628
|
+
* than ordinary worker IPC. */
|
|
4629
|
+
export function hasQueuedActivationAdmissionGate(ds) {
|
|
4630
|
+
return ds.session.queuedActivationPending === true
|
|
4631
|
+
|| (ds.session.queuedActivationTail?.length ?? 0) > 0
|
|
4632
|
+
|| (ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0
|
|
4633
|
+
|| ds.queuedActivationTailReleasePending !== undefined
|
|
4634
|
+
|| (ds.initialStartPending === true
|
|
4635
|
+
&& ds.session.queuedActivationInput !== undefined);
|
|
4636
|
+
}
|
|
4637
|
+
/** Central quarantine decision for one fork boundary — the SINGLE authority that
|
|
4638
|
+
* keeps a restore-time "tail-only quarantine" from being forked incorrectly.
|
|
4639
|
+
*
|
|
4640
|
+
* A quarantined owner (see restoreActiveSessions) has an old activation-tail head
|
|
4641
|
+
* that failed to promote transiently: the head sits un-promoted in the tail with
|
|
4642
|
+
* the admission gate held (worker:null). The invariant across EVERY fork boundary
|
|
4643
|
+
* (restore reattach, IM inbound refork, web-terminal lazy wake, and any future
|
|
4644
|
+
* one) is enforced here so callers cannot each miss it:
|
|
4645
|
+
*
|
|
4646
|
+
* - Not quarantined → pass the caller's args through unchanged.
|
|
4647
|
+
* - Quarantined + NON-empty prompt → REFUSE (`fork:false`). forkWorker cannot
|
|
4648
|
+
* verify the caller durably staged this turn behind the old head, so letting
|
|
4649
|
+
* it through could overtake the head. The inbound path must durable-admit the
|
|
4650
|
+
* turn into the tail first, then blank-recover (empty prompt) through here.
|
|
4651
|
+
* - Quarantined + empty prompt → retry the old head's promotion. On failure keep
|
|
4652
|
+
* the flag/gate/worker:null and REFUSE (a blank fork would leave a live worker
|
|
4653
|
+
* beside an unpromoted tail and permanently wedge the FIFO gate). On success,
|
|
4654
|
+
* clear the flag and rewrite the fork to recover the PROMOTED OLD HEAD (never
|
|
4655
|
+
* the caller's turn): Codex App recovers through its ledger with an empty
|
|
4656
|
+
* prompt; a non-Codex CLI resubmits the exact `queuedActivationInput` with the
|
|
4657
|
+
* persisted resume/turn/attempt — mirroring the daemon activation-recovery fork.
|
|
4658
|
+
*
|
|
4659
|
+
* Extracted (and exported) so the orchestration can be unit-tested without a real
|
|
4660
|
+
* tmux/worker: mock `promoteQueuedActivationTail` and assert refuse vs. recover
|
|
4661
|
+
* args, then that forkWorker applies them.
|
|
4662
|
+
*/
|
|
4663
|
+
export function resolveQuarantinedForkPlan(ds, promptInput, resumeOrTurnId) {
|
|
4664
|
+
if (!ds.quarantinedActivationTailPromotion) {
|
|
4665
|
+
return { fork: true, promptInput, resumeOrTurnId };
|
|
4666
|
+
}
|
|
4667
|
+
const content = typeof promptInput === 'string' ? promptInput : promptInput.content;
|
|
4668
|
+
if (content.length > 0) {
|
|
4669
|
+
logger.warn(`[${tag(ds)}] Refused non-empty fork of a quarantined tail-only owner; `
|
|
4670
|
+
+ `caller must durable-admit the turn behind the old head, then blank-recover`);
|
|
4671
|
+
return { fork: false, promptInput, resumeOrTurnId };
|
|
4672
|
+
}
|
|
4673
|
+
if (!promoteQueuedActivationTail(ds, { send: false })) {
|
|
4674
|
+
logger.warn(`[${tag(ds)}] Quarantined activation-tail promotion still failing at fork boundary; `
|
|
4675
|
+
+ `keeping worker:null quarantined owner (no fork) to avoid a live worker beside an unpromoted tail`);
|
|
4676
|
+
return { fork: false, promptInput, resumeOrTurnId };
|
|
4677
|
+
}
|
|
4678
|
+
ds.quarantinedActivationTailPromotion = undefined;
|
|
4679
|
+
// Promotion succeeded: the old head is now the tokened queued activation. Fork
|
|
4680
|
+
// THAT, exactly like the daemon's queuedActivation recovery — Codex App through
|
|
4681
|
+
// its dispatch ledger (empty prompt), non-Codex by resubmitting the exact input.
|
|
4682
|
+
const recoverThroughCodexLedger = (ds.session.cliId ?? getBot(ds.larkAppId).config.cliId) === 'codex-app';
|
|
4683
|
+
return {
|
|
4684
|
+
fork: true,
|
|
4685
|
+
promptInput: recoverThroughCodexLedger ? '' : (ds.session.queuedActivationInput ?? ''),
|
|
4686
|
+
resumeOrTurnId: {
|
|
4687
|
+
resume: ds.session.queuedActivationResume ?? ds.hasHistory,
|
|
4688
|
+
turnId: ds.session.queuedActivationTurnId,
|
|
4689
|
+
dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
|
|
4690
|
+
},
|
|
4691
|
+
};
|
|
4692
|
+
}
|
|
4693
|
+
/**
|
|
4694
|
+
* Fork (or re-attach) a worker for `ds`.
|
|
4695
|
+
*
|
|
4696
|
+
* Returns `false` ONLY when a quarantined tail-only owner's promotion could not
|
|
4697
|
+
* be recovered at this fork boundary (see resolveQuarantinedForkPlan): the caller
|
|
4698
|
+
* MUST treat the session as still worker-less (no live worker was started) and
|
|
4699
|
+
* leave the admission gate held. Every other outcome — forked, re-attached,
|
|
4700
|
+
* staged behind an ACK, routed through a live owner, or spawn-deferred during
|
|
4701
|
+
* device isolation — returns `true`.
|
|
4702
|
+
*/
|
|
3492
4703
|
export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
3493
4704
|
const gatedPrompt = typeof promptInput === 'string' ? { content: promptInput } : promptInput;
|
|
3494
4705
|
const transferGate = transferInputGates.get(ds);
|
|
@@ -3506,6 +4717,11 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
3506
4717
|
...(gatedDispatchAttempt !== undefined
|
|
3507
4718
|
? { dispatchAttempt: gatedDispatchAttempt }
|
|
3508
4719
|
: {}),
|
|
4720
|
+
// R6-B3 (transfer-gate sibling): sendWorkerInput reads steer
|
|
4721
|
+
// authorization from OPTS, not the payload. A steerable opening/refork
|
|
4722
|
+
// rerouted through the transfer gate must carry the flag here too, or it
|
|
4723
|
+
// silently downgrades true → false like the direct-route branch did.
|
|
4724
|
+
...(gatedPrompt.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
|
|
3509
4725
|
});
|
|
3510
4726
|
}
|
|
3511
4727
|
else {
|
|
@@ -3519,7 +4735,10 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
3519
4735
|
}
|
|
3520
4736
|
});
|
|
3521
4737
|
}
|
|
3522
|
-
|
|
4738
|
+
// Buffered/deferred behind the routing transfer: no live worker started
|
|
4739
|
+
// now, but the turn is durably staged, so this is not the quarantine
|
|
4740
|
+
// refusal that boolean-false signals to callers.
|
|
4741
|
+
return true;
|
|
3523
4742
|
}
|
|
3524
4743
|
// Device enrollment briefly freezes every daemon before the one-way host
|
|
3525
4744
|
// marker is installed and legacy local CLIs are torn down. Do this before
|
|
@@ -3532,34 +4751,34 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
3532
4751
|
}
|
|
3533
4752
|
})) {
|
|
3534
4753
|
logger.info(`[${tag(ds)}] worker spawn deferred during device credential activation`);
|
|
3535
|
-
return;
|
|
4754
|
+
return true;
|
|
3536
4755
|
}
|
|
4756
|
+
// Central quarantine guard — the single source of truth for the tail-only
|
|
4757
|
+
// restore quarantine invariant (documented on resolveQuarantinedForkPlan). Runs
|
|
4758
|
+
// before ANY prompt derivation or session mutation so a refusal is side-effect
|
|
4759
|
+
// free, and so a recovered plan rewrites the prompt/resume args used below.
|
|
4760
|
+
const quarantinePlan = resolveQuarantinedForkPlan(ds, promptInput, resumeOrTurnId);
|
|
4761
|
+
if (!quarantinePlan.fork)
|
|
4762
|
+
return false;
|
|
4763
|
+
promptInput = quarantinePlan.promptInput;
|
|
4764
|
+
resumeOrTurnId = quarantinePlan.resumeOrTurnId;
|
|
4765
|
+
// Refuse a closed or superseded session before any mutation (master guard).
|
|
3537
4766
|
if (!canForkRegisteredSession(ds))
|
|
3538
|
-
return;
|
|
3539
|
-
const cb = requireCallbacks();
|
|
3540
|
-
const bot = getBot(ds.larkAppId);
|
|
3541
|
-
const botCfg = bot.config;
|
|
4767
|
+
return false;
|
|
3542
4768
|
const promptPayload = typeof promptInput === 'string' ? { content: promptInput } : promptInput;
|
|
3543
4769
|
const prompt = promptPayload.content;
|
|
3544
|
-
//
|
|
3545
|
-
//
|
|
3546
|
-
//
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
ds.session.queuedCodexAppText = undefined;
|
|
3551
|
-
ds.session.queuedCodexAppMessageContext = undefined;
|
|
3552
|
-
sessionStore.updateSession(ds.session);
|
|
3553
|
-
}
|
|
3554
|
-
// worker.js lives in the same directory as daemon.js (src/)
|
|
3555
|
-
const workerPath = join(__dirname, '..', 'worker.js');
|
|
3556
|
-
const t = tag(ds);
|
|
3557
|
-
ds.localProcessAttestation = undefined;
|
|
3558
|
-
ds.workerReady = false;
|
|
4770
|
+
// R4-B1: the frozen steer authorization rides on the CliTurnPayload (computed
|
|
4771
|
+
// once by the daemon at admission, COPIED here). A bare-string promptInput or a
|
|
4772
|
+
// system/recovery opening carries no flag ⇒ forced serial. Never re-inferred.
|
|
4773
|
+
const initCodexAppSteerable = typeof promptInput === 'object'
|
|
4774
|
+
&& promptInput !== null
|
|
4775
|
+
&& promptInput.codexAppSteerable === true;
|
|
3559
4776
|
let resume = false;
|
|
3560
4777
|
let initTurnId;
|
|
3561
4778
|
let initDispatchAttempt;
|
|
3562
4779
|
let restartAttemptId;
|
|
4780
|
+
let initCodexAppInputGateFrozen = promptInput === ds.session.queuedActivationInput;
|
|
4781
|
+
let initAtMostOnce;
|
|
3563
4782
|
if (typeof resumeOrTurnId === 'string') {
|
|
3564
4783
|
initTurnId = resumeOrTurnId;
|
|
3565
4784
|
}
|
|
@@ -3568,406 +4787,728 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
3568
4787
|
initTurnId = resumeOrTurnId.turnId;
|
|
3569
4788
|
initDispatchAttempt = resumeOrTurnId.dispatchAttempt;
|
|
3570
4789
|
restartAttemptId = resumeOrTurnId.restartAttemptId;
|
|
4790
|
+
initCodexAppInputGateFrozen ||= resumeOrTurnId.codexAppInputGateFrozen === true;
|
|
4791
|
+
initAtMostOnce = resumeOrTurnId.atMostOnce;
|
|
3571
4792
|
}
|
|
3572
4793
|
else {
|
|
3573
4794
|
resume = resumeOrTurnId;
|
|
3574
4795
|
}
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
//
|
|
3579
|
-
//
|
|
3580
|
-
|
|
3581
|
-
//
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
logger.warn(`[${t}] workingDir "${rawCwd}" does not exist — falling back to ${cwd}`);
|
|
3588
|
-
// Materialise the resolved launch dir on the live session. getSessionWorkingDir()
|
|
3589
|
-
// falls back to the bot-default workingDir, but the usage ledger and dashboard read
|
|
3590
|
-
// `ds.workingDir ?? s.workingDir` RAW (without that fallback). A session that inherits
|
|
3591
|
-
// the bot-default workingDir — i.e. one never pinned via /repo or /cd — therefore leaves
|
|
3592
|
-
// ds.workingDir undefined, so getSessionTokenUsage() is handed cwd=undefined, cannot
|
|
3593
|
-
// locate the CLI transcript, and the session's token usage silently never records.
|
|
3594
|
-
// Pinning the resolved cwd here (it equals what the worker actually forked into) closes
|
|
3595
|
-
// that gap without touching the persisted session.workingDir "unset = follow default"
|
|
3596
|
-
// semantics: this is re-derived on every fork/restore.
|
|
3597
|
-
ds.workingDir = cwd;
|
|
3598
|
-
// Also persist the effective launch dir onto the SESSION record so a sibling
|
|
3599
|
-
// bot @-ed into the same anchor can inherit it (inherit-peer reads the
|
|
3600
|
-
// persisted session.workingDir cross-process, even across daemons). Without
|
|
3601
|
-
// this, a session running on the bot-default/fallback dir leaves
|
|
3602
|
-
// session.workingDir empty and is invisible to cross-bot same-dir inheritance.
|
|
3603
|
-
// Only FILL IN a missing workingDir (default/fallback-spawned sessions) — never
|
|
3604
|
-
// overwrite an already-pinned value (oncall/repo-card sessions keep their stored
|
|
3605
|
-
// form). Persist only a genuinely-resolved dir, never the homedir() crash-fallback
|
|
3606
|
-
// (cwd !== rawCwd → a transiently-missing dir can't pin to ~). Also exclude a
|
|
3607
|
-
// LEGITIMATELY-resolved homedir: a bot whose workingDir is unset/`~` resolves to
|
|
3608
|
-
// $HOME, and pinning that would let a sibling bot inherit $HOME (launch in the home
|
|
3609
|
-
// dir with no repo context) instead of getting its own repo card. Compared via
|
|
3610
|
-
// realpath so a symlinked/aliased $HOME is excluded too, not just the literal string.
|
|
3611
|
-
if (!ds.session.workingDir && cwd === rawCwd && !resolvesToHome(cwd)) {
|
|
3612
|
-
ds.session.workingDir = cwd;
|
|
3613
|
-
sessionStore.updateSession(ds.session);
|
|
3614
|
-
}
|
|
3615
|
-
// Sandbox decision is RECORDED ON THE SESSION at creation and reused on
|
|
3616
|
-
// restore — so toggling the live bot flag never retroactively (un)sandboxes a
|
|
3617
|
-
// historical session. A brand-new session (resume=false) with no recorded
|
|
3618
|
-
// decision adopts the live bot flag; a restore (resume=true) with no recorded
|
|
3619
|
-
// decision predates the sandbox feature → stays NOT sandboxed.
|
|
3620
|
-
if (ds.session.sandbox === undefined) {
|
|
3621
|
-
if (!resume) {
|
|
3622
|
-
ds.session.sandbox = botCfg.sandbox === true;
|
|
3623
|
-
ds.session.sandboxPaths = botCfg.sandboxPaths;
|
|
3624
|
-
ds.session.sandboxHidePaths = botCfg.sandboxHidePaths ?? [];
|
|
3625
|
-
ds.session.sandboxReadonlyPaths = botCfg.sandboxReadonlyPaths ?? [];
|
|
3626
|
-
ds.session.sandboxNetwork = botCfg.sandboxNetwork !== false;
|
|
4796
|
+
if (ds.session.queuedActivationPending && ds.session.queuedActivationResume !== undefined) {
|
|
4797
|
+
resume = ds.session.queuedActivationResume;
|
|
4798
|
+
}
|
|
4799
|
+
// Central double-fork guard. A live worker with durable ownership must never
|
|
4800
|
+
// be killed and replaced: empty reforks are rejected, while a real follow-up
|
|
4801
|
+
// is appended through the existing worker's ordinary durable FIFO. Keep this
|
|
4802
|
+
// before queued/cwd/sandbox mutations so a rejected refork is side-effect free.
|
|
4803
|
+
if (ds.worker && !ds.worker.killed
|
|
4804
|
+
&& hasProtectedSessionMutationOwnership(ds)) {
|
|
4805
|
+
if (prompt.length === 0) {
|
|
4806
|
+
logger.warn(`[${tag(ds)}] Refused empty worker refork while durable activation ownership is non-empty`);
|
|
4807
|
+
return true;
|
|
3627
4808
|
}
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
ds
|
|
4809
|
+
if (hasQueuedActivationAdmissionGate(ds)) {
|
|
4810
|
+
const turnId = initTurnId
|
|
4811
|
+
?? ds.currentReplyTarget?.turnId
|
|
4812
|
+
?? `queued-activation-followup-${randomUUID()}`;
|
|
4813
|
+
admitQueuedActivationTail(ds, {
|
|
4814
|
+
userPrompt: prompt,
|
|
4815
|
+
cliInput: {
|
|
4816
|
+
content: prompt,
|
|
4817
|
+
...(promptPayload.codexAppInput
|
|
4818
|
+
? { codexAppInput: promptPayload.codexAppInput }
|
|
4819
|
+
: {}),
|
|
4820
|
+
// R5-B1-2: preserve the frozen steer authorization on the double-fork
|
|
4821
|
+
// staged tail entry (only `=== true`).
|
|
4822
|
+
...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
4823
|
+
},
|
|
4824
|
+
turnId,
|
|
4825
|
+
...(initDispatchAttempt !== undefined
|
|
4826
|
+
? { dispatchAttempt: initDispatchAttempt }
|
|
4827
|
+
: {}),
|
|
4828
|
+
});
|
|
4829
|
+
logger.info(`[${tag(ds)}] Staged double-fork prompt behind queued activation ACK `
|
|
4830
|
+
+ `(turn=${turnId})`);
|
|
4831
|
+
return true;
|
|
3633
4832
|
}
|
|
3634
|
-
|
|
4833
|
+
const routed = sendWorkerInput(ds, promptPayload, initTurnId, {
|
|
4834
|
+
...(initDispatchAttempt !== undefined ? { dispatchAttempt: initDispatchAttempt } : {}),
|
|
4835
|
+
// R6-B3: sendWorkerInput reads steer authorization from OPTS (not the
|
|
4836
|
+
// payload), so the direct-route double-fork (protected ownership, no
|
|
4837
|
+
// activation gate — e.g. only an unsettled ledger) must pass it through
|
|
4838
|
+
// opts too. The gate branch above already admits with the flag; this
|
|
4839
|
+
// sibling branch would otherwise silently downgrade true → false.
|
|
4840
|
+
...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
4841
|
+
});
|
|
4842
|
+
logger[routed ? 'info' : 'warn'](`[${tag(ds)}] ${routed ? 'Routed' : 'Failed to route'} double-fork prompt through existing durable owner`);
|
|
4843
|
+
return true;
|
|
3635
4844
|
}
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
//
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
4845
|
+
const cb = requireCallbacks();
|
|
4846
|
+
const bot = getBot(ds.larkAppId);
|
|
4847
|
+
const botCfg = bot.config;
|
|
4848
|
+
// A bare /repo placeholder (and a non-Codex empty group-join setup) owns no
|
|
4849
|
+
// model turn. Starting its CLI with an empty prompt must not mint a queued
|
|
4850
|
+
// activation token: the worker has nothing to submit and could never ACK it.
|
|
4851
|
+
// Real raw openings and clean Codex sidecars remain tokened.
|
|
4852
|
+
if (ds.session.queued
|
|
4853
|
+
&& ds.session.pendingRepoSetup
|
|
4854
|
+
&& prompt.length === 0
|
|
4855
|
+
&& !promptPayload.codexAppInput
|
|
4856
|
+
&& !ds.pendingRawInput
|
|
4857
|
+
&& (ds.session.queuedActivationTail?.length ?? 0) === 0) {
|
|
4858
|
+
const priorJournal = snapshotQueuedActivationJournal(ds.session);
|
|
4859
|
+
ds.session.queued = false;
|
|
4860
|
+
clearQueuedActivationJournal(ds.session);
|
|
3645
4861
|
try {
|
|
3646
|
-
|
|
4862
|
+
sessionStore.updateSession(ds.session);
|
|
3647
4863
|
}
|
|
3648
|
-
catch {
|
|
3649
|
-
|
|
3650
|
-
|
|
4864
|
+
catch (err) {
|
|
4865
|
+
ds.session.queued = true;
|
|
4866
|
+
restoreQueuedActivationJournal(ds.session, priorJournal);
|
|
4867
|
+
throw err;
|
|
3651
4868
|
}
|
|
3652
|
-
catch { /* ignore */ }
|
|
3653
|
-
ds.worker = null;
|
|
3654
|
-
ds.workerReady = false;
|
|
3655
|
-
ds.workerPort = null;
|
|
3656
|
-
ds.workerToken = null;
|
|
3657
|
-
ds.workerViewToken = null;
|
|
3658
|
-
ds.managedTurnOrigin = undefined;
|
|
3659
|
-
}
|
|
3660
|
-
// Re-establishing a worker ends the cold-resume-suspended state: clear the
|
|
3661
|
-
// persisted marker so a future restart no longer treats this session's
|
|
3662
|
-
// backing as a deliberate-missing (a genuine later zombie must still close).
|
|
3663
|
-
if (ds.session.suspendedColdResume) {
|
|
3664
|
-
ds.session.suspendedColdResume = undefined;
|
|
3665
|
-
sessionStore.updateSession(ds.session);
|
|
3666
4869
|
}
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
4870
|
+
const queuedForkSnapshot = ds.session.queued
|
|
4871
|
+
? {
|
|
4872
|
+
queued: true,
|
|
4873
|
+
queuedPrompt: ds.session.queuedPrompt,
|
|
4874
|
+
queuedCodexAppText: ds.session.queuedCodexAppText,
|
|
4875
|
+
queuedCodexAppMessageContext: ds.session.queuedCodexAppMessageContext,
|
|
4876
|
+
queuedActivationPending: ds.session.queuedActivationPending,
|
|
4877
|
+
queuedActivationToken: ds.session.queuedActivationToken,
|
|
4878
|
+
queuedActivationInput: ds.session.queuedActivationInput,
|
|
4879
|
+
queuedActivationTurnId: ds.session.queuedActivationTurnId,
|
|
4880
|
+
queuedActivationDispatchAttempt: ds.session.queuedActivationDispatchAttempt,
|
|
4881
|
+
queuedActivationResume: ds.session.queuedActivationResume,
|
|
4882
|
+
initialStartPending: ds.initialStartPending,
|
|
4883
|
+
}
|
|
4884
|
+
: undefined;
|
|
4885
|
+
let acceptedForkDispatch;
|
|
4886
|
+
let spawnedWorker;
|
|
4887
|
+
let worker;
|
|
4888
|
+
let startupState;
|
|
4889
|
+
let initMsg;
|
|
4890
|
+
let agentCfg;
|
|
4891
|
+
const t = tag(ds);
|
|
4892
|
+
ds.localProcessAttestation = undefined;
|
|
4893
|
+
try {
|
|
4894
|
+
// worker.js lives in the same directory as daemon.js (src/)
|
|
4895
|
+
const workerPath = join(__dirname, '..', 'worker.js');
|
|
4896
|
+
// Per-turn authority is never inferred from mutable session state. Human
|
|
4897
|
+
// message routes pass their accepted Lark message id explicitly; restore,
|
|
4898
|
+
// scheduler, card retry and other system starts stay unattributed. Falling
|
|
4899
|
+
// back to currentReplyTarget here would let a later system prompt reuse an
|
|
4900
|
+
// older human turn after a worker replacement.
|
|
4901
|
+
let initAttributionTurnId = initTurnId
|
|
4902
|
+
?? (queuedForkSnapshot ? ds.session.pendingRepoSetup?.turnId : undefined);
|
|
4903
|
+
// Reply routing is frozen only from the same explicitly accepted turn. A
|
|
4904
|
+
// system prompt must not borrow an older mutable currentReplyTarget.
|
|
4905
|
+
const initReplyTurnId = initTurnId;
|
|
4906
|
+
const initReplyContext = prompt.length > 0
|
|
4907
|
+
? frozenReplyContextForTurn(ds, initReplyTurnId)
|
|
4908
|
+
: undefined;
|
|
4909
|
+
// A fork() whose cwd no longer exists emits an unhandled 'error' (spawn
|
|
4910
|
+
// ENOENT) that crashes the WHOLE daemon (→ pm2 crash-loop). Fall back to
|
|
4911
|
+
// home so a stale session workingDir can never take the daemon down.
|
|
4912
|
+
const rawCwd = cb.getSessionWorkingDir(ds);
|
|
4913
|
+
const cwd = rawCwd && existsSync(rawCwd) ? rawCwd : homedir();
|
|
4914
|
+
if (cwd !== rawCwd)
|
|
4915
|
+
logger.warn(`[${t}] workingDir "${rawCwd}" does not exist — falling back to ${cwd}`);
|
|
4916
|
+
// Materialise the resolved launch dir on the live session. getSessionWorkingDir()
|
|
4917
|
+
// falls back to the bot-default workingDir, but the usage ledger and dashboard read
|
|
4918
|
+
// `ds.workingDir ?? s.workingDir` RAW (without that fallback). A session that inherits
|
|
4919
|
+
// the bot-default workingDir — i.e. one never pinned via /repo or /cd — therefore leaves
|
|
4920
|
+
// ds.workingDir undefined, so getSessionTokenUsage() is handed cwd=undefined, cannot
|
|
4921
|
+
// locate the CLI transcript, and the session's token usage silently never records.
|
|
4922
|
+
// Pinning the resolved cwd here (it equals what the worker actually forked into) closes
|
|
4923
|
+
// that gap without touching the persisted session.workingDir "unset = follow default"
|
|
4924
|
+
// semantics: this is re-derived on every fork/restore.
|
|
4925
|
+
ds.workingDir = cwd;
|
|
4926
|
+
// Also persist the effective launch dir onto the SESSION record so a sibling
|
|
4927
|
+
// bot @-ed into the same anchor can inherit it (inherit-peer reads the
|
|
4928
|
+
// persisted session.workingDir cross-process, even across daemons). Without
|
|
4929
|
+
// this, a session running on the bot-default/fallback dir leaves
|
|
4930
|
+
// session.workingDir empty and is invisible to cross-bot same-dir inheritance.
|
|
4931
|
+
// Only FILL IN a missing workingDir (default/fallback-spawned sessions) — never
|
|
4932
|
+
// overwrite an already-pinned value (oncall/repo-card sessions keep their stored
|
|
4933
|
+
// form). Persist only a genuinely-resolved dir, never the homedir() crash-fallback
|
|
4934
|
+
// (cwd !== rawCwd → a transiently-missing dir can't pin to ~). Also exclude a
|
|
4935
|
+
// LEGITIMATELY-resolved homedir: a bot whose workingDir is unset/`~` resolves to
|
|
4936
|
+
// $HOME, and pinning that would let a sibling bot inherit $HOME (launch in the home
|
|
4937
|
+
// dir with no repo context) instead of getting its own repo card. Compared via
|
|
4938
|
+
// realpath so a symlinked/aliased $HOME is excluded too, not just the literal string.
|
|
4939
|
+
if (!ds.session.workingDir && cwd === rawCwd && !resolvesToHome(cwd)) {
|
|
4940
|
+
ds.session.workingDir = cwd;
|
|
3684
4941
|
sessionStore.updateSession(ds.session);
|
|
3685
4942
|
}
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
4943
|
+
// Sandbox decision is RECORDED ON THE SESSION at creation and reused on
|
|
4944
|
+
// restore — so toggling the live bot flag never retroactively (un)sandboxes a
|
|
4945
|
+
// historical session. A brand-new session (resume=false) with no recorded
|
|
4946
|
+
// decision adopts the live bot flag; a restore (resume=true) with no recorded
|
|
4947
|
+
// decision predates the sandbox feature → stays NOT sandboxed.
|
|
4948
|
+
if (ds.session.sandbox === undefined) {
|
|
4949
|
+
if (!resume) {
|
|
4950
|
+
ds.session.sandbox = botCfg.sandbox === true;
|
|
4951
|
+
ds.session.sandboxPaths = botCfg.sandboxPaths;
|
|
4952
|
+
ds.session.sandboxHidePaths = botCfg.sandboxHidePaths ?? [];
|
|
4953
|
+
ds.session.sandboxReadonlyPaths = botCfg.sandboxReadonlyPaths ?? [];
|
|
4954
|
+
ds.session.sandboxNetwork = botCfg.sandboxNetwork !== false;
|
|
4955
|
+
}
|
|
4956
|
+
else {
|
|
4957
|
+
ds.session.sandbox = false;
|
|
4958
|
+
ds.session.sandboxHidePaths = [];
|
|
4959
|
+
ds.session.sandboxReadonlyPaths = [];
|
|
4960
|
+
ds.session.sandboxNetwork = true;
|
|
4961
|
+
}
|
|
3692
4962
|
sessionStore.updateSession(ds.session);
|
|
3693
4963
|
}
|
|
3694
|
-
|
|
3695
|
-
|
|
4964
|
+
// Reserve and durably publish the replacement lifetime before killing an
|
|
4965
|
+
// existing worker. A failed reservation leaves the old worker untouched;
|
|
4966
|
+
// a successful reservation immediately invalidates any late old-worker ACK.
|
|
4967
|
+
const workerGeneration = reserveWorkerGeneration(ds);
|
|
4968
|
+
// Guard against double-fork: if a worker is already running, kill it first
|
|
4969
|
+
if (ds.worker && !ds.worker.killed) {
|
|
4970
|
+
const replacedWorker = ds.worker;
|
|
4971
|
+
logger.warn(`[${t}] Worker already running (pid: ${replacedWorker.pid}), killing before re-fork`);
|
|
4972
|
+
trackLifecycleRetirement(ds, replacedWorker);
|
|
4973
|
+
try {
|
|
4974
|
+
replacedWorker.send({ type: 'close' });
|
|
4975
|
+
}
|
|
4976
|
+
catch { /* ignore */ }
|
|
4977
|
+
try {
|
|
4978
|
+
replacedWorker.kill();
|
|
4979
|
+
}
|
|
4980
|
+
catch { /* ignore */ }
|
|
4981
|
+
ds.worker = null;
|
|
4982
|
+
ds.workerReady = false;
|
|
4983
|
+
ds.workerPort = null;
|
|
4984
|
+
ds.workerToken = null;
|
|
4985
|
+
ds.workerViewToken = null;
|
|
4986
|
+
ds.managedTurnOrigin = undefined;
|
|
4987
|
+
}
|
|
4988
|
+
// Re-establishing a worker ends the cold-resume-suspended state: clear the
|
|
4989
|
+
// persisted marker so a future restart no longer treats this session's
|
|
4990
|
+
// backing as a deliberate-missing (a genuine later zombie must still close).
|
|
4991
|
+
if (ds.session.suspendedColdResume) {
|
|
4992
|
+
ds.session.suspendedColdResume = undefined;
|
|
3696
4993
|
sessionStore.updateSession(ds.session);
|
|
3697
4994
|
}
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
}
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
4995
|
+
// Re-establishing a worker also reclaims any crash-diagnostic shell a prior
|
|
4996
|
+
// worker left parked but couldn't clean (hard-killed while parked, daemon
|
|
4997
|
+
// still alive → next message reforks here). The fresh CLI spawns under the
|
|
4998
|
+
// real bmx-<sid>; without this, bmx-diag-<sid> + its .ansi file would leak.
|
|
4999
|
+
if (!ds.initConfig?.adoptMode && !ds.adoptedFrom)
|
|
5000
|
+
reclaimParkedCrashDiagnostic(ds);
|
|
5001
|
+
agentCfg = sessionAgentConfig(ds, botCfg);
|
|
5002
|
+
if (!initTurnId && prompt.length > 0 && agentCfg.cliId === 'codex-app') {
|
|
5003
|
+
initAttributionTurnId = `codex-app-dispatch-${randomUUID()}`;
|
|
5004
|
+
}
|
|
5005
|
+
ensureCliEnv(agentCfg.cliId, agentCfg.cliPathOverride);
|
|
5006
|
+
let nativeSessionTitle;
|
|
5007
|
+
let nativeSessionTitlePrompt;
|
|
5008
|
+
if (agentCfg.cliId === 'codex' && !ds.adoptedFrom) {
|
|
5009
|
+
const isFreshNativeSession = !resume && !ds.session.cliSessionId;
|
|
5010
|
+
const titlePrompt = extractBotmuxLarkNativeSessionTitlePrompt(promptPayload.codexAppInput?.text ?? prompt, bot.botName ? [{ name: bot.botName }] : undefined);
|
|
5011
|
+
if (isFreshNativeSession && !ds.session.nativeSessionTitleUserDefined) {
|
|
5012
|
+
ds.session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(titlePrompt ? ds.session.title : undefined, bot.botName ? [{ name: bot.botName }] : undefined, ds.chatType === 'group' ? ds.session.chatDisplayName : undefined);
|
|
5013
|
+
ds.session.nativeSessionTitleAwaitingContent = titlePrompt ? undefined : true;
|
|
5014
|
+
nativeSessionTitlePrompt = titlePrompt;
|
|
5015
|
+
sessionStore.updateSession(ds.session);
|
|
5016
|
+
}
|
|
5017
|
+
else if (ds.session.nativeSessionTitleAwaitingContent
|
|
5018
|
+
&& !ds.session.nativeSessionTitleUserDefined
|
|
5019
|
+
&& titlePrompt) {
|
|
5020
|
+
ds.session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(titlePrompt);
|
|
5021
|
+
ds.session.nativeSessionTitleAwaitingContent = undefined;
|
|
5022
|
+
nativeSessionTitlePrompt = titlePrompt;
|
|
5023
|
+
sessionStore.updateSession(ds.session);
|
|
5024
|
+
}
|
|
5025
|
+
else if (isFreshNativeSession && !ds.session.nativeSessionTitle) {
|
|
5026
|
+
ds.session.nativeSessionTitle = ds.session.title;
|
|
5027
|
+
sessionStore.updateSession(ds.session);
|
|
5028
|
+
}
|
|
5029
|
+
if (isFreshNativeSession
|
|
5030
|
+
|| (resume && !!ds.session.cliSessionId && !!ds.session.nativeSessionTitle)
|
|
5031
|
+
|| (resume
|
|
5032
|
+
&& !!ds.session.nativeSessionTitleAwaitingContent
|
|
5033
|
+
&& !!ds.session.nativeSessionTitle)
|
|
5034
|
+
|| (!!nativeSessionTitlePrompt && !!ds.session.nativeSessionTitle)) {
|
|
5035
|
+
nativeSessionTitle = ds.session.nativeSessionTitle;
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
// Claude Code blocks on the interactive folder-trust dialog the first time
|
|
5039
|
+
// it runs in an untrusted workingDir; pre-accept it so the spawn doesn't hang.
|
|
5040
|
+
// Seed CLI (Claude Code fork) has the same dialog — drive both off the
|
|
5041
|
+
// adapter's claude-family fields, writing to each variant's own .claude.json
|
|
5042
|
+
// (`~/.claude.json` for claude, `.claude-runtime/.claude.json` for seed).
|
|
5043
|
+
const familyAdapter = createCliAdapterSync(agentCfg.cliId, agentCfg.cliPathOverride);
|
|
5044
|
+
if (familyAdapter.claudeStateJsonPath)
|
|
5045
|
+
ensureClaudeFolderTrust(cwd, familyAdapter.claudeStateJsonPath);
|
|
5046
|
+
const resolvedBackendType = resolvePairedSpawnBackendType(agentCfg.cliId, ds.session.backendType, botCfg.backendType, config.daemon.backendType);
|
|
5047
|
+
if (ds.session.cliId !== agentCfg.cliId || ds.session.backendType !== resolvedBackendType) {
|
|
5048
|
+
ds.session.cliId = agentCfg.cliId;
|
|
5049
|
+
ds.session.backendType = resolvedBackendType;
|
|
5050
|
+
sessionStore.updateSession(ds.session);
|
|
5051
|
+
}
|
|
5052
|
+
// Prepend the botmux wrapper bin dir to PATH so CLIs can call `botmux send` etc.
|
|
5053
|
+
// Single source of truth (resolveBotmuxWrapperBinDir): core-only → dedicated
|
|
5054
|
+
// `<SESSION_DATA_DIR>/bin` (not the shared ~/.botmux/bin), matching where the
|
|
5055
|
+
// daemon WROTE the wrapper — else the worker PATH would miss it or a same-HOME
|
|
5056
|
+
// fleet wrapper would shadow it (codex P1).
|
|
5057
|
+
const botmuxBinDir = resolveBotmuxWrapperBinDir(process.env);
|
|
5058
|
+
const pathWithBotmux = prependBotmuxBin(botmuxBinDir, process.env.PATH);
|
|
5059
|
+
const forkEnv = workerForkEnv(process.env);
|
|
5060
|
+
// Dequeue only after every earlier launch-preparation write has finished.
|
|
5061
|
+
// The exact input remains journaled until the worker confirms its adapter
|
|
5062
|
+
// submission boundary; daemon send/ready are intentionally too early.
|
|
5063
|
+
const recoveredActivationLedgerEntry = ds.session.queuedActivationPending
|
|
5064
|
+
? [...(ds.session.codexAppDispatchLedger ?? [])]
|
|
5065
|
+
.reverse()
|
|
5066
|
+
.find(entry => entry.state === 'accepted' || entry.state === 'prepared')
|
|
5067
|
+
: undefined;
|
|
5068
|
+
const queuedActivationToken = queuedForkSnapshot
|
|
5069
|
+
? randomUUID()
|
|
5070
|
+
: ds.session.queuedActivationPending
|
|
5071
|
+
? (ds.session.queuedActivationToken
|
|
5072
|
+
?? recoveredActivationLedgerEntry?.queuedActivationToken
|
|
5073
|
+
?? randomUUID())
|
|
5074
|
+
: undefined;
|
|
5075
|
+
if (queuedForkSnapshot) {
|
|
5076
|
+
ds.session.queued = false;
|
|
5077
|
+
ds.session.queuedActivationPending = true;
|
|
5078
|
+
ds.session.queuedActivationToken = queuedActivationToken;
|
|
5079
|
+
ds.session.queuedActivationResume = resume;
|
|
5080
|
+
ds.initialStartPending = true;
|
|
5081
|
+
}
|
|
5082
|
+
else if (ds.session.queuedActivationPending && queuedActivationToken) {
|
|
5083
|
+
// Migrate journals written before the token/ACK protocol. The queued
|
|
5084
|
+
// activation is the newest unsettled FIFO entry; stamp the same token onto
|
|
5085
|
+
// both journal and entry before the recovery worker can submit it.
|
|
5086
|
+
let migrated = ds.session.queuedActivationToken !== queuedActivationToken;
|
|
5087
|
+
ds.session.queuedActivationToken = queuedActivationToken;
|
|
5088
|
+
ds.initialStartPending = true;
|
|
5089
|
+
if (recoveredActivationLedgerEntry
|
|
5090
|
+
&& recoveredActivationLedgerEntry.queuedActivationToken !== queuedActivationToken) {
|
|
5091
|
+
recoveredActivationLedgerEntry.queuedActivationToken = queuedActivationToken;
|
|
5092
|
+
migrated = true;
|
|
5093
|
+
}
|
|
5094
|
+
if (migrated)
|
|
5095
|
+
sessionStore.updateSession(ds.session);
|
|
5096
|
+
}
|
|
5097
|
+
// Snapshot the prior durable FIFO before accepting this fork's new prompt.
|
|
5098
|
+
// A pure reattach receives the full snapshot; a refork carrying N+1 restores
|
|
5099
|
+
// old N first and reserves N+1 through the normal worker write path.
|
|
5100
|
+
const codexAppRecoveredDispatches = agentCfg.cliId === 'codex-app'
|
|
5101
|
+
? (ds.session.codexAppDispatchLedger ?? []).map(entry => ({ ...entry }))
|
|
5102
|
+
: [];
|
|
5103
|
+
const promptCodexAppInput = initCodexAppInputGateFrozen
|
|
5104
|
+
? cloneFrozenCodexAppInput(promptPayload.codexAppInput, initAttributionTurnId)
|
|
5105
|
+
: codexAppInputForSession(ds, promptPayload.codexAppInput, initAttributionTurnId);
|
|
5106
|
+
if (queuedForkSnapshot) {
|
|
5107
|
+
ds.session.queuedActivationInput = {
|
|
5108
|
+
content: prompt,
|
|
5109
|
+
...(promptCodexAppInput ? { codexAppInput: promptCodexAppInput } : {}),
|
|
5110
|
+
// R4-B1: preserve the frozen steer authorization when re-parking N+1 behind
|
|
5111
|
+
// a recovery fork (COPY, never re-infer).
|
|
5112
|
+
...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
5113
|
+
};
|
|
5114
|
+
ds.session.queuedActivationTurnId = initAttributionTurnId;
|
|
5115
|
+
ds.session.queuedActivationDispatchAttempt = initDispatchAttempt;
|
|
5116
|
+
}
|
|
5117
|
+
const initVcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, initTurnId ?? initReplyTurnId);
|
|
5118
|
+
const codexAppDispatchId = prompt.length > 0
|
|
5119
|
+
? acceptCodexAppDispatch(ds, {
|
|
5120
|
+
content: prompt,
|
|
5121
|
+
...(promptCodexAppInput ? { codexAppInput: promptCodexAppInput } : {}),
|
|
5122
|
+
...(queuedActivationToken ? { queuedActivationToken } : {}),
|
|
5123
|
+
...(initReplyTurnId ? { replyTurnId: initReplyTurnId } : {}),
|
|
5124
|
+
...(initReplyContext ? {
|
|
5125
|
+
replyTarget: initReplyContext.target,
|
|
5126
|
+
...(initReplyContext.quoteTargetId
|
|
5127
|
+
? { quoteTargetId: initReplyContext.quoteTargetId }
|
|
5128
|
+
: {}),
|
|
5129
|
+
...(initReplyContext.replyTargetSenderOpenId
|
|
5130
|
+
? { replyTargetSenderOpenId: initReplyContext.replyTargetSenderOpenId }
|
|
5131
|
+
: {}),
|
|
5132
|
+
...(initReplyContext.replyTargetSenderIsBot !== undefined
|
|
5133
|
+
? { replyTargetSenderIsBot: initReplyContext.replyTargetSenderIsBot }
|
|
5134
|
+
: {}),
|
|
5135
|
+
} : {}),
|
|
5136
|
+
// R4-B1: COPY the frozen steer authorization onto the fork's
|
|
5137
|
+
// accept-ledger entry so a recovered/opening first turn is steerable
|
|
5138
|
+
// exactly as the daemon admitted it (admission computed once; COPIED).
|
|
5139
|
+
...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
5140
|
+
}, initAttributionTurnId, initDispatchAttempt, initVcMeetingImTurnOrigin)
|
|
5141
|
+
: undefined;
|
|
5142
|
+
if (codexAppDispatchId && initAttributionTurnId) {
|
|
5143
|
+
acceptedForkDispatch = {
|
|
5144
|
+
dispatchId: codexAppDispatchId,
|
|
5145
|
+
turnId: initAttributionTurnId,
|
|
5146
|
+
...(initDispatchAttempt !== undefined
|
|
5147
|
+
? { dispatchAttempt: initDispatchAttempt }
|
|
5148
|
+
: {}),
|
|
5149
|
+
};
|
|
5150
|
+
}
|
|
5151
|
+
if (queuedForkSnapshot && !codexAppDispatchId) {
|
|
5152
|
+
sessionStore.updateSession(ds.session);
|
|
5153
|
+
}
|
|
5154
|
+
worker = fork(workerPath, [], {
|
|
5155
|
+
windowsHide: true,
|
|
5156
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
5157
|
+
execArgv: workerForkExecArgv(),
|
|
5158
|
+
cwd,
|
|
5159
|
+
env: {
|
|
5160
|
+
...forkEnv,
|
|
5161
|
+
PATH: pathWithBotmux,
|
|
5162
|
+
CLAUDECODE: undefined,
|
|
5163
|
+
BOTMUX: '1', // Marker so user scripts/skills can detect a botmux-spawned CLI
|
|
5164
|
+
SESSION_DATA_DIR: config.session.dataDir,
|
|
5165
|
+
BOTMUX_SESSION_ID: ds.session.sessionId,
|
|
5166
|
+
LARK_APP_ID: botCfg.larkAppId,
|
|
5167
|
+
// Withhold the real secret from the worker's own CLI env for a no-transport
|
|
5168
|
+
// session (apiOnly bot or HTTP virtual chat). SEPARATE leak from the
|
|
5169
|
+
// init-message larkAppSecret — the spawned CLI env carries it directly from
|
|
5170
|
+
// botCfg. Empty it here too so no secret reaches the worker/sandbox.
|
|
5171
|
+
LARK_APP_SECRET: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
|
|
5172
|
+
},
|
|
3759
5173
|
});
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
5174
|
+
spawnedWorker = worker;
|
|
5175
|
+
startupState = {
|
|
5176
|
+
ready: false, failureNotified: false,
|
|
5177
|
+
initTurnId: initAttributionTurnId,
|
|
5178
|
+
initDispatchAttempt,
|
|
5179
|
+
};
|
|
5180
|
+
// A fork-level failure (spawn ENOENT, etc.) emits 'error'; without a handler
|
|
5181
|
+
// the unhandled event crashes the daemon. It also happens before worker IPC
|
|
5182
|
+
// exists, so this daemon-side branch must be the user-visible fallback.
|
|
5183
|
+
worker.on('error', (err) => {
|
|
5184
|
+
const reason = err?.message ?? String(err);
|
|
5185
|
+
logger.error(`[${t}] Worker fork error: ${reason}`);
|
|
5186
|
+
if (ds.worker === worker) {
|
|
5187
|
+
if (ds.session.queuedActivationPending) {
|
|
5188
|
+
// The durable opening journal remains authoritative until the adapter
|
|
5189
|
+
// proves submission. A failed child must release only its runtime
|
|
5190
|
+
// claim so the next generation can replay that exact head.
|
|
5191
|
+
ds.initialStartPending = false;
|
|
5192
|
+
ds.initialStartClaimToken = undefined;
|
|
5193
|
+
}
|
|
5194
|
+
else {
|
|
5195
|
+
reparkQueuedActivationFollowUpTail(ds, 'worker error during activation follow-up handoff');
|
|
5196
|
+
}
|
|
5197
|
+
const retainExactRetirementGeneration = ds.riffShutdownState !== undefined
|
|
5198
|
+
|| ds.riffCloseState !== undefined;
|
|
5199
|
+
if (!retainExactRetirementGeneration) {
|
|
5200
|
+
ds.worker = null;
|
|
5201
|
+
ds.workerPort = null;
|
|
5202
|
+
ds.workerToken = null;
|
|
5203
|
+
ds.workerViewToken = null;
|
|
5204
|
+
ds.managedTurnOrigin = undefined;
|
|
5205
|
+
ds.riffCloseState = undefined;
|
|
5206
|
+
}
|
|
5207
|
+
// The retirement coordinator owns a prepared generation. Keep the exact
|
|
5208
|
+
// ChildProcess pointer until exit so it can decide whether abort/commit
|
|
5209
|
+
// was durably verified.
|
|
5210
|
+
try {
|
|
5211
|
+
worker.kill();
|
|
5212
|
+
}
|
|
5213
|
+
catch { /* best-effort failed-child fence */ }
|
|
5214
|
+
}
|
|
5215
|
+
if (startupState.failureNotified)
|
|
5216
|
+
return;
|
|
5217
|
+
startupState.failureNotified = true;
|
|
5218
|
+
emitSessionLifecycleHook(ds, 'session.requires_attention', {
|
|
5219
|
+
reason: 'worker_fork_error',
|
|
5220
|
+
message: reason,
|
|
5221
|
+
});
|
|
5222
|
+
// A dedicated VC receiver and a silent schedule have no auxiliary Lark
|
|
5223
|
+
// output channel. Keep lifecycle/dashboard state above, but never leak a
|
|
5224
|
+
// fork diagnostic into the chat.
|
|
5225
|
+
if (ds.session.vcMeetingReceiver || isSilentScheduledTurn(ds, initAttributionTurnId)) {
|
|
5226
|
+
logger.info(`[${t}] Managed/silent fork failure kept out of auxiliary Lark UI `
|
|
5227
|
+
+ `turn=${initAttributionTurnId?.slice(0, 12) ?? '-'} attempt=${initDispatchAttempt}: ${reason}`);
|
|
5228
|
+
return;
|
|
5229
|
+
}
|
|
5230
|
+
const cliName = sessionCliDisplayName(ds, botCfg);
|
|
5231
|
+
const message = tr('worker.start_failed', { cliName, reason }, botLocale(botCfg));
|
|
5232
|
+
void cb.sessionReply(sessionAnchorId(ds), message, 'text', ds.larkAppId, fallbackTurnId(ds, initAttributionTurnId), ds.session.vcMeetingReceiver
|
|
5233
|
+
? { sourceSessionId: ds.session.sessionId }
|
|
5234
|
+
: undefined).catch(replyErr => logger.error(`[${t}] Failed to deliver worker fork error to Lark: ${replyErr}`));
|
|
5235
|
+
});
|
|
5236
|
+
// Pipe worker stdout/stderr to daemon logger.
|
|
5237
|
+
// Both go through logger.info → daemon.log (not error.log). Worker stderr
|
|
5238
|
+
// is NOT necessarily an error: CLI adapters (claude, codex, etc.) write
|
|
5239
|
+
// progress, version banners, deprecation warnings, etc. there. The line
|
|
5240
|
+
// is still visible (tagged `:err`) for triage. Real worker faults arrive
|
|
5241
|
+
// separately via the IPC `Worker error` branch and stay as logger.error.
|
|
5242
|
+
worker.stdout?.on('data', (data) => {
|
|
5243
|
+
for (const line of data.toString().split('\n')) {
|
|
5244
|
+
const trimmed = line.trim();
|
|
5245
|
+
if (trimmed)
|
|
5246
|
+
logger.info(`[${t}:out] ${trimmed}`);
|
|
5247
|
+
}
|
|
5248
|
+
});
|
|
5249
|
+
worker.stderr?.on('data', (data) => {
|
|
5250
|
+
for (const line of data.toString().split('\n')) {
|
|
5251
|
+
logWorkerStderr(t, line.trim());
|
|
5252
|
+
}
|
|
5253
|
+
});
|
|
5254
|
+
// Send init config — use per-bot settings
|
|
5255
|
+
const runtimeIdentity = runtimeBuildIdentity();
|
|
5256
|
+
initMsg = {
|
|
5257
|
+
type: 'init',
|
|
5258
|
+
sessionId: ds.session.sessionId,
|
|
5259
|
+
chatId: ds.chatId,
|
|
5260
|
+
chatType: ds.chatType,
|
|
5261
|
+
rootMessageId: sessionAnchorId(ds),
|
|
5262
|
+
workingDir: cwd,
|
|
5263
|
+
cliId: agentCfg.cliId,
|
|
5264
|
+
cliRuntime: agentCfg.cliRuntime,
|
|
5265
|
+
cliPathOverride: agentCfg.cliPathOverride,
|
|
5266
|
+
wrapperCli: agentCfg.wrapperCli,
|
|
5267
|
+
launchShell: botCfg.launchShell,
|
|
5268
|
+
model: agentCfg.model,
|
|
5269
|
+
reasoningEffort: agentCfg.reasoningEffort,
|
|
5270
|
+
disableCliBypass: botCfg.disableCliBypass === true,
|
|
5271
|
+
codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault,
|
|
5272
|
+
// Startup commands run on every fresh spawn (incl. resume) so session-only
|
|
5273
|
+
// settings like `/effort ultracode` are re-established. Adopt sessions are
|
|
5274
|
+
// observed, not driven — forkAdoptWorker intentionally omits this.
|
|
5275
|
+
startupCommands: botCfg.startupCommands,
|
|
5276
|
+
// Per-bot env (bots.json `env`) — injected into the CLI process only (e.g.
|
|
5277
|
+
// ANTHROPIC_BASE_URL/AUTH_TOKEN for a GLM/3rd-party bot). Adopt sessions are
|
|
5278
|
+
// observed, not driven, so forkAdoptWorker intentionally omits it.
|
|
5279
|
+
env: botCfg.env,
|
|
5280
|
+
// Use the decision recorded on the session (above), NOT the live bot flag, so
|
|
5281
|
+
// historical sessions never get retroactively sandboxed on restart.
|
|
5282
|
+
sandbox: ds.session.sandbox === true,
|
|
5283
|
+
sandboxPaths: ds.session.sandboxPaths ?? botCfg.sandboxPaths,
|
|
5284
|
+
sandboxHidePaths: ds.session.sandboxHidePaths ?? [],
|
|
5285
|
+
sandboxReadonlyPaths: ds.session.sandboxReadonlyPaths ?? [],
|
|
5286
|
+
sandboxNetwork: ds.session.sandboxNetwork !== false,
|
|
5287
|
+
// Per-bot local read isolation (enforced worker-side; the worker gates it).
|
|
5288
|
+
// Sibling data needs no app-id enumeration: per-bot dirs are denied wholesale
|
|
5289
|
+
// and per-bot session files by filename pattern (see buildV2DenyPaths).
|
|
5290
|
+
// HARD credential boundary for a no-transport session (apiOnly bot OR HTTP
|
|
5291
|
+
// virtual chat): force read isolation so the CLI physically cannot read the
|
|
5292
|
+
// full bots.json / sibling BOT_HOME / send-cred / lark-cli store — a model
|
|
5293
|
+
// that deletes/forges the ancestry marker or bypasses the CLI still cannot
|
|
5294
|
+
// build ANY (sibling) Lark client. The pid-marker gate is only friendly
|
|
5295
|
+
// early-reject; THIS is the fail-closed boundary. Reuses the existing unified
|
|
5296
|
+
// fs-policy (mac+Linux fail-closed); a backend that can't isolate locally
|
|
5297
|
+
// refuses to spawn rather than leak creds.
|
|
5298
|
+
readIsolation: botCfg.readIsolation === true
|
|
5299
|
+
|| !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }),
|
|
5300
|
+
readDenyExtraPaths: botCfg.readDenyExtraPaths ?? [],
|
|
5301
|
+
// Identifies THIS daemon lifetime. Stamped onto isolated panes so the worker
|
|
5302
|
+
// can tell a suspend→resume reattach (same boot id, still isolated) from a
|
|
5303
|
+
// stale pane surviving a daemon restart (different id → kill + cold-spawn).
|
|
5304
|
+
daemonBootId: DAEMON_BOOT_ID,
|
|
5305
|
+
// Freeze-once: an already-running session keeps the backend stamped at spawn
|
|
5306
|
+
// (ds.session.backendType) even if the bot's live `backendType` changed since —
|
|
5307
|
+
// otherwise a cold-resume/refork would re-derive from live config and strand
|
|
5308
|
+
// the real persistent pane (the stamp is written below; restore reads it via
|
|
5309
|
+
// getSessionPersistentBackendType). A brand-new session (no stamp) resolves
|
|
5310
|
+
// from live config, so a dashboard backend switch only affects NEW sessions.
|
|
5311
|
+
backendType: resolvePairedSpawnBackendType(agentCfg.cliId, ds.session.backendType, botCfg.backendType, config.daemon.backendType),
|
|
5312
|
+
// Shared Herdr is not derivable from sessionId: preserve the exact host +
|
|
5313
|
+
// managed-agent affinity across daemon/worker replacement.
|
|
5314
|
+
persistentBackendTarget: ds.session.persistentBackendTarget,
|
|
5315
|
+
backendConfig: botCfg.riff,
|
|
5316
|
+
riffParentTaskId: ds.session.riffParentTaskId,
|
|
5317
|
+
riffRepoDirs: ds.session.riffRepoDirs,
|
|
5318
|
+
deferredScheduleRun: ds.session.deferredScheduleRun,
|
|
5319
|
+
...(nativeSessionTitle ? { nativeSessionTitle } : {}),
|
|
5320
|
+
...(nativeSessionTitlePrompt ? { nativeSessionTitlePrompt } : {}),
|
|
5321
|
+
prompt,
|
|
5322
|
+
...(promptCodexAppInput ? { promptCodexAppInput } : {}),
|
|
5323
|
+
...(queuedActivationToken ? { queuedActivationToken } : {}),
|
|
5324
|
+
resume,
|
|
5325
|
+
// One-shot native fork intent (see Session.pendingForkSession). Only the
|
|
5326
|
+
// child's FIRST spawn resumes the SOURCE transcript (cliSessionId still
|
|
5327
|
+
// points at the parent's CLI id here) while forking forward into a new id;
|
|
5328
|
+
// the worker clears the marker + persists the child's own new id, so a
|
|
5329
|
+
// later refork resumes the child normally (pendingForkSession=false).
|
|
5330
|
+
forkSession: ds.session.pendingForkSession === true,
|
|
5331
|
+
cliSessionId: ds.session.cliSessionId,
|
|
5332
|
+
ownerOpenId: ds.ownerOpenId,
|
|
5333
|
+
webPort: ds.session.webPort,
|
|
5334
|
+
larkAppId: botCfg.larkAppId,
|
|
5335
|
+
// Freeze on the session transport capability: a no-transport session
|
|
5336
|
+
// (apiOnly bot OR HTTP virtual chat) must not even RECEIVE the real secret —
|
|
5337
|
+
// withholding it removes the capability rather than gating a tamperable flag
|
|
5338
|
+
// the sandboxed agent could flip. The worker then physically cannot dial
|
|
5339
|
+
// Feishu (uploader/cred-write are also skipped downstream on the same test).
|
|
5340
|
+
larkAppSecret: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
|
|
5341
|
+
apiOnly: botCfg.apiOnly,
|
|
5342
|
+
// Freeze the ACTUAL loaded bots-config path (getLoadedConfigPath) so a
|
|
5343
|
+
// no-transport worker's fs-policy denies it from a HOST-owned fact, not a
|
|
5344
|
+
// guess off BOTS_CONFIG env (which the agent could see/forge). When it lives
|
|
5345
|
+
// outside every botmux authority root, buildFsPolicy fails the spawn closed
|
|
5346
|
+
// rather than silently masking an arbitrary parent dir (codex P1). Omitted
|
|
5347
|
+
// from forkAdoptWorker below — its observe branch returns before fs-policy.
|
|
5348
|
+
loadedBotsConfigPath: getLoadedConfigPath(),
|
|
5349
|
+
brand: normalizeBrand(botCfg.brand),
|
|
5350
|
+
botName: bot.botName,
|
|
5351
|
+
botOpenId: bot.botOpenId,
|
|
5352
|
+
locale: botLocale(botCfg),
|
|
5353
|
+
turnId: initAttributionTurnId,
|
|
5354
|
+
...(initReplyTurnId ? { replyTurnId: initReplyTurnId } : {}),
|
|
5355
|
+
dispatchAttempt: initDispatchAttempt,
|
|
5356
|
+
...(codexAppDispatchId ? { codexAppDispatchId } : {}),
|
|
5357
|
+
// R4-B1: COPY the frozen steer authorization onto the first-turn init so the
|
|
5358
|
+
// worker can fold follow-ups into this opening turn (canSteer requires the
|
|
5359
|
+
// group root to be steerable). System/recovery openings carry no flag.
|
|
5360
|
+
...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
5361
|
+
...(codexAppRecoveredDispatches.length > 0
|
|
5362
|
+
? { codexAppRecoveredDispatches }
|
|
5363
|
+
: {}),
|
|
5364
|
+
...((ds.session.codexAppGenerationCommits?.length ?? 0) > 0
|
|
5365
|
+
? { codexAppGenerationCommits: ds.session.codexAppGenerationCommits }
|
|
5366
|
+
: {}),
|
|
5367
|
+
// At-most-once (idempotency lease): ride the flag on the init message so the
|
|
5368
|
+
// worker tags the keyed init prompt no-replay (codex #776 round-7 #1).
|
|
5369
|
+
...(initAtMostOnce ? { atMostOnce: true } : {}),
|
|
5370
|
+
vcMeetingImTurnOrigin: initVcMeetingImTurnOrigin,
|
|
5371
|
+
pluginBindings: botCfg.plugins,
|
|
5372
|
+
skillPolicy: botCfg.skills,
|
|
5373
|
+
...(runtimeIdentity.status === 'known'
|
|
5374
|
+
? { runnerBuildId: runtimeIdentity.id }
|
|
5375
|
+
: {}),
|
|
5376
|
+
...(ds.session.runnerBuildId ? { persistedRunnerBuildId: ds.session.runnerBuildId } : {}),
|
|
5377
|
+
...(restartAttemptId ? { restartAttemptId } : {}),
|
|
5378
|
+
};
|
|
5379
|
+
// A persisted port from an older ZMX implementation must never revive the
|
|
5380
|
+
// removed Web TUI or get forwarded as a preferred listen port. The worker
|
|
5381
|
+
// will signal lifecycle readiness with port=0 instead.
|
|
5382
|
+
if (!backendSupportsWebTerminal(initMsg.backendType)) {
|
|
5383
|
+
initMsg.webPort = undefined;
|
|
5384
|
+
if (ds.session.webPort !== undefined) {
|
|
5385
|
+
ds.session.webPort = undefined;
|
|
5386
|
+
sessionStore.updateSession(ds.session);
|
|
5387
|
+
}
|
|
3767
5388
|
}
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
5389
|
+
ds.initConfig = initMsg;
|
|
5390
|
+
// Stamp cliId on the persisted session so the dashboard can show a CLI badge
|
|
5391
|
+
// even after the session is closed. Do this before installing worker handlers:
|
|
5392
|
+
// a fast worker can emit `ready` immediately after init, and card rendering
|
|
5393
|
+
// must see the session-level CLI identity rather than the bot default.
|
|
5394
|
+
if (ds.session.cliId !== agentCfg.cliId) {
|
|
5395
|
+
ds.session.cliId = agentCfg.cliId;
|
|
5396
|
+
sessionStore.updateSession(ds.session);
|
|
5397
|
+
}
|
|
5398
|
+
// Stamp the resolved backend on the persisted session. Since PTY退役, the
|
|
5399
|
+
// worker no longer silently downgrades an unavailable backend (it hard-gates
|
|
5400
|
+
// instead), so the requested backend here IS the effective one for any
|
|
5401
|
+
// session that actually runs. Restore reads this back (see
|
|
5402
|
+
// getSessionPersistentBackendType) so an upgraded daemon doesn't re-derive a
|
|
5403
|
+
// session's backend from the now-always-tmux default and misclassify a legacy
|
|
5404
|
+
// PTY session as a tmux zombie.
|
|
5405
|
+
if (ds.session.backendType !== initMsg.backendType) {
|
|
5406
|
+
ds.session.backendType = initMsg.backendType;
|
|
5407
|
+
sessionStore.updateSession(ds.session);
|
|
5408
|
+
}
|
|
5409
|
+
// Use shared handler for IPC messages and exit
|
|
5410
|
+
setupWorkerHandlers(ds, worker, startupState, workerGeneration);
|
|
5411
|
+
ds.worker = worker;
|
|
5412
|
+
if (shouldTrackOrdinaryImDelivery(ds, initMsg)) {
|
|
5413
|
+
sendOrdinaryImDeliveryTracked(ds, initMsg);
|
|
5414
|
+
}
|
|
5415
|
+
else {
|
|
5416
|
+
worker.send(initMsg);
|
|
5417
|
+
}
|
|
5418
|
+
ds.spawnedAt = Date.now();
|
|
5419
|
+
// master: per-runtime-key CLI version (the init send already happened above via
|
|
5420
|
+
// the tracked-delivery if/else — do NOT re-send initMsg here).
|
|
5421
|
+
ds.cliVersion = getCurrentCliVersion(runtimeInstallationKey({
|
|
5422
|
+
cliId: agentCfg.cliId,
|
|
5423
|
+
cliRuntime: agentCfg.cliRuntime,
|
|
5424
|
+
cliPathOverride: agentCfg.cliPathOverride,
|
|
5425
|
+
}));
|
|
5426
|
+
// #597: init has been dispatched. A later child 'error' event is ambiguous
|
|
5427
|
+
// (init may already be executing), so only synchronous failures before this
|
|
5428
|
+
// point are safe to compensate; clear the pre-init compensation handle.
|
|
5429
|
+
spawnedWorker = undefined;
|
|
5430
|
+
}
|
|
5431
|
+
catch (err) {
|
|
5432
|
+
if (ds.worker === spawnedWorker) {
|
|
5433
|
+
ds.worker = null;
|
|
5434
|
+
ds.workerPort = null;
|
|
5435
|
+
ds.workerToken = null;
|
|
5436
|
+
ds.workerViewToken = null;
|
|
3785
5437
|
}
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
5438
|
+
if (spawnedWorker) {
|
|
5439
|
+
try {
|
|
5440
|
+
spawnedWorker.kill();
|
|
5441
|
+
}
|
|
5442
|
+
catch { /* best-effort pre-init child fence */ }
|
|
3790
5443
|
}
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
const promptCodexAppInput = codexAppInputForSession(ds, promptPayload.codexAppInput, initAttributionTurnId);
|
|
3794
|
-
const runtimeIdentity = runtimeBuildIdentity();
|
|
3795
|
-
const initMsg = {
|
|
3796
|
-
type: 'init',
|
|
3797
|
-
sessionId: ds.session.sessionId,
|
|
3798
|
-
chatId: ds.chatId,
|
|
3799
|
-
chatType: ds.chatType,
|
|
3800
|
-
rootMessageId: sessionAnchorId(ds),
|
|
3801
|
-
workingDir: cwd,
|
|
3802
|
-
cliId: agentCfg.cliId,
|
|
3803
|
-
cliRuntime: agentCfg.cliRuntime,
|
|
3804
|
-
cliPathOverride: agentCfg.cliPathOverride,
|
|
3805
|
-
wrapperCli: agentCfg.wrapperCli,
|
|
3806
|
-
launchShell: botCfg.launchShell,
|
|
3807
|
-
model: agentCfg.model,
|
|
3808
|
-
reasoningEffort: agentCfg.reasoningEffort,
|
|
3809
|
-
disableCliBypass: botCfg.disableCliBypass === true,
|
|
3810
|
-
codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault,
|
|
3811
|
-
// Startup commands run on every fresh spawn (incl. resume) so session-only
|
|
3812
|
-
// settings like `/effort ultracode` are re-established. Adopt sessions are
|
|
3813
|
-
// observed, not driven — forkAdoptWorker intentionally omits this.
|
|
3814
|
-
startupCommands: botCfg.startupCommands,
|
|
3815
|
-
// Per-bot env (bots.json `env`) — injected into the CLI process only (e.g.
|
|
3816
|
-
// ANTHROPIC_BASE_URL/AUTH_TOKEN for a GLM/3rd-party bot). Adopt sessions are
|
|
3817
|
-
// observed, not driven, so forkAdoptWorker intentionally omits it.
|
|
3818
|
-
env: botCfg.env,
|
|
3819
|
-
// Use the decision recorded on the session (above), NOT the live bot flag, so
|
|
3820
|
-
// historical sessions never get retroactively sandboxed on restart.
|
|
3821
|
-
sandbox: ds.session.sandbox === true,
|
|
3822
|
-
sandboxPaths: ds.session.sandboxPaths ?? botCfg.sandboxPaths,
|
|
3823
|
-
sandboxHidePaths: ds.session.sandboxHidePaths ?? [],
|
|
3824
|
-
sandboxReadonlyPaths: ds.session.sandboxReadonlyPaths ?? [],
|
|
3825
|
-
sandboxNetwork: ds.session.sandboxNetwork !== false,
|
|
3826
|
-
// Per-bot local read isolation (enforced worker-side; the worker gates it).
|
|
3827
|
-
// Sibling data needs no app-id enumeration: per-bot dirs are denied wholesale
|
|
3828
|
-
// and per-bot session files by filename pattern (see buildV2DenyPaths).
|
|
3829
|
-
// HARD credential boundary for a no-transport session (apiOnly bot OR HTTP
|
|
3830
|
-
// virtual chat): force read isolation so the CLI physically cannot read the
|
|
3831
|
-
// full bots.json / sibling BOT_HOME / send-cred / lark-cli store — a model
|
|
3832
|
-
// that deletes/forges the ancestry marker or bypasses the CLI still cannot
|
|
3833
|
-
// build ANY (sibling) Lark client. The pid-marker gate is only friendly
|
|
3834
|
-
// early-reject; THIS is the fail-closed boundary. Reuses the existing unified
|
|
3835
|
-
// fs-policy (mac+Linux fail-closed); a backend that can't isolate locally
|
|
3836
|
-
// refuses to spawn rather than leak creds.
|
|
3837
|
-
readIsolation: botCfg.readIsolation === true
|
|
3838
|
-
|| !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }),
|
|
3839
|
-
readDenyExtraPaths: botCfg.readDenyExtraPaths ?? [],
|
|
3840
|
-
// Identifies THIS daemon lifetime. Stamped onto isolated panes so the worker
|
|
3841
|
-
// can tell a suspend→resume reattach (same boot id, still isolated) from a
|
|
3842
|
-
// stale pane surviving a daemon restart (different id → kill + cold-spawn).
|
|
3843
|
-
daemonBootId: DAEMON_BOOT_ID,
|
|
3844
|
-
// Freeze-once: an already-running session keeps the backend stamped at spawn
|
|
3845
|
-
// (ds.session.backendType) even if the bot's live `backendType` changed since —
|
|
3846
|
-
// otherwise a cold-resume/refork would re-derive from live config and strand
|
|
3847
|
-
// the real persistent pane (the stamp is written below; restore reads it via
|
|
3848
|
-
// getSessionPersistentBackendType). A brand-new session (no stamp) resolves
|
|
3849
|
-
// from live config, so a dashboard backend switch only affects NEW sessions.
|
|
3850
|
-
backendType: resolvePairedSpawnBackendType(agentCfg.cliId, ds.session.backendType, botCfg.backendType, config.daemon.backendType),
|
|
3851
|
-
// Shared Herdr is not derivable from sessionId: preserve the exact host +
|
|
3852
|
-
// managed-agent affinity across daemon/worker replacement.
|
|
3853
|
-
persistentBackendTarget: ds.session.persistentBackendTarget,
|
|
3854
|
-
backendConfig: botCfg.riff,
|
|
3855
|
-
riffParentTaskId: ds.session.riffParentTaskId,
|
|
3856
|
-
riffRepoDirs: ds.session.riffRepoDirs,
|
|
3857
|
-
deferredScheduleRun: ds.session.deferredScheduleRun,
|
|
3858
|
-
...(nativeSessionTitle ? { nativeSessionTitle } : {}),
|
|
3859
|
-
...(nativeSessionTitlePrompt ? { nativeSessionTitlePrompt } : {}),
|
|
3860
|
-
prompt,
|
|
3861
|
-
...(promptCodexAppInput ? { promptCodexAppInput } : {}),
|
|
3862
|
-
resume,
|
|
3863
|
-
// One-shot native fork intent (see Session.pendingForkSession). Only the
|
|
3864
|
-
// child's FIRST spawn resumes the SOURCE transcript (cliSessionId still
|
|
3865
|
-
// points at the parent's CLI id here) while forking forward into a new id;
|
|
3866
|
-
// the worker clears the marker + persists the child's own new id, so a
|
|
3867
|
-
// later refork resumes the child normally (pendingForkSession=false).
|
|
3868
|
-
forkSession: ds.session.pendingForkSession === true,
|
|
3869
|
-
cliSessionId: ds.session.cliSessionId,
|
|
3870
|
-
ownerOpenId: ds.ownerOpenId,
|
|
3871
|
-
webPort: ds.session.webPort,
|
|
3872
|
-
larkAppId: botCfg.larkAppId,
|
|
3873
|
-
// Freeze on the session transport capability: a no-transport session
|
|
3874
|
-
// (apiOnly bot OR HTTP virtual chat) must not even RECEIVE the real secret —
|
|
3875
|
-
// withholding it removes the capability rather than gating a tamperable flag
|
|
3876
|
-
// the sandboxed agent could flip. The worker then physically cannot dial
|
|
3877
|
-
// Feishu (uploader/cred-write are also skipped downstream on the same test).
|
|
3878
|
-
larkAppSecret: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
|
|
3879
|
-
apiOnly: botCfg.apiOnly,
|
|
3880
|
-
// Freeze the ACTUAL loaded bots-config path (getLoadedConfigPath) so a
|
|
3881
|
-
// no-transport worker's fs-policy denies it from a HOST-owned fact, not a
|
|
3882
|
-
// guess off BOTS_CONFIG env (which the agent could see/forge). When it lives
|
|
3883
|
-
// outside every botmux authority root, buildFsPolicy fails the spawn closed
|
|
3884
|
-
// rather than silently masking an arbitrary parent dir (codex P1). Omitted
|
|
3885
|
-
// from forkAdoptWorker below — its observe branch returns before fs-policy.
|
|
3886
|
-
loadedBotsConfigPath: getLoadedConfigPath(),
|
|
3887
|
-
brand: normalizeBrand(botCfg.brand),
|
|
3888
|
-
botName: bot.botName,
|
|
3889
|
-
botOpenId: bot.botOpenId,
|
|
3890
|
-
locale: botLocale(botCfg),
|
|
3891
|
-
turnId: initAttributionTurnId,
|
|
3892
|
-
dispatchAttempt: initDispatchAttempt,
|
|
3893
|
-
vcMeetingImTurnOrigin: resolveVcMeetingImTurnOrigin(ds.session, initAttributionTurnId),
|
|
3894
|
-
pluginBindings: botCfg.plugins,
|
|
3895
|
-
skillPolicy: botCfg.skills,
|
|
3896
|
-
...(runtimeIdentity.status === 'known'
|
|
3897
|
-
? { runnerBuildId: runtimeIdentity.id }
|
|
3898
|
-
: {}),
|
|
3899
|
-
...(ds.session.runnerBuildId ? { persistedRunnerBuildId: ds.session.runnerBuildId } : {}),
|
|
3900
|
-
...(restartAttemptId ? { restartAttemptId } : {}),
|
|
3901
|
-
};
|
|
3902
|
-
// A persisted port from an older ZMX implementation must never revive the
|
|
3903
|
-
// removed Web TUI or get forwarded as a preferred listen port. The worker
|
|
3904
|
-
// will signal lifecycle readiness with port=0 instead.
|
|
3905
|
-
if (!backendSupportsWebTerminal(initMsg.backendType)) {
|
|
3906
|
-
initMsg.webPort = undefined;
|
|
3907
|
-
if (ds.session.webPort !== undefined) {
|
|
3908
|
-
ds.session.webPort = undefined;
|
|
3909
|
-
sessionStore.updateSession(ds.session);
|
|
5444
|
+
try {
|
|
5445
|
+
rollbackWorkerForkPreInit(ds, queuedForkSnapshot, acceptedForkDispatch);
|
|
3910
5446
|
}
|
|
5447
|
+
catch (rollbackErr) {
|
|
5448
|
+
logger.error(`[${tag(ds)}] Worker pre-init failure could not durably restore queued/FIFO state: `
|
|
5449
|
+
+ `${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`);
|
|
5450
|
+
throw new AggregateError([err, rollbackErr], `Worker pre-init failed and durable rollback failed for ${ds.session.sessionId}`);
|
|
5451
|
+
}
|
|
5452
|
+
throw err;
|
|
3911
5453
|
}
|
|
3912
5454
|
ds.initConfig = initMsg;
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
// a fast worker can emit `ready` immediately after init, and card rendering
|
|
3916
|
-
// must see the session-level CLI identity rather than the bot default.
|
|
3917
|
-
if (ds.session.cliId !== agentCfg.cliId) {
|
|
3918
|
-
ds.session.cliId = agentCfg.cliId;
|
|
3919
|
-
sessionStore.updateSession(ds.session);
|
|
3920
|
-
}
|
|
3921
|
-
// Stamp the resolved backend on the persisted session. Since PTY退役, the
|
|
3922
|
-
// worker no longer silently downgrades an unavailable backend (it hard-gates
|
|
3923
|
-
// instead), so the requested backend here IS the effective one for any
|
|
3924
|
-
// session that actually runs. Restore reads this back (see
|
|
3925
|
-
// getSessionPersistentBackendType) so an upgraded daemon doesn't re-derive a
|
|
3926
|
-
// session's backend from the now-always-tmux default and misclassify a legacy
|
|
3927
|
-
// PTY session as a tmux zombie.
|
|
3928
|
-
if (ds.session.backendType !== initMsg.backendType) {
|
|
3929
|
-
ds.session.backendType = initMsg.backendType;
|
|
3930
|
-
sessionStore.updateSession(ds.session);
|
|
3931
|
-
}
|
|
3932
|
-
// Use shared handler for IPC messages and exit
|
|
3933
|
-
setupWorkerHandlers(ds, worker, startupState, workerGeneration);
|
|
3934
|
-
ds.worker = worker;
|
|
3935
|
-
if (shouldTrackOrdinaryImDelivery(ds, initMsg)) {
|
|
3936
|
-
sendOrdinaryImDeliveryTracked(ds, initMsg);
|
|
5455
|
+
try {
|
|
5456
|
+
sessionStore.updateSessionPid(ds.session.sessionId, worker.pid ?? null);
|
|
3937
5457
|
}
|
|
3938
|
-
|
|
3939
|
-
|
|
5458
|
+
catch (err) {
|
|
5459
|
+
// Init may already be executing. PID bookkeeping failure must not turn an
|
|
5460
|
+
// accepted activation into a retryable error or orphan the attached child.
|
|
5461
|
+
logger.error(`[${t}] Failed to persist attached worker pid: ${err instanceof Error ? err.message : String(err)}`);
|
|
3940
5462
|
}
|
|
3941
|
-
ds.spawnedAt = Date.now();
|
|
3942
|
-
ds.cliVersion = getCurrentCliVersion(runtimeInstallationKey({
|
|
3943
|
-
cliId: agentCfg.cliId,
|
|
3944
|
-
cliRuntime: agentCfg.cliRuntime,
|
|
3945
|
-
cliPathOverride: agentCfg.cliPathOverride,
|
|
3946
|
-
}));
|
|
3947
|
-
sessionStore.updateSessionPid(ds.session.sessionId, worker.pid ?? null);
|
|
3948
5463
|
logger.info(`[${t}] Worker forked (pid: ${worker.pid}, active: ${cb.getActiveCount()})`);
|
|
3949
5464
|
// Reset the exit-emit flag for the freshly spawned worker so a subsequent
|
|
3950
5465
|
// exit publishes again (the previous lifecycle's flag would otherwise mask it).
|
|
3951
5466
|
ds.exitEventEmitted = false;
|
|
3952
5467
|
// Notify dashboard SSE subscribers a new session is live.
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
}
|
|
5468
|
+
try {
|
|
5469
|
+
dashboardEventBus.publish({
|
|
5470
|
+
type: 'session.spawned',
|
|
5471
|
+
body: { session: composeRowFromActive(ds) },
|
|
5472
|
+
});
|
|
5473
|
+
}
|
|
5474
|
+
catch (err) {
|
|
5475
|
+
logger.error(`[${t}] Failed to publish attached worker state: ${err instanceof Error ? err.message : String(err)}`);
|
|
5476
|
+
}
|
|
5477
|
+
try {
|
|
5478
|
+
cb.enforceLiveSessionCap?.();
|
|
5479
|
+
}
|
|
5480
|
+
catch (err) {
|
|
5481
|
+
logger.error(`[${t}] Failed to enforce live-session cap after attach: ${err instanceof Error ? err.message : String(err)}`);
|
|
5482
|
+
}
|
|
5483
|
+
try {
|
|
5484
|
+
emitSessionLifecycleHook(ds, 'session.start', {
|
|
5485
|
+
reason: resume ? 'resume' : 'worker_spawn',
|
|
5486
|
+
pid: worker.pid ?? null,
|
|
5487
|
+
});
|
|
5488
|
+
}
|
|
5489
|
+
catch (err) {
|
|
5490
|
+
logger.error(`[${t}] Failed to emit attached worker lifecycle hook: ${err instanceof Error ? err.message : String(err)}`);
|
|
5491
|
+
}
|
|
3962
5492
|
// Usage ledger: fresh spawns anchor the baseline so pre-existing transcript
|
|
3963
5493
|
// history is never billed. Restores reconcile instead — an in-flight turn
|
|
3964
5494
|
// may have completed inside tmux while the daemon was down, and that work
|
|
3965
5495
|
// was submitted by botmux (anchoring would swallow it).
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
5496
|
+
try {
|
|
5497
|
+
if (resume)
|
|
5498
|
+
reconcileUsageForDaemonSession(ds);
|
|
5499
|
+
else
|
|
5500
|
+
anchorUsageForDaemonSession(ds);
|
|
5501
|
+
}
|
|
5502
|
+
catch (err) {
|
|
5503
|
+
logger.error(`[${t}] Failed to initialize attached worker usage state: ${err instanceof Error ? err.message : String(err)}`);
|
|
5504
|
+
}
|
|
5505
|
+
try {
|
|
5506
|
+
recordOwnershipForDaemonSession(ds);
|
|
5507
|
+
}
|
|
5508
|
+
catch (err) {
|
|
5509
|
+
logger.error(`[${t}] Failed to record attached worker ownership: ${err instanceof Error ? err.message : String(err)}`);
|
|
5510
|
+
}
|
|
5511
|
+
return true;
|
|
3971
5512
|
}
|
|
3972
5513
|
// ─── Shared worker IPC handler ──────────────────────────────────────────────
|
|
3973
5514
|
/**
|
|
@@ -4064,6 +5605,13 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4064
5605
|
// Codex badge before a role switch starts a non-Codex worker.
|
|
4065
5606
|
ds.codexServiceTier = undefined;
|
|
4066
5607
|
ds.pendingCodexTierCardRefresh = undefined;
|
|
5608
|
+
// Active runtime is likewise authority of this exact worker generation. The
|
|
5609
|
+
// new worker republishes it via active_runtime after re-reading the rollout;
|
|
5610
|
+
// clear it here so the window between respawn and that first observation
|
|
5611
|
+
// cannot leave a stale model/effort tail on the card.
|
|
5612
|
+
ds.activeModel = undefined;
|
|
5613
|
+
ds.activeReasoningEffort = undefined;
|
|
5614
|
+
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
4067
5615
|
const handlerSession = ds.session;
|
|
4068
5616
|
const handlerAnchor = sessionAnchorId(ds);
|
|
4069
5617
|
const handlerLarkAppId = ds.larkAppId;
|
|
@@ -4266,6 +5814,67 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4266
5814
|
};
|
|
4267
5815
|
break;
|
|
4268
5816
|
}
|
|
5817
|
+
case 'queued_activation_submitted': {
|
|
5818
|
+
if (ds.worker !== worker || msg.sessionId !== ds.session.sessionId)
|
|
5819
|
+
break;
|
|
5820
|
+
if (!ds.session.queuedActivationPending
|
|
5821
|
+
|| !ds.session.queuedActivationToken
|
|
5822
|
+
|| ds.session.queuedActivationToken !== msg.activationToken) {
|
|
5823
|
+
logger.warn(`[${t}] Ignored stale queued activation ACK ${msg.activationToken.substring(0, 8)}`);
|
|
5824
|
+
break;
|
|
5825
|
+
}
|
|
5826
|
+
const releaseReservation = async (attempt) => {
|
|
5827
|
+
if (ds.worker !== worker || !ds.initialStartPending)
|
|
5828
|
+
return;
|
|
5829
|
+
try {
|
|
5830
|
+
if (cb.onQueuedActivationSubmitted) {
|
|
5831
|
+
const released = await cb.onQueuedActivationSubmitted(ds, msg.activationToken);
|
|
5832
|
+
if (released === false && ds.worker === worker && ds.initialStartPending) {
|
|
5833
|
+
const delayMs = Math.min(100 * (2 ** Math.min(attempt, 6)), 5_000);
|
|
5834
|
+
logger.warn(`[${t}] Queued activation follow-up was not accepted (attempt ${attempt + 1}); `
|
|
5835
|
+
+ `retrying in ${delayMs}ms`);
|
|
5836
|
+
const timer = setTimeout(() => { void releaseReservation(attempt + 1); }, delayMs);
|
|
5837
|
+
timer.unref?.();
|
|
5838
|
+
}
|
|
5839
|
+
}
|
|
5840
|
+
else {
|
|
5841
|
+
ds.initialStartPending = false;
|
|
5842
|
+
ds.initialStartClaimToken = undefined;
|
|
5843
|
+
}
|
|
5844
|
+
}
|
|
5845
|
+
catch (err) {
|
|
5846
|
+
logger.error(`[${t}] Queued activation follow-up release failed unexpectedly; `
|
|
5847
|
+
+ `delivery was not retried because acceptance is unknown: `
|
|
5848
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
5849
|
+
}
|
|
5850
|
+
};
|
|
5851
|
+
const journal = snapshotQueuedActivationJournal(ds.session);
|
|
5852
|
+
const persistActivationAck = async (attempt) => {
|
|
5853
|
+
if (ds.worker !== worker
|
|
5854
|
+
|| !ds.session.queuedActivationPending
|
|
5855
|
+
|| ds.session.queuedActivationToken !== msg.activationToken)
|
|
5856
|
+
return;
|
|
5857
|
+
clearQueuedActivationJournal(ds.session);
|
|
5858
|
+
try {
|
|
5859
|
+
sessionStore.updateSession(ds.session);
|
|
5860
|
+
}
|
|
5861
|
+
catch (err) {
|
|
5862
|
+
restoreQueuedActivationJournal(ds.session, journal);
|
|
5863
|
+
const delayMs = Math.min(100 * (2 ** Math.min(attempt, 6)), 5_000);
|
|
5864
|
+
logger.error(`[${t}] Failed to persist queued activation ACK; retained journal and `
|
|
5865
|
+
+ `will retry in ${delayMs}ms: ${err instanceof Error ? err.message : String(err)}`);
|
|
5866
|
+
const timer = setTimeout(() => { void persistActivationAck(attempt + 1); }, delayMs);
|
|
5867
|
+
timer.unref?.();
|
|
5868
|
+
return;
|
|
5869
|
+
}
|
|
5870
|
+
// Crossing the adapter boundary means this session now owns CLI
|
|
5871
|
+
// history even if the worker dies before it can publish a session id.
|
|
5872
|
+
ds.hasHistory = true;
|
|
5873
|
+
await releaseReservation(0);
|
|
5874
|
+
};
|
|
5875
|
+
await persistActivationAck(0);
|
|
5876
|
+
break;
|
|
5877
|
+
}
|
|
4269
5878
|
case 'ready': {
|
|
4270
5879
|
if (!ownsLifecycleMutation()) {
|
|
4271
5880
|
logger.warn(`[${t}] Ignored ready from stale/transferring worker generation`);
|
|
@@ -4468,6 +6077,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4468
6077
|
recallFrozenCards(ds);
|
|
4469
6078
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
4470
6079
|
flushPendingRiffUrlPatch(ds);
|
|
6080
|
+
flushPendingActiveRuntimePatch(ds);
|
|
4471
6081
|
flushPendingCodexServiceTierPatch(ds);
|
|
4472
6082
|
// Fresh ready POST: if this turn is already `working` (e.g. relay
|
|
4473
6083
|
// resume where the CLI kept running), arm here — same authorized arm
|
|
@@ -4478,9 +6088,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4478
6088
|
if (!ownsLifecycleMutation())
|
|
4479
6089
|
break;
|
|
4480
6090
|
if (err instanceof MessageWithdrawnError) {
|
|
4481
|
-
|
|
4482
|
-
killWorker(ds);
|
|
4483
|
-
cb.closeSession(ds);
|
|
6091
|
+
await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating worker-ready card');
|
|
4484
6092
|
break;
|
|
4485
6093
|
}
|
|
4486
6094
|
logger.warn(`[${t}] Failed to send streaming card, falling back to static card: ${err}`);
|
|
@@ -4513,9 +6121,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4513
6121
|
if (!ownsLifecycleMutation())
|
|
4514
6122
|
break;
|
|
4515
6123
|
if (fallbackErr instanceof MessageWithdrawnError) {
|
|
4516
|
-
|
|
4517
|
-
killWorker(ds);
|
|
4518
|
-
cb.closeSession(ds);
|
|
6124
|
+
await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating fallback worker-ready card');
|
|
4519
6125
|
break;
|
|
4520
6126
|
}
|
|
4521
6127
|
throw fallbackErr;
|
|
@@ -4556,10 +6162,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4556
6162
|
sendWorkerSessionInput(ds, {
|
|
4557
6163
|
type: 'raw_input',
|
|
4558
6164
|
content: rawInput,
|
|
4559
|
-
...(
|
|
6165
|
+
...((ds.session.queuedActivationTurnId ?? rawTurnId)
|
|
6166
|
+
? { turnId: ds.session.queuedActivationTurnId ?? rawTurnId }
|
|
6167
|
+
: {}),
|
|
4560
6168
|
followUpContent: followUp?.cliInput,
|
|
4561
6169
|
...(followUp?.turnId ? { followUpTurnId: followUp.turnId } : {}),
|
|
4562
6170
|
...(followUpCodexAppInput ? { followUpCodexAppInput } : {}),
|
|
6171
|
+
...(ds.session.queuedActivationToken
|
|
6172
|
+
? { queuedActivationToken: ds.session.queuedActivationToken }
|
|
6173
|
+
: {}),
|
|
4563
6174
|
});
|
|
4564
6175
|
logger.info(`[${t}] Sent pending raw input after prompt_ready: ${rawInput.substring(0, 80)}${followUp ? ` (+follow-up ${followUp.cliInput.length} chars)` : ''}`);
|
|
4565
6176
|
if (followUp)
|
|
@@ -4638,6 +6249,24 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4638
6249
|
sessionStore.updateSession(ds.session);
|
|
4639
6250
|
break;
|
|
4640
6251
|
}
|
|
6252
|
+
case 'active_runtime': {
|
|
6253
|
+
if (ds.worker !== worker
|
|
6254
|
+
|| ds.workerGeneration !== workerGeneration
|
|
6255
|
+
|| ds.session.workerGeneration !== workerGeneration) {
|
|
6256
|
+
logger.warn(`[${t}] Ignored active_runtime from stale worker generation`);
|
|
6257
|
+
break;
|
|
6258
|
+
}
|
|
6259
|
+
const model = msg.model?.trim() || undefined;
|
|
6260
|
+
const reasoningEffort = msg.reasoningEffort?.trim() || undefined;
|
|
6261
|
+
if (ds.activeModel === model
|
|
6262
|
+
&& ds.activeReasoningEffort === reasoningEffort) {
|
|
6263
|
+
break;
|
|
6264
|
+
}
|
|
6265
|
+
ds.activeModel = model;
|
|
6266
|
+
ds.activeReasoningEffort = reasoningEffort;
|
|
6267
|
+
scheduleActiveRuntimePatch(ds);
|
|
6268
|
+
break;
|
|
6269
|
+
}
|
|
4641
6270
|
case 'codex_service_tier': {
|
|
4642
6271
|
if (ds.worker !== worker
|
|
4643
6272
|
|| ds.workerGeneration !== workerGeneration
|
|
@@ -4648,6 +6277,13 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4648
6277
|
ds.codexServiceTier = effectiveCliId === 'codex'
|
|
4649
6278
|
? (msg.snapshot ?? undefined)
|
|
4650
6279
|
: undefined;
|
|
6280
|
+
// Model/effort now flow through the active_runtime channel (Codex
|
|
6281
|
+
// publishes them from every turn_context, same as TRAE), so this
|
|
6282
|
+
// handler is scoped to the ⚡ service-tier badge only and must NOT
|
|
6283
|
+
// also write activeModel/activeReasoningEffort — doing so would race
|
|
6284
|
+
// the active_runtime writer and, since thread_settings_applied is not
|
|
6285
|
+
// emitted in many sessions, could clobber the good value with a stale
|
|
6286
|
+
// one.
|
|
4651
6287
|
scheduleCodexServiceTierPatch(ds);
|
|
4652
6288
|
break;
|
|
4653
6289
|
}
|
|
@@ -4783,6 +6419,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4783
6419
|
recallFrozenCards(ds);
|
|
4784
6420
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
4785
6421
|
flushPendingRiffUrlPatch(ds);
|
|
6422
|
+
flushPendingActiveRuntimePatch(ds);
|
|
4786
6423
|
flushPendingCodexServiceTierPatch(ds);
|
|
4787
6424
|
// New-turn POST is the FIRST working screen_update of the turn —
|
|
4788
6425
|
// the else (same-turn PATCH) branch never runs for it, so arm the
|
|
@@ -4790,18 +6427,17 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
4790
6427
|
// the POSTING sentinel). syncUsageRefreshTimer re-checks state.
|
|
4791
6428
|
syncUsageRefreshTimer(ds);
|
|
4792
6429
|
})
|
|
4793
|
-
.catch(err => {
|
|
6430
|
+
.catch(async (err) => {
|
|
4794
6431
|
if (!ownsLifecycleMutation())
|
|
4795
6432
|
return;
|
|
4796
6433
|
if (err instanceof MessageWithdrawnError) {
|
|
4797
|
-
|
|
4798
|
-
killWorker(ds);
|
|
4799
|
-
cb.closeSession(ds);
|
|
6434
|
+
await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating streaming card');
|
|
4800
6435
|
return;
|
|
4801
6436
|
}
|
|
4802
6437
|
logger.debug(`[${t}] Failed to create streaming card: ${err}`);
|
|
4803
6438
|
ds.streamCardId = undefined;
|
|
4804
6439
|
clearPendingLocalCliOpenReadinessPatch(ds);
|
|
6440
|
+
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
4805
6441
|
ds.pendingCodexTierCardRefresh = undefined;
|
|
4806
6442
|
persistStreamCardState(ds);
|
|
4807
6443
|
});
|
|
@@ -5202,6 +6838,30 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5202
6838
|
}
|
|
5203
6839
|
break;
|
|
5204
6840
|
}
|
|
6841
|
+
// Riff is a remote lineage-owning backend, not a local CLI process.
|
|
6842
|
+
// The worker intentionally refuses restart because tearing it down can
|
|
6843
|
+
// destroy or orphan the remote sandbox. Stop before crash-loop
|
|
6844
|
+
// accounting and tell the user how to recover, rather than logging an
|
|
6845
|
+
// "auto-restart" whose IPC is guaranteed to be a no-op.
|
|
6846
|
+
if (isRiffBackendSession(ds)) {
|
|
6847
|
+
const retirementPhase = riffRetirementAdmissionPhase(ds);
|
|
6848
|
+
logger.warn(`[${t}] Riff backend exited; automatic restart is unsupported`
|
|
6849
|
+
+ (retirementPhase ? ` (${retirementPhase})` : ''));
|
|
6850
|
+
// Explicit /close and daemon shutdown already own the user-visible
|
|
6851
|
+
// lifecycle. Only an unexpected backend exit needs recovery guidance.
|
|
6852
|
+
if (!retirementPhase && !suppressExitUi) {
|
|
6853
|
+
try {
|
|
6854
|
+
await scopedReply(tr('cmd.restart.riff_unsupported', undefined, loc), 'text', undefined);
|
|
6855
|
+
}
|
|
6856
|
+
catch (replyErr) {
|
|
6857
|
+
if (replyErr instanceof MessageWithdrawnError) {
|
|
6858
|
+
logger.warn(`[${t}] Root message withdrawn, closing stale session`);
|
|
6859
|
+
cb.closeSession(ds);
|
|
6860
|
+
}
|
|
6861
|
+
}
|
|
6862
|
+
}
|
|
6863
|
+
break;
|
|
6864
|
+
}
|
|
5205
6865
|
// Rate-limit auto-restart to prevent crash loops
|
|
5206
6866
|
const key = ds.session.sessionId;
|
|
5207
6867
|
const rc = restartCounts.get(key) ?? { count: 0, lastAt: 0 };
|
|
@@ -5266,8 +6926,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5266
6926
|
}
|
|
5267
6927
|
catch (replyErr) {
|
|
5268
6928
|
if (replyErr instanceof MessageWithdrawnError && ownsLifecycleMutation()) {
|
|
5269
|
-
|
|
5270
|
-
cb.closeSession(ds);
|
|
6929
|
+
await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while sending crash diagnostic');
|
|
5271
6930
|
}
|
|
5272
6931
|
}
|
|
5273
6932
|
}
|
|
@@ -5279,7 +6938,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5279
6938
|
if (ds.worker && !ds.worker.killed) {
|
|
5280
6939
|
logger.info(`[${t}] Auto-restarting ${sessionCliDisplayName(ds, botCfg)}...`);
|
|
5281
6940
|
ds.workerReady = false;
|
|
5282
|
-
ds.worker.send({ type: 'restart', env: latestPerBotEnvForRestart(ds) });
|
|
6941
|
+
ds.worker.send({ type: 'restart', reason: 'cli_crash', env: latestPerBotEnvForRestart(ds) });
|
|
5283
6942
|
}
|
|
5284
6943
|
break;
|
|
5285
6944
|
}
|
|
@@ -5317,8 +6976,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5317
6976
|
if (msg.taskId === null) {
|
|
5318
6977
|
// follow-up 血缘断裂:清掉持久化锚点,否则 daemon 重启会复活已判坏的 parent。
|
|
5319
6978
|
if (ds.session.riffParentTaskId) {
|
|
6979
|
+
const priorTaskId = ds.session.riffParentTaskId;
|
|
5320
6980
|
ds.session.riffParentTaskId = undefined;
|
|
5321
|
-
|
|
6981
|
+
try {
|
|
6982
|
+
sessionStore.updateSession(ds.session);
|
|
6983
|
+
}
|
|
6984
|
+
catch (err) {
|
|
6985
|
+
ds.session.riffParentTaskId = priorTaskId;
|
|
6986
|
+
logger.error(`[${t}] Failed to clear Riff lineage: ${err instanceof Error ? err.message : String(err)}`);
|
|
6987
|
+
}
|
|
5322
6988
|
}
|
|
5323
6989
|
break;
|
|
5324
6990
|
}
|
|
@@ -5329,7 +6995,32 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5329
6995
|
// next message continues the riff conversation in the warm sandbox
|
|
5330
6996
|
// instead of cold-booting a context-less fresh task (4-5 min).
|
|
5331
6997
|
ds.session.riffParentTaskId = msg.taskId;
|
|
5332
|
-
|
|
6998
|
+
try {
|
|
6999
|
+
sessionStore.updateSession(ds.session);
|
|
7000
|
+
}
|
|
7001
|
+
catch (err) {
|
|
7002
|
+
// Retain the newest runtime lineage. A close_result or later task-id
|
|
7003
|
+
// event retries persistence; reverting here would make a follow-up
|
|
7004
|
+
// target the stale parent while the worker owns the new child.
|
|
7005
|
+
logger.error(`[${t}] Failed to persist Riff lineage ${msg.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
7006
|
+
}
|
|
7007
|
+
break;
|
|
7008
|
+
}
|
|
7009
|
+
case 'close_result': {
|
|
7010
|
+
const pending = pendingRiffWorkerCloses.get(msg.requestId);
|
|
7011
|
+
if (!pending
|
|
7012
|
+
|| pending.worker !== worker
|
|
7013
|
+
|| pending.sessionId !== ds.session.sessionId
|
|
7014
|
+
|| ds.worker !== worker) {
|
|
7015
|
+
logger.warn(`[${t}] Ignored stale/unmatched Riff close result ${msg.requestId}`);
|
|
7016
|
+
break;
|
|
7017
|
+
}
|
|
7018
|
+
pendingRiffWorkerCloses.delete(msg.requestId);
|
|
7019
|
+
pending.resolve({
|
|
7020
|
+
ok: msg.ok,
|
|
7021
|
+
...(msg.taskId ? { taskId: msg.taskId } : {}),
|
|
7022
|
+
...(msg.error ? { error: msg.error } : {}),
|
|
7023
|
+
});
|
|
5333
7024
|
break;
|
|
5334
7025
|
}
|
|
5335
7026
|
case 'deferred_topic_materialized': {
|
|
@@ -5507,8 +7198,41 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5507
7198
|
logger.warn(`[${t}] Dropped managed_turn_origin with mismatched sessionId`);
|
|
5508
7199
|
break;
|
|
5509
7200
|
}
|
|
7201
|
+
// macOS uses one stable per-session pathname visible inside Seatbelt.
|
|
7202
|
+
// Only the daemon handler for the CURRENT ChildProcess generation may
|
|
7203
|
+
// replace it. Stale workers can still emit IPC, but the identity guard
|
|
7204
|
+
// above drops them before filesystem mutation, so they cannot overwrite
|
|
7205
|
+
// a successor capability (or unlink it during teardown).
|
|
7206
|
+
if (process.platform === 'darwin' && msg.originChannelId) {
|
|
7207
|
+
if (!/^[a-f0-9]{64}$/.test(msg.originChannelId)) {
|
|
7208
|
+
ds.managedTurnOrigin = undefined;
|
|
7209
|
+
logger.error(`[${t}] Refused managed origin publication with an invalid pane channel`);
|
|
7210
|
+
break;
|
|
7211
|
+
}
|
|
7212
|
+
try {
|
|
7213
|
+
const ipcPort = Number(process.env.BOTMUX_DAEMON_IPC_PORT);
|
|
7214
|
+
replaceManagedOriginCapabilityFile(managedOriginCapabilityPath(config.session.dataDir, msg.sessionId, msg.originChannelId), JSON.stringify({
|
|
7215
|
+
sessionId: msg.sessionId,
|
|
7216
|
+
channelId: msg.originChannelId,
|
|
7217
|
+
capability: msg.capability,
|
|
7218
|
+
...(Number.isSafeInteger(ipcPort) && ipcPort > 0 && ipcPort <= 65_535
|
|
7219
|
+
? { ipcPort }
|
|
7220
|
+
: {}),
|
|
7221
|
+
...(msg.turnId ? { turnId: msg.turnId } : {}),
|
|
7222
|
+
...(msg.dispatchAttempt !== undefined
|
|
7223
|
+
? { dispatchAttempt: msg.dispatchAttempt }
|
|
7224
|
+
: {}),
|
|
7225
|
+
}));
|
|
7226
|
+
}
|
|
7227
|
+
catch (err) {
|
|
7228
|
+
ds.managedTurnOrigin = undefined;
|
|
7229
|
+
logger.error(`[${t}] Failed to publish daemon-owned managed origin capability: ${err instanceof Error ? err.message : String(err)}`);
|
|
7230
|
+
break;
|
|
7231
|
+
}
|
|
7232
|
+
}
|
|
5510
7233
|
ds.managedTurnOrigin = {
|
|
5511
7234
|
capability: msg.capability,
|
|
7235
|
+
...(msg.originChannelId ? { originChannelId: msg.originChannelId } : {}),
|
|
5512
7236
|
...(msg.turnId ? { turnId: msg.turnId } : {}),
|
|
5513
7237
|
...(msg.dispatchAttempt !== undefined
|
|
5514
7238
|
? { dispatchAttempt: msg.dispatchAttempt }
|
|
@@ -5533,6 +7257,12 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5533
7257
|
logger.warn(`[${t}] Ignored stale managed turn origin revoke after capability rotation`);
|
|
5534
7258
|
break;
|
|
5535
7259
|
}
|
|
7260
|
+
if (msg.originChannelId
|
|
7261
|
+
&& ds.managedTurnOrigin?.originChannelId
|
|
7262
|
+
&& ds.managedTurnOrigin.originChannelId !== msg.originChannelId) {
|
|
7263
|
+
logger.warn(`[${t}] Ignored managed_turn_origin_revoked for a different pane channel`);
|
|
7264
|
+
break;
|
|
7265
|
+
}
|
|
5536
7266
|
if (!msg.capability && ds.managedTurnOrigin
|
|
5537
7267
|
&& (ds.managedTurnOrigin.turnId !== msg.turnId
|
|
5538
7268
|
|| ds.managedTurnOrigin.dispatchAttempt !== msg.dispatchAttempt)) {
|
|
@@ -5542,11 +7272,252 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5542
7272
|
ds.managedTurnOrigin = undefined;
|
|
5543
7273
|
break;
|
|
5544
7274
|
}
|
|
7275
|
+
case 'codex_app_dispatch_transition': {
|
|
7276
|
+
const acknowledge = (ok, error) => {
|
|
7277
|
+
try {
|
|
7278
|
+
worker.send({
|
|
7279
|
+
type: 'codex_app_dispatch_persisted',
|
|
7280
|
+
requestId: msg.requestId,
|
|
7281
|
+
ok,
|
|
7282
|
+
...(error ? { error } : {}),
|
|
7283
|
+
});
|
|
7284
|
+
}
|
|
7285
|
+
catch { /* worker exit makes the prepared/final state replayable */ }
|
|
7286
|
+
};
|
|
7287
|
+
if (ds.worker !== worker) {
|
|
7288
|
+
acknowledge(false, 'stale_worker_generation');
|
|
7289
|
+
break;
|
|
7290
|
+
}
|
|
7291
|
+
if (msg.sessionId !== ds.session.sessionId) {
|
|
7292
|
+
acknowledge(false, 'session_mismatch');
|
|
7293
|
+
break;
|
|
7294
|
+
}
|
|
7295
|
+
const ledger = ds.session.codexAppDispatchLedger ?? [];
|
|
7296
|
+
let next;
|
|
7297
|
+
if (msg.operation === 'submit') {
|
|
7298
|
+
if (msg.entries.length !== 1) {
|
|
7299
|
+
acknowledge(false, 'submit_requires_one_entry');
|
|
7300
|
+
break;
|
|
7301
|
+
}
|
|
7302
|
+
next = prepareCodexAppDispatch(ledger, msg.entries[0]);
|
|
7303
|
+
}
|
|
7304
|
+
else if (msg.operation === 'retry') {
|
|
7305
|
+
if (msg.entries.length !== 1) {
|
|
7306
|
+
acknowledge(false, 'retry_requires_one_entry');
|
|
7307
|
+
break;
|
|
7308
|
+
}
|
|
7309
|
+
next = retryPreparedCodexAppDispatch(ledger, msg.entries[0]);
|
|
7310
|
+
}
|
|
7311
|
+
else {
|
|
7312
|
+
if (msg.entries.length !== 1) {
|
|
7313
|
+
acknowledge(false, 'cancel_requires_one_entry');
|
|
7314
|
+
break;
|
|
7315
|
+
}
|
|
7316
|
+
next = cancelCodexAppDispatch(ledger, msg.entries[0]);
|
|
7317
|
+
}
|
|
7318
|
+
if (!next.ok) {
|
|
7319
|
+
acknowledge(false, next.error);
|
|
7320
|
+
break;
|
|
7321
|
+
}
|
|
7322
|
+
const priorLedger = ds.session.codexAppDispatchLedger;
|
|
7323
|
+
ds.session.codexAppDispatchLedger = next.ledger;
|
|
7324
|
+
let persisted = false;
|
|
7325
|
+
try {
|
|
7326
|
+
sessionStore.updateSession(ds.session);
|
|
7327
|
+
persisted = true;
|
|
7328
|
+
acknowledge(true);
|
|
7329
|
+
}
|
|
7330
|
+
catch (err) {
|
|
7331
|
+
ds.session.codexAppDispatchLedger = priorLedger;
|
|
7332
|
+
acknowledge(false, err?.message ?? 'session_store_write_failed');
|
|
7333
|
+
}
|
|
7334
|
+
if (persisted && !hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
|
|
7335
|
+
try {
|
|
7336
|
+
await cb.onCodexAppLedgerDrained?.(ds);
|
|
7337
|
+
}
|
|
7338
|
+
catch (err) {
|
|
7339
|
+
logger.error(`[${t}] post-drain cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7340
|
+
}
|
|
7341
|
+
}
|
|
7342
|
+
break;
|
|
7343
|
+
}
|
|
7344
|
+
case 'codex_app_generation_active': {
|
|
7345
|
+
if (ds.worker !== worker || msg.sessionId !== ds.session.sessionId)
|
|
7346
|
+
break;
|
|
7347
|
+
if (!msg.fresh)
|
|
7348
|
+
break;
|
|
7349
|
+
const priorCommits = ds.session.codexAppGenerationCommits;
|
|
7350
|
+
ds.session.codexAppGenerationCommits = retainFreshCodexAppGeneration(ds.session.codexAppGenerationCommits ?? [], msg.generation);
|
|
7351
|
+
try {
|
|
7352
|
+
sessionStore.updateSession(ds.session);
|
|
7353
|
+
}
|
|
7354
|
+
catch (err) {
|
|
7355
|
+
ds.session.codexAppGenerationCommits = priorCommits;
|
|
7356
|
+
logger.error(`[${t}] Failed to persist fresh Codex App generation: ${err instanceof Error ? err.message : String(err)}`);
|
|
7357
|
+
}
|
|
7358
|
+
break;
|
|
7359
|
+
}
|
|
5545
7360
|
case 'session_close_ready': {
|
|
5546
7361
|
resolveCloseFence(msg.sessionId, workerGeneration);
|
|
5547
7362
|
break;
|
|
5548
7363
|
}
|
|
5549
7364
|
case 'final_output': {
|
|
7365
|
+
if (msg.codexAppSettlement) {
|
|
7366
|
+
const settlement = msg.codexAppSettlement;
|
|
7367
|
+
const acknowledge = (ok, error) => {
|
|
7368
|
+
try {
|
|
7369
|
+
worker.send({
|
|
7370
|
+
type: 'codex_app_dispatch_persisted',
|
|
7371
|
+
requestId: settlement.requestId,
|
|
7372
|
+
ok,
|
|
7373
|
+
...(error ? { error } : {}),
|
|
7374
|
+
});
|
|
7375
|
+
}
|
|
7376
|
+
catch { /* runner keeps the final unacknowledged for replacement */ }
|
|
7377
|
+
};
|
|
7378
|
+
if (ds.worker !== worker) {
|
|
7379
|
+
acknowledge(false, 'stale_worker_generation');
|
|
7380
|
+
break;
|
|
7381
|
+
}
|
|
7382
|
+
if (msg.sessionId !== ds.session.sessionId) {
|
|
7383
|
+
acknowledge(false, 'session_mismatch');
|
|
7384
|
+
break;
|
|
7385
|
+
}
|
|
7386
|
+
if (committedCodexAppSequence(ds.session.codexAppGenerationCommits ?? [], settlement.generation, settlement.seq)) {
|
|
7387
|
+
acknowledge(true);
|
|
7388
|
+
if (!hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
|
|
7389
|
+
try {
|
|
7390
|
+
await cb.onCodexAppLedgerDrained?.(ds);
|
|
7391
|
+
}
|
|
7392
|
+
catch (err) {
|
|
7393
|
+
logger.error(`[${t}] post-drain cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7394
|
+
}
|
|
7395
|
+
}
|
|
7396
|
+
break;
|
|
7397
|
+
}
|
|
7398
|
+
const identity = {
|
|
7399
|
+
dispatchId: settlement.dispatchId,
|
|
7400
|
+
turnId: msg.turnId,
|
|
7401
|
+
...(msg.dispatchAttempt !== undefined
|
|
7402
|
+
? { dispatchAttempt: msg.dispatchAttempt }
|
|
7403
|
+
: {}),
|
|
7404
|
+
};
|
|
7405
|
+
const preview = settleCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], ds.session.codexAppGenerationCommits ?? [], identity, settlement.generation, settlement.seq);
|
|
7406
|
+
if (!preview.ok) {
|
|
7407
|
+
acknowledge(false, preview.error);
|
|
7408
|
+
break;
|
|
7409
|
+
}
|
|
7410
|
+
// R4/R5-B4 defense-in-depth: a `steer_superseded` settlement silently
|
|
7411
|
+
// advances the FIFO without delivering, so it is ONLY legitimate for a
|
|
7412
|
+
// genuine plain-Lark steerable head that STILL has a successor. Reject
|
|
7413
|
+
// (ACK=false, no pop, no receiver completion, no mutation) BEFORE the
|
|
7414
|
+
// commit block below if the settled entry is not steerable, not an
|
|
7415
|
+
// explicit Lark sink, belongs to a VC / durable-receiver / special
|
|
7416
|
+
// channel, or is the SOLE remaining head. R5: `deliverySink` must be
|
|
7417
|
+
// exactly 'lark' — admission always writes both sink and steerable
|
|
7418
|
+
// together, so "steerable=true + sink missing" can only be a
|
|
7419
|
+
// mixed/corrupt/legacy ledger and must fail closed, not be treated as
|
|
7420
|
+
// safe Lark. And a lone forged superseded (no successor) must never
|
|
7421
|
+
// silently commit the only head — the real final would then find no
|
|
7422
|
+
// pending turn. preview.ledger is the post-settle remainder.
|
|
7423
|
+
if (msg.disposition === 'steer_superseded') {
|
|
7424
|
+
const entry = preview.settledEntry;
|
|
7425
|
+
const supersededHeadOk = entry.codexAppSteerable === true
|
|
7426
|
+
&& entry.deliverySink === 'lark'
|
|
7427
|
+
&& entry.vcMeetingImTurnOrigin === undefined
|
|
7428
|
+
&& ds.session.vcMeetingReceiver === undefined
|
|
7429
|
+
&& preview.ledger.length > 0;
|
|
7430
|
+
if (!supersededHeadOk) {
|
|
7431
|
+
logger.warn(`[${t}] Rejected steer_superseded for non-steerable/special/last head `
|
|
7432
|
+
+ `(turn ${msg.turnId.substring(0, 8)}, sink=${entry.deliverySink ?? 'legacy'}, `
|
|
7433
|
+
+ `steerable=${entry.codexAppSteerable === true}, successors=${preview.ledger.length})`);
|
|
7434
|
+
acknowledge(false, 'superseded_head_not_plain_lark_steerable_with_successor');
|
|
7435
|
+
break;
|
|
7436
|
+
}
|
|
7437
|
+
}
|
|
7438
|
+
const key = `${ds.session.sessionId}:${settlement.generation}:${settlement.seq}`;
|
|
7439
|
+
let inFlight = codexAppFinalSettlementInFlight.get(key);
|
|
7440
|
+
if (!inFlight) {
|
|
7441
|
+
inFlight = (async () => {
|
|
7442
|
+
const unavailableSinkFailClosed = codexAppDeliveryMustFailClosed(ds, preview.settledEntry);
|
|
7443
|
+
const deliverySuppressed = msg.suppressDelivery === true
|
|
7444
|
+
|| managedFinalOutputSuppressed(msg.turnId, msg.dispatchAttempt)
|
|
7445
|
+
|| unavailableSinkFailClosed;
|
|
7446
|
+
if (unavailableSinkFailClosed
|
|
7447
|
+
&& preview.settledEntry.deliverySink !== 'suppressed'
|
|
7448
|
+
&& msg.suppressDelivery !== true) {
|
|
7449
|
+
logger.warn(`[${t}] Codex App recovery suppressed unavailable `
|
|
7450
|
+
+ `${preview.settledEntry.deliverySink ?? 'legacy non-Lark'} sink `
|
|
7451
|
+
+ `(turn ${msg.turnId.substring(0, 8)})`);
|
|
7452
|
+
}
|
|
7453
|
+
const alreadyDelivered = ds.lastBridgeEmittedUuid === finalOutputDedupeKey(ds, msg);
|
|
7454
|
+
const owned = deliverySuppressed || !msg.content.trim() || alreadyDelivered
|
|
7455
|
+
? true
|
|
7456
|
+
: await new Promise(resolve => {
|
|
7457
|
+
deliverFinalOutput(ds, msg, t, 0, resolve, () => ds.worker === worker
|
|
7458
|
+
&& ds.session.sessionId === msg.sessionId, preview.settledEntry.replyTarget);
|
|
7459
|
+
});
|
|
7460
|
+
if (!owned)
|
|
7461
|
+
return false;
|
|
7462
|
+
// Re-read after the asynchronous external delivery. A concurrent
|
|
7463
|
+
// replacement may already have committed this signed sequence;
|
|
7464
|
+
// that is idempotent success, never a second FIFO pop.
|
|
7465
|
+
if (committedCodexAppSequence(ds.session.codexAppGenerationCommits ?? [], settlement.generation, settlement.seq))
|
|
7466
|
+
return true;
|
|
7467
|
+
const committed = settleCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], ds.session.codexAppGenerationCommits ?? [], identity, settlement.generation, settlement.seq);
|
|
7468
|
+
if (!committed.ok)
|
|
7469
|
+
return false;
|
|
7470
|
+
if (msg.dispatchAttempt !== undefined) {
|
|
7471
|
+
try {
|
|
7472
|
+
// Durable receivers must not depend on the worker surviving
|
|
7473
|
+
// the daemon ACK long enough to emit a later terminal IPC.
|
|
7474
|
+
// Persist the exact completed attempt first; the worker's
|
|
7475
|
+
// ordered duplicate terminal remains idempotent.
|
|
7476
|
+
await cb.onTurnTerminal?.(ds, {
|
|
7477
|
+
type: 'turn_terminal',
|
|
7478
|
+
sessionId: ds.session.sessionId,
|
|
7479
|
+
turnId: msg.turnId,
|
|
7480
|
+
dispatchAttempt: msg.dispatchAttempt,
|
|
7481
|
+
status: 'completed',
|
|
7482
|
+
}, { workerGeneration });
|
|
7483
|
+
}
|
|
7484
|
+
catch (err) {
|
|
7485
|
+
logger.error(`[${t}] Failed to persist Codex App settlement terminal: ${err instanceof Error ? err.message : String(err)}`);
|
|
7486
|
+
return false;
|
|
7487
|
+
}
|
|
7488
|
+
}
|
|
7489
|
+
const priorLedger = ds.session.codexAppDispatchLedger;
|
|
7490
|
+
const priorCommits = ds.session.codexAppGenerationCommits;
|
|
7491
|
+
ds.session.codexAppDispatchLedger = committed.ledger;
|
|
7492
|
+
ds.session.codexAppGenerationCommits = committed.commits;
|
|
7493
|
+
try {
|
|
7494
|
+
// One atomic sessions-file replacement owns both the exact FIFO
|
|
7495
|
+
// pop and cumulative runner ACK boundary. Only after this write
|
|
7496
|
+
// may the worker acknowledge final-end to the runner.
|
|
7497
|
+
sessionStore.updateSession(ds.session);
|
|
7498
|
+
return true;
|
|
7499
|
+
}
|
|
7500
|
+
catch (err) {
|
|
7501
|
+
ds.session.codexAppDispatchLedger = priorLedger;
|
|
7502
|
+
ds.session.codexAppGenerationCommits = priorCommits;
|
|
7503
|
+
logger.error(`[${t}] Failed to persist Codex App final settlement: ${err instanceof Error ? err.message : String(err)}`);
|
|
7504
|
+
return false;
|
|
7505
|
+
}
|
|
7506
|
+
})().finally(() => codexAppFinalSettlementInFlight.delete(key));
|
|
7507
|
+
codexAppFinalSettlementInFlight.set(key, inFlight);
|
|
7508
|
+
}
|
|
7509
|
+
const persisted = await inFlight;
|
|
7510
|
+
acknowledge(persisted, persisted ? undefined : 'final_settlement_failed');
|
|
7511
|
+
if (persisted && !hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
|
|
7512
|
+
try {
|
|
7513
|
+
await cb.onCodexAppLedgerDrained?.(ds);
|
|
7514
|
+
}
|
|
7515
|
+
catch (err) {
|
|
7516
|
+
logger.error(`[${t}] post-drain cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7517
|
+
}
|
|
7518
|
+
}
|
|
7519
|
+
break;
|
|
7520
|
+
}
|
|
5550
7521
|
// Adopt-bridge: worker harvested the assistant turn from Claude Code's
|
|
5551
7522
|
// transcript JSONL and forwarded it to us. Dedup with a session-scoped
|
|
5552
7523
|
// key so a re-drain can't re-send the same answer or cross-suppress
|
|
@@ -5586,7 +7557,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5586
7557
|
if (msg.turnId.startsWith('mlrp_turn_')) {
|
|
5587
7558
|
markMessageListenerRunPreviewRunning(msg.turnId);
|
|
5588
7559
|
}
|
|
5589
|
-
deliverFinalOutput(ds, msg, t, 0, ownsLifecycleMutation);
|
|
7560
|
+
deliverFinalOutput(ds, msg, t, 0, undefined, ownsLifecycleMutation);
|
|
5590
7561
|
break;
|
|
5591
7562
|
}
|
|
5592
7563
|
case 'adopt_preamble': {
|
|
@@ -5648,13 +7619,29 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5648
7619
|
// old worker's exit fires AFTER the new worker has been assigned.
|
|
5649
7620
|
if (ds.worker === worker) {
|
|
5650
7621
|
restartCoordinator.failSession(ds.session.sessionId);
|
|
7622
|
+
if (ds.session.queuedActivationPending) {
|
|
7623
|
+
// Journal ownership is backend-independent. The next worker replays
|
|
7624
|
+
// this exact head with queuedActivationResume before durable tail N+1.
|
|
7625
|
+
ds.initialStartPending = false;
|
|
7626
|
+
ds.initialStartClaimToken = undefined;
|
|
7627
|
+
}
|
|
7628
|
+
else {
|
|
7629
|
+
reparkQueuedActivationFollowUpTail(ds, 'worker exit during activation follow-up handoff');
|
|
7630
|
+
}
|
|
5651
7631
|
ds.worker = null;
|
|
5652
7632
|
ds.workerReady = false;
|
|
5653
7633
|
ds.workerPort = null;
|
|
5654
7634
|
// Dead worker generation — stop the periodic usage refresh immediately
|
|
5655
7635
|
// instead of waiting a tick for it to self-clear on !workerHasInitialized.
|
|
5656
7636
|
clearUsageRefreshTimer(ds);
|
|
7637
|
+
ds.workerToken = null;
|
|
7638
|
+
ds.workerViewToken = null;
|
|
5657
7639
|
ds.managedTurnOrigin = undefined;
|
|
7640
|
+
if (ds.riffCloseState) {
|
|
7641
|
+
ds.riffCloseState = { ...ds.riffCloseState, phase: 'uncertain' };
|
|
7642
|
+
}
|
|
7643
|
+
// Do not clear riffShutdownState here. Only the shutdown coordinator can
|
|
7644
|
+
// release a generation after lineage persistence or admission restore.
|
|
5658
7645
|
// This worker generation is gone. Invalidate any stuck-warning card it
|
|
5659
7646
|
// posted so a late click cannot inject keys into a replacement worker.
|
|
5660
7647
|
invalidateStuckWarning(ds, 'worker_exit');
|
|
@@ -5710,6 +7697,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
5710
7697
|
}
|
|
5711
7698
|
// ─── Bridge final-output delivery (with retry) ──────────────────────────────
|
|
5712
7699
|
const FINAL_OUTPUT_RETRY_BACKOFF_MS = [0, 5000, 15000]; // immediate, +5s, +15s
|
|
7700
|
+
const codexAppFinalSettlementInFlight = new Map();
|
|
5713
7701
|
function finalOutputDedupeKey(ds, msg) {
|
|
5714
7702
|
return `${msg.sessionId ?? ds.session.sessionId}:${msg.lastUuid || msg.turnId}`;
|
|
5715
7703
|
}
|
|
@@ -5784,14 +7772,16 @@ async function finishTurnReactions(ds) {
|
|
|
5784
7772
|
}
|
|
5785
7773
|
}
|
|
5786
7774
|
}
|
|
5787
|
-
/** Deliver a bridge `final_output` to Lark. The worker
|
|
5788
|
-
*
|
|
5789
|
-
* daemon owns
|
|
7775
|
+
/** Deliver a bridge `final_output` to Lark. The current worker generation pops
|
|
7776
|
+
* the turn at emit time, while replacement recovery may replay it with the
|
|
7777
|
+
* same provider key; the daemon owns bounded transient retries. After 3 attempts we log
|
|
5790
7778
|
* and give up — the user's answer is lost; better than leaking memory
|
|
5791
7779
|
* via an unbounded retry loop. */
|
|
5792
|
-
function deliverFinalOutput(ds, msg, t, attempt,
|
|
5793
|
-
if (!
|
|
7780
|
+
function deliverFinalOutput(ds, msg, t, attempt, onComplete, isStillOwned = () => true, frozenReplyTarget, frozenUsage) {
|
|
7781
|
+
if (!isStillOwned()) {
|
|
7782
|
+
onComplete?.(false);
|
|
5794
7783
|
return;
|
|
7784
|
+
}
|
|
5795
7785
|
let cardUsage = frozenUsage;
|
|
5796
7786
|
const managedReceiver = !!ds.session.vcMeetingReceiver;
|
|
5797
7787
|
// Wait Mode / HTTP Sync Override:
|
|
@@ -5804,6 +7794,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
5804
7794
|
waitPromise.resolve(msg.content);
|
|
5805
7795
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
5806
7796
|
logger.info(`[${t}] Intercepted final_output for Wait Mode HTTP request (turn ${msg.turnId.substring(0, 8)})`);
|
|
7797
|
+
onComplete?.(true);
|
|
5807
7798
|
return;
|
|
5808
7799
|
}
|
|
5809
7800
|
const asyncResult = managedReceiver ? undefined : ds.asyncTriggerResults?.get(msg.turnId);
|
|
@@ -5818,8 +7809,14 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
5818
7809
|
// (with content + usage) after a daemon restart drops the in-memory Map.
|
|
5819
7810
|
// Stamp the owning bot for cross-bot isolation.
|
|
5820
7811
|
asyncTriggerStore.recordCompleted(ds.session.sessionId, msg.turnId, msg.content, completedAt, ds.larkAppId, msg.usage);
|
|
7812
|
+
// This idempotent async turn produced its terminal output — clear the
|
|
7813
|
+
// worker-exit convergence stamp so a later graceful exit of this generation
|
|
7814
|
+
// is not retro-failed (codex #776 round-6 finding #1).
|
|
7815
|
+
if (ds.idempotentAsyncTurn?.triggerId === msg.turnId)
|
|
7816
|
+
ds.idempotentAsyncTurn = undefined;
|
|
5821
7817
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
5822
7818
|
logger.info(`[${t}] Captured final_output for Async HTTP request (turn ${msg.turnId.substring(0, 8)})`);
|
|
7819
|
+
onComplete?.(true);
|
|
5823
7820
|
return;
|
|
5824
7821
|
}
|
|
5825
7822
|
const cb = requireCallbacks();
|
|
@@ -5828,13 +7825,17 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
5828
7825
|
? { ...opts, sourceSessionId: ds.session.sessionId }
|
|
5829
7826
|
: opts);
|
|
5830
7827
|
setTimeout(async () => {
|
|
5831
|
-
if (!
|
|
7828
|
+
if (!isStillOwned()) {
|
|
7829
|
+
logger.info(`[${t}] Bridge final_output abandoned — worker/session ownership changed`);
|
|
7830
|
+
onComplete?.(false);
|
|
5832
7831
|
return;
|
|
7832
|
+
}
|
|
5833
7833
|
// Guard: if the user closed the session (or it was torn down for any
|
|
5834
7834
|
// other reason) between attempts, don't post a stale final answer to
|
|
5835
7835
|
// a closed thread.
|
|
5836
7836
|
if (ds.session.status === 'closed') {
|
|
5837
7837
|
logger.info(`[${t}] Bridge final_output abandoned — session closed (turn ${msg.turnId.substring(0, 8)})`);
|
|
7838
|
+
onComplete?.(true);
|
|
5838
7839
|
return;
|
|
5839
7840
|
}
|
|
5840
7841
|
try {
|
|
@@ -5846,11 +7847,15 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
5846
7847
|
// (模型没显式 botmux send),默认 @ 回原评论人,仅首块加。
|
|
5847
7848
|
const chunks = chunkCommentText(msg.content);
|
|
5848
7849
|
for (let i = 0; i < chunks.length; i++) {
|
|
5849
|
-
if (!
|
|
7850
|
+
if (!isStillOwned()) {
|
|
7851
|
+
onComplete?.(false);
|
|
5850
7852
|
return;
|
|
7853
|
+
}
|
|
5851
7854
|
await replyToDocComment(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, chunks[i], i === 0 ? docTurn.replyToOpenId : undefined);
|
|
5852
|
-
if (!
|
|
7855
|
+
if (!isStillOwned()) {
|
|
7856
|
+
onComplete?.(false);
|
|
5853
7857
|
return;
|
|
7858
|
+
}
|
|
5854
7859
|
}
|
|
5855
7860
|
// The user-visible reply is committed. Consume the route and dedupe
|
|
5856
7861
|
// marker BEFORE best-effort reaction cleanup: a missing/expired
|
|
@@ -5866,6 +7871,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
5866
7871
|
}
|
|
5867
7872
|
}
|
|
5868
7873
|
logger.info(`[${t}] doc-comment final_output → posted ${chunks.length} comment(s) on file=${docTurn.fileToken.slice(0, 12)} (turn ${msg.turnId.substring(0, 8)})`);
|
|
7874
|
+
onComplete?.(true);
|
|
5869
7875
|
return;
|
|
5870
7876
|
}
|
|
5871
7877
|
// A doc-native session has a virtual `doc:<token>` chat anchor, not a
|
|
@@ -5876,6 +7882,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
5876
7882
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
5877
7883
|
logger.error(`[${t}] Suppressed doc-native final_output without comment target ` +
|
|
5878
7884
|
`(turn ${msg.turnId.substring(0, 8)})`);
|
|
7885
|
+
onComplete?.(true);
|
|
5879
7886
|
return;
|
|
5880
7887
|
}
|
|
5881
7888
|
// Wrap the model's reply in the same card chrome `botmux send` uses
|
|
@@ -5906,6 +7913,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
5906
7913
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
5907
7914
|
logger.warn(`[${t}] VC final_output lost current membership authority `
|
|
5908
7915
|
+ `(${managedDecision.errorCode}) turn=${msg.turnId.substring(0, 8)}`);
|
|
7916
|
+
onComplete?.(true);
|
|
5909
7917
|
return;
|
|
5910
7918
|
}
|
|
5911
7919
|
const revalidateManagedSend = () => {
|
|
@@ -6019,10 +8027,23 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
6019
8027
|
}, proposedOutput)
|
|
6020
8028
|
: undefined;
|
|
6021
8029
|
const preparedListenerReply = preparedImReply ?? preparedDeliveryReply;
|
|
8030
|
+
// Codex App final settlement is delivery-before-commit. If the daemon
|
|
8031
|
+
// dies after Lark accepts the reply but before the FIFO/sequence commit,
|
|
8032
|
+
// the replacement must retry with the same provider key. Keep the key
|
|
8033
|
+
// dispatch-stable (not generation/seq-stable), so the user-visible Lark
|
|
8034
|
+
// message is idempotent across crash reconciliation. The existing
|
|
8035
|
+
// outbound-hook contract remains unchanged; it is a separate best-effort
|
|
8036
|
+
// side effect and is not covered by the provider UUID.
|
|
8037
|
+
const codexAppSettlementReply = msg.codexAppSettlement
|
|
8038
|
+
? {
|
|
8039
|
+
uuid: `ca_${msg.codexAppSettlement.dispatchId}`.slice(0, 50),
|
|
8040
|
+
}
|
|
8041
|
+
: undefined;
|
|
6022
8042
|
if (preparedListenerReply?.kind === 'conflict') {
|
|
6023
8043
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
6024
8044
|
logger.error(`[${t}] VC listener fallback suppressed (${preparedListenerReply.reason}) `
|
|
6025
8045
|
+ `turn=${msg.turnId.substring(0, 8)}: ${preparedListenerReply.detail}`);
|
|
8046
|
+
onComplete?.(true);
|
|
6026
8047
|
return;
|
|
6027
8048
|
}
|
|
6028
8049
|
const canonicalOutput = preparedListenerReply?.canonicalOutput ?? proposedOutput;
|
|
@@ -6062,13 +8083,18 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
6062
8083
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
6063
8084
|
logger.info(`[${t}] VC listener fallback replayed existing provider result `
|
|
6064
8085
|
+ `(turn ${msg.turnId.substring(0, 8)})`);
|
|
8086
|
+
onComplete?.(true);
|
|
6065
8087
|
return;
|
|
6066
8088
|
}
|
|
6067
8089
|
// Always deliver the answer as a fresh message — never PATCH a card in
|
|
6068
8090
|
// place. message.patch is silent (no Feishu notification / unread), which
|
|
6069
8091
|
// used to swallow the answer; a brand-new message always pings.
|
|
6070
8092
|
revalidateManagedSend();
|
|
6071
|
-
|
|
8093
|
+
if (!isStillOwned()) {
|
|
8094
|
+
onComplete?.(false);
|
|
8095
|
+
return;
|
|
8096
|
+
}
|
|
8097
|
+
const deliveryReplyOptions = preparedListenerReply
|
|
6072
8098
|
? {
|
|
6073
8099
|
uuid: preparedListenerReply.providerKey,
|
|
6074
8100
|
quoteMessageId: canonicalOutput.quoteTargetId,
|
|
@@ -6084,9 +8110,14 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
6084
8110
|
}
|
|
6085
8111
|
: {}),
|
|
6086
8112
|
}
|
|
6087
|
-
:
|
|
6088
|
-
|
|
8113
|
+
: codexAppSettlementReply;
|
|
8114
|
+
const messageId = await scopedReply(canonicalOutput.content, canonicalOutput.msgType, msg.replyTurnId ?? msg.turnId, frozenReplyTarget && !managedReceiver
|
|
8115
|
+
? { ...deliveryReplyOptions, replyTarget: frozenReplyTarget }
|
|
8116
|
+
: deliveryReplyOptions);
|
|
8117
|
+
if (!isStillOwned()) {
|
|
8118
|
+
onComplete?.(true);
|
|
6089
8119
|
return;
|
|
8120
|
+
}
|
|
6090
8121
|
recordPrimaryOutput(messageId);
|
|
6091
8122
|
if (msg.turnId.startsWith('mlrp_turn_')) {
|
|
6092
8123
|
markMessageListenerRunPreviewReplied(msg.turnId, {
|
|
@@ -6099,16 +8130,24 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
6099
8130
|
}
|
|
6100
8131
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
6101
8132
|
logger.info(`[${t}] Bridge final_output forwarded (turn ${msg.turnId.substring(0, 8)}, ${msg.content.length} chars, kind=${msg.kind ?? 'bridge'}, attempt ${attempt + 1})`);
|
|
8133
|
+
onComplete?.(true);
|
|
6102
8134
|
}
|
|
6103
8135
|
catch (err) {
|
|
6104
|
-
if (!
|
|
8136
|
+
if (!isStillOwned()) {
|
|
8137
|
+
onComplete?.(false);
|
|
6105
8138
|
return;
|
|
8139
|
+
}
|
|
6106
8140
|
if (err instanceof MessageWithdrawnError) {
|
|
6107
|
-
//
|
|
6108
|
-
//
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
8141
|
+
// Withdrawal is permanent for this target, but it is not explicit
|
|
8142
|
+
// abandon. Keep an unsettled FIFO owner recoverable; only ledger-empty
|
|
8143
|
+
// sessions may commit the dedupe marker and auto-close.
|
|
8144
|
+
if (await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while forwarding final_output')) {
|
|
8145
|
+
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
8146
|
+
onComplete?.(true);
|
|
8147
|
+
}
|
|
8148
|
+
else {
|
|
8149
|
+
onComplete?.(false);
|
|
8150
|
+
}
|
|
6112
8151
|
return;
|
|
6113
8152
|
}
|
|
6114
8153
|
const next = attempt + 1;
|
|
@@ -6122,10 +8161,11 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
|
|
|
6122
8161
|
}
|
|
6123
8162
|
// Don't commit the dedup marker — leave room for any future
|
|
6124
8163
|
// retransmit (e.g. daemon restart that re-fires the IPC).
|
|
8164
|
+
onComplete?.(false);
|
|
6125
8165
|
return;
|
|
6126
8166
|
}
|
|
6127
8167
|
logger.warn(`[${t}] Bridge final_output attempt ${next} failed (${err.message}); retrying in ${FINAL_OUTPUT_RETRY_BACKOFF_MS[next]}ms`);
|
|
6128
|
-
deliverFinalOutput(ds, msg, t, next,
|
|
8168
|
+
deliverFinalOutput(ds, msg, t, next, onComplete, isStillOwned, frozenReplyTarget, cardUsage);
|
|
6129
8169
|
}
|
|
6130
8170
|
}, FINAL_OUTPUT_RETRY_BACKOFF_MS[attempt] ?? 0);
|
|
6131
8171
|
}
|
|
@@ -6272,6 +8312,7 @@ export function forkAdoptWorker(ds, opts) {
|
|
|
6272
8312
|
const worker = fork(workerPath, [], {
|
|
6273
8313
|
windowsHide: true,
|
|
6274
8314
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
8315
|
+
execArgv: workerForkExecArgv(),
|
|
6275
8316
|
cwd: adoptCwd,
|
|
6276
8317
|
env: {
|
|
6277
8318
|
...forkEnv,
|
|
@@ -6694,6 +8735,11 @@ function cleanupPersistentBackendSessions(backendType, activeSessions_, runtimeS
|
|
|
6694
8735
|
}
|
|
6695
8736
|
catch { /* first run */ }
|
|
6696
8737
|
const currentCliId = config.daemon.cliId;
|
|
8738
|
+
// Codex App sessions with an unsettled dispatch ledger must reconcile before
|
|
8739
|
+
// any CLI-change sweep tears their backing pane down.
|
|
8740
|
+
const unsettledNames = new Set(activeSessions_
|
|
8741
|
+
.filter(hasProtectedSessionMutationOwnership)
|
|
8742
|
+
.map(session => backend.sessionName(session.sessionId)));
|
|
6697
8743
|
const belongsToBackend = (session) => session.backendType === backendType ||
|
|
6698
8744
|
(session.backendType === undefined && backendType === 'tmux');
|
|
6699
8745
|
const activeNames = new Set([...activeSessions_, ...runtimeSessionRows]
|
|
@@ -6822,6 +8868,10 @@ function cleanupPersistentBackendSessions(backendType, activeSessions_, runtimeS
|
|
|
6822
8868
|
logger.info(`CLI_ID changed (${lastCliId} → ${currentCliId}), killing all ${backendType} sessions`);
|
|
6823
8869
|
// Legacy per-topic hosts are still enumerable by bmx-* name.
|
|
6824
8870
|
for (const name of backend.listBotmuxSessions()) {
|
|
8871
|
+
if (unsettledNames.has(name)) {
|
|
8872
|
+
logger.warn(`Preserving ${backendType} ${name}: unsettled Codex App dispatch must reconcile first`);
|
|
8873
|
+
continue;
|
|
8874
|
+
}
|
|
6825
8875
|
// A dispatcher-created runtime session already runs the current CLI.
|
|
6826
8876
|
// Never let the stale last-cli marker tear it down during restore.
|
|
6827
8877
|
if (runtimeNames.has(name))
|