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
|
@@ -12,7 +12,7 @@ import * as sessionStore from '../services/session-store.js';
|
|
|
12
12
|
import * as messageQueue from '../services/message-queue.js';
|
|
13
13
|
import { downloadMessageResource, listChatBotMembers, UserTokenMissingError } from '../im/lark/client.js';
|
|
14
14
|
import { logger } from '../utils/logger.js';
|
|
15
|
-
import { forkWorker, sendWorkerInput, forkAdoptWorker, adoptSandboxBlocked, killStalePids, sweepDeadPidMarkers, getCurrentCliVersion, restoreUsageLimitRuntimeState,
|
|
15
|
+
import { forkWorker, sendWorkerInput, promoteQueuedActivationTail, forkAdoptWorker, adoptSandboxBlocked, killStalePids, sweepDeadPidMarkers, getCurrentCliVersion, restoreUsageLimitRuntimeState, setActiveSessionSafe, setActiveSessionIfActive, isDisposableCommandScratch, isRelayableRealSession, closeSession, getActiveSessionsRegistry, suspendWorker, withActiveSessionKeyLock, isSessionTransferring, deferUntilSessionTransferSettled } from './worker-pool.js';
|
|
16
16
|
import { createCliAdapterSync } from '../adapters/cli/registry.js';
|
|
17
17
|
import { buildBotmuxShellHints } from '../adapters/cli/shared-hints.js';
|
|
18
18
|
import { resolveSkillInjectionModeForApp, builtinSkillEntries, buildBuiltinSkillCatalogBlock, builtinSkillHelpPointer, } from '../skills/injection-mode.js';
|
|
@@ -26,7 +26,10 @@ import { composeSpawnCodexAppContext, composeSpawnUserContent, deriveSessionTitl
|
|
|
26
26
|
import { validateZellijAdoptTarget } from './zellij-adopt-discovery.js';
|
|
27
27
|
import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js';
|
|
28
28
|
import { addCodexAppContext } from '../utils/codex-app-context.js';
|
|
29
|
-
import {
|
|
29
|
+
import { hasUnsettledCodexAppDispatch } from '../utils/codex-app-dispatch-ledger.js';
|
|
30
|
+
import { hasProtectedSessionMutationOwnership } from './session-mutation-guard.js';
|
|
31
|
+
import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, } from './types.js';
|
|
32
|
+
import { stagePendingRepoSetup, persistPendingRepoCardMessageId, restorePendingRepoRuntime } from './pending-repo-journal.js';
|
|
30
33
|
import { announceSessionRow, markSessionActivity, announcePendingRepoSession } from './session-activity.js';
|
|
31
34
|
import { scanMultipleProjects } from '../services/project-scanner.js';
|
|
32
35
|
import { buildRepoSelectCard } from '../im/lark/card-builder.js';
|
|
@@ -50,6 +53,88 @@ function sessionCreatedAtMs(session) {
|
|
|
50
53
|
function sessionLastMessageAtMs(session) {
|
|
51
54
|
return session.lastMessageAt ? (Date.parse(session.lastMessageAt) || sessionCreatedAtMs(session)) : sessionCreatedAtMs(session);
|
|
52
55
|
}
|
|
56
|
+
async function resumeRestoredPendingRepoSetup(ds, activeSessions) {
|
|
57
|
+
const setup = ds.session.pendingRepoSetup;
|
|
58
|
+
if (!setup || ds.session.queuedActivationPending || !ds.pendingRepo)
|
|
59
|
+
return;
|
|
60
|
+
const anchor = sessionAnchorId(ds);
|
|
61
|
+
const notify = async (content) => {
|
|
62
|
+
const { sendMessage, replyMessage } = await import('../im/lark/client.js');
|
|
63
|
+
return ds.scope === 'thread'
|
|
64
|
+
? replyMessage(ds.larkAppId, anchor, content, 'text', true)
|
|
65
|
+
: sendMessage(ds.larkAppId, ds.chatId, content, 'text');
|
|
66
|
+
};
|
|
67
|
+
if (setup.mode === 'auto_worktree' && setup.baseDir) {
|
|
68
|
+
const { runAutoWorktreeCommit } = await import('../im/lark/card-handler.js');
|
|
69
|
+
void runAutoWorktreeCommit({
|
|
70
|
+
ds,
|
|
71
|
+
anchor,
|
|
72
|
+
larkAppId: ds.larkAppId,
|
|
73
|
+
baseDir: setup.baseDir,
|
|
74
|
+
title: ds.session.title,
|
|
75
|
+
prompt: setup.prompt,
|
|
76
|
+
operatorOpenId: ds.session.ownerOpenId,
|
|
77
|
+
activeSessions,
|
|
78
|
+
notify,
|
|
79
|
+
}).catch((err) => {
|
|
80
|
+
// Git/worktree recovery is deliberately detached. A failed publish or
|
|
81
|
+
// build may not reject daemon startup or erase this durable setup owner.
|
|
82
|
+
restorePendingRepoRuntime(ds);
|
|
83
|
+
logger.error(`[${ds.session.sessionId.substring(0, 8)}] Pending auto-worktree recovery failed; `
|
|
84
|
+
+ `durable setup retained for retry: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
+
});
|
|
86
|
+
logger.info(`[${ds.session.sessionId.substring(0, 8)}] Restarted durable pending auto-worktree setup`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Always publish a fresh picker after restart: a persisted message id proves
|
|
90
|
+
// identity, not that the Lark message still exists. Persist the replacement
|
|
91
|
+
// before best-effort withdrawal so exactly one card remains authoritative.
|
|
92
|
+
const oldCardMessageId = setup.repoCardMessageId;
|
|
93
|
+
const scanDirs = getProjectScanDirs(ds).filter(dir => existsSync(dir));
|
|
94
|
+
const projects = scanDirs.length > 0
|
|
95
|
+
? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions())
|
|
96
|
+
: [];
|
|
97
|
+
if (projects.length > 0) {
|
|
98
|
+
const bot = getBot(ds.larkAppId);
|
|
99
|
+
const card = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(ds.larkAppId), bot.config.worktreeMultiPicker);
|
|
100
|
+
const { sendMessage, replyMessage, deleteMessage } = await import('../im/lark/client.js');
|
|
101
|
+
const messageId = ds.scope === 'thread'
|
|
102
|
+
? await replyMessage(ds.larkAppId, anchor, card, 'interactive', true)
|
|
103
|
+
: await sendMessage(ds.larkAppId, ds.chatId, card, 'interactive');
|
|
104
|
+
ds.repoCardMessageId = messageId;
|
|
105
|
+
persistPendingRepoCardMessageId(ds, messageId);
|
|
106
|
+
if (oldCardMessageId && oldCardMessageId !== messageId) {
|
|
107
|
+
void deleteMessage(ds.larkAppId, oldCardMessageId).catch(() => { });
|
|
108
|
+
}
|
|
109
|
+
announcePendingRepoSession(ds);
|
|
110
|
+
logger.info(`[${ds.session.sessionId.substring(0, 8)}] Re-posted durable pending repo picker`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// The original pre-card scan may have crashed before learning there was no
|
|
114
|
+
// selectable repo. Resume the same no-project fallback without replacing N.
|
|
115
|
+
ds.pendingRepo = false;
|
|
116
|
+
ensureSessionWhiteboard(ds);
|
|
117
|
+
if (setup.rawInput) {
|
|
118
|
+
rememberLastCliInput(ds, setup.rawInput, setup.rawInput);
|
|
119
|
+
forkWorker(ds, '', { resume: false, turnId: setup.turnId });
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const bot = getBot(ds.larkAppId);
|
|
123
|
+
const availableBots = await getAvailableBots(ds.larkAppId, ds.chatId);
|
|
124
|
+
const input = buildNewTopicCliInput(setup.prompt, ds.session.sessionId, ds.session.cliId ?? bot.config.cliId, ds.session.cliPathOverride ?? bot.config.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(ds.larkAppId), ds.pendingSender, {
|
|
125
|
+
larkAppId: ds.larkAppId,
|
|
126
|
+
chatId: ds.chatId,
|
|
127
|
+
whiteboardId: ds.session.whiteboardId,
|
|
128
|
+
substituteTrigger: ds.pendingSubstituteTrigger,
|
|
129
|
+
codexAppText: ds.pendingCodexAppText,
|
|
130
|
+
codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
|
|
131
|
+
codexAppMessageContext: ds.pendingCodexAppMessageContext,
|
|
132
|
+
});
|
|
133
|
+
rememberLastCliInput(ds, setup.prompt, input);
|
|
134
|
+
forkWorker(ds, input, { resume: false, turnId: setup.turnId });
|
|
135
|
+
}
|
|
136
|
+
logger.info(`[${ds.session.sessionId.substring(0, 8)}] Resumed durable pending repo opening without selectable projects`);
|
|
137
|
+
}
|
|
53
138
|
function quarantineUnregisteredRestoreSession(session, reason) {
|
|
54
139
|
session.restoreQuarantinedAt ??= new Date().toISOString();
|
|
55
140
|
try {
|
|
@@ -150,6 +235,23 @@ async function closeActiveSessionIfCliMismatch(ds) {
|
|
|
150
235
|
const mismatch = sessionBotCliMismatch(ds);
|
|
151
236
|
if (!mismatch)
|
|
152
237
|
return 'not_mismatched';
|
|
238
|
+
// A config mismatch is not an explicit user close. In particular, bots.json
|
|
239
|
+
// may be edited while the daemon is down, leaving a persisted backend-neutral
|
|
240
|
+
// activation owner (or Codex App final) that only the frozen generation can
|
|
241
|
+
// reconcile on restart. Preserve it until its durable ownership settles.
|
|
242
|
+
// Report 'not_mismatched' so every caller leaves the row alone (no close, no
|
|
243
|
+
// teardown-failure quarantine, no transfer resweep) exactly as PR #597's
|
|
244
|
+
// boolean `return false` did before master's enum refactor.
|
|
245
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
246
|
+
logger.warn(`[${ds.session.sessionId.substring(0, 8)}] CLI mismatch retained until `
|
|
247
|
+
+ 'durable activation ownership is reconciled');
|
|
248
|
+
return 'not_mismatched';
|
|
249
|
+
}
|
|
250
|
+
// queuedActivationPending is the backend-neutral write-ahead owner for an
|
|
251
|
+
// opening/successor that has not crossed its adapter submission boundary.
|
|
252
|
+
// Its durable tail is ordered behind that owner. A bot config edit is not an
|
|
253
|
+
// explicit abandon operation, so it must not close backend work or a
|
|
254
|
+
// tail-only restore before recovery promotes its first entry.
|
|
153
255
|
const tag = ds.session.sessionId.substring(0, 8);
|
|
154
256
|
if (isSessionTransferring(ds)) {
|
|
155
257
|
logger.warn(`[${tag}] CLI mismatch close deferred until routing transfer settles`);
|
|
@@ -206,6 +308,11 @@ export async function closeCliMismatchedSessionsForBot(larkAppId) {
|
|
|
206
308
|
continue;
|
|
207
309
|
if (ds.adoptedFrom || ds.session.adoptedFrom || ds.session.title?.startsWith('Adopt:'))
|
|
208
310
|
continue;
|
|
311
|
+
// Defense in depth: the dashboard toggle preflights the whole bot before
|
|
312
|
+
// changing config. Never let a refused suspend fall through into
|
|
313
|
+
// closeSession: close is explicit abandon, and a settings toggle is not.
|
|
314
|
+
if (hasProtectedSessionMutationOwnership(ds))
|
|
315
|
+
continue;
|
|
209
316
|
if (isSessionTransferring(ds)) {
|
|
210
317
|
armCliMismatchResweep(ds);
|
|
211
318
|
continue;
|
|
@@ -246,6 +353,11 @@ export async function suspendActiveSessionsForBot(larkAppId) {
|
|
|
246
353
|
continue;
|
|
247
354
|
if (ds.adoptedFrom || ds.session.adoptedFrom || ds.session.title?.startsWith('Adopt:'))
|
|
248
355
|
continue;
|
|
356
|
+
// A settings helper is never an explicit abandon boundary. In particular,
|
|
357
|
+
// suspendWorker intentionally refuses pending Codex App ownership; do not
|
|
358
|
+
// reinterpret that refusal as permission to close and erase the FIFO.
|
|
359
|
+
if (hasProtectedSessionMutationOwnership(ds))
|
|
360
|
+
continue;
|
|
249
361
|
if (isSessionTransferring(ds)) {
|
|
250
362
|
logger.warn(`[${ds.session.sessionId.substring(0, 8)}] Read-isolation cold restart deferred during routing transfer`);
|
|
251
363
|
continue;
|
|
@@ -1192,17 +1304,29 @@ const RECOVERY_FORK_DELAY_MS = config.daemon.recoveryForkDelayMs ?? 250;
|
|
|
1192
1304
|
* staggered to avoid a thundering-herd CPU/IO spike when many sessions survive a
|
|
1193
1305
|
* restart: spawn `batchSize` workers, wait `delayMs`, repeat.
|
|
1194
1306
|
*
|
|
1195
|
-
* Sessions whose worker is already live are skipped
|
|
1196
|
-
*
|
|
1197
|
-
* during one of
|
|
1198
|
-
* worker
|
|
1307
|
+
* Sessions whose worker is already live are skipped. Startup admissions are
|
|
1308
|
+
* held behind restore, but lifecycle callbacks or a future recovery path can
|
|
1309
|
+
* still close/replace/wake an entry during one of the batch delays; re-forking
|
|
1310
|
+
* that stale object would kill the current worker via the double-fork guard.
|
|
1199
1311
|
*/
|
|
1200
|
-
export async function staggeredRecoveryFork(sessions, fork, batchSize = RECOVERY_FORK_BATCH_SIZE, delayMs = RECOVERY_FORK_DELAY_MS) {
|
|
1312
|
+
export async function staggeredRecoveryFork(sessions, fork, batchSize = RECOVERY_FORK_BATCH_SIZE, delayMs = RECOVERY_FORK_DELAY_MS, stillOwned = ds => ds.session.status === 'active') {
|
|
1201
1313
|
let spawnedInBatch = 0;
|
|
1202
1314
|
for (const ds of sessions) {
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1315
|
+
// The batch delay is a lifecycle boundary: close/replace can remove this
|
|
1316
|
+
// exact object while we sleep. Never resurrect a closed or orphaned ds.
|
|
1317
|
+
if (ds.worker || ds.session.status !== 'active' || !stillOwned(ds))
|
|
1318
|
+
continue;
|
|
1319
|
+
try {
|
|
1320
|
+
fork(ds);
|
|
1321
|
+
}
|
|
1322
|
+
catch (err) {
|
|
1323
|
+
// One malformed/stale pane or synchronous init-IPC failure must not
|
|
1324
|
+
// abort recovery for every later durable owner. forkWorker compensates
|
|
1325
|
+
// its own pre-init journal mutations; isolate this row and continue.
|
|
1326
|
+
logger.error(`[${ds.session.sessionId.substring(0, 8)}] Recovery fork failed; retained for later retry: `
|
|
1327
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1206
1330
|
if (++spawnedInBatch >= batchSize) {
|
|
1207
1331
|
spawnedInBatch = 0;
|
|
1208
1332
|
if (delayMs > 0)
|
|
@@ -1210,7 +1334,7 @@ export async function staggeredRecoveryFork(sessions, fork, batchSize = RECOVERY
|
|
|
1210
1334
|
}
|
|
1211
1335
|
}
|
|
1212
1336
|
}
|
|
1213
|
-
export async function restoreActiveSessions(activeSessions) {
|
|
1337
|
+
export async function restoreActiveSessions(activeSessions, quarantinedSessionIds = new Set()) {
|
|
1214
1338
|
const sessions = sessionStore.listSessions();
|
|
1215
1339
|
const restorePriority = (session) => {
|
|
1216
1340
|
if (session.adoptedFrom || session.cliId || session.lastCliInput || session.backendType)
|
|
@@ -1225,6 +1349,13 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1225
1349
|
// every startup candidate regardless of this disk ordering.
|
|
1226
1350
|
const active = sessions
|
|
1227
1351
|
.filter(s => s.status === 'active')
|
|
1352
|
+
// Idempotency quarantine (at-most-once): a session the boot reconcile just
|
|
1353
|
+
// terminalized as `dispatch_unknown` (or dropped as a pre-dispatch reserved
|
|
1354
|
+
// orphan) must NOT be re-attached — the poller now sees it `failed`, so
|
|
1355
|
+
// reviving its old pane would diverge state from execution. The reconcile
|
|
1356
|
+
// best-effort closed it; this is the belt-and-suspenders exclusion in case
|
|
1357
|
+
// that close failed.
|
|
1358
|
+
.filter(s => !quarantinedSessionIds.has(s.sessionId))
|
|
1228
1359
|
.sort((a, b) => restorePriority(b) - restorePriority(a));
|
|
1229
1360
|
// Sweep dead CLI-pid markers regardless of whether we have sessions to restore:
|
|
1230
1361
|
// the landmine files (recycled-PID misroute source) accumulate across every
|
|
@@ -1252,66 +1383,196 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1252
1383
|
const stillOwnsRestoreRegistration = (ds) => ds.session.status === 'active'
|
|
1253
1384
|
&& activeSessions.get(activeSessionKey(ds)) === ds;
|
|
1254
1385
|
for (const session of active) {
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
// Persisted metadata is the authoritative adopt marker. The title is
|
|
1266
|
-
// user-editable via /rename and must not change restore semantics.
|
|
1267
|
-
if (session.adoptedFrom) {
|
|
1268
|
-
const adopted = session.adoptedFrom;
|
|
1269
|
-
// Fail-closed BEFORE building an adopt DaemonSession: a sandbox-enabled
|
|
1270
|
-
// session (frozen `session.sandbox`, or a bot that now requires the
|
|
1271
|
-
// sandbox) can't attach to an already-running host CLI (confinement is
|
|
1272
|
-
// spawn-time only). Convert it to a plain cold-start IN PLACE — clear the
|
|
1273
|
-
// adopt metadata, normalize the "Adopt: …" title (else the next restart's
|
|
1274
|
-
// legacy title-only branch below would permanently close it), persist —
|
|
1275
|
-
// then fall through to the normal restore path so it registers/announces
|
|
1276
|
-
// as an ordinary session, NOT an adopt row. Doing the conversion here (not
|
|
1277
|
-
// via a worker-pool side-effect after announceSessionRow) keeps daemon
|
|
1278
|
-
// orchestration state consistent.
|
|
1279
|
-
let adoptBotCfg = {};
|
|
1280
|
-
try {
|
|
1281
|
-
adoptBotCfg = getBot(session.larkAppId ?? '').config;
|
|
1386
|
+
try {
|
|
1387
|
+
// True when this row's durable activation-tail promotion failed transiently
|
|
1388
|
+
// and it was registered as a quarantined owner (see the promotion block).
|
|
1389
|
+
let quarantinedActivationTailPromotion = false;
|
|
1390
|
+
// Dispatcher/IPC may create and register this exact persisted row while
|
|
1391
|
+
// startup restore is running. The runtime object is authoritative; never
|
|
1392
|
+
// rebuild or later close it as a collision loser.
|
|
1393
|
+
if (runtimeWinnerFor(session.sessionId)) {
|
|
1394
|
+
logger.debug(`[${session.sessionId.substring(0, 8)}] Already registered by live runtime during restore; skipping snapshot row`);
|
|
1395
|
+
continue;
|
|
1282
1396
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1397
|
+
// Restored sessions persisted before the scope field was added default to
|
|
1398
|
+
// 'thread' — that matches the legacy thread-only behaviour.
|
|
1399
|
+
const scope = session.scope === 'chat' ? 'chat' : 'thread';
|
|
1400
|
+
// Persisted metadata is the authoritative adopt marker. The title is
|
|
1401
|
+
// user-editable via /rename and must not change restore semantics.
|
|
1402
|
+
if (session.adoptedFrom) {
|
|
1403
|
+
const adopted = session.adoptedFrom;
|
|
1404
|
+
// Fail-closed BEFORE building an adopt DaemonSession: a sandbox-enabled
|
|
1405
|
+
// session (frozen `session.sandbox`, or a bot that now requires the
|
|
1406
|
+
// sandbox) can't attach to an already-running host CLI (confinement is
|
|
1407
|
+
// spawn-time only). Convert it to a plain cold-start IN PLACE — clear the
|
|
1408
|
+
// adopt metadata, normalize the "Adopt: …" title (else the next restart's
|
|
1409
|
+
// legacy title-only branch below would permanently close it), persist —
|
|
1410
|
+
// then fall through to the normal restore path so it registers/announces
|
|
1411
|
+
// as an ordinary session, NOT an adopt row. Doing the conversion here (not
|
|
1412
|
+
// via a worker-pool side-effect after announceSessionRow) keeps daemon
|
|
1413
|
+
// orchestration state consistent.
|
|
1414
|
+
let adoptBotCfg = {};
|
|
1291
1415
|
try {
|
|
1292
|
-
|
|
1416
|
+
adoptBotCfg = getBot(session.larkAppId ?? '').config;
|
|
1293
1417
|
}
|
|
1294
|
-
catch { /*
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
continue;
|
|
1418
|
+
catch { /* unknown bot → only the frozen decision matters */ }
|
|
1419
|
+
if (adoptSandboxBlocked(adoptBotCfg, session)) {
|
|
1420
|
+
logger.warn(`[${session.sessionId.substring(0, 8)}] isolated/no-transport session persisted as adopt — converting to cold-start (a sandbox / apiOnly bot can't wrap a live external CLI)`);
|
|
1421
|
+
session.adoptedFrom = undefined;
|
|
1422
|
+
if (session.title?.startsWith('Adopt:')) {
|
|
1423
|
+
const project = session.title.slice('Adopt:'.length).trim();
|
|
1424
|
+
session.title = project || 'Session';
|
|
1425
|
+
}
|
|
1426
|
+
try {
|
|
1427
|
+
sessionStore.updateSession(session);
|
|
1428
|
+
}
|
|
1429
|
+
catch { /* best-effort */ }
|
|
1430
|
+
// fall through: session.adoptedFrom is now unset → normal restore below
|
|
1308
1431
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1432
|
+
else {
|
|
1433
|
+
const frozenRuntimeExecutable = session.cliRuntime?.source === 'configured'
|
|
1434
|
+
? session.cliRuntime.executable
|
|
1435
|
+
: undefined;
|
|
1436
|
+
const validation = adopted.zellijPaneId
|
|
1437
|
+
? (typeof adopted.originalCliPid === 'number' && validateZellijAdoptTarget(adopted.zellijSession ?? '', adopted.zellijPaneId, adopted.originalCliPid, adopted.cliId, frozenRuntimeExecutable) ? 'alive' : 'missing')
|
|
1438
|
+
: validateAdoptTargetState(adopted, frozenRuntimeExecutable);
|
|
1439
|
+
if (validation === 'missing') {
|
|
1440
|
+
logger.info(`Closing adopt session ${session.sessionId} (adopted target exited: ${adoptTargetLabel(adopted)})`);
|
|
1441
|
+
sessionStore.closeSession(session.sessionId);
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
if (validation === 'unknown') {
|
|
1445
|
+
logger.warn(`Keeping adopt session ${session.sessionId} active but quarantined until the target can be verified (target validation failed: ${adoptTargetLabel(adopted)})`);
|
|
1446
|
+
quarantineUnregisteredRestoreSession(session, 'adopt_target_validation_unknown');
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
// Original CLI still alive — re-register and fork adopt worker
|
|
1450
|
+
const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
|
|
1451
|
+
const ds = {
|
|
1452
|
+
session,
|
|
1453
|
+
worker: null,
|
|
1454
|
+
workerPort: null,
|
|
1455
|
+
workerToken: null,
|
|
1456
|
+
larkAppId,
|
|
1457
|
+
chatId: session.chatId,
|
|
1458
|
+
chatType: session.chatType ?? 'group',
|
|
1459
|
+
scope,
|
|
1460
|
+
spawnedAt: sessionCreatedAtMs(session),
|
|
1461
|
+
cliVersion: getCurrentCliVersion(),
|
|
1462
|
+
lastMessageAt: sessionLastMessageAtMs(session),
|
|
1463
|
+
hasHistory: false,
|
|
1464
|
+
workingDir: adopted.cwd,
|
|
1465
|
+
ownerOpenId: session.ownerOpenId,
|
|
1466
|
+
adoptedFrom: adopted,
|
|
1467
|
+
streamCardId: session.streamCardId,
|
|
1468
|
+
streamCardNonce: session.streamCardNonce,
|
|
1469
|
+
displayMode: session.displayMode === 'screenshot' || session.displayMode === 'hidden'
|
|
1470
|
+
? session.displayMode
|
|
1471
|
+
: (session.streamExpanded ? 'screenshot' : 'hidden'),
|
|
1472
|
+
currentImageKey: session.currentImageKey,
|
|
1473
|
+
currentTurnTitle: session.currentTurnTitle,
|
|
1474
|
+
usageLimit: session.usageLimit,
|
|
1475
|
+
lastUserPrompt: session.lastUserPrompt,
|
|
1476
|
+
lastCliInput: session.lastCliInput,
|
|
1477
|
+
lastCodexAppInput: session.lastCodexAppInput,
|
|
1478
|
+
replyThreadAliases: session.replyThreadAliases,
|
|
1479
|
+
currentReplyTarget: session.currentReplyTarget,
|
|
1480
|
+
// Restart stays silent for adopt sessions too: forkAdoptWorker shares
|
|
1481
|
+
// setupWorkerHandlers, so the recovery ready/screen_update would post a
|
|
1482
|
+
// card without this. Cleared on the first real CLI input.
|
|
1483
|
+
suppressRecoveryCard: true,
|
|
1484
|
+
};
|
|
1485
|
+
const anchor = sessionAnchorId(ds);
|
|
1486
|
+
messageQueue.ensureQueue(anchor);
|
|
1487
|
+
if (ds.usageLimit)
|
|
1488
|
+
restoreUsageLimitRuntimeState(ds);
|
|
1489
|
+
// Same-key collision guard: if a prior iteration already set an entry
|
|
1490
|
+
// at this key (legitimately possible if disk holds two active sessions
|
|
1491
|
+
// resolving to the same chat-scope key — e.g. a leaked scratch +
|
|
1492
|
+
// relayed real session from a prior buggy run), reject and close the
|
|
1493
|
+
// incoming loser rather than silently overwriting the runtime winner.
|
|
1494
|
+
// #597: setActiveSessionSafe returns a structured result so a
|
|
1495
|
+
// both_pending / cleanup_failed collision keeps the durable row + pane
|
|
1496
|
+
// (quarantined) instead of aborting daemon startup.
|
|
1497
|
+
const registration = await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds);
|
|
1498
|
+
// A live runtime object (dispatcher/IPC woke this exact session during
|
|
1499
|
+
// restore) is authoritative; skip the snapshot row without treating the
|
|
1500
|
+
// benign race as a collision error.
|
|
1501
|
+
if (runtimeWinnerFor(session.sessionId, ds)) {
|
|
1502
|
+
logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won adopt restore registration`);
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
if (!registration.accepted) {
|
|
1506
|
+
if (registration.reason === 'both_pending' || registration.reason === 'cleanup_failed') {
|
|
1507
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] Isolated adopt restore collision; `
|
|
1508
|
+
+ `durable row/pane retained without aborting daemon startup: ${registration.reason === 'both_pending'
|
|
1509
|
+
? `two protected owners at ${activeSessionKey(ds)}`
|
|
1510
|
+
: `cleanup failed for ${registration.cleanupSessionId}: ${registration.error}`}`);
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
logger.warn(`[${session.sessionId.substring(0, 8)}] restore collision lost to unsettled session ${registration.keptSessionId.substring(0, 8)}`);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
restoredByThisInvocation.push(ds);
|
|
1517
|
+
announceSessionRow(ds);
|
|
1518
|
+
forkAdoptWorker(ds, { restoredFromMetadata: true });
|
|
1519
|
+
logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adoptTargetLabel(adopted)}, scope: ${scope})`);
|
|
1312
1520
|
continue;
|
|
1313
1521
|
}
|
|
1314
|
-
|
|
1522
|
+
}
|
|
1523
|
+
// Title-only adopt sessions have no target metadata and can only come from
|
|
1524
|
+
// legacy records. They cannot be validated or safely restored.
|
|
1525
|
+
if (session.title?.startsWith('Adopt:')) {
|
|
1526
|
+
logger.debug(`Closing adopt session ${session.sessionId} (no persisted metadata)`);
|
|
1527
|
+
sessionStore.closeSession(session.sessionId);
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (session.queuedActivationPending) {
|
|
1531
|
+
const unsettledCodex = hasUnsettledCodexAppDispatch(session.codexAppDispatchLedger);
|
|
1532
|
+
if (session.cliId === 'codex-app') {
|
|
1533
|
+
if (!unsettledCodex && !session.pendingRepoSetup?.rawInput) {
|
|
1534
|
+
// An activation FIFO can become empty only after its exact prepared
|
|
1535
|
+
// head settled. A lost submission-ACK persistence must not re-accept
|
|
1536
|
+
// the old prompt after that terminal proof.
|
|
1537
|
+
const priorTerminalActivation = {
|
|
1538
|
+
queuedActivationPending: session.queuedActivationPending,
|
|
1539
|
+
queuedActivationToken: session.queuedActivationToken,
|
|
1540
|
+
queuedActivationInput: session.queuedActivationInput,
|
|
1541
|
+
queuedActivationTurnId: session.queuedActivationTurnId,
|
|
1542
|
+
queuedActivationDispatchAttempt: session.queuedActivationDispatchAttempt,
|
|
1543
|
+
queuedActivationResume: session.queuedActivationResume,
|
|
1544
|
+
queuedPrompt: session.queuedPrompt,
|
|
1545
|
+
queuedCodexAppText: session.queuedCodexAppText,
|
|
1546
|
+
queuedCodexAppMessageContext: session.queuedCodexAppMessageContext,
|
|
1547
|
+
pendingRepoSetup: session.pendingRepoSetup,
|
|
1548
|
+
};
|
|
1549
|
+
session.queuedActivationPending = undefined;
|
|
1550
|
+
session.queuedActivationToken = undefined;
|
|
1551
|
+
session.queuedActivationInput = undefined;
|
|
1552
|
+
session.queuedActivationTurnId = undefined;
|
|
1553
|
+
session.queuedActivationDispatchAttempt = undefined;
|
|
1554
|
+
session.queuedActivationResume = undefined;
|
|
1555
|
+
session.queuedPrompt = undefined;
|
|
1556
|
+
session.queuedCodexAppText = undefined;
|
|
1557
|
+
session.queuedCodexAppMessageContext = undefined;
|
|
1558
|
+
session.pendingRepoSetup = undefined;
|
|
1559
|
+
try {
|
|
1560
|
+
sessionStore.updateSession(session);
|
|
1561
|
+
}
|
|
1562
|
+
catch (err) {
|
|
1563
|
+
Object.assign(session, priorTerminalActivation);
|
|
1564
|
+
throw err;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
// Queued(待办池)会话:CLI 从没起过,restore 必须保持 parked(hasHistory:false +
|
|
1570
|
+
// queued),绝不能走下面 hasHistory:true 的通用分支——否则下一条消息会 --resume 一个
|
|
1571
|
+
// 不存在的 CLI 会话。pendingPrompt 从持久化的 queuedPrompt 恢复,供激活时发首轮。
|
|
1572
|
+
if (session.queued) {
|
|
1573
|
+
if (hasUnsettledCodexAppDispatch(session.codexAppDispatchLedger)) {
|
|
1574
|
+
throw new Error(`queued session ${session.sessionId} carries an unsettled Codex App dispatch`);
|
|
1575
|
+
}
|
|
1315
1576
|
const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
|
|
1316
1577
|
const ds = {
|
|
1317
1578
|
session,
|
|
@@ -1326,66 +1587,54 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1326
1587
|
cliVersion: getCurrentCliVersion(),
|
|
1327
1588
|
lastMessageAt: sessionLastMessageAtMs(session),
|
|
1328
1589
|
hasHistory: false,
|
|
1329
|
-
workingDir:
|
|
1590
|
+
workingDir: session.workingDir,
|
|
1330
1591
|
ownerOpenId: session.ownerOpenId,
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
: (session.streamExpanded ? 'screenshot' : 'hidden'),
|
|
1337
|
-
currentImageKey: session.currentImageKey,
|
|
1338
|
-
currentTurnTitle: session.currentTurnTitle,
|
|
1339
|
-
usageLimit: session.usageLimit,
|
|
1340
|
-
lastUserPrompt: session.lastUserPrompt,
|
|
1341
|
-
lastCliInput: session.lastCliInput,
|
|
1342
|
-
lastCodexAppInput: session.lastCodexAppInput,
|
|
1343
|
-
replyThreadAliases: session.replyThreadAliases,
|
|
1344
|
-
currentReplyTarget: session.currentReplyTarget,
|
|
1345
|
-
// Restart stays silent for adopt sessions too: forkAdoptWorker shares
|
|
1346
|
-
// setupWorkerHandlers, so the recovery ready/screen_update would post a
|
|
1347
|
-
// card without this. Cleared on the first real CLI input.
|
|
1348
|
-
suppressRecoveryCard: true,
|
|
1592
|
+
pendingPrompt: session.queuedPrompt,
|
|
1593
|
+
pendingCodexAppText: session.queuedCodexAppText,
|
|
1594
|
+
pendingCodexAppMessageContext: session.queuedCodexAppMessageContext,
|
|
1595
|
+
pendingAttachments: session.queuedAttachments,
|
|
1596
|
+
currentTurnTitle: session.currentTurnTitle ?? session.title,
|
|
1349
1597
|
};
|
|
1598
|
+
const restoredPendingRepo = restorePendingRepoRuntime(ds);
|
|
1350
1599
|
const anchor = sessionAnchorId(ds);
|
|
1351
1600
|
messageQueue.ensureQueue(anchor);
|
|
1352
|
-
|
|
1353
|
-
restoreUsageLimitRuntimeState(ds);
|
|
1354
|
-
// Same-key collision guard: if a prior iteration already set an entry
|
|
1355
|
-
// at this key (legitimately possible if disk holds two active sessions
|
|
1356
|
-
// resolving to the same chat-scope key — e.g. a leaked scratch +
|
|
1357
|
-
// relayed real session from a prior buggy run), reject and close the
|
|
1358
|
-
// incoming loser rather than silently overwriting the runtime winner.
|
|
1359
|
-
const adoptKey = activeSessionKey(ds);
|
|
1360
|
-
const adoptRegistered = await setActiveSessionSafe(activeSessions, adoptKey, ds);
|
|
1361
|
-
const adoptCurrent = activeSessions.get(adoptKey);
|
|
1601
|
+
const registration = await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds);
|
|
1362
1602
|
if (runtimeWinnerFor(session.sessionId, ds)) {
|
|
1363
|
-
logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won
|
|
1603
|
+
logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won queued restore registration`);
|
|
1364
1604
|
continue;
|
|
1365
1605
|
}
|
|
1366
|
-
if (!
|
|
1367
|
-
|
|
1368
|
-
|
|
1606
|
+
if (!registration.accepted) {
|
|
1607
|
+
if (registration.reason === 'both_pending' || registration.reason === 'cleanup_failed') {
|
|
1608
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] Isolated queued restore collision; `
|
|
1609
|
+
+ `exact setup journal retained without aborting daemon startup: ${registration.reason === 'both_pending'
|
|
1610
|
+
? `two protected owners at ${activeSessionKey(ds)}`
|
|
1611
|
+
: `cleanup failed for ${registration.cleanupSessionId}: ${registration.error}`}`);
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
logger.warn(`[${session.sessionId.substring(0, 8)}] queued restore collision lost to unsettled session ${registration.keptSessionId.substring(0, 8)}`);
|
|
1369
1615
|
continue;
|
|
1370
1616
|
}
|
|
1371
1617
|
restoredByThisInvocation.push(ds);
|
|
1618
|
+
// 重启后把待办池卡片重新广播给 dashboard,否则会从看板消失(#277 同款修复,
|
|
1619
|
+
// 我这条 queued 分支提前 continue 绕过了下面的 announceSessionRow,要自己补)。
|
|
1372
1620
|
announceSessionRow(ds);
|
|
1373
|
-
|
|
1374
|
-
|
|
1621
|
+
if (restoredPendingRepo) {
|
|
1622
|
+
try {
|
|
1623
|
+
await resumeRestoredPendingRepoSetup(ds, activeSessions);
|
|
1624
|
+
}
|
|
1625
|
+
catch (err) {
|
|
1626
|
+
// One unavailable scan/Lark send/worktree import must not abort the
|
|
1627
|
+
// entire daemon restore. Rebuild volatile buffers from the retained
|
|
1628
|
+
// setup journal so a later retry/card action remains lossless.
|
|
1629
|
+
restorePendingRepoRuntime(ds);
|
|
1630
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] Pending-repo restore failed; `
|
|
1631
|
+
+ `durable setup retained for retry: ${err instanceof Error ? err.message : String(err)}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
logger.info(`[${session.sessionId.substring(0, 8)}] Restored queued `
|
|
1635
|
+
+ `${restoredPendingRepo ? 'pending-repo' : '(待办池)'} session (scope: ${scope})`);
|
|
1375
1636
|
continue;
|
|
1376
1637
|
}
|
|
1377
|
-
}
|
|
1378
|
-
// Title-only adopt sessions have no target metadata and can only come from
|
|
1379
|
-
// legacy records. They cannot be validated or safely restored.
|
|
1380
|
-
if (session.title?.startsWith('Adopt:')) {
|
|
1381
|
-
logger.debug(`Closing adopt session ${session.sessionId} (no persisted metadata)`);
|
|
1382
|
-
sessionStore.closeSession(session.sessionId);
|
|
1383
|
-
continue;
|
|
1384
|
-
}
|
|
1385
|
-
// Queued(待办池)会话:CLI 从没起过,restore 必须保持 parked(hasHistory:false +
|
|
1386
|
-
// queued),绝不能走下面 hasHistory:true 的通用分支——否则下一条消息会 --resume 一个
|
|
1387
|
-
// 不存在的 CLI 会话。pendingPrompt 从持久化的 queuedPrompt 恢复,供激活时发首轮。
|
|
1388
|
-
if (session.queued) {
|
|
1389
1638
|
const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
|
|
1390
1639
|
const ds = {
|
|
1391
1640
|
session,
|
|
@@ -1399,164 +1648,203 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1399
1648
|
spawnedAt: sessionCreatedAtMs(session),
|
|
1400
1649
|
cliVersion: getCurrentCliVersion(),
|
|
1401
1650
|
lastMessageAt: sessionLastMessageAtMs(session),
|
|
1402
|
-
hasHistory:
|
|
1651
|
+
hasHistory: session.queuedActivationPending
|
|
1652
|
+
? (session.queuedActivationResume ?? false)
|
|
1653
|
+
: true, // restored ordinary sessions have prior CLI history
|
|
1654
|
+
initialStartPending: session.queuedActivationPending === true
|
|
1655
|
+
|| (session.queuedActivationTail?.length ?? 0) > 0,
|
|
1403
1656
|
workingDir: session.workingDir,
|
|
1404
1657
|
ownerOpenId: session.ownerOpenId,
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1658
|
+
// Restore persisted streaming-card state — next screen_update will PATCH
|
|
1659
|
+
// the existing card instead of POSTing a fresh one. If the card was
|
|
1660
|
+
// withdrawn while we were down, the PATCH fails with MessageWithdrawnError
|
|
1661
|
+
// and the existing handler (worker-pool flushCardPatch) clears streamCardId,
|
|
1662
|
+
// letting the next update create a new card.
|
|
1663
|
+
streamCardId: session.streamCardId,
|
|
1664
|
+
streamCardNonce: session.streamCardNonce,
|
|
1665
|
+
displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
|
|
1666
|
+
currentImageKey: session.currentImageKey,
|
|
1667
|
+
currentTurnTitle: session.currentTurnTitle,
|
|
1668
|
+
usageLimit: session.usageLimit,
|
|
1669
|
+
lastUserPrompt: session.lastUserPrompt,
|
|
1670
|
+
lastCliInput: session.lastCliInput,
|
|
1671
|
+
lastCodexAppInput: session.lastCodexAppInput,
|
|
1672
|
+
replyThreadAliases: session.replyThreadAliases,
|
|
1673
|
+
currentReplyTarget: session.currentReplyTarget,
|
|
1674
|
+
// Restart stays silent in the group: the recovery re-fork won't post or
|
|
1675
|
+
// patch a streaming card. Cleared on the first real CLI input.
|
|
1676
|
+
suppressRecoveryCard: true,
|
|
1410
1677
|
};
|
|
1678
|
+
if (session.deferredScheduleRun) {
|
|
1679
|
+
const binding = readDeferredTopicBinding(config.session.dataDir, session.sessionId);
|
|
1680
|
+
if (!binding) {
|
|
1681
|
+
// A daemon restart makes an unmaterialized hidden run ambiguous: there
|
|
1682
|
+
// is no visible conversation to resume and no safe user-facing place
|
|
1683
|
+
// to report recovery. Fence its backing process and close the audit row
|
|
1684
|
+
// instead of resurrecting an invisible worker.
|
|
1685
|
+
const backendType = getSessionPersistentBackendType(ds);
|
|
1686
|
+
if (backendType) {
|
|
1687
|
+
try {
|
|
1688
|
+
killPersistentBackendTarget(persistentBackendTargetForSession(ds), session.sessionId);
|
|
1689
|
+
}
|
|
1690
|
+
catch (err) {
|
|
1691
|
+
// Keep the row active when destruction cannot be proved. Closing it
|
|
1692
|
+
// would strand a possibly-live hidden run with no ownership record;
|
|
1693
|
+
// skipping registration also prevents this restore pass from
|
|
1694
|
+
// accidentally reattaching it. Continue with the remaining rows.
|
|
1695
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] Could not tear down unmaterialized deferred run; keeping active row: `
|
|
1696
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1697
|
+
quarantineUnregisteredRestoreSession(session, 'deferred_run_teardown_unverified');
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
sessionStore.closeSession(session.sessionId);
|
|
1702
|
+
removeDeferredTopicBinding(config.session.dataDir, session.sessionId);
|
|
1703
|
+
logger.info(`[${session.sessionId.substring(0, 8)}] Closed unmaterialized deferred schedule run during restore`);
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
const nowIso = new Date().toISOString();
|
|
1707
|
+
const aliases = { ...(session.replyThreadAliases ?? {}) };
|
|
1708
|
+
aliases[binding.rootMessageId] = {
|
|
1709
|
+
createdAt: aliases[binding.rootMessageId]?.createdAt ?? binding.createdAt,
|
|
1710
|
+
lastUsedAt: nowIso,
|
|
1711
|
+
};
|
|
1712
|
+
session.replyThreadAliases = aliases;
|
|
1713
|
+
session.rootMessageId = binding.rootMessageId;
|
|
1714
|
+
ds.replyThreadAliases = aliases;
|
|
1715
|
+
sessionStore.updateSession(session);
|
|
1716
|
+
}
|
|
1717
|
+
// Literal pending-repo passthroughs have an empty init prompt and therefore
|
|
1718
|
+
// no Codex ledger entry. Their setup journal is the exact replay owner; the
|
|
1719
|
+
// recovered worker sends it at prompt_ready with the retained token.
|
|
1720
|
+
if (session.queuedActivationPending && session.pendingRepoSetup?.rawInput) {
|
|
1721
|
+
ds.pendingRawInput = session.pendingRepoSetup.rawInput;
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const mismatchClose = await closeActiveSessionIfCliMismatch(ds);
|
|
1725
|
+
if (mismatchClose !== 'not_mismatched') {
|
|
1726
|
+
if (mismatchClose === 'teardown_failed') {
|
|
1727
|
+
quarantineUnregisteredRestoreSession(session, 'cli_mismatch_teardown_unverified');
|
|
1728
|
+
}
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
catch (err) {
|
|
1733
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] CLI mismatch close failed during restore; keeping active row: `
|
|
1734
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1735
|
+
quarantineUnregisteredRestoreSession(session, 'cli_mismatch_close_failed');
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
if (!ds.session.queuedActivationPending
|
|
1739
|
+
&& (ds.session.queuedActivationTail?.length ?? 0) > 0
|
|
1740
|
+
&& !promoteQueuedActivationTail(ds, { send: false })) {
|
|
1741
|
+
// Promotion here only fails on a transient durable-write error (send:false
|
|
1742
|
+
// skips the worker-liveness gate, and the caller already checked a non-empty
|
|
1743
|
+
// tail). Throwing would drop to the isolation catch below WITHOUT registering
|
|
1744
|
+
// the row — leaving it active-on-disk but invisible to activeSessions, so IM
|
|
1745
|
+
// `/close` cannot reach it and a later inbound to the same anchor mints a
|
|
1746
|
+
// second active row while this one's tail dangles. Instead register it as a
|
|
1747
|
+
// visible quarantined owner: the unpromoted tail keeps protected ownership
|
|
1748
|
+
// (occupies the anchor, blocks a duplicate, stays closeable).
|
|
1749
|
+
//
|
|
1750
|
+
// The gate (`initialStartPending`, computed true below from the non-empty
|
|
1751
|
+
// tail) MUST stay up: no activation is actually in flight (promotion
|
|
1752
|
+
// FAILED), and the invariant is that the old tail head must not be overtaken
|
|
1753
|
+
// by a later turn. Self-healing happens at the next FORK BOUNDARY via the
|
|
1754
|
+
// central quarantine guard inside forkWorker (see resolveQuarantinedForkPlan):
|
|
1755
|
+
// it retries `promoteQueuedActivationTail` and, on success, cold-forks the
|
|
1756
|
+
// promoted head; on failure it refuses the fork and keeps this worker:null
|
|
1757
|
+
// owner. The `quarantinedActivationTailPromotion` flag set below is what the
|
|
1758
|
+
// central guard keys on.
|
|
1759
|
+
quarantinedActivationTailPromotion = true;
|
|
1760
|
+
logger.warn(`[${session.sessionId.substring(0, 8)}] Deferred durable activation-tail promotion `
|
|
1761
|
+
+ `(transient persistence failure); registering as a quarantined owner so `
|
|
1762
|
+
+ `it stays visible/closeable and retries promotion at the next fork boundary`);
|
|
1763
|
+
}
|
|
1411
1764
|
const anchor = sessionAnchorId(ds);
|
|
1765
|
+
// A tail-only quarantine (promotion failed above) must NOT clear its gate:
|
|
1766
|
+
// the invariant is "retry the old head's promotion at the next fork boundary;
|
|
1767
|
+
// on failure keep owning the gate; never let a later turn overtake". Every
|
|
1768
|
+
// fork boundary (eager `toReattach` blank fork, daemon inbound refork, and
|
|
1769
|
+
// web-terminal lazy wake) routes through forkWorker, whose central guard
|
|
1770
|
+
// (resolveQuarantinedForkPlan) retries promotion first and refuses the fork
|
|
1771
|
+
// if it still fails — so a blank fork can never leave a live worker beside an
|
|
1772
|
+
// unpromoted tail and permanently wedge the gate. Mark the runtime flag the
|
|
1773
|
+
// guard keys on.
|
|
1774
|
+
if (quarantinedActivationTailPromotion)
|
|
1775
|
+
ds.quarantinedActivationTailPromotion = true;
|
|
1412
1776
|
messageQueue.ensureQueue(anchor);
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1777
|
+
if (ds.usageLimit)
|
|
1778
|
+
restoreUsageLimitRuntimeState(ds);
|
|
1779
|
+
// Same-key collision guard — see adopt-branch comment above.
|
|
1780
|
+
const registration = await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds);
|
|
1416
1781
|
if (runtimeWinnerFor(session.sessionId, ds)) {
|
|
1417
|
-
logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won
|
|
1782
|
+
logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won restore registration`);
|
|
1418
1783
|
continue;
|
|
1419
1784
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1785
|
+
// Normalize the registrar result. The authoritative registrar returns the
|
|
1786
|
+
// rich SetActiveSessionResult object; a legacy/boolean CAS (some test
|
|
1787
|
+
// doubles, or a future first-wins primitive) may return a bare false at
|
|
1788
|
+
// runtime, so read defensively through `any`.
|
|
1789
|
+
const registrationRaw = registration;
|
|
1790
|
+
const registrationAccepted = registrationRaw !== false
|
|
1791
|
+
&& registrationRaw.accepted !== false;
|
|
1792
|
+
const registrationReason = typeof registrationRaw === 'object' && registrationRaw !== null
|
|
1793
|
+
? registrationRaw.reason
|
|
1794
|
+
: undefined;
|
|
1795
|
+
const registrationKeptId = typeof registrationRaw === 'object' && registrationRaw !== null
|
|
1796
|
+
? registrationRaw.keptSessionId
|
|
1797
|
+
: undefined;
|
|
1798
|
+
if (!registrationAccepted) {
|
|
1799
|
+
if (registrationReason === 'both_pending' || registrationReason === 'cleanup_failed') {
|
|
1800
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] Isolated active restore collision; `
|
|
1801
|
+
+ `durable row/pane retained without aborting daemon startup: ${registrationReason === 'both_pending'
|
|
1802
|
+
? `two protected owners at ${activeSessionKey(ds)}`
|
|
1803
|
+
: `cleanup failed for ${registration.cleanupSessionId}: ${registration.error}`}`);
|
|
1804
|
+
// Master's reboot-resume visibility fix: this row stays active-on-disk
|
|
1805
|
+
// but was never registered into activeSessions, so upsert a dormant
|
|
1806
|
+
// persisted projection lest an already-open dashboard drop it.
|
|
1807
|
+
quarantineUnregisteredRestoreSession(session, 'restore_collision_close_failed');
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
// Ordinary collision: a higher-priority row (real/adopt over queued over
|
|
1811
|
+
// scratch, per restorePriority) or a fresh runtime occupant already owns
|
|
1812
|
+
// this anchor. THIS row is the loser — close it so its store row doesn't
|
|
1813
|
+
// linger as a ghost-active duplicate. A rejected teardown keeps it active
|
|
1814
|
+
// (quarantined) for retry rather than crash-looping daemon boot.
|
|
1815
|
+
logger.warn(`[${session.sessionId.substring(0, 8)}] restore collision lost to session `
|
|
1816
|
+
+ `${registrationKeptId ? registrationKeptId.substring(0, 8) : 'existing owner'}; closing the duplicate loser`);
|
|
1817
|
+
try {
|
|
1818
|
+
await closeSession(session.sessionId);
|
|
1819
|
+
}
|
|
1820
|
+
catch (err) {
|
|
1821
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] Could not close restore collision loser; keeping active row: `
|
|
1822
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1823
|
+
quarantineUnregisteredRestoreSession(session, 'restore_collision_close_failed');
|
|
1824
|
+
}
|
|
1423
1825
|
continue;
|
|
1424
1826
|
}
|
|
1425
1827
|
restoredByThisInvocation.push(ds);
|
|
1426
|
-
// 重启后把待办池卡片重新广播给 dashboard,否则会从看板消失(#277 同款修复,
|
|
1427
|
-
// 我这条 queued 分支提前 continue 绕过了下面的 announceSessionRow,要自己补)。
|
|
1428
1828
|
announceSessionRow(ds);
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
workerPort: null,
|
|
1437
|
-
workerToken: null,
|
|
1438
|
-
larkAppId,
|
|
1439
|
-
chatId: session.chatId,
|
|
1440
|
-
chatType: session.chatType ?? 'group',
|
|
1441
|
-
scope,
|
|
1442
|
-
spawnedAt: sessionCreatedAtMs(session),
|
|
1443
|
-
cliVersion: getCurrentCliVersion(),
|
|
1444
|
-
lastMessageAt: sessionLastMessageAtMs(session),
|
|
1445
|
-
hasHistory: true, // restored sessions have prior CLI history
|
|
1446
|
-
workingDir: session.workingDir,
|
|
1447
|
-
ownerOpenId: session.ownerOpenId,
|
|
1448
|
-
// Restore persisted streaming-card state — next screen_update will PATCH
|
|
1449
|
-
// the existing card instead of POSTing a fresh one. If the card was
|
|
1450
|
-
// withdrawn while we were down, the PATCH fails with MessageWithdrawnError
|
|
1451
|
-
// and the existing handler (worker-pool flushCardPatch) clears streamCardId,
|
|
1452
|
-
// letting the next update create a new card.
|
|
1453
|
-
streamCardId: session.streamCardId,
|
|
1454
|
-
streamCardNonce: session.streamCardNonce,
|
|
1455
|
-
displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
|
|
1456
|
-
currentImageKey: session.currentImageKey,
|
|
1457
|
-
currentTurnTitle: session.currentTurnTitle,
|
|
1458
|
-
usageLimit: session.usageLimit,
|
|
1459
|
-
lastUserPrompt: session.lastUserPrompt,
|
|
1460
|
-
lastCliInput: session.lastCliInput,
|
|
1461
|
-
lastCodexAppInput: session.lastCodexAppInput,
|
|
1462
|
-
replyThreadAliases: session.replyThreadAliases,
|
|
1463
|
-
currentReplyTarget: session.currentReplyTarget,
|
|
1464
|
-
// Restart stays silent in the group: the recovery re-fork won't post or
|
|
1465
|
-
// patch a streaming card. Cleared on the first real CLI input.
|
|
1466
|
-
suppressRecoveryCard: true,
|
|
1467
|
-
};
|
|
1468
|
-
if (session.deferredScheduleRun) {
|
|
1469
|
-
const binding = readDeferredTopicBinding(config.session.dataDir, session.sessionId);
|
|
1470
|
-
if (!binding) {
|
|
1471
|
-
// A daemon restart makes an unmaterialized hidden run ambiguous: there
|
|
1472
|
-
// is no visible conversation to resume and no safe user-facing place
|
|
1473
|
-
// to report recovery. Fence its backing process and close the audit row
|
|
1474
|
-
// instead of resurrecting an invisible worker.
|
|
1475
|
-
const backendType = getSessionPersistentBackendType(ds);
|
|
1476
|
-
if (backendType) {
|
|
1477
|
-
try {
|
|
1478
|
-
killPersistentBackendTarget(persistentBackendTargetForSession(ds), session.sessionId);
|
|
1479
|
-
}
|
|
1480
|
-
catch (err) {
|
|
1481
|
-
// Keep the row active when destruction cannot be proved. Closing it
|
|
1482
|
-
// would strand a possibly-live hidden run with no ownership record;
|
|
1483
|
-
// skipping registration also prevents this restore pass from
|
|
1484
|
-
// accidentally reattaching it. Continue with the remaining rows.
|
|
1485
|
-
logger.error(`[${session.sessionId.substring(0, 8)}] Could not tear down unmaterialized deferred run; keeping active row: `
|
|
1486
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1487
|
-
quarantineUnregisteredRestoreSession(session, 'deferred_run_teardown_unverified');
|
|
1488
|
-
continue;
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
sessionStore.closeSession(session.sessionId);
|
|
1492
|
-
removeDeferredTopicBinding(config.session.dataDir, session.sessionId);
|
|
1493
|
-
logger.info(`[${session.sessionId.substring(0, 8)}] Closed unmaterialized deferred schedule run during restore`);
|
|
1494
|
-
continue;
|
|
1495
|
-
}
|
|
1496
|
-
const nowIso = new Date().toISOString();
|
|
1497
|
-
const aliases = { ...(session.replyThreadAliases ?? {}) };
|
|
1498
|
-
aliases[binding.rootMessageId] = {
|
|
1499
|
-
createdAt: aliases[binding.rootMessageId]?.createdAt ?? binding.createdAt,
|
|
1500
|
-
lastUsedAt: nowIso,
|
|
1501
|
-
};
|
|
1502
|
-
session.replyThreadAliases = aliases;
|
|
1503
|
-
session.rootMessageId = binding.rootMessageId;
|
|
1504
|
-
ds.replyThreadAliases = aliases;
|
|
1505
|
-
sessionStore.updateSession(session);
|
|
1506
|
-
}
|
|
1507
|
-
try {
|
|
1508
|
-
const mismatchClose = await closeActiveSessionIfCliMismatch(ds);
|
|
1509
|
-
if (mismatchClose !== 'not_mismatched') {
|
|
1510
|
-
if (mismatchClose === 'teardown_failed') {
|
|
1511
|
-
quarantineUnregisteredRestoreSession(session, 'cli_mismatch_teardown_unverified');
|
|
1512
|
-
}
|
|
1513
|
-
continue;
|
|
1829
|
+
if (session.initialUserTurnPending) {
|
|
1830
|
+
// `hasHistory: true` above means "there may be a CLI process/transcript to
|
|
1831
|
+
// reattach or resume", NOT "a user has ever spoken to it". This session's
|
|
1832
|
+
// CLI was booted idle by the repo flow and never received a real turn, so
|
|
1833
|
+
// the next business message still routes through the new-topic opening
|
|
1834
|
+
// builder and cold-spawns instead of `--resume`. See core/initial-user-turn.ts.
|
|
1835
|
+
logger.info(`[${session.sessionId.substring(0, 8)}] Restored empty-started session — its first real user turn still opens as a new topic`);
|
|
1514
1836
|
}
|
|
1837
|
+
logger.debug(`Registered session ${session.sessionId} (scope: ${scope}, anchor: ${anchor})`);
|
|
1515
1838
|
}
|
|
1516
1839
|
catch (err) {
|
|
1517
|
-
|
|
1840
|
+
// Restore is a per-row reconciliation job. A malformed legacy hybrid or
|
|
1841
|
+
// one transient persistence failure must retain that row for inspection
|
|
1842
|
+
// and retry without preventing every later healthy session from loading.
|
|
1843
|
+
logger.error(`[${session.sessionId.substring(0, 8)}] Isolated session restore failure; `
|
|
1844
|
+
+ `durable row retained and daemon startup continuing: `
|
|
1518
1845
|
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1519
|
-
quarantineUnregisteredRestoreSession(session, 'cli_mismatch_close_failed');
|
|
1520
1846
|
continue;
|
|
1521
1847
|
}
|
|
1522
|
-
const anchor = sessionAnchorId(ds);
|
|
1523
|
-
messageQueue.ensureQueue(anchor);
|
|
1524
|
-
if (ds.usageLimit)
|
|
1525
|
-
restoreUsageLimitRuntimeState(ds);
|
|
1526
|
-
// Same-key collision guard — see adopt-branch comment above.
|
|
1527
|
-
const restoreKey = activeSessionKey(ds);
|
|
1528
|
-
const restored = await setActiveSessionSafe(activeSessions, restoreKey, ds);
|
|
1529
|
-
const restoreCurrent = activeSessions.get(restoreKey);
|
|
1530
|
-
if (runtimeWinnerFor(session.sessionId, ds)) {
|
|
1531
|
-
logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won restore registration`);
|
|
1532
|
-
continue;
|
|
1533
|
-
}
|
|
1534
|
-
if (!restored || session.status !== 'active' || restoreCurrent !== ds) {
|
|
1535
|
-
logger.warn(`[${session.sessionId.substring(0, 8)}] Restore was cancelled while resolving a routing collision`);
|
|
1536
|
-
try {
|
|
1537
|
-
await closeSession(session.sessionId);
|
|
1538
|
-
}
|
|
1539
|
-
catch (err) {
|
|
1540
|
-
// A rejected ZMX ownership/generation probe means teardown was not
|
|
1541
|
-
// proven. Keep the persisted row active for a later retry and continue
|
|
1542
|
-
// restoring unrelated sessions instead of crash-looping daemon boot.
|
|
1543
|
-
logger.error(`[${session.sessionId.substring(0, 8)}] Could not close restore collision loser; keeping active row: `
|
|
1544
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
1545
|
-
quarantineUnregisteredRestoreSession(session, 'restore_collision_close_failed');
|
|
1546
|
-
}
|
|
1547
|
-
continue;
|
|
1548
|
-
}
|
|
1549
|
-
restoredByThisInvocation.push(ds);
|
|
1550
|
-
announceSessionRow(ds);
|
|
1551
|
-
if (session.initialUserTurnPending) {
|
|
1552
|
-
// `hasHistory: true` above means "there may be a CLI process/transcript to
|
|
1553
|
-
// reattach or resume", NOT "a user has ever spoken to it". This session's
|
|
1554
|
-
// CLI was booted idle by the repo flow and never received a real turn, so
|
|
1555
|
-
// the next business message still routes through the new-topic opening
|
|
1556
|
-
// builder and cold-spawns instead of `--resume`. See core/initial-user-turn.ts.
|
|
1557
|
-
logger.info(`[${session.sessionId.substring(0, 8)}] Restored empty-started session — its first real user turn still opens as a new topic`);
|
|
1558
|
-
}
|
|
1559
|
-
logger.debug(`Registered session ${session.sessionId} (scope: ${scope}, anchor: ${anchor})`);
|
|
1560
1848
|
}
|
|
1561
1849
|
// Persistent backends: auto-fork workers for sessions whose backing session
|
|
1562
1850
|
// survived daemon restart. Probe + zombie-close runs synchronously here; the
|
|
@@ -1584,7 +1872,14 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1584
1872
|
if (ds.session.queued)
|
|
1585
1873
|
continue;
|
|
1586
1874
|
const backendType = getSessionPersistentBackendType(ds);
|
|
1587
|
-
if (!backendType
|
|
1875
|
+
if (!backendType) {
|
|
1876
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
1877
|
+
logger.warn(`[${ds.session.sessionId.substring(0, 8)}] non-persistent backend has durable activation ownership — scheduling eager recovery`);
|
|
1878
|
+
toReattach.push(ds);
|
|
1879
|
+
}
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
if (!shouldAutoForkOnRestore(backendType))
|
|
1588
1883
|
continue;
|
|
1589
1884
|
// Honour the worker-selected target (Herdr may own an agent inside a shared
|
|
1590
1885
|
// host session) rather than assuming the deterministic whole-session name.
|
|
@@ -1622,6 +1917,19 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1622
1917
|
: probeSnapshots.get(backendType)?.get(backendTarget.sessionName) ?? 'unknown';
|
|
1623
1918
|
if (probe === 'missing') {
|
|
1624
1919
|
const tag = ds.session.sessionId.substring(0, 8);
|
|
1920
|
+
if (ds.session.queuedActivationPending) {
|
|
1921
|
+
logger.warn(`[${tag}] ${backendType} backing is missing with a tokened activation — scheduling fresh exact recovery`);
|
|
1922
|
+
toReattach.push(ds);
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
// A missing pane is not disposable session metadata when a backend-neutral
|
|
1926
|
+
// durable activation owner is present. Start a fresh resume worker so its
|
|
1927
|
+
// exact head/tail can reconcile rather than strand.
|
|
1928
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
1929
|
+
logger.warn(`[${tag}] ${backendType} backing is missing with durable activation ownership — scheduling fresh resume recovery`);
|
|
1930
|
+
toReattach.push(ds);
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1625
1933
|
// A missing backing pane is NEVER an auto-close trigger for a managed
|
|
1626
1934
|
// session. Whatever wiped the pane — a host reboot (whole multiplexer
|
|
1627
1935
|
// server gone, every pane at once), the idle-worker sweeper reclaiming
|
|
@@ -1648,7 +1956,8 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1648
1956
|
// tidier dashboard while risking irreversible session loss, so the whole
|
|
1649
1957
|
// close-on-missing path was removed. A TTL sweep (evict long-idle active
|
|
1650
1958
|
// rows) is the right tool for dashboard tidiness and is intentionally out
|
|
1651
|
-
// of scope here.
|
|
1959
|
+
// of scope here. (A cap-suspended `suspendedColdResume` row is naturally
|
|
1960
|
+
// covered by this same keep-for-lazy-cold-resume path.)
|
|
1652
1961
|
logger.info(`[${tag}] ${backendType} backing session "${backendName}" is missing — keeping active for lazy cold-resume (transcript is still resumable)`);
|
|
1653
1962
|
continue;
|
|
1654
1963
|
}
|
|
@@ -1660,6 +1969,11 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1660
1969
|
// store closed → no lazy recovery). Keep the worker-less active record and
|
|
1661
1970
|
// let it re-attach on the next message, exactly like the old behaviour.
|
|
1662
1971
|
const tag = ds.session.sessionId.substring(0, 8);
|
|
1972
|
+
if (hasProtectedSessionMutationOwnership(ds)) {
|
|
1973
|
+
logger.warn(`[${tag}] ${backendType} probe inconclusive with unsettled Codex App ownership — scheduling bounded eager recovery`);
|
|
1974
|
+
toReattach.push(ds);
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1663
1977
|
logger.warn(`[${tag}] ${backendType} backing session "${backendName}" probe inconclusive — keeping active session for lazy recovery`);
|
|
1664
1978
|
continue;
|
|
1665
1979
|
}
|
|
@@ -1684,7 +1998,27 @@ export async function restoreActiveSessions(activeSessions) {
|
|
|
1684
1998
|
}
|
|
1685
1999
|
// Staggered re-fork (see staggeredRecoveryFork): empty prompt = re-attach
|
|
1686
2000
|
// only, no new turn — same as the old per-session eager fork.
|
|
1687
|
-
await staggeredRecoveryFork(toReattach, (ds) =>
|
|
2001
|
+
await staggeredRecoveryFork(toReattach, (ds) => {
|
|
2002
|
+
// A quarantined tail-only owner (restore promotion failed transiently) is
|
|
2003
|
+
// handled by the CENTRAL guard inside forkWorker: this blank fork retries
|
|
2004
|
+
// the old head's promotion first and, if it still fails, refuses to fork
|
|
2005
|
+
// (returns false) — keeping the worker:null owner so a blank fork never
|
|
2006
|
+
// leaves a live worker beside an unpromoted tail. `recoverExactNonCodex`
|
|
2007
|
+
// below is the DIFFERENT, already-tokened recovery case (queuedActivation
|
|
2008
|
+
// is pending), which a quarantined owner is not (its promotion failed, so
|
|
2009
|
+
// queuedActivationPending stayed false) — the guard rewrites the fork args
|
|
2010
|
+
// in that case instead.
|
|
2011
|
+
const recoverExactNonCodex = ds.session.queuedActivationPending
|
|
2012
|
+
&& ds.session.cliId !== 'codex-app'
|
|
2013
|
+
&& ds.session.queuedActivationInput;
|
|
2014
|
+
forkWorker(ds, recoverExactNonCodex || '', recoverExactNonCodex
|
|
2015
|
+
? {
|
|
2016
|
+
resume: ds.session.queuedActivationResume ?? ds.hasHistory,
|
|
2017
|
+
turnId: ds.session.queuedActivationTurnId,
|
|
2018
|
+
dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
|
|
2019
|
+
}
|
|
2020
|
+
: true);
|
|
2021
|
+
}, RECOVERY_FORK_BATCH_SIZE, RECOVERY_FORK_DELAY_MS, ds => activeSessions.get(activeSessionKey(ds)) === ds);
|
|
1688
2022
|
const hasPersistentBackend = [...activeSessions.values()].some(ds => !!getSessionPersistentBackendType(ds));
|
|
1689
2023
|
logger.info(`Restored ${active.length} session(s)${hasPersistentBackend ? '' : ', waiting for messages to resume'}`);
|
|
1690
2024
|
}
|
|
@@ -1730,7 +2064,15 @@ export async function ensureTerminalWorkerPort(ds) {
|
|
|
1730
2064
|
}
|
|
1731
2065
|
if (!ds.worker) {
|
|
1732
2066
|
logger.info(`[${ds.session.sessionId.substring(0, 8)}] terminal accessed with no live worker — waking to re-attach`);
|
|
1733
|
-
|
|
2067
|
+
// Lazy-wake is a fork boundary too. The central guard inside forkWorker
|
|
2068
|
+
// refuses (returns false) for a quarantined tail-only owner whose promotion
|
|
2069
|
+
// still fails — waking a blank worker beside an unpromoted tail would wedge
|
|
2070
|
+
// the FIFO gate. Report unavailable (the terminal retries / 502s) instead of
|
|
2071
|
+
// blocking 10s for a port that will never arrive.
|
|
2072
|
+
if (!forkWorker(ds, '', true)) {
|
|
2073
|
+
logger.warn(`[${ds.session.sessionId.substring(0, 8)}] terminal wake refused (quarantined tail-only owner); serving unavailable`);
|
|
2074
|
+
return undefined;
|
|
2075
|
+
}
|
|
1734
2076
|
}
|
|
1735
2077
|
// Wait (bounded) for the re-forked worker to report its HTTP port via `ready`.
|
|
1736
2078
|
// Re-attach is fast (~1-2s in practice); 10s covers a slow CLI restart.
|
|
@@ -1767,7 +2109,7 @@ export async function ensureTerminalWorkerPort(ds) {
|
|
|
1767
2109
|
* - 'resume_cancelled' — a concurrent close won while resume was committing
|
|
1768
2110
|
*/
|
|
1769
2111
|
export async function resumeSession(sessionId, activeSessions) {
|
|
1770
|
-
|
|
2112
|
+
let session = sessionStore.getSession(sessionId);
|
|
1771
2113
|
if (!session)
|
|
1772
2114
|
return { ok: false, error: 'not_found' };
|
|
1773
2115
|
if (session.status !== 'closed')
|
|
@@ -1801,125 +2143,177 @@ export async function resumeSession(sessionId, activeSessions) {
|
|
|
1801
2143
|
const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
|
|
1802
2144
|
const anchor = storedSessionAnchorId(session);
|
|
1803
2145
|
const key = sessionKey(anchor, larkAppId);
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
2146
|
+
return withActiveSessionKeyLock(activeSessions, key, async () => {
|
|
2147
|
+
// The resume request may have waited behind a fresh creator. Re-read the
|
|
2148
|
+
// durable row and keep the already-active creator as first owner.
|
|
2149
|
+
const latest = sessionStore.getSession(sessionId);
|
|
2150
|
+
if (!latest)
|
|
2151
|
+
return { ok: false, error: 'not_found' };
|
|
2152
|
+
if (latest.status !== 'closed')
|
|
2153
|
+
return { ok: false, error: 'not_closed' };
|
|
2154
|
+
if (latest.vcMeetingReceiver)
|
|
2155
|
+
return { ok: false, error: 'vc_receiver_managed' };
|
|
2156
|
+
if (latest.deferredScheduleRun
|
|
2157
|
+
&& !readDeferredTopicBinding(config.session.dataDir, latest.sessionId)) {
|
|
2158
|
+
return { ok: false, error: 'deferred_unmaterialized' };
|
|
2159
|
+
}
|
|
2160
|
+
if (latest.title?.startsWith('Adopt:') || latest.adoptedFrom) {
|
|
2161
|
+
return { ok: false, error: 'adopt_unsupported' };
|
|
2162
|
+
}
|
|
2163
|
+
session = latest;
|
|
2164
|
+
// In-memory occupant check. A daemon-command scratch (e.g. an unconfirmed
|
|
2165
|
+
// `/relay` picker, a bare `/help`) parks a worker:null placeholder at this
|
|
2166
|
+
// anchor; daemon.ts creates one for ANY DAEMON_COMMAND in a session-less
|
|
2167
|
+
// chat. It's not a real conversation, so it must NOT block resume — but it
|
|
2168
|
+
// also can't just be ignored: leaving it in the Map while we re-register
|
|
2169
|
+
// the resumed session at the same key would orphan its still-active store
|
|
2170
|
+
// row (the exact ghost-active bug we fixed elsewhere). So: close it (evicts
|
|
2171
|
+
// Map + marks store closed + dashboard event), then fall through to resume.
|
|
2172
|
+
//
|
|
2173
|
+
// We keep blocking on a real session (isRelayableRealSession) AND on a
|
|
2174
|
+
// pendingRepo session — the latter is a worker:null placeholder too, but it
|
|
2175
|
+
// represents deliberate in-progress setup (user is picking a repo), not a
|
|
2176
|
+
// throwaway command container, so clobbering it would lose real intent.
|
|
2177
|
+
const existing = activeSessions.get(key);
|
|
2178
|
+
if (existing) {
|
|
2179
|
+
// Master's isDisposableCommandScratch subsumes isRelayableRealSession /
|
|
2180
|
+
// pendingRepo / queued / adopt / pending-prompt. PR #597 additionally
|
|
2181
|
+
// refuses to clobber a durable activation owner or an opening that has not
|
|
2182
|
+
// crossed its submission boundary (initialStartPending), which the scratch
|
|
2183
|
+
// predicate does not cover — keep both so neither intent is lost.
|
|
2184
|
+
if (hasProtectedSessionMutationOwnership(existing)
|
|
2185
|
+
|| existing.initialStartPending
|
|
2186
|
+
|| !isDisposableCommandScratch(existing)) {
|
|
2187
|
+
return { ok: false, error: 'anchor_occupied', activeSessionId: existing.session.sessionId };
|
|
2188
|
+
}
|
|
2189
|
+
await closeSession(existing.session.sessionId);
|
|
2190
|
+
const replacement = activeSessions.get(key);
|
|
2191
|
+
if (replacement) {
|
|
2192
|
+
return { ok: false, error: 'anchor_occupied', activeSessionId: replacement.session.sessionId };
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
// Belt-and-suspenders: also scan persisted sessions for any *other* active
|
|
2196
|
+
// session pinned to the same (larkAppId, scope, anchor). The in-memory Map
|
|
2197
|
+
// is the authoritative routing source for a running daemon, but it's only
|
|
2198
|
+
// hydrated for sessions that survived restoreActiveSessions. Cross-process
|
|
2199
|
+
// and partial-load situations (e.g. another bot's daemon writes a session
|
|
2200
|
+
// file but our Map hasn't caught up, or a closed session was orphaned by a
|
|
2201
|
+
// crash that left a sibling session active in the same anchor) can leave a
|
|
2202
|
+
// store-level conflict invisible to the Map check above.
|
|
2203
|
+
//
|
|
2204
|
+
// Same scratch carve-out applies on disk: a persisted scratch has neither
|
|
2205
|
+
// `cliId` / `lastCliInput` (those are only written once a real backend ran).
|
|
2206
|
+
// A real conflict (any marker present) still
|
|
2207
|
+
// blocks; scratch-only conflicts get closed so they stop occupying the
|
|
2208
|
+
// anchor on disk.
|
|
2209
|
+
//
|
|
2210
|
+
// CAVEAT — this path canNOT honor the pendingRepo carve-out the in-memory
|
|
2211
|
+
// branch above applies: `pendingRepo` is a runtime DaemonSession flag that
|
|
2212
|
+
// is never persisted to the store, so a pendingRepo session that's only
|
|
2213
|
+
// visible here as a disk row (not in our Map) looks identical to a scratch
|
|
2214
|
+
// and would be closed. Safe under the production topology (one daemon per
|
|
2215
|
+
// bot): this scan is larkAppId-scoped to OUR bot, and our bot's live
|
|
2216
|
+
// pendingRepo sessions are always in our Map (handled by the in-memory
|
|
2217
|
+
// branch first). A disk-only active row with no CLI markers for our own
|
|
2218
|
+
// bot is therefore a genuine scratch or a crash leftover — closing it is
|
|
2219
|
+
// correct either way. The two branches are intentionally NOT identical;
|
|
2220
|
+
// don't "unify" them by reading pendingRepo here (it isn't there to read).
|
|
2221
|
+
const conflicts = sessionStore.listSessions().filter(s => s.sessionId !== sessionId
|
|
2222
|
+
&& s.status === 'active'
|
|
2223
|
+
// Legacy active rows predate per-bot stamping. Conservatively attribute an
|
|
2224
|
+
// unscoped row to this daemon, matching transfer/restore collision rules;
|
|
2225
|
+
// otherwise a disk-only legacy anchor can be ghosted by the resumed row.
|
|
2226
|
+
&& (!s.larkAppId || s.larkAppId === larkAppId)
|
|
2227
|
+
&& (s.scope === 'chat' ? 'chat' : 'thread') === scope
|
|
2228
|
+
&& storedSessionAnchorId(s) === anchor);
|
|
2229
|
+
const realConflict = conflicts.find(s => hasProtectedSessionMutationOwnership(s)
|
|
2230
|
+
|| !!s.cliId
|
|
2231
|
+
|| !!s.lastCliInput
|
|
2232
|
+
|| !!s.adoptedFrom
|
|
2233
|
+
|| s.queued === true);
|
|
2234
|
+
if (realConflict) {
|
|
2235
|
+
return { ok: false, error: 'anchor_occupied', activeSessionId: realConflict.sessionId };
|
|
2236
|
+
}
|
|
2237
|
+
for (const scratch of conflicts) {
|
|
2238
|
+
await closeSession(scratch.sessionId);
|
|
2239
|
+
}
|
|
2240
|
+
const replacementAfterDiskCleanup = activeSessions.get(key);
|
|
2241
|
+
if (replacementAfterDiskCleanup) {
|
|
2242
|
+
return {
|
|
2243
|
+
ok: false,
|
|
2244
|
+
error: 'anchor_occupied',
|
|
2245
|
+
activeSessionId: replacementAfterDiskCleanup.session.sessionId,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
// Scratch cleanup and disk scans can await. A creator that does not yet use
|
|
2249
|
+
// the shared key lock still wins if it published meanwhile; never reactivate
|
|
2250
|
+
// the closed row and then replace that fresh owner.
|
|
2251
|
+
const lateExisting = activeSessions.get(key);
|
|
2252
|
+
if (lateExisting) {
|
|
2253
|
+
return {
|
|
2254
|
+
ok: false,
|
|
2255
|
+
error: 'anchor_occupied',
|
|
2256
|
+
activeSessionId: lateExisting.session.sessionId,
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
// Reactivate and abandon any queued/setup ownership in one durable replace.
|
|
2260
|
+
// Closed rows created by older releases may still contain a prepared head,
|
|
2261
|
+
// tail, or repo picker; generic resume starts a new lifecycle and must never
|
|
2262
|
+
// replay those abandoned inputs.
|
|
2263
|
+
const reactivated = sessionStore.reactivateClosedSession(sessionId);
|
|
2264
|
+
if (!reactivated.ok)
|
|
2265
|
+
return reactivated;
|
|
2266
|
+
session = reactivated.session;
|
|
2267
|
+
const now = Date.now();
|
|
2268
|
+
const ds = {
|
|
2269
|
+
session,
|
|
2270
|
+
worker: null,
|
|
2271
|
+
workerPort: null,
|
|
2272
|
+
workerToken: null,
|
|
2273
|
+
larkAppId,
|
|
2274
|
+
chatId: session.chatId,
|
|
2275
|
+
chatType: session.chatType ?? 'group',
|
|
2276
|
+
scope,
|
|
2277
|
+
spawnedAt: sessionCreatedAtMs(session),
|
|
2278
|
+
cliVersion: getCurrentCliVersion(),
|
|
2279
|
+
lastMessageAt: now,
|
|
2280
|
+
hasHistory: true, // resumed sessions carry CLI history (--resume on next fork)
|
|
2281
|
+
workingDir: session.workingDir,
|
|
2282
|
+
ownerOpenId: session.ownerOpenId,
|
|
2283
|
+
streamCardId: session.streamCardId,
|
|
2284
|
+
streamCardNonce: session.streamCardNonce,
|
|
2285
|
+
displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
|
|
2286
|
+
currentImageKey: session.currentImageKey,
|
|
2287
|
+
currentTurnTitle: session.currentTurnTitle,
|
|
2288
|
+
usageLimit: session.usageLimit,
|
|
2289
|
+
lastUserPrompt: session.lastUserPrompt,
|
|
2290
|
+
lastCliInput: session.lastCliInput,
|
|
2291
|
+
lastCodexAppInput: session.lastCodexAppInput,
|
|
2292
|
+
replyThreadAliases: session.replyThreadAliases,
|
|
2293
|
+
currentReplyTarget: session.currentReplyTarget,
|
|
1871
2294
|
};
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
ownerOpenId: session.ownerOpenId,
|
|
1895
|
-
streamCardId: session.streamCardId,
|
|
1896
|
-
streamCardNonce: session.streamCardNonce,
|
|
1897
|
-
displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
|
|
1898
|
-
currentImageKey: session.currentImageKey,
|
|
1899
|
-
currentTurnTitle: session.currentTurnTitle,
|
|
1900
|
-
usageLimit: session.usageLimit,
|
|
1901
|
-
lastUserPrompt: session.lastUserPrompt,
|
|
1902
|
-
lastCliInput: session.lastCliInput,
|
|
1903
|
-
lastCodexAppInput: session.lastCodexAppInput,
|
|
1904
|
-
replyThreadAliases: session.replyThreadAliases,
|
|
1905
|
-
currentReplyTarget: session.currentReplyTarget,
|
|
1906
|
-
};
|
|
1907
|
-
messageQueue.ensureQueue(anchor);
|
|
1908
|
-
// setActiveSessionSafe over a bare Map.set: the scratch-eviction above
|
|
1909
|
-
// should already have freed `key`, but its compare-and-set gate lets any
|
|
1910
|
-
// concurrent runtime occupant win rather than silently orphaning it.
|
|
1911
|
-
const registered = await setActiveSessionSafe(activeSessions, key, ds);
|
|
1912
|
-
if (!registered || session.status !== 'active' || activeSessions.get(key) !== ds) {
|
|
1913
|
-
const occupant = activeSessions.get(key);
|
|
1914
|
-
if (session.status === 'active')
|
|
1915
|
-
await closeSession(sessionId);
|
|
1916
|
-
if (occupant && occupant !== ds) {
|
|
1917
|
-
return { ok: false, error: 'anchor_occupied', activeSessionId: occupant.session.sessionId };
|
|
1918
|
-
}
|
|
1919
|
-
return { ok: false, error: 'resume_cancelled' };
|
|
1920
|
-
}
|
|
1921
|
-
logger.info(`Resumed session ${sessionId.substring(0, 8)} (scope: ${scope}, anchor: ${anchor.substring(0, 12)})`);
|
|
1922
|
-
return { ok: true, ds };
|
|
2295
|
+
messageQueue.ensureQueue(anchor);
|
|
2296
|
+
// Already inside withActiveSessionKeyLock(activeSessions, key) above, so use
|
|
2297
|
+
// the SYNCHRONOUS non-locking compare-and-set here — setActiveSessionSafe
|
|
2298
|
+
// re-acquires the same non-reentrant key lock and would self-deadlock. The
|
|
2299
|
+
// scratch-eviction above should already have freed `key`, but this gate lets
|
|
2300
|
+
// any concurrent runtime occupant (or a protected Codex App / activation
|
|
2301
|
+
// owner) win rather than silently orphaning it; on loss close THIS resumed
|
|
2302
|
+
// row — never the surviving occupant — matching PR #597's "a user resume must
|
|
2303
|
+
// not clobber a newly-created worker" rule.
|
|
2304
|
+
const registered = setActiveSessionIfActive(activeSessions, key, ds);
|
|
2305
|
+
if (!registered || session.status !== 'active' || activeSessions.get(key) !== ds) {
|
|
2306
|
+
const occupant = activeSessions.get(key);
|
|
2307
|
+
if (session.status === 'active')
|
|
2308
|
+
await closeSession(sessionId);
|
|
2309
|
+
if (occupant && occupant !== ds) {
|
|
2310
|
+
return { ok: false, error: 'anchor_occupied', activeSessionId: occupant.session.sessionId };
|
|
2311
|
+
}
|
|
2312
|
+
return { ok: false, error: 'resume_cancelled' };
|
|
2313
|
+
}
|
|
2314
|
+
logger.info(`Resumed session ${sessionId.substring(0, 8)} (scope: ${scope}, anchor: ${anchor.substring(0, 12)})`);
|
|
2315
|
+
return { ok: true, ds };
|
|
2316
|
+
});
|
|
1923
2317
|
}
|
|
1924
2318
|
// ─── Scheduled task execution ────────────────────────────────────────────────
|
|
1925
2319
|
/**
|
|
@@ -2137,143 +2531,181 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
|
|
|
2137
2531
|
}
|
|
2138
2532
|
}
|
|
2139
2533
|
refreshCliVersion(bot.config);
|
|
2140
|
-
// Silent fires flip the model's default from "post progress via botmux send"
|
|
2141
|
-
// to "say nothing unless the alert condition is met".
|
|
2142
2534
|
const firePrompt = silent
|
|
2143
2535
|
? `${buildSilentScheduleHint(task.name, localeForBot(larkAppId))}\n\n${task.prompt}`
|
|
2144
2536
|
: task.prompt;
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2537
|
+
const key = sessionKey(anchor, larkAppId);
|
|
2538
|
+
return withActiveSessionKeyLock(activeSessions, key, async () => {
|
|
2539
|
+
// Reuse the canonical owner only when it is an actual conversation. A
|
|
2540
|
+
// worker:null entry is also used for deliberately non-runnable states:
|
|
2541
|
+
// first-turn setup, repo selection (including an in-flight repo commit),
|
|
2542
|
+
// worktree creation, and dashboard backlog. Waking any of those here would
|
|
2543
|
+
// let the scheduled prompt overtake (or replace) the opening prompt that
|
|
2544
|
+
// owns the reservation.
|
|
2545
|
+
const existing = activeSessions.get(key);
|
|
2546
|
+
if (existing) {
|
|
2547
|
+
const reservedState = existing.pendingRepo
|
|
2548
|
+
? 'pending_repo'
|
|
2549
|
+
: existing.pendingRepoCommitInFlight
|
|
2550
|
+
? 'pending_repo_commit'
|
|
2551
|
+
: existing.initialStartPending
|
|
2552
|
+
? 'initial_start_pending'
|
|
2553
|
+
: existing.worktreeCreating
|
|
2554
|
+
? 'worktree_creating'
|
|
2555
|
+
: existing.session.queued
|
|
2556
|
+
? 'queued_backlog'
|
|
2557
|
+
: undefined;
|
|
2558
|
+
if (reservedState) {
|
|
2559
|
+
throw new Error(`Scheduled task ${task.id} found owner ${existing.session.sessionId} `
|
|
2560
|
+
+ `in ${reservedState}; preserving its opening prompt`);
|
|
2561
|
+
}
|
|
2562
|
+
if (hasProtectedSessionMutationOwnership(existing)) {
|
|
2563
|
+
throw new Error(`Scheduled task ${task.id} found durable activation owner `
|
|
2564
|
+
+ `${existing.session.sessionId}; preserving it for recovery`);
|
|
2565
|
+
}
|
|
2566
|
+
// Cold-resume the exact registered owner (master's resilience model). A
|
|
2567
|
+
// deliberately suspended session keeps its active registration with
|
|
2568
|
+
// worker=null; scheduled continuations must wake THAT same row instead of
|
|
2569
|
+
// spawning a competing session the registration CAS would reject. Live
|
|
2570
|
+
// inject when the worker is up; on refusal or throw, fall back to a
|
|
2571
|
+
// cold-resume fork rather than hard-failing the scheduled turn. A proven
|
|
2572
|
+
// disposable command scratch (no worker, no CLI markers, no pending
|
|
2573
|
+
// intent) is NOT a resumable conversation — retire it below and spawn a
|
|
2574
|
+
// fresh scheduled session instead.
|
|
2575
|
+
const resumableOwner = isRelayableRealSession(existing)
|
|
2576
|
+
|| !!existing.session.suspendedColdResume;
|
|
2577
|
+
if (isContinuation && resumableOwner) {
|
|
2578
|
+
markSessionActivity(existing);
|
|
2579
|
+
ensureSessionWhiteboard(existing);
|
|
2580
|
+
if (sharedTopicRootId) {
|
|
2581
|
+
beginReplyTargetTurn(existing, sharedTopicRootId, scheduledTurnId);
|
|
2582
|
+
sessionStore.updateSession(existing.session);
|
|
2188
2583
|
}
|
|
2584
|
+
const input = buildFollowUpCliInput(firePrompt, existing.session.sessionId, {
|
|
2585
|
+
isAdoptMode: false,
|
|
2586
|
+
cliId: existing.session.cliId ?? bot.config.cliId,
|
|
2587
|
+
cliPathOverride: existing.session.cliPathOverride ?? bot.config.cliPathOverride,
|
|
2588
|
+
locale: localeForBot(larkAppId),
|
|
2589
|
+
larkAppId,
|
|
2590
|
+
chatId: task.chatId,
|
|
2591
|
+
whiteboardId: existing.session.whiteboardId,
|
|
2592
|
+
});
|
|
2593
|
+
rememberLastCliInput(existing, task.prompt, input);
|
|
2594
|
+
if (silent)
|
|
2595
|
+
armSilentScheduledTurn(existing, scheduledTurnId);
|
|
2596
|
+
if (existing.worker && !existing.worker.killed) {
|
|
2597
|
+
try {
|
|
2598
|
+
if (sendWorkerInput(existing, input, scheduledTurnId)) {
|
|
2599
|
+
logger.info(`[scheduler] Task "${task.name}" injected into live session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`);
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
catch (err) {
|
|
2604
|
+
logger.warn(`[scheduler] Live injection threw (${err.message}); cold-resuming registered session`);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
if (activeSessions.get(key) !== existing || existing.session.status !== 'active') {
|
|
2608
|
+
if (silent)
|
|
2609
|
+
disarmSilentScheduledTurn(existing, scheduledTurnId);
|
|
2610
|
+
throw new Error(`scheduled continuation lost active session ${existing.session.sessionId}`);
|
|
2611
|
+
}
|
|
2612
|
+
try {
|
|
2613
|
+
forkWorker(existing, input, { resume: existing.hasHistory, turnId: scheduledTurnId });
|
|
2614
|
+
}
|
|
2615
|
+
catch (err) {
|
|
2616
|
+
if (silent)
|
|
2617
|
+
disarmSilentScheduledTurn(existing, scheduledTurnId);
|
|
2618
|
+
throw err;
|
|
2619
|
+
}
|
|
2620
|
+
logger.info(`[scheduler] Task "${task.name}" cold-resumed session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`);
|
|
2621
|
+
return;
|
|
2189
2622
|
}
|
|
2190
|
-
|
|
2191
|
-
|
|
2623
|
+
// Not a resumable conversation. A daemon-command scratch has no CLI
|
|
2624
|
+
// history and no pending user intent — retire the exact scratch before
|
|
2625
|
+
// creating the scheduled conversation so its store row doesn't linger as
|
|
2626
|
+
// a ghost-active; never fork it as if it were resumable. Only do this on a
|
|
2627
|
+
// continuation (the task's own retained anchor); a non-continuation
|
|
2628
|
+
// fresh-topic collision preserves the occupant and lets the registration
|
|
2629
|
+
// CAS below report the loss observably.
|
|
2630
|
+
if (isContinuation && isDisposableCommandScratch(existing)) {
|
|
2631
|
+
await closeSession(existing.session.sessionId);
|
|
2632
|
+
if (activeSessions.get(key) === existing)
|
|
2633
|
+
activeSessions.delete(key);
|
|
2192
2634
|
}
|
|
2193
2635
|
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2636
|
+
// Spawn a fresh session bound to the chosen anchor.
|
|
2637
|
+
// Thread-scope: rootMessageId = anchor. Chat-scope: rootMessageId stores the
|
|
2638
|
+
// chatId-as-seed for audit (sessionAnchorId() returns chatId via scope). If a
|
|
2639
|
+
// formerly chat-scope task was redirected into a converted topic chat, promote
|
|
2640
|
+
// the runtime session to thread-scope so follow-up replies stay in-thread.
|
|
2641
|
+
const deferredFreshTopic = executionPosition === 'new-topic' && silent;
|
|
2642
|
+
const runtimeScope = deferredFreshTopic
|
|
2643
|
+
? 'chat'
|
|
2644
|
+
: scope === 'chat' && anchor !== task.chatId ? 'thread' : scope;
|
|
2645
|
+
const session = sessionStore.createSession(task.chatId, anchor, `${t('schedule.title_prefix', undefined, localeForBot(larkAppId))} ${task.name}`, task.chatType === 'p2p' ? 'p2p' : 'group');
|
|
2646
|
+
const now = Date.now();
|
|
2647
|
+
session.larkAppId = larkAppId;
|
|
2648
|
+
session.scope = runtimeScope;
|
|
2649
|
+
if (deferredFreshTopic) {
|
|
2650
|
+
session.deferredScheduleRun = {
|
|
2651
|
+
taskId: task.id,
|
|
2652
|
+
turnId: scheduledTurnId,
|
|
2653
|
+
routingAnchor: anchor,
|
|
2654
|
+
...(task.topicTitle?.trim() ? { topicTitle: task.topicTitle.trim() } : {}),
|
|
2655
|
+
createdAt: new Date(now).toISOString(),
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
session.lastMessageAt = new Date(now).toISOString();
|
|
2659
|
+
sessionStore.updateSession(session);
|
|
2660
|
+
messageQueue.ensureQueue(anchor);
|
|
2661
|
+
const ds = {
|
|
2662
|
+
session,
|
|
2663
|
+
worker: null,
|
|
2664
|
+
workerPort: null,
|
|
2665
|
+
workerToken: null,
|
|
2666
|
+
larkAppId,
|
|
2667
|
+
chatId: task.chatId,
|
|
2668
|
+
chatType: task.chatType === 'p2p' ? 'p2p' : 'group',
|
|
2669
|
+
scope: runtimeScope,
|
|
2670
|
+
spawnedAt: sessionCreatedAtMs(session),
|
|
2671
|
+
cliVersion: getCurrentCliVersion(),
|
|
2672
|
+
lastMessageAt: now,
|
|
2673
|
+
hasHistory: isContinuation,
|
|
2674
|
+
workingDir: task.workingDir,
|
|
2675
|
+
initialStartPending: true,
|
|
2676
|
+
pendingPrompt: firePrompt,
|
|
2677
|
+
};
|
|
2678
|
+
if (sharedTopicRootId) {
|
|
2679
|
+
beginReplyTargetTurn(ds, sharedTopicRootId, scheduledTurnId);
|
|
2680
|
+
sessionStore.updateSession(ds.session);
|
|
2198
2681
|
}
|
|
2682
|
+
ensureSessionWhiteboard(ds);
|
|
2683
|
+
const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId });
|
|
2684
|
+
// Compare-and-set registration (master): a concurrent creator/restore may
|
|
2685
|
+
// have claimed this anchor between the scratch cleanup above and here.
|
|
2686
|
+
// Refuse to overwrite the live occupant, retire THIS rejected candidate's
|
|
2687
|
+
// durable row, and fail observably rather than reporting a false success.
|
|
2688
|
+
if (!setActiveSessionIfActive(activeSessions, key, ds)) {
|
|
2689
|
+
const winner = activeSessions.get(key);
|
|
2690
|
+
await closeSession(session.sessionId);
|
|
2691
|
+
throw new Error(`scheduled task ${task.id} lost active-session registration for ${anchor}` +
|
|
2692
|
+
(winner ? ` to ${winner.session.sessionId}` : ''));
|
|
2693
|
+
}
|
|
2694
|
+
rememberLastCliInput(ds, task.prompt, prompt);
|
|
2695
|
+
if (silent)
|
|
2696
|
+
armSilentScheduledTurn(ds, scheduledTurnId);
|
|
2199
2697
|
try {
|
|
2200
|
-
forkWorker(
|
|
2201
|
-
resume: existing.hasHistory,
|
|
2202
|
-
turnId: scheduledTurnId,
|
|
2203
|
-
});
|
|
2698
|
+
forkWorker(ds, prompt, scheduledTurnId);
|
|
2204
2699
|
}
|
|
2205
2700
|
catch (err) {
|
|
2206
2701
|
if (silent)
|
|
2207
|
-
disarmSilentScheduledTurn(
|
|
2702
|
+
disarmSilentScheduledTurn(ds, scheduledTurnId);
|
|
2208
2703
|
throw err;
|
|
2209
2704
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
// Thread-scope: rootMessageId = anchor. Chat-scope: rootMessageId stores the
|
|
2215
|
-
// chatId-as-seed for audit (sessionAnchorId() returns chatId via scope). If a
|
|
2216
|
-
// formerly chat-scope task was redirected into a converted topic chat, promote
|
|
2217
|
-
// the runtime session to thread-scope so follow-up replies stay in-thread.
|
|
2218
|
-
const deferredFreshTopic = executionPosition === 'new-topic' && silent;
|
|
2219
|
-
const runtimeScope = deferredFreshTopic
|
|
2220
|
-
? 'chat'
|
|
2221
|
-
: scope === 'chat' && anchor !== task.chatId ? 'thread' : scope;
|
|
2222
|
-
const session = sessionStore.createSession(task.chatId, anchor, `${t('schedule.title_prefix', undefined, localeForBot(larkAppId))} ${task.name}`, task.chatType === 'p2p' ? 'p2p' : 'group');
|
|
2223
|
-
const now = Date.now();
|
|
2224
|
-
session.larkAppId = larkAppId;
|
|
2225
|
-
session.scope = runtimeScope;
|
|
2226
|
-
if (deferredFreshTopic) {
|
|
2227
|
-
session.deferredScheduleRun = {
|
|
2228
|
-
taskId: task.id,
|
|
2229
|
-
turnId: scheduledTurnId,
|
|
2230
|
-
routingAnchor: anchor,
|
|
2231
|
-
...(task.topicTitle?.trim() ? { topicTitle: task.topicTitle.trim() } : {}),
|
|
2232
|
-
createdAt: new Date(now).toISOString(),
|
|
2233
|
-
};
|
|
2234
|
-
}
|
|
2235
|
-
session.lastMessageAt = new Date(now).toISOString();
|
|
2236
|
-
sessionStore.updateSession(session);
|
|
2237
|
-
messageQueue.ensureQueue(anchor);
|
|
2238
|
-
const ds = {
|
|
2239
|
-
session,
|
|
2240
|
-
worker: null,
|
|
2241
|
-
workerPort: null,
|
|
2242
|
-
workerToken: null,
|
|
2243
|
-
larkAppId,
|
|
2244
|
-
chatId: task.chatId,
|
|
2245
|
-
chatType: task.chatType === 'p2p' ? 'p2p' : 'group',
|
|
2246
|
-
scope: runtimeScope,
|
|
2247
|
-
spawnedAt: sessionCreatedAtMs(session),
|
|
2248
|
-
cliVersion: getCurrentCliVersion(),
|
|
2249
|
-
lastMessageAt: now,
|
|
2250
|
-
hasHistory: isContinuation,
|
|
2251
|
-
workingDir: task.workingDir,
|
|
2252
|
-
};
|
|
2253
|
-
if (sharedTopicRootId) {
|
|
2254
|
-
beginReplyTargetTurn(ds, sharedTopicRootId, scheduledTurnId);
|
|
2255
|
-
sessionStore.updateSession(ds.session);
|
|
2256
|
-
}
|
|
2257
|
-
ensureSessionWhiteboard(ds);
|
|
2258
|
-
const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId });
|
|
2259
|
-
if (!setActiveSessionIfActive(activeSessions, activeKey, ds)) {
|
|
2260
|
-
const winner = activeSessions.get(activeKey);
|
|
2261
|
-
await closeSession(session.sessionId);
|
|
2262
|
-
throw new Error(`scheduled task ${task.id} lost active-session registration for ${anchor}` +
|
|
2263
|
-
(winner ? ` to ${winner.session.sessionId}` : ''));
|
|
2264
|
-
}
|
|
2265
|
-
rememberLastCliInput(ds, task.prompt, prompt);
|
|
2266
|
-
if (silent)
|
|
2267
|
-
armSilentScheduledTurn(ds, scheduledTurnId);
|
|
2268
|
-
try {
|
|
2269
|
-
forkWorker(ds, prompt, scheduledTurnId);
|
|
2270
|
-
}
|
|
2271
|
-
catch (err) {
|
|
2272
|
-
if (silent)
|
|
2273
|
-
disarmSilentScheduledTurn(ds, scheduledTurnId);
|
|
2274
|
-
throw err;
|
|
2275
|
-
}
|
|
2276
|
-
logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, scope: ${runtimeScope}, anchor: ${anchor}, continuation: ${isContinuation}${silent ? ', silent' : ''})`);
|
|
2705
|
+
ds.initialStartPending = false;
|
|
2706
|
+
ds.pendingPrompt = undefined;
|
|
2707
|
+
logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, scope: ${runtimeScope}, anchor: ${anchor}, continuation: ${isContinuation}${silent ? ', silent' : ''})`);
|
|
2708
|
+
});
|
|
2277
2709
|
}
|
|
2278
2710
|
// ─── Dashboard「创建会话」spawn / activate ───────────────────────────────────
|
|
2279
2711
|
/** 解析 dashboard 创建会话的 pinned workingDir:本群 oncall 绑定优先(弹框填了工作目录会
|
|
@@ -2319,6 +2751,14 @@ async function forkOrShowRepoCard(ds, userContent) {
|
|
|
2319
2751
|
const baseDir = ds.workingDir;
|
|
2320
2752
|
ds.pendingRepo = true; // router buffers concurrent msgs; commit clears it
|
|
2321
2753
|
ds.pendingPrompt = userContent; // folded into the first turn by commitRepoSelection
|
|
2754
|
+
stagePendingRepoSetup(ds, {
|
|
2755
|
+
mode: 'auto_worktree',
|
|
2756
|
+
baseDir,
|
|
2757
|
+
turnId: ds.currentReplyTarget?.turnId ?? ds.session.rootMessageId,
|
|
2758
|
+
});
|
|
2759
|
+
// Route visibility moves atomically from initial-start reservation to
|
|
2760
|
+
// pendingRepo before the dynamic import can yield.
|
|
2761
|
+
ds.initialStartPending = false;
|
|
2322
2762
|
// (The pending dashboard row is announced inside runAutoWorktreeCommit so all
|
|
2323
2763
|
// three spawn callers get it from one place — no publish needed here.)
|
|
2324
2764
|
const { runAutoWorktreeCommit } = await import('../im/lark/card-handler.js');
|
|
@@ -2330,41 +2770,75 @@ async function forkOrShowRepoCard(ds, userContent) {
|
|
|
2330
2770
|
notify: (m) => sendMessage(larkAppId, ds.chatId, m),
|
|
2331
2771
|
});
|
|
2332
2772
|
logger.info(`[createSession] session ${ds.session.sessionId.substring(0, 8)} → pending, building worktree off ${baseDir}`);
|
|
2333
|
-
return;
|
|
2773
|
+
return 'pending_repo';
|
|
2334
2774
|
}
|
|
2335
2775
|
}
|
|
2336
|
-
const buildPrompt = () => buildNewTopicCliInput(userContent, ds.session.sessionId, bot.config.cliId, bot.config.cliPathOverride, ds.pendingAttachments,
|
|
2776
|
+
const buildPrompt = () => buildNewTopicCliInput(userContent, ds.session.sessionId, bot.config.cliId, bot.config.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, undefined, ds.pendingFollowUps, { name: bot.botName, openId: bot.botOpenId }, locale, ds.pendingSender, {
|
|
2337
2777
|
larkAppId,
|
|
2338
2778
|
chatId: ds.chatId,
|
|
2339
2779
|
whiteboardId: ds.session.whiteboardId,
|
|
2340
2780
|
codexAppText: ds.pendingCodexAppText,
|
|
2341
2781
|
codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
|
|
2342
2782
|
codexAppMessageContext: ds.pendingCodexAppMessageContext,
|
|
2783
|
+
codexAppFollowUps: ds.pendingCodexAppFollowUps,
|
|
2784
|
+
codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
|
|
2343
2785
|
});
|
|
2344
2786
|
if (!ds.workingDir) {
|
|
2345
2787
|
// 没钉目录 → 复用 /repo 选择卡片让用户在群里选仓库。
|
|
2346
2788
|
const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d));
|
|
2347
2789
|
const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : [];
|
|
2348
2790
|
if (projects.length > 0) {
|
|
2791
|
+
const card = buildRepoSelectCard(projects, getSessionWorkingDir(ds), ds.chatId, locale, bot.config.worktreeMultiPicker);
|
|
2792
|
+
const { sendMessage } = await import('../im/lark/client.js');
|
|
2793
|
+
ds.pendingRepo = true;
|
|
2794
|
+
ds.pendingPrompt = userContent;
|
|
2349
2795
|
try {
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
ds.repoCardMessageId = await sendMessage(larkAppId, ds.chatId, card, 'interactive');
|
|
2355
|
-
announcePendingRepoSession(ds);
|
|
2356
|
-
// 弹卡片这条路不经 forkWorker,session.spawned 不会自动发——手动 upsert 一条,
|
|
2357
|
-
// 让 dashboard 显示这条「待选仓库」会话(in_progress 首次 spawn 走这里才会出现)。
|
|
2358
|
-
dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
|
|
2359
|
-
logger.info(`[createSession] repo select card posted for session ${ds.session.sessionId.substring(0, 8)} (${projects.length} projects)`);
|
|
2360
|
-
return;
|
|
2796
|
+
stagePendingRepoSetup(ds, {
|
|
2797
|
+
mode: 'picker',
|
|
2798
|
+
turnId: ds.currentReplyTarget?.turnId ?? ds.session.rootMessageId,
|
|
2799
|
+
});
|
|
2361
2800
|
}
|
|
2362
2801
|
catch (err) {
|
|
2363
|
-
//
|
|
2802
|
+
// Nothing external was published. The journal helper rolled its own
|
|
2803
|
+
// mutation back, so direct fork remains a safe fallback.
|
|
2364
2804
|
ds.pendingRepo = false;
|
|
2365
2805
|
ds.pendingPrompt = undefined;
|
|
2366
2806
|
ds.repoCardMessageId = undefined;
|
|
2367
|
-
logger.warn(`[createSession] repo
|
|
2807
|
+
logger.warn(`[createSession] repo setup stage failed (${err?.message ?? err}); forking with default cwd`);
|
|
2808
|
+
}
|
|
2809
|
+
if (ds.pendingRepo) {
|
|
2810
|
+
let publishedCardId;
|
|
2811
|
+
try {
|
|
2812
|
+
publishedCardId = await sendMessage(larkAppId, ds.chatId, card, 'interactive');
|
|
2813
|
+
}
|
|
2814
|
+
catch (err) {
|
|
2815
|
+
// No picker exists, so the already-durable opening may safely move
|
|
2816
|
+
// into forkWorker's tokened activation journal.
|
|
2817
|
+
ds.pendingRepo = false;
|
|
2818
|
+
ds.repoCardMessageId = undefined;
|
|
2819
|
+
logger.warn(`[createSession] repo card publish failed (${err?.message ?? err}); forking with default cwd`);
|
|
2820
|
+
}
|
|
2821
|
+
if (publishedCardId) {
|
|
2822
|
+
ds.repoCardMessageId = publishedCardId;
|
|
2823
|
+
try {
|
|
2824
|
+
persistPendingRepoCardMessageId(ds, publishedCardId);
|
|
2825
|
+
}
|
|
2826
|
+
catch (err) {
|
|
2827
|
+
// The card is already visible. Keep its runtime identity and the
|
|
2828
|
+
// durable setup owner fail-closed; restart will publish and persist
|
|
2829
|
+
// a fresh picker instead of starting the CLI behind this card.
|
|
2830
|
+
logger.error(`[createSession] repo card id persistence failed for ${ds.session.sessionId.substring(0, 8)}; `
|
|
2831
|
+
+ `retaining pending picker ${publishedCardId}: `
|
|
2832
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2833
|
+
}
|
|
2834
|
+
ds.initialStartPending = false;
|
|
2835
|
+
announcePendingRepoSession(ds);
|
|
2836
|
+
// 弹卡片这条路不经 forkWorker,session.spawned 不会自动发——手动 upsert 一条,
|
|
2837
|
+
// 让 dashboard 显示这条「待选仓库」会话(in_progress 首次 spawn 走这里才会出现)。
|
|
2838
|
+
dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
|
|
2839
|
+
logger.info(`[createSession] repo select card posted for session ${ds.session.sessionId.substring(0, 8)} (${projects.length} projects)`);
|
|
2840
|
+
return 'pending_repo';
|
|
2841
|
+
}
|
|
2368
2842
|
}
|
|
2369
2843
|
}
|
|
2370
2844
|
}
|
|
@@ -2372,11 +2846,25 @@ async function forkOrShowRepoCard(ds, userContent) {
|
|
|
2372
2846
|
const prompt = buildPrompt();
|
|
2373
2847
|
rememberLastCliInput(ds, userContent, prompt);
|
|
2374
2848
|
forkWorker(ds, prompt);
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
ds.
|
|
2379
|
-
|
|
2849
|
+
// forkWorker pre-accept is synchronous. Keep the reservation and all input
|
|
2850
|
+
// buffers intact if it throws; only expose the normal worker state after it
|
|
2851
|
+
// has accepted the first turn.
|
|
2852
|
+
if (!ds.session.queuedActivationPending) {
|
|
2853
|
+
ds.initialStartPending = false;
|
|
2854
|
+
ds.pendingPrompt = undefined;
|
|
2855
|
+
ds.pendingCodexAppText = undefined;
|
|
2856
|
+
ds.pendingCodexAppApplicationContext = undefined;
|
|
2857
|
+
ds.pendingCodexAppMessageContext = undefined;
|
|
2858
|
+
ds.pendingChatContext = undefined;
|
|
2859
|
+
ds.pendingAttachments = undefined;
|
|
2860
|
+
ds.pendingMentions = undefined;
|
|
2861
|
+
ds.pendingSender = undefined;
|
|
2862
|
+
ds.pendingFollowUps = undefined;
|
|
2863
|
+
ds.pendingFollowUpTurnIds = undefined;
|
|
2864
|
+
ds.pendingCodexAppFollowUps = undefined;
|
|
2865
|
+
ds.pendingCodexAppFollowUpContexts = undefined;
|
|
2866
|
+
}
|
|
2867
|
+
return 'forked';
|
|
2380
2868
|
}
|
|
2381
2869
|
/** 在新建的飞书群里为某个 bot 拉起一条 chat-scope 会话(dashboard「创建会话」用)。
|
|
2382
2870
|
* column='in_progress' → 立即 forkWorker 把内容当首轮发给 CLI;
|
|
@@ -2396,8 +2884,11 @@ export async function spawnDashboardSession(activeSessions, refreshCliVersion, a
|
|
|
2396
2884
|
// chat-scope:锚点就是 chatId。先挡掉「同群同 bot 已有真会话」的撞键(会被
|
|
2397
2885
|
// Map.set 覆盖而泄漏 worker)。queued 占位 / 纯 scratch 不算冲突。
|
|
2398
2886
|
const anchor = chatId;
|
|
2399
|
-
const
|
|
2400
|
-
|
|
2887
|
+
const key = sessionKey(anchor, larkAppId);
|
|
2888
|
+
const existing = activeSessions.get(key);
|
|
2889
|
+
if (existing && (existing.worker || existing.session.queued || existing.pendingRepo
|
|
2890
|
+
|| existing.initialStartPending || existing.worktreeCreating || isRelayableRealSession(existing)
|
|
2891
|
+
|| hasProtectedSessionMutationOwnership(existing))) {
|
|
2401
2892
|
return { ok: false, error: 'session_exists' };
|
|
2402
2893
|
}
|
|
2403
2894
|
const quarantinedPersisted = sessionStore.listSessions().find(session => session.status === 'active'
|
|
@@ -2439,67 +2930,140 @@ export async function spawnDashboardSession(activeSessions, refreshCliVersion, a
|
|
|
2439
2930
|
// 也带编排上下文(coworkers 只有此刻可靠,激活时已无从重算)。
|
|
2440
2931
|
const userContent = composeSpawnUserContent({ content, role, coworkers: args.coworkers, locale });
|
|
2441
2932
|
const codexAppMessageContext = composeSpawnCodexAppContext({ role, coworkers: args.coworkers, locale });
|
|
2442
|
-
const
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
session.
|
|
2457
|
-
session.
|
|
2458
|
-
session.
|
|
2459
|
-
session.
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
larkAppId,
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2933
|
+
const registered = await withActiveSessionKeyLock(activeSessions, key, async () => {
|
|
2934
|
+
const current = activeSessions.get(key);
|
|
2935
|
+
if (current) {
|
|
2936
|
+
const protectedOwner = current.worker || current.session.queued || current.pendingRepo
|
|
2937
|
+
|| current.initialStartPending || current.worktreeCreating
|
|
2938
|
+
|| isRelayableRealSession(current)
|
|
2939
|
+
|| hasProtectedSessionMutationOwnership(current);
|
|
2940
|
+
if (protectedOwner)
|
|
2941
|
+
return undefined;
|
|
2942
|
+
await closeSession(current.session.sessionId);
|
|
2943
|
+
}
|
|
2944
|
+
const resolvedTitle = args.title || deriveSessionTitleFromContent(content);
|
|
2945
|
+
const session = sessionStore.createSession(chatId, bannerMessageId ?? chatId, resolvedTitle, 'group');
|
|
2946
|
+
const now = Date.now();
|
|
2947
|
+
session.larkAppId = larkAppId;
|
|
2948
|
+
session.scope = 'chat';
|
|
2949
|
+
session.ownerOpenId = args.ownerOpenId ?? getOwnerOpenId(larkAppId);
|
|
2950
|
+
session.creatorOpenId = session.ownerOpenId;
|
|
2951
|
+
if (args.ownerUnionId)
|
|
2952
|
+
session.ownerUnionId = args.ownerUnionId;
|
|
2953
|
+
session.lastMessageAt = new Date(now).toISOString();
|
|
2954
|
+
if (args.attachments?.length)
|
|
2955
|
+
session.dashboardAttachments = args.attachments;
|
|
2956
|
+
if (column === 'backlog') {
|
|
2957
|
+
session.queued = true;
|
|
2958
|
+
session.queuedPrompt = userContent;
|
|
2959
|
+
session.queuedCodexAppText = content;
|
|
2960
|
+
session.queuedCodexAppMessageContext = codexAppMessageContext;
|
|
2961
|
+
if (args.attachments?.length)
|
|
2962
|
+
session.queuedAttachments = args.attachments;
|
|
2963
|
+
session.kanbanColumn = 'backlog';
|
|
2964
|
+
}
|
|
2965
|
+
const workingDir = resolveDashboardSpawnWorkingDir(larkAppId, chatId);
|
|
2966
|
+
if (workingDir)
|
|
2967
|
+
session.workingDir = workingDir;
|
|
2968
|
+
sessionStore.updateSession(session);
|
|
2969
|
+
messageQueue.ensureQueue(anchor);
|
|
2970
|
+
const ds = {
|
|
2971
|
+
session,
|
|
2972
|
+
worker: null,
|
|
2973
|
+
workerPort: null,
|
|
2974
|
+
workerToken: null,
|
|
2975
|
+
larkAppId,
|
|
2976
|
+
chatId,
|
|
2977
|
+
chatType: 'group',
|
|
2978
|
+
scope: 'chat',
|
|
2979
|
+
spawnedAt: sessionCreatedAtMs(session),
|
|
2980
|
+
cliVersion: getCurrentCliVersion(),
|
|
2981
|
+
lastMessageAt: now,
|
|
2982
|
+
hasHistory: false,
|
|
2983
|
+
workingDir,
|
|
2984
|
+
ownerOpenId: session.ownerOpenId,
|
|
2985
|
+
currentTurnTitle: resolvedTitle,
|
|
2986
|
+
pendingCodexAppText: content,
|
|
2987
|
+
pendingCodexAppMessageContext: codexAppMessageContext,
|
|
2988
|
+
pendingAttachments: args.attachments,
|
|
2989
|
+
};
|
|
2990
|
+
if (column !== 'backlog') {
|
|
2991
|
+
ds.initialStartPending = true;
|
|
2992
|
+
ds.pendingPrompt = userContent;
|
|
2993
|
+
}
|
|
2994
|
+
activeSessions.set(key, ds);
|
|
2995
|
+
if (column === 'backlog') {
|
|
2996
|
+
ds.pendingPrompt = userContent;
|
|
2997
|
+
dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
|
|
2998
|
+
}
|
|
2999
|
+
else {
|
|
3000
|
+
// Keep the key reservation through initial worker/repo-picker
|
|
3001
|
+
// installation. Otherwise a concurrent creator can classify this brief
|
|
3002
|
+
// worker:null interval as disposable scratch, replace it, and let this
|
|
3003
|
+
// caller resume by forking an orphan.
|
|
3004
|
+
try {
|
|
3005
|
+
await forkOrShowRepoCard(ds, userContent);
|
|
3006
|
+
}
|
|
3007
|
+
catch (err) {
|
|
3008
|
+
const durableOwner = hasProtectedSessionMutationOwnership(ds.session);
|
|
3009
|
+
const liveWorkerOwner = !!ds.worker && !ds.worker.killed;
|
|
3010
|
+
if (durableOwner || liveWorkerOwner) {
|
|
3011
|
+
// `forkOrShowRepoCard` can fail after the repo/setup journal was
|
|
3012
|
+
// durably staged (for example picker publish fallback followed by a
|
|
3013
|
+
// fork rejection). That journal is now the exact opening owner; never
|
|
3014
|
+
// erase it in the generic spawn rollback. Rebuild volatile picker
|
|
3015
|
+
// buffers where possible and leave this exact map row active/retryable.
|
|
3016
|
+
if (!ds.session.queuedActivationPending && ds.session.pendingRepoSetup) {
|
|
3017
|
+
restorePendingRepoRuntime(ds);
|
|
3018
|
+
}
|
|
3019
|
+
ds.initialStartPending = ds.session.queuedActivationPending === true
|
|
3020
|
+
|| (ds.session.queuedActivationTail?.length ?? 0) > 0;
|
|
3021
|
+
// The create IPC reports failure, but this durable row is still the
|
|
3022
|
+
// authoritative retry owner. Upsert it so dashboard clients do not
|
|
3023
|
+
// see an invisible occupied chat until the next full hydrate.
|
|
3024
|
+
announceSessionRow(ds);
|
|
3025
|
+
logger.error(`[createSession] opening failed after durable ownership transfer for ${session.sessionId}; `
|
|
3026
|
+
+ `retaining exact active owner: ${err instanceof Error ? err.message : String(err)}`);
|
|
3027
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
3028
|
+
}
|
|
3029
|
+
// No durable or live owner accepted the opening. Roll back only our
|
|
3030
|
+
// exact runtime claim and close only our row; a concurrently published
|
|
3031
|
+
// successor must survive.
|
|
3032
|
+
if (activeSessions.get(key) === ds)
|
|
3033
|
+
activeSessions.delete(key);
|
|
3034
|
+
try {
|
|
3035
|
+
sessionStore.closeSession(session.sessionId);
|
|
3036
|
+
}
|
|
3037
|
+
catch (closeErr) {
|
|
3038
|
+
logger.error(`[createSession] failed to close unaccepted session ${session.sessionId}: `
|
|
3039
|
+
+ `${closeErr instanceof Error ? closeErr.message : String(closeErr)}`);
|
|
3040
|
+
}
|
|
3041
|
+
return {
|
|
3042
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
return { ds, session };
|
|
3047
|
+
});
|
|
3048
|
+
if (!registered)
|
|
2491
3049
|
return { ok: false, error: 'session_exists' };
|
|
2492
|
-
|
|
3050
|
+
if ('error' in registered && typeof registered.error === 'string') {
|
|
3051
|
+
return { ok: false, error: registered.error };
|
|
3052
|
+
}
|
|
3053
|
+
// PR #597 builds `ds`, registers it, and runs forkOrShowRepoCard INSIDE the
|
|
3054
|
+
// key lock above (returning { ds, session }); master's post-lock ds
|
|
3055
|
+
// construction + registration CAS is the pre-lock equivalent and would
|
|
3056
|
+
// double-register here, so it is intentionally dropped in favor of the locked
|
|
3057
|
+
// path. Master's quarantinedPersisted anchor guard is preserved before the
|
|
3058
|
+
// lock, and the backlog publish / forkOrShowRepoCard both happen in-callback.
|
|
3059
|
+
const { ds, session } = registered;
|
|
2493
3060
|
if (column === 'backlog') {
|
|
2494
3061
|
// Parked:不起 CLI。手动广播 session.spawned,让 dashboard 立刻显示待办池卡片
|
|
2495
3062
|
// (forkWorker 才会自动发这个事件,parked 路径要自己发)。
|
|
2496
|
-
ds.pendingPrompt = userContent;
|
|
2497
|
-
dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
|
|
2498
3063
|
logger.info(`[createSession] queued session ${session.sessionId.substring(0, 8)} (bot=${larkAppId}, chat=${chatId}, role=${role})`);
|
|
2499
3064
|
return { ok: true, sessionId: session.sessionId };
|
|
2500
3065
|
}
|
|
2501
3066
|
// in_progress:立即开跑或弹 /repo 卡片(没钉目录时)。userContent 已按角色包装好。
|
|
2502
|
-
await forkOrShowRepoCard(ds, userContent);
|
|
2503
3067
|
logger.info(`[createSession] spawned session ${session.sessionId.substring(0, 8)} (bot=${larkAppId}, chat=${chatId}, role=${role}, pendingRepo=${!!ds.pendingRepo})`);
|
|
2504
3068
|
return { ok: true, sessionId: session.sessionId };
|
|
2505
3069
|
}
|
|
@@ -2511,35 +3075,104 @@ export async function activateQueuedSession(ds) {
|
|
|
2511
3075
|
return (ds.worker && !ds.worker.killed) ? { ok: true } : { ok: false, error: 'not_queued' };
|
|
2512
3076
|
}
|
|
2513
3077
|
if (ds.worker && !ds.worker.killed) {
|
|
3078
|
+
if (ds.session.queuedActivationPending
|
|
3079
|
+
|| (ds.session.queuedActivationTail?.length ?? 0) > 0
|
|
3080
|
+
|| ds.session.pendingRepoSetup) {
|
|
3081
|
+
// A contradictory live+queued snapshot can occur around recovery or a
|
|
3082
|
+
// stale dashboard action. Never reinterpret the live child as proof that
|
|
3083
|
+
// its durable activation/setup owner crossed the adapter ACK boundary.
|
|
3084
|
+
logger.warn(`[${ds.session.sessionId.substring(0, 8)}] Ignored queued activation cleanup while durable ownership remains`);
|
|
3085
|
+
return { ok: true };
|
|
3086
|
+
}
|
|
2514
3087
|
// 不该发生(queued 一定 worker:null),但保险:清标记即可。
|
|
2515
3088
|
ds.session.queued = false;
|
|
2516
3089
|
ds.session.queuedPrompt = undefined;
|
|
2517
3090
|
ds.session.queuedCodexAppText = undefined;
|
|
2518
3091
|
ds.session.queuedCodexAppMessageContext = undefined;
|
|
2519
3092
|
ds.session.queuedAttachments = undefined;
|
|
3093
|
+
ds.session.queuedActivationPending = undefined;
|
|
3094
|
+
ds.session.queuedActivationToken = undefined;
|
|
3095
|
+
ds.session.queuedActivationInput = undefined;
|
|
3096
|
+
ds.session.queuedActivationTurnId = undefined;
|
|
3097
|
+
ds.session.queuedActivationDispatchAttempt = undefined;
|
|
3098
|
+
ds.session.queuedActivationResume = undefined;
|
|
3099
|
+
ds.session.pendingRepoSetup = undefined;
|
|
2520
3100
|
sessionStore.updateSession(ds.session);
|
|
2521
3101
|
return { ok: true };
|
|
2522
3102
|
}
|
|
2523
|
-
|
|
2524
|
-
//
|
|
2525
|
-
//
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
ds.session.
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
3103
|
+
// Repo selection / auto-worktree already owns this activation attempt. Keep
|
|
3104
|
+
// the durable queued payload as its crash-recovery journal and make repeated
|
|
3105
|
+
// dashboard starts idempotent instead of posting a second picker/build.
|
|
3106
|
+
if (ds.pendingRepo)
|
|
3107
|
+
return { ok: true };
|
|
3108
|
+
const activate = async () => {
|
|
3109
|
+
if (!ds.session.queued) {
|
|
3110
|
+
return (ds.worker && !ds.worker.killed) ? { ok: true } : { ok: false, error: 'not_queued' };
|
|
3111
|
+
}
|
|
3112
|
+
const content = ds.session.queuedPrompt ?? ds.pendingPrompt ?? '';
|
|
3113
|
+
// Preserve the durable queued payload until fork or pendingRepo setup has
|
|
3114
|
+
// succeeded. Ordinary inbound routing does not take the key lock, so the
|
|
3115
|
+
// runtime reservation must also be visible throughout every await.
|
|
3116
|
+
ds.initialStartPending = true;
|
|
3117
|
+
ds.pendingPrompt = content;
|
|
3118
|
+
ds.pendingCodexAppText ??= ds.session.queuedCodexAppText;
|
|
3119
|
+
ds.pendingCodexAppMessageContext ??= ds.session.queuedCodexAppMessageContext;
|
|
3120
|
+
ds.pendingAttachments ??= ds.session.queuedAttachments;
|
|
3121
|
+
let outcome;
|
|
3122
|
+
try {
|
|
3123
|
+
const exactRetry = ds.session.queuedActivationInput;
|
|
3124
|
+
if (exactRetry) {
|
|
3125
|
+
forkWorker(ds, exactRetry, {
|
|
3126
|
+
resume: ds.session.queuedActivationResume ?? ds.hasHistory,
|
|
3127
|
+
turnId: ds.session.queuedActivationTurnId,
|
|
3128
|
+
dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
|
|
3129
|
+
});
|
|
3130
|
+
outcome = 'forked';
|
|
3131
|
+
}
|
|
3132
|
+
else {
|
|
3133
|
+
outcome = await forkOrShowRepoCard(ds, content);
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
catch (err) {
|
|
3137
|
+
// queued remains authoritative and retryable. Undo only transient route
|
|
3138
|
+
// state installed by this failed attempt; never discard the opening
|
|
3139
|
+
// prompt/sidecar before a worker or repo picker accepted it.
|
|
3140
|
+
ds.initialStartPending = false;
|
|
3141
|
+
ds.pendingRepo = false;
|
|
3142
|
+
ds.repoCardMessageId = undefined;
|
|
3143
|
+
ds.pendingPrompt = content;
|
|
3144
|
+
return { ok: false, error: err?.message ?? 'start_failed' };
|
|
3145
|
+
}
|
|
3146
|
+
// Ownership has transferred to a worker or pending-repo flow. Nothing
|
|
3147
|
+
// below this point may advertise a retryable activation failure.
|
|
3148
|
+
if (outcome === 'forked') {
|
|
3149
|
+
ds.session.queued = false;
|
|
3150
|
+
ds.session.queuedAttachments = undefined;
|
|
3151
|
+
}
|
|
3152
|
+
// pending_repo deliberately retains queued + queuedPrompt: pendingPrompt
|
|
3153
|
+
// is runtime-only, so clearing the durable copy here would lose the first
|
|
3154
|
+
// turn if the daemon dies before repo selection/worktree commit forks.
|
|
3155
|
+
if (ds.session.kanbanColumn === 'backlog')
|
|
3156
|
+
ds.session.kanbanColumn = 'in_progress';
|
|
3157
|
+
try {
|
|
3158
|
+
sessionStore.updateSession(ds.session);
|
|
3159
|
+
}
|
|
3160
|
+
catch (err) {
|
|
3161
|
+
logger.error(`[createSession] queued activation metadata persistence failed after ownership transfer: `
|
|
3162
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
3163
|
+
}
|
|
3164
|
+
logger.info(`[createSession] activated queued session ${ds.session.sessionId.substring(0, 8)} (bot=${ds.larkAppId}, pendingRepo=${!!ds.pendingRepo})`);
|
|
3165
|
+
return { ok: true };
|
|
3166
|
+
};
|
|
3167
|
+
const registry = getActiveSessionsRegistry();
|
|
3168
|
+
if (!registry)
|
|
3169
|
+
return activate();
|
|
3170
|
+
const key = activeSessionKey(ds);
|
|
3171
|
+
return withActiveSessionKeyLock(registry, key, async () => {
|
|
3172
|
+
if (registry.get(key) !== ds || ds.session.status !== 'active') {
|
|
3173
|
+
return { ok: false, error: 'session_not_active' };
|
|
3174
|
+
}
|
|
3175
|
+
return activate();
|
|
3176
|
+
});
|
|
2544
3177
|
}
|
|
2545
3178
|
//# sourceMappingURL=session-manager.js.map
|