@swarmclawai/swarmclaw 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +5 -2
- package/skills/coding-agent/SKILL.md +111 -0
- package/skills/github/SKILL.md +140 -0
- package/skills/nano-banana-pro/SKILL.md +62 -0
- package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
- package/skills/nano-pdf/SKILL.md +53 -0
- package/skills/openai-image-gen/SKILL.md +78 -0
- package/skills/openai-image-gen/scripts/gen.py +328 -0
- package/skills/resourceful-problem-solving/SKILL.md +49 -0
- package/skills/skill-creator/SKILL.md +147 -0
- package/skills/skill-creator/scripts/init_skill.py +378 -0
- package/skills/skill-creator/scripts/quick_validate.py +159 -0
- package/skills/summarize/SKILL.md +77 -0
- package/src/app/api/auth/route.ts +20 -5
- package/src/app/api/chats/[id]/deploy/route.ts +11 -6
- package/src/app/api/chats/[id]/devserver/route.ts +17 -20
- package/src/app/api/chats/[id]/messages/route.ts +15 -11
- package/src/app/api/chats/[id]/route.ts +9 -10
- package/src/app/api/chats/[id]/stop/route.ts +5 -7
- package/src/app/api/chats/messages-route.test.ts +8 -6
- package/src/app/api/chats/route.ts +9 -10
- package/src/app/api/credentials/[id]/route.ts +4 -1
- package/src/app/api/extensions/marketplace/route.ts +5 -2
- package/src/app/api/ip/route.ts +2 -2
- package/src/app/api/memory/maintenance/route.ts +5 -2
- package/src/app/api/preview-server/route.ts +15 -12
- package/src/app/api/projects/[id]/route.ts +7 -46
- package/src/app/api/system/status/route.ts +11 -0
- package/src/app/api/upload/route.ts +4 -1
- package/src/cli/index.js +7 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-files-editor.tsx +44 -32
- package/src/components/agents/personality-builder.tsx +13 -7
- package/src/components/agents/trash-list.tsx +1 -1
- package/src/components/chat/chat-area.tsx +45 -23
- package/src/components/chat/message-bubble.test.ts +35 -0
- package/src/components/chat/message-bubble.tsx +20 -9
- package/src/components/chat/message-list.tsx +62 -42
- package/src/components/chat/swarm-status-card.tsx +10 -3
- package/src/components/input/chat-input.tsx +34 -14
- package/src/components/layout/daemon-indicator.tsx +7 -8
- package/src/components/layout/update-banner.tsx +8 -13
- package/src/components/logs/log-list.tsx +1 -1
- package/src/components/memory/memory-card.tsx +3 -1
- package/src/components/org-chart/org-chart-view.tsx +4 -0
- package/src/components/projects/project-list.tsx +4 -2
- package/src/components/projects/tabs/overview-tab.tsx +3 -2
- package/src/components/secrets/secret-sheet.tsx +1 -1
- package/src/components/secrets/secrets-list.tsx +1 -1
- package/src/components/shared/agent-switch-dialog.tsx +12 -6
- package/src/components/shared/dir-browser.tsx +22 -18
- package/src/components/skills/skill-sheet.tsx +2 -3
- package/src/components/tasks/task-list.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +1 -1
- package/src/hooks/use-openclaw-gateway.ts +46 -27
- package/src/instrumentation.ts +10 -7
- package/src/lib/chat/assistant-render-id.ts +3 -0
- package/src/lib/chat/chat-streaming-state.test.ts +42 -3
- package/src/lib/chat/chat-streaming-state.ts +20 -8
- package/src/lib/chat/chat.ts +18 -2
- package/src/lib/chat/queued-message-queue.test.ts +23 -1
- package/src/lib/chat/queued-message-queue.ts +11 -2
- package/src/lib/providers/anthropic.ts +6 -3
- package/src/lib/providers/claude-cli.ts +9 -3
- package/src/lib/providers/cli-utils.test.ts +124 -0
- package/src/lib/providers/cli-utils.ts +15 -0
- package/src/lib/providers/codex-cli.ts +9 -3
- package/src/lib/providers/gemini-cli.ts +6 -2
- package/src/lib/providers/index.ts +4 -1
- package/src/lib/providers/ollama.ts +5 -2
- package/src/lib/providers/openai.ts +8 -5
- package/src/lib/providers/opencode-cli.ts +6 -2
- package/src/lib/server/activity/activity-log.ts +21 -0
- package/src/lib/server/agents/agent-availability.test.ts +10 -5
- package/src/lib/server/agents/agent-cascade.ts +79 -59
- package/src/lib/server/agents/agent-registry.ts +23 -4
- package/src/lib/server/agents/agent-repository.ts +90 -0
- package/src/lib/server/agents/delegation-job-repository.ts +53 -0
- package/src/lib/server/agents/delegation-jobs.ts +11 -4
- package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
- package/src/lib/server/agents/guardian.ts +2 -2
- package/src/lib/server/agents/main-agent-loop.ts +14 -6
- package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
- package/src/lib/server/agents/subagent-runtime.ts +9 -6
- package/src/lib/server/agents/subagent-swarm.ts +3 -2
- package/src/lib/server/agents/task-session.ts +3 -4
- package/src/lib/server/approvals/approval-repository.ts +30 -0
- package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
- package/src/lib/server/autonomy/supervisor-reflection.ts +14 -1
- package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
- package/src/lib/server/chat-execution/chat-execution.ts +84 -1914
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
- package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
- package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
- package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
- package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
- package/src/lib/server/chat-execution/continuation-evaluator.ts +4 -3
- package/src/lib/server/chat-execution/continuation-limits.ts +6 -3
- package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
- package/src/lib/server/chat-execution/message-classifier.ts +5 -2
- package/src/lib/server/chat-execution/post-stream-finalization.ts +5 -2
- package/src/lib/server/chat-execution/prompt-builder.ts +22 -1
- package/src/lib/server/chat-execution/prompt-sections.ts +55 -13
- package/src/lib/server/chat-execution/response-completeness.ts +5 -2
- package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
- package/src/lib/server/chat-execution/stream-agent-chat.ts +58 -25
- package/src/lib/server/chatrooms/chatroom-memory-bridge.ts +6 -3
- package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
- package/src/lib/server/connectors/bluebubbles.ts +7 -4
- package/src/lib/server/connectors/connector-inbound.ts +16 -13
- package/src/lib/server/connectors/connector-lifecycle.ts +11 -8
- package/src/lib/server/connectors/connector-outbound.ts +6 -3
- package/src/lib/server/connectors/connector-repository.ts +58 -0
- package/src/lib/server/connectors/discord.ts +10 -7
- package/src/lib/server/connectors/email.ts +17 -14
- package/src/lib/server/connectors/googlechat.ts +7 -4
- package/src/lib/server/connectors/inbound-audio-transcription.ts +5 -2
- package/src/lib/server/connectors/matrix.ts +6 -3
- package/src/lib/server/connectors/openclaw.ts +20 -17
- package/src/lib/server/connectors/outbox.ts +4 -1
- package/src/lib/server/connectors/runtime-state.test.ts +117 -0
- package/src/lib/server/connectors/runtime-state.ts +19 -0
- package/src/lib/server/connectors/session-consolidation.ts +5 -2
- package/src/lib/server/connectors/signal.ts +9 -6
- package/src/lib/server/connectors/slack.ts +13 -10
- package/src/lib/server/connectors/teams.ts +8 -5
- package/src/lib/server/connectors/telegram.ts +15 -12
- package/src/lib/server/connectors/whatsapp.ts +32 -29
- package/src/lib/server/credentials/credential-repository.ts +7 -0
- package/src/lib/server/embeddings.ts +4 -1
- package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
- package/src/lib/server/link-understanding.ts +4 -1
- package/src/lib/server/memory/memory-abstract.test.ts +59 -0
- package/src/lib/server/memory/memory-abstract.ts +59 -0
- package/src/lib/server/memory/memory-db.ts +40 -14
- package/src/lib/server/missions/mission-repository.ts +74 -0
- package/src/lib/server/missions/mission-service/actions.ts +6 -0
- package/src/lib/server/missions/mission-service/bindings.ts +9 -0
- package/src/lib/server/missions/mission-service/context.ts +4 -0
- package/src/lib/server/missions/mission-service/core.ts +2269 -0
- package/src/lib/server/missions/mission-service/queries.ts +12 -0
- package/src/lib/server/missions/mission-service/recovery.ts +5 -0
- package/src/lib/server/missions/mission-service/ticks.ts +9 -0
- package/src/lib/server/missions/mission-service.test.ts +9 -2
- package/src/lib/server/missions/mission-service.ts +6 -2263
- package/src/lib/server/openclaw/gateway.ts +8 -5
- package/src/lib/server/persistence/repository-utils.ts +154 -0
- package/src/lib/server/persistence/storage-context.ts +51 -0
- package/src/lib/server/persistence/transaction.ts +1 -0
- package/src/lib/server/project-utils.ts +13 -0
- package/src/lib/server/projects/project-repository.ts +36 -0
- package/src/lib/server/projects/project-service.ts +79 -0
- package/src/lib/server/protocols/protocol-agent-turn.ts +5 -2
- package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
- package/src/lib/server/protocols/protocol-run-lifecycle.ts +5 -2
- package/src/lib/server/protocols/protocol-step-helpers.ts +4 -1
- package/src/lib/server/provider-health.ts +18 -0
- package/src/lib/server/query-expansion.ts +4 -1
- package/src/lib/server/runtime/alert-dispatch.ts +8 -7
- package/src/lib/server/runtime/daemon-policy.ts +1 -1
- package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
- package/src/lib/server/runtime/daemon-state/health.ts +6 -0
- package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
- package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
- package/src/lib/server/runtime/daemon-state.test.ts +48 -0
- package/src/lib/server/runtime/daemon-state.ts +3 -1331
- package/src/lib/server/runtime/estop-repository.ts +4 -0
- package/src/lib/server/runtime/estop.ts +3 -1
- package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
- package/src/lib/server/runtime/heartbeat-service.ts +78 -34
- package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
- package/src/lib/server/runtime/idle-window.ts +6 -3
- package/src/lib/server/runtime/network.ts +11 -0
- package/src/lib/server/runtime/orchestrator-events.ts +2 -2
- package/src/lib/server/runtime/perf.ts +4 -1
- package/src/lib/server/runtime/process-manager.ts +7 -4
- package/src/lib/server/runtime/queue/claims.ts +4 -0
- package/src/lib/server/runtime/queue/core.ts +2079 -0
- package/src/lib/server/runtime/queue/execution.ts +7 -0
- package/src/lib/server/runtime/queue/followups.ts +4 -0
- package/src/lib/server/runtime/queue/queries.ts +12 -0
- package/src/lib/server/runtime/queue/recovery.ts +7 -0
- package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
- package/src/lib/server/runtime/queue-repository.ts +17 -0
- package/src/lib/server/runtime/queue.ts +5 -2058
- package/src/lib/server/runtime/run-ledger.ts +6 -5
- package/src/lib/server/runtime/run-repository.ts +73 -0
- package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
- package/src/lib/server/runtime/runtime-settings.ts +1 -1
- package/src/lib/server/runtime/runtime-state.ts +99 -0
- package/src/lib/server/runtime/scheduler.ts +13 -8
- package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
- package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
- package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
- package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
- package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
- package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
- package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
- package/src/lib/server/runtime/session-run-manager.ts +72 -1374
- package/src/lib/server/runtime/watch-job-repository.ts +35 -0
- package/src/lib/server/runtime/watch-jobs.ts +3 -1
- package/src/lib/server/sandbox/bridge-auth-registry.ts +6 -0
- package/src/lib/server/sandbox/novnc-auth.ts +10 -0
- package/src/lib/server/schedules/schedule-repository.ts +42 -0
- package/src/lib/server/session-tools/context.ts +14 -0
- package/src/lib/server/session-tools/discovery.ts +9 -6
- package/src/lib/server/session-tools/index.ts +3 -1
- package/src/lib/server/session-tools/platform.ts +1 -1
- package/src/lib/server/session-tools/subagent.ts +23 -2
- package/src/lib/server/session-tools/wallet.ts +4 -1
- package/src/lib/server/sessions/session-repository.ts +85 -0
- package/src/lib/server/settings/settings-repository.ts +25 -0
- package/src/lib/server/skills/clawhub-client.ts +4 -1
- package/src/lib/server/skills/runtime-skill-resolver.ts +8 -2
- package/src/lib/server/skills/skill-discovery.test.ts +2 -2
- package/src/lib/server/skills/skill-discovery.ts +2 -2
- package/src/lib/server/skills/skill-eligibility.ts +6 -0
- package/src/lib/server/skills/skill-repository.ts +14 -0
- package/src/lib/server/solana.ts +6 -0
- package/src/lib/server/storage-auth.ts +5 -5
- package/src/lib/server/storage-normalization.ts +4 -0
- package/src/lib/server/storage.ts +32 -32
- package/src/lib/server/tasks/task-followups.ts +4 -1
- package/src/lib/server/tasks/task-repository.ts +54 -0
- package/src/lib/server/tool-loop-detection.ts +8 -3
- package/src/lib/server/tool-planning.ts +226 -0
- package/src/lib/server/tool-retry.ts +4 -3
- package/src/lib/server/usage/usage-repository.ts +30 -0
- package/src/lib/server/wallet/wallet-portfolio.ts +29 -0
- package/src/lib/server/webhooks/webhook-repository.ts +10 -0
- package/src/lib/server/ws-hub.ts +5 -2
- package/src/lib/strip-internal-metadata.test.ts +78 -37
- package/src/lib/strip-internal-metadata.ts +20 -6
- package/src/stores/use-approval-store.ts +7 -1
- package/src/stores/use-chat-store.test.ts +54 -0
- package/src/stores/use-chat-store.ts +26 -6
- package/src/types/index.ts +6 -0
- /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
|
@@ -1,533 +1,52 @@
|
|
|
1
|
-
import { genId } from '@/lib/id'
|
|
2
|
-
import type {
|
|
3
|
-
RunEventRecord,
|
|
4
|
-
SessionRunHeartbeatConfig,
|
|
5
|
-
SessionQueueSnapshot,
|
|
6
|
-
SessionQueuedTurn,
|
|
7
|
-
SessionRunRecord,
|
|
8
|
-
SessionRunStatus,
|
|
9
|
-
SSEEvent,
|
|
10
|
-
} from '@/types'
|
|
11
1
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
tryAcquireRuntimeLock,
|
|
17
|
-
} from '@/lib/server/storage'
|
|
18
|
-
import { executeSessionChatTurn, type ExecuteChatTurnResult } from '@/lib/server/chat-execution/chat-execution'
|
|
19
|
-
import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
|
|
20
|
-
import { log } from '@/lib/server/logger'
|
|
21
|
-
import { isInternalHeartbeatRun } from '@/lib/server/runtime/heartbeat-source'
|
|
22
|
-
import { cleanupSessionBrowser } from '@/lib/server/session-tools/web'
|
|
23
|
-
import { cancelDelegationJobsForParentSession } from '@/lib/server/agents/delegation-jobs'
|
|
24
|
-
import { getMainLoopStateForSession, handleMainLoopRunResult } from '@/lib/server/agents/main-agent-loop'
|
|
25
|
-
import { observeAutonomyRunOutcome } from '@/lib/server/autonomy/supervisor-reflection'
|
|
26
|
-
import { observeLearnedSkillRunOutcome } from '@/lib/server/skills/learned-skills'
|
|
27
|
-
import { errorMessage, hmrSingleton } from '@/lib/shared-utils'
|
|
28
|
-
import { getEnabledToolIds } from '@/lib/capability-selection'
|
|
2
|
+
acquireExternalSessionExecutionHold as acquireExternalSessionExecutionHoldInternal,
|
|
3
|
+
hasActiveNonHeartbeatSessionLease,
|
|
4
|
+
resetSessionRunManagerStateForTests,
|
|
5
|
+
} from '@/lib/server/runtime/session-run-manager/state'
|
|
29
6
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
heartbeatConfig?: SessionRunHeartbeatConfig
|
|
56
|
-
replyToId?: string
|
|
57
|
-
resolve: (value: ExecuteChatTurnResult) => void
|
|
58
|
-
reject: (error: Error) => void
|
|
59
|
-
promise: Promise<ExecuteChatTurnResult>
|
|
60
|
-
/** Whether this entry has been counted in nonHeartbeatWorkCount (prevents double-decrement). */
|
|
61
|
-
nonHeartbeatCounted?: boolean
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface RuntimeState {
|
|
65
|
-
runningByExecution: Map<string, QueueEntry>
|
|
66
|
-
queueByExecution: Map<string, QueueEntry[]>
|
|
67
|
-
runs: Map<string, SessionRunRecord>
|
|
68
|
-
recentRunIds: string[]
|
|
69
|
-
promises: Map<string, Promise<ExecuteChatTurnResult>>
|
|
70
|
-
deferredDrainTimers: Map<string, ReturnType<typeof setTimeout>>
|
|
71
|
-
activityLeaseRenewTimers: Map<string, ReturnType<typeof setInterval>>
|
|
72
|
-
externalSessionHolds: Map<string, number>
|
|
73
|
-
externalHoldTimers: Map<string, ReturnType<typeof setTimeout>>
|
|
74
|
-
drainDepth: Map<string, number>
|
|
75
|
-
lastQueuedAt: number
|
|
76
|
-
nonHeartbeatWorkCount: Map<string, number>
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const MAX_RECENT_RUNS = 500
|
|
80
|
-
const COLLECT_COALESCE_WINDOW_MS = 1500
|
|
81
|
-
const SHARED_ACTIVITY_LEASE_TTL_MS = 15_000
|
|
82
|
-
const SHARED_ACTIVITY_LEASE_RENEW_MS = 5_000
|
|
83
|
-
const EXTERNAL_HOLD_TTL_MS = 60_000
|
|
84
|
-
const MAX_DRAIN_DEPTH = 25
|
|
85
|
-
const HEARTBEAT_BUSY_RETRY_MS = 1_000
|
|
86
|
-
const STALE_QUEUED_RUN_MS = 15_000
|
|
87
|
-
const SHARED_ACTIVITY_LEASE_OWNER = `session-run:${process.pid}:${genId(6)}`
|
|
88
|
-
const state: RuntimeState = hmrSingleton<RuntimeState>('__swarmclaw_session_run_manager__', () => ({
|
|
89
|
-
runningByExecution: new Map<string, QueueEntry>(),
|
|
90
|
-
queueByExecution: new Map<string, QueueEntry[]>(),
|
|
91
|
-
runs: new Map<string, SessionRunRecord>(),
|
|
92
|
-
recentRunIds: [],
|
|
93
|
-
promises: new Map<string, Promise<ExecuteChatTurnResult>>(),
|
|
94
|
-
deferredDrainTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
|
95
|
-
activityLeaseRenewTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
|
96
|
-
externalSessionHolds: new Map<string, number>(),
|
|
97
|
-
externalHoldTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
|
98
|
-
drainDepth: new Map<string, number>(),
|
|
99
|
-
lastQueuedAt: 0,
|
|
100
|
-
nonHeartbeatWorkCount: new Map<string, number>(),
|
|
101
|
-
}))
|
|
102
|
-
const recoveryState = hmrSingleton('__swarmclaw_session_run_recovery__', () => ({ completed: false }))
|
|
103
|
-
|
|
104
|
-
// Backfill fields for hot-reloaded state objects created by older code versions.
|
|
105
|
-
if (!state.runningByExecution) state.runningByExecution = new Map<string, QueueEntry>()
|
|
106
|
-
if (!state.queueByExecution) state.queueByExecution = new Map<string, QueueEntry[]>()
|
|
107
|
-
if (!state.runs) state.runs = new Map<string, SessionRunRecord>()
|
|
108
|
-
if (!state.recentRunIds) state.recentRunIds = []
|
|
109
|
-
if (!state.promises) state.promises = new Map<string, Promise<ExecuteChatTurnResult>>()
|
|
110
|
-
if (!state.deferredDrainTimers) state.deferredDrainTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
111
|
-
if (!state.activityLeaseRenewTimers) state.activityLeaseRenewTimers = new Map<string, ReturnType<typeof setInterval>>()
|
|
112
|
-
if (!state.externalSessionHolds) state.externalSessionHolds = new Map<string, number>()
|
|
113
|
-
if (!state.externalHoldTimers) state.externalHoldTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
114
|
-
if (!state.drainDepth) state.drainDepth = new Map<string, number>()
|
|
115
|
-
if (typeof state.lastQueuedAt !== 'number') state.lastQueuedAt = 0
|
|
116
|
-
if (!state.nonHeartbeatWorkCount) state.nonHeartbeatWorkCount = new Map<string, number>()
|
|
117
|
-
|
|
118
|
-
function now() {
|
|
119
|
-
return Date.now()
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function nextQueuedAt() {
|
|
123
|
-
const current = now()
|
|
124
|
-
const next = current <= state.lastQueuedAt ? state.lastQueuedAt + 1 : current
|
|
125
|
-
state.lastQueuedAt = next
|
|
126
|
-
return next
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function messagePreview(text: string): string {
|
|
130
|
-
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 140)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function trimRecentRuns() {
|
|
134
|
-
while (state.recentRunIds.length > MAX_RECENT_RUNS) {
|
|
135
|
-
const id = state.recentRunIds.shift()
|
|
136
|
-
if (!id) continue
|
|
137
|
-
state.runs.delete(id)
|
|
138
|
-
state.promises.delete(id)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function syncRunRecord(run: SessionRunRecord): SessionRunRecord {
|
|
143
|
-
state.runs.set(run.id, run)
|
|
144
|
-
persistRun(run)
|
|
145
|
-
return run
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function registerRun(run: SessionRunRecord) {
|
|
149
|
-
syncRunRecord(run)
|
|
150
|
-
state.recentRunIds.push(run.id)
|
|
151
|
-
trimRecentRuns()
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function shouldPersistRunEvent(event: SSEEvent): boolean {
|
|
155
|
-
return event.t !== 'd' && event.t !== 'thinking' && event.t !== 'reset'
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function persistEventForRun(entry: QueueEntry, event: SSEEvent, opts?: {
|
|
159
|
-
phase?: RunEventRecord['phase']
|
|
160
|
-
status?: SessionRunStatus
|
|
161
|
-
summary?: string
|
|
162
|
-
}): void {
|
|
163
|
-
if (!shouldPersistRunEvent(event)) return
|
|
164
|
-
appendPersistedRunEvent({
|
|
165
|
-
runId: entry.run.id,
|
|
166
|
-
sessionId: entry.run.sessionId,
|
|
167
|
-
phase: opts?.phase || 'event',
|
|
168
|
-
status: opts?.status,
|
|
169
|
-
summary: opts?.summary,
|
|
170
|
-
event,
|
|
171
|
-
})
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Chain an external AbortSignal to an internal AbortController so that
|
|
175
|
-
* when the caller (e.g. HTTP request) disconnects, the run is cancelled. */
|
|
176
|
-
function chainCallerSignal(callerSignal: AbortSignal, controller: AbortController): void {
|
|
177
|
-
if (callerSignal.aborted) {
|
|
178
|
-
controller.abort()
|
|
179
|
-
return
|
|
180
|
-
}
|
|
181
|
-
const onAbort = () => controller.abort()
|
|
182
|
-
callerSignal.addEventListener('abort', onAbort, { once: true })
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function emitToSubscribers(entry: QueueEntry, event: SSEEvent) {
|
|
186
|
-
persistEventForRun(entry, event)
|
|
187
|
-
for (const send of entry.onEvents) {
|
|
188
|
-
try {
|
|
189
|
-
send(event)
|
|
190
|
-
} catch {
|
|
191
|
-
// Subscriber stream can be closed by the client.
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function emitRunMeta(entry: QueueEntry, status: SessionRunStatus, extra?: Record<string, unknown>) {
|
|
197
|
-
const event: SSEEvent = {
|
|
198
|
-
t: 'md',
|
|
199
|
-
text: JSON.stringify({
|
|
200
|
-
run: {
|
|
201
|
-
id: entry.run.id,
|
|
202
|
-
sessionId: entry.run.sessionId,
|
|
203
|
-
status,
|
|
204
|
-
source: entry.run.source,
|
|
205
|
-
internal: entry.run.internal,
|
|
206
|
-
...extra,
|
|
207
|
-
},
|
|
208
|
-
}),
|
|
209
|
-
}
|
|
210
|
-
persistEventForRun(entry, event, { phase: 'status', status })
|
|
211
|
-
for (const send of entry.onEvents) {
|
|
212
|
-
try {
|
|
213
|
-
send(event)
|
|
214
|
-
} catch {
|
|
215
|
-
// Subscriber stream can be closed by the client.
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
notifySessionRunState(entry.run.sessionId)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function notifySessionRunState(sessionId: string): void {
|
|
222
|
-
notify('runs')
|
|
223
|
-
notify('sessions')
|
|
224
|
-
notify(`session:${sessionId}`)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function queueAutonomyObservation(input: {
|
|
228
|
-
runId: string
|
|
229
|
-
sessionId: string
|
|
230
|
-
source: string
|
|
231
|
-
status: SessionRunStatus
|
|
232
|
-
resultText?: string | null
|
|
233
|
-
error?: string | null
|
|
234
|
-
toolEvents?: ExecuteChatTurnResult['toolEvents']
|
|
235
|
-
sourceMessage?: string | null
|
|
236
|
-
}) {
|
|
237
|
-
const session = loadSession(input.sessionId)
|
|
238
|
-
void observeAutonomyRunOutcome({
|
|
239
|
-
runId: input.runId,
|
|
240
|
-
sessionId: input.sessionId,
|
|
241
|
-
agentId: session?.agentId || null,
|
|
242
|
-
source: input.source,
|
|
243
|
-
status: input.status,
|
|
244
|
-
resultText: input.resultText,
|
|
245
|
-
error: input.error || undefined,
|
|
246
|
-
toolEvents: input.toolEvents,
|
|
247
|
-
mainLoopState: getMainLoopStateForSession(input.sessionId),
|
|
248
|
-
sourceMessage: input.sourceMessage,
|
|
249
|
-
}).then(({ reflection }) => observeLearnedSkillRunOutcome({
|
|
250
|
-
runId: input.runId,
|
|
251
|
-
sessionId: input.sessionId,
|
|
252
|
-
agentId: session?.agentId || null,
|
|
253
|
-
source: input.source,
|
|
254
|
-
status: input.status,
|
|
255
|
-
resultText: input.resultText,
|
|
256
|
-
error: input.error || undefined,
|
|
257
|
-
toolEvents: input.toolEvents,
|
|
258
|
-
reflection,
|
|
259
|
-
})).catch((err: unknown) => {
|
|
260
|
-
log.warn('session-run', `Autonomy observation failed for ${input.runId}`, {
|
|
261
|
-
sessionId: input.sessionId,
|
|
262
|
-
error: errorMessage(err),
|
|
263
|
-
})
|
|
264
|
-
})
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function markRunningEntryCancelled(entry: QueueEntry, reason: string) {
|
|
268
|
-
if (entry.run.status === 'cancelled') return
|
|
269
|
-
entry.run.status = 'cancelled'
|
|
270
|
-
entry.run.endedAt = now()
|
|
271
|
-
entry.run.error = reason
|
|
272
|
-
syncRunRecord(entry.run)
|
|
273
|
-
emitRunMeta(entry, 'cancelled', { reason })
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function abortSessionRuntime(entry: QueueEntry, reason: string) {
|
|
277
|
-
markRunningEntryCancelled(entry, reason)
|
|
278
|
-
entry.signalController.abort()
|
|
279
|
-
try { active.get(entry.run.sessionId)?.kill?.() } catch { /* noop */ }
|
|
280
|
-
active.delete(entry.run.sessionId)
|
|
281
|
-
try { cleanupSessionBrowser(entry.run.sessionId) } catch { /* noop */ }
|
|
282
|
-
try { cancelDelegationJobsForParentSession(entry.run.sessionId, reason) } catch { /* noop */ }
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function executionKeyForSession(sessionId: string): string {
|
|
286
|
-
return `session:${sessionId}`
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function nonHeartbeatActivityLeaseName(sessionId: string): string {
|
|
290
|
-
return `session-non-heartbeat:${sessionId}`
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function hasActiveNonHeartbeatSessionLease(sessionId: string): boolean {
|
|
294
|
-
return isRuntimeLockActive(nonHeartbeatActivityLeaseName(sessionId))
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function hasExternalSessionExecutionHold(sessionId: string): boolean {
|
|
298
|
-
return (state.externalSessionHolds.get(sessionId) || 0) > 0
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function acquireExternalSessionExecutionHold(sessionId: string): () => void {
|
|
302
|
-
const current = state.externalSessionHolds.get(sessionId) || 0
|
|
303
|
-
state.externalSessionHolds.set(sessionId, current + 1)
|
|
304
|
-
let released = false
|
|
305
|
-
const holdKey = `${sessionId}:${current + 1}`
|
|
306
|
-
const ttlTimer = setTimeout(() => {
|
|
307
|
-
if (released) return
|
|
308
|
-
log.warn('session-run', 'External hold auto-released after TTL', { sessionId, holdKey, ttlMs: EXTERNAL_HOLD_TTL_MS })
|
|
309
|
-
release()
|
|
310
|
-
}, EXTERNAL_HOLD_TTL_MS)
|
|
311
|
-
state.externalHoldTimers.set(holdKey, ttlTimer)
|
|
312
|
-
const release = () => {
|
|
313
|
-
if (released) return
|
|
314
|
-
released = true
|
|
315
|
-
const timer = state.externalHoldTimers.get(holdKey)
|
|
316
|
-
if (timer) {
|
|
317
|
-
clearTimeout(timer)
|
|
318
|
-
state.externalHoldTimers.delete(holdKey)
|
|
319
|
-
}
|
|
320
|
-
const next = (state.externalSessionHolds.get(sessionId) || 1) - 1
|
|
321
|
-
if (next > 0) state.externalSessionHolds.set(sessionId, next)
|
|
322
|
-
else state.externalSessionHolds.delete(sessionId)
|
|
323
|
-
void drainExecution(executionKeyForSession(sessionId))
|
|
324
|
-
}
|
|
325
|
-
return release
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function queueForExecution(executionKey: string): QueueEntry[] {
|
|
329
|
-
const existing = state.queueByExecution.get(executionKey)
|
|
330
|
-
if (existing) return existing
|
|
331
|
-
const created: QueueEntry[] = []
|
|
332
|
-
state.queueByExecution.set(executionKey, created)
|
|
333
|
-
return created
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function normalizeMode(mode: string | undefined, internal: boolean): SessionQueueMode {
|
|
337
|
-
if (mode === 'steer' || mode === 'collect' || mode === 'followup') return mode
|
|
338
|
-
return internal ? 'collect' : 'followup'
|
|
339
|
-
}
|
|
7
|
+
cancelAllHeartbeatRuns as cancelAllHeartbeatRunsInternal,
|
|
8
|
+
cancelAllRuns as cancelAllRunsInternal,
|
|
9
|
+
cancelQueuedRunById as cancelQueuedRunByIdInternal,
|
|
10
|
+
cancelQueuedRunsForSession as cancelQueuedRunsForSessionInternal,
|
|
11
|
+
cancelSessionRuns as cancelSessionRunsInternal,
|
|
12
|
+
} from '@/lib/server/runtime/session-run-manager/cancellation'
|
|
13
|
+
import { drainExecution as drainExecutionInternal } from '@/lib/server/runtime/session-run-manager/drain'
|
|
14
|
+
import { enqueueSessionRun as enqueueSessionRunInternal } from '@/lib/server/runtime/session-run-manager/enqueue'
|
|
15
|
+
import {
|
|
16
|
+
getRunById as getRunByIdInternal,
|
|
17
|
+
getSessionExecutionState as getSessionExecutionStateInternal,
|
|
18
|
+
getSessionQueueSnapshot as getSessionQueueSnapshotInternal,
|
|
19
|
+
getSessionRunState as getSessionRunStateInternal,
|
|
20
|
+
listRunEvents as listRunEventsInternal,
|
|
21
|
+
listRuns as listRunsInternal,
|
|
22
|
+
} from '@/lib/server/runtime/session-run-manager/queries'
|
|
23
|
+
import {
|
|
24
|
+
ensureRecoveredPersistedRuns as ensureRecoveredPersistedRunsInternal,
|
|
25
|
+
repairSessionRunQueue as repairSessionRunQueueInternal,
|
|
26
|
+
sweepStuckRuns as sweepStuckRunsInternal,
|
|
27
|
+
} from '@/lib/server/runtime/session-run-manager/recovery'
|
|
28
|
+
import type {
|
|
29
|
+
EnqueueSessionRunInput,
|
|
30
|
+
EnqueueSessionRunResult,
|
|
31
|
+
} from '@/lib/server/runtime/session-run-manager/types'
|
|
340
32
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
...target,
|
|
347
|
-
status: 'cancelled',
|
|
348
|
-
endedAt: target.endedAt || interruptedAt,
|
|
349
|
-
interruptedAt,
|
|
350
|
-
interruptedReason: reason,
|
|
351
|
-
error: target.error || reason,
|
|
352
|
-
}
|
|
353
|
-
}) || {
|
|
354
|
-
...run,
|
|
355
|
-
status: 'cancelled',
|
|
356
|
-
endedAt: run.endedAt || interruptedAt,
|
|
357
|
-
interruptedAt,
|
|
358
|
-
interruptedReason: reason,
|
|
359
|
-
error: run.error || reason,
|
|
360
|
-
}
|
|
361
|
-
state.runs.set(next.id, next)
|
|
362
|
-
if (!state.recentRunIds.includes(next.id)) {
|
|
363
|
-
state.recentRunIds.push(next.id)
|
|
364
|
-
trimRecentRuns()
|
|
365
|
-
}
|
|
366
|
-
appendPersistedRunEvent({
|
|
367
|
-
runId: next.id,
|
|
368
|
-
sessionId: next.sessionId,
|
|
369
|
-
phase: 'status',
|
|
370
|
-
status: 'cancelled',
|
|
371
|
-
summary: reason,
|
|
372
|
-
event: {
|
|
373
|
-
t: 'md',
|
|
374
|
-
text: JSON.stringify({
|
|
375
|
-
run: {
|
|
376
|
-
id: next.id,
|
|
377
|
-
sessionId: next.sessionId,
|
|
378
|
-
status: 'cancelled',
|
|
379
|
-
interrupted: true,
|
|
380
|
-
reason,
|
|
381
|
-
},
|
|
382
|
-
}),
|
|
383
|
-
},
|
|
384
|
-
})
|
|
385
|
-
return next
|
|
386
|
-
}
|
|
33
|
+
export type {
|
|
34
|
+
EnqueueSessionRunInput,
|
|
35
|
+
EnqueueSessionRunResult,
|
|
36
|
+
SessionQueueMode,
|
|
37
|
+
} from '@/lib/server/runtime/session-run-manager/types'
|
|
387
38
|
|
|
388
39
|
function ensureRecoveredPersistedRuns(): void {
|
|
389
|
-
|
|
390
|
-
recoveryState.completed = true
|
|
391
|
-
const staleRuns = loadRecoverableStaleRuns()
|
|
392
|
-
if (!staleRuns.length) return
|
|
393
|
-
const recoveryBlocked = isAutonomyEstopEngaged() || isAllEstopEngaged()
|
|
394
|
-
|
|
395
|
-
for (const run of staleRuns) {
|
|
396
|
-
const interrupted = markPersistedRunInterrupted(run, 'Interrupted by server restart before the run completed.')
|
|
397
|
-
const payload = interrupted.recoveryPayload
|
|
398
|
-
if (
|
|
399
|
-
recoveryBlocked
|
|
400
|
-
|| interrupted.recoveredFromRestart
|
|
401
|
-
|| !payload
|
|
402
|
-
|| !isRestartRecoverableSource(interrupted.source)
|
|
403
|
-
) {
|
|
404
|
-
continue
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
try {
|
|
408
|
-
enqueueSessionRun({
|
|
409
|
-
sessionId: interrupted.sessionId,
|
|
410
|
-
message: payload.message,
|
|
411
|
-
imagePath: payload.imagePath,
|
|
412
|
-
imageUrl: payload.imageUrl,
|
|
413
|
-
attachedFiles: payload.attachedFiles,
|
|
414
|
-
internal: payload.internal,
|
|
415
|
-
source: payload.source,
|
|
416
|
-
mode: normalizeMode(payload.mode, payload.internal),
|
|
417
|
-
dedupeKey: interrupted.dedupeKey,
|
|
418
|
-
maxRuntimeMs: payload.maxRuntimeMs,
|
|
419
|
-
modelOverride: payload.modelOverride,
|
|
420
|
-
heartbeatConfig: payload.heartbeatConfig,
|
|
421
|
-
replyToId: payload.replyToId,
|
|
422
|
-
executionGroupKey: payload.executionGroupKey,
|
|
423
|
-
recoveredFromRestart: true,
|
|
424
|
-
recoveredFromRunId: interrupted.id,
|
|
425
|
-
})
|
|
426
|
-
} catch (err: unknown) {
|
|
427
|
-
log.warn('session-run', `Failed to requeue interrupted run ${interrupted.id}`, {
|
|
428
|
-
sessionId: interrupted.sessionId,
|
|
429
|
-
error: errorMessage(err),
|
|
430
|
-
})
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function isNonHeartbeatEntry(entry: QueueEntry): boolean {
|
|
436
|
-
return !isInternalHeartbeatRun(entry.run.internal, entry.run.source)
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function incrementNonHeartbeatWork(entry: QueueEntry): void {
|
|
440
|
-
if (!isNonHeartbeatEntry(entry)) return
|
|
441
|
-
entry.nonHeartbeatCounted = true
|
|
442
|
-
state.nonHeartbeatWorkCount.set(entry.run.sessionId, (state.nonHeartbeatWorkCount.get(entry.run.sessionId) || 0) + 1)
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function decrementNonHeartbeatWork(entry: QueueEntry): void {
|
|
446
|
-
if (!entry.nonHeartbeatCounted) return
|
|
447
|
-
entry.nonHeartbeatCounted = false
|
|
448
|
-
const sessionId = entry.run.sessionId
|
|
449
|
-
const count = (state.nonHeartbeatWorkCount.get(sessionId) || 0) - 1
|
|
450
|
-
if (count <= 0) state.nonHeartbeatWorkCount.delete(sessionId)
|
|
451
|
-
else state.nonHeartbeatWorkCount.set(sessionId, count)
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function hasLocalNonHeartbeatWork(sessionId: string): boolean {
|
|
455
|
-
return (state.nonHeartbeatWorkCount.get(sessionId) || 0) > 0
|
|
40
|
+
ensureRecoveredPersistedRunsInternal(enqueueSessionRun)
|
|
456
41
|
}
|
|
457
42
|
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
if (!timer) return
|
|
461
|
-
clearTimeout(timer)
|
|
462
|
-
state.deferredDrainTimers.delete(executionKey)
|
|
43
|
+
function drainExecution(executionKey: string): Promise<void> {
|
|
44
|
+
return drainExecutionInternal(executionKey, { enqueueSessionRun })
|
|
463
45
|
}
|
|
464
46
|
|
|
465
|
-
function
|
|
466
|
-
|
|
467
|
-
if (idx === -1) return false
|
|
468
|
-
queue.splice(idx, 1)
|
|
469
|
-
return true
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function scheduleDeferredDrain(executionKey: string, delayMs = HEARTBEAT_BUSY_RETRY_MS): void {
|
|
473
|
-
if (state.deferredDrainTimers.has(executionKey)) return
|
|
474
|
-
const timer = setTimeout(() => {
|
|
475
|
-
state.deferredDrainTimers.delete(executionKey)
|
|
47
|
+
export function acquireExternalSessionExecutionHold(sessionId: string): () => void {
|
|
48
|
+
return acquireExternalSessionExecutionHoldInternal(sessionId, (executionKey) => {
|
|
476
49
|
void drainExecution(executionKey)
|
|
477
|
-
}, delayMs)
|
|
478
|
-
state.deferredDrainTimers.set(executionKey, timer)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function stopSessionActivityLease(sessionId: string): void {
|
|
482
|
-
const timer = state.activityLeaseRenewTimers.get(sessionId)
|
|
483
|
-
if (timer) {
|
|
484
|
-
clearInterval(timer)
|
|
485
|
-
state.activityLeaseRenewTimers.delete(sessionId)
|
|
486
|
-
}
|
|
487
|
-
releaseRuntimeLock(nonHeartbeatActivityLeaseName(sessionId), SHARED_ACTIVITY_LEASE_OWNER)
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function startSessionActivityLease(sessionId: string): void {
|
|
491
|
-
if (state.activityLeaseRenewTimers.has(sessionId)) return
|
|
492
|
-
const leaseName = nonHeartbeatActivityLeaseName(sessionId)
|
|
493
|
-
tryAcquireRuntimeLock(leaseName, SHARED_ACTIVITY_LEASE_OWNER, SHARED_ACTIVITY_LEASE_TTL_MS)
|
|
494
|
-
const timer = setInterval(() => {
|
|
495
|
-
if (!hasLocalNonHeartbeatWork(sessionId)) {
|
|
496
|
-
stopSessionActivityLease(sessionId)
|
|
497
|
-
return
|
|
498
|
-
}
|
|
499
|
-
tryAcquireRuntimeLock(leaseName, SHARED_ACTIVITY_LEASE_OWNER, SHARED_ACTIVITY_LEASE_TTL_MS)
|
|
500
|
-
}, SHARED_ACTIVITY_LEASE_RENEW_MS)
|
|
501
|
-
state.activityLeaseRenewTimers.set(sessionId, timer)
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function reconcileSessionActivityLease(sessionId: string): void {
|
|
505
|
-
if (hasLocalNonHeartbeatWork(sessionId)) startSessionActivityLease(sessionId)
|
|
506
|
-
else stopSessionActivityLease(sessionId)
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function resolveRecoveredQueuedEntry(entry: QueueEntry, reason: string): void {
|
|
510
|
-
decrementNonHeartbeatWork(entry)
|
|
511
|
-
if (entry.run.status === 'completed' || entry.run.status === 'failed' || entry.run.status === 'cancelled') {
|
|
512
|
-
entry.run.endedAt = entry.run.endedAt || now()
|
|
513
|
-
} else {
|
|
514
|
-
entry.run.status = 'failed'
|
|
515
|
-
entry.run.endedAt = now()
|
|
516
|
-
}
|
|
517
|
-
entry.run.error = reason
|
|
518
|
-
syncRunRecord(entry.run)
|
|
519
|
-
emitToSubscribers(entry, { t: 'err', text: reason })
|
|
520
|
-
emitRunMeta(entry, 'failed', {
|
|
521
|
-
error: reason,
|
|
522
|
-
recovered: true,
|
|
523
|
-
})
|
|
524
|
-
entry.resolve({
|
|
525
|
-
runId: entry.run.id,
|
|
526
|
-
sessionId: entry.run.sessionId,
|
|
527
|
-
text: '',
|
|
528
|
-
persisted: false,
|
|
529
|
-
toolEvents: [],
|
|
530
|
-
error: reason,
|
|
531
50
|
})
|
|
532
51
|
}
|
|
533
52
|
|
|
@@ -542,900 +61,79 @@ export function repairSessionRunQueue(
|
|
|
542
61
|
kickedExecutionKeys: number
|
|
543
62
|
recoveredQueuedRuns: number
|
|
544
63
|
} {
|
|
545
|
-
|
|
546
|
-
const reason = opts?.reason || 'Recovered stale queued run'
|
|
547
|
-
const targetExecutionKey = typeof opts?.executionKey === 'string' && opts.executionKey.trim()
|
|
548
|
-
? opts.executionKey.trim()
|
|
549
|
-
: null
|
|
550
|
-
const queuedNow = now()
|
|
551
|
-
let kickedExecutionKeys = 0
|
|
552
|
-
let recoveredQueuedRuns = 0
|
|
553
|
-
|
|
554
|
-
for (const [executionKey, queue] of state.queueByExecution.entries()) {
|
|
555
|
-
if (targetExecutionKey && executionKey !== targetExecutionKey) continue
|
|
556
|
-
if (!queue.length) {
|
|
557
|
-
clearDeferredDrain(executionKey)
|
|
558
|
-
state.queueByExecution.delete(executionKey)
|
|
559
|
-
continue
|
|
560
|
-
}
|
|
561
|
-
if (state.runningByExecution.has(executionKey)) continue
|
|
562
|
-
|
|
563
|
-
const matching = queue.filter((entry) => entry.run.sessionId === sessionId)
|
|
564
|
-
if (!matching.length) continue
|
|
565
|
-
|
|
566
|
-
for (const entry of [...matching]) {
|
|
567
|
-
const missingPromise = !state.promises.has(entry.run.id)
|
|
568
|
-
const previousStatus = entry.run.status
|
|
569
|
-
const nonQueued = previousStatus !== 'queued'
|
|
570
|
-
const ageMs = Math.max(0, queuedNow - (entry.run.queuedAt || 0))
|
|
571
|
-
const stale = nonQueued || missingPromise || ageMs >= maxQueuedAgeMs
|
|
572
|
-
if (!stale) continue
|
|
573
|
-
if (!deleteQueueEntry(queue, entry)) continue
|
|
574
|
-
clearDeferredDrain(executionKey)
|
|
575
|
-
resolveRecoveredQueuedEntry(entry, reason)
|
|
576
|
-
recoveredQueuedRuns += 1
|
|
577
|
-
log.warn('session-run', `Recovered stale queued run ${entry.run.id}`, {
|
|
578
|
-
sessionId: entry.run.sessionId,
|
|
579
|
-
executionKey,
|
|
580
|
-
source: entry.run.source,
|
|
581
|
-
ageMs,
|
|
582
|
-
missingPromise,
|
|
583
|
-
previousStatus,
|
|
584
|
-
})
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
if (!queue.length) {
|
|
588
|
-
clearDeferredDrain(executionKey)
|
|
589
|
-
state.queueByExecution.delete(executionKey)
|
|
590
|
-
continue
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
if (queue.some((entry) => entry.run.sessionId === sessionId)) {
|
|
594
|
-
clearDeferredDrain(executionKey)
|
|
595
|
-
kickedExecutionKeys += 1
|
|
596
|
-
void drainExecution(executionKey)
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
if (recoveredQueuedRuns > 0) reconcileSessionActivityLease(sessionId)
|
|
601
|
-
return { kickedExecutionKeys, recoveredQueuedRuns }
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function cancelPendingForSession(sessionId: string, reason: string): number {
|
|
605
|
-
let cancelled = 0
|
|
606
|
-
for (const [key, queue] of state.queueByExecution.entries()) {
|
|
607
|
-
if (!queue.length) continue
|
|
608
|
-
const keep: QueueEntry[] = []
|
|
609
|
-
for (const entry of queue) {
|
|
610
|
-
if (entry.run.sessionId !== sessionId) {
|
|
611
|
-
keep.push(entry)
|
|
612
|
-
continue
|
|
613
|
-
}
|
|
614
|
-
entry.run.status = 'cancelled'
|
|
615
|
-
entry.run.endedAt = now()
|
|
616
|
-
entry.run.error = reason
|
|
617
|
-
syncRunRecord(entry.run)
|
|
618
|
-
emitRunMeta(entry, 'cancelled', { reason })
|
|
619
|
-
entry.reject(new Error(reason))
|
|
620
|
-
decrementNonHeartbeatWork(entry)
|
|
621
|
-
cancelled++
|
|
622
|
-
}
|
|
623
|
-
if (keep.length > 0) state.queueByExecution.set(key, keep)
|
|
624
|
-
else state.queueByExecution.delete(key)
|
|
625
|
-
}
|
|
626
|
-
reconcileSessionActivityLease(sessionId)
|
|
627
|
-
return cancelled
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
function cancelQueuedEntries(
|
|
631
|
-
matcher: (entry: QueueEntry) => boolean,
|
|
632
|
-
reason: string,
|
|
633
|
-
): { cancelled: number; sessionIds: Set<string> } {
|
|
634
|
-
let cancelled = 0
|
|
635
|
-
const sessionIds = new Set<string>()
|
|
636
|
-
for (const [key, queue] of state.queueByExecution.entries()) {
|
|
637
|
-
if (!queue.length) continue
|
|
638
|
-
const keep: QueueEntry[] = []
|
|
639
|
-
for (const entry of queue) {
|
|
640
|
-
if (!matcher(entry)) {
|
|
641
|
-
keep.push(entry)
|
|
642
|
-
continue
|
|
643
|
-
}
|
|
644
|
-
entry.run.status = 'cancelled'
|
|
645
|
-
entry.run.endedAt = now()
|
|
646
|
-
entry.run.error = reason
|
|
647
|
-
syncRunRecord(entry.run)
|
|
648
|
-
emitRunMeta(entry, 'cancelled', { reason })
|
|
649
|
-
entry.reject(new Error(reason))
|
|
650
|
-
decrementNonHeartbeatWork(entry)
|
|
651
|
-
sessionIds.add(entry.run.sessionId)
|
|
652
|
-
cancelled += 1
|
|
653
|
-
}
|
|
654
|
-
if (keep.length > 0) state.queueByExecution.set(key, keep)
|
|
655
|
-
else state.queueByExecution.delete(key)
|
|
656
|
-
}
|
|
657
|
-
for (const sessionId of sessionIds) reconcileSessionActivityLease(sessionId)
|
|
658
|
-
return { cancelled, sessionIds }
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
export function cancelAllHeartbeatRuns(reason = 'Heartbeat disabled globally'): { cancelledQueued: number; abortedRunning: number } {
|
|
662
|
-
ensureRecoveredPersistedRuns()
|
|
663
|
-
let cancelledQueued = 0
|
|
664
|
-
let abortedRunning = 0
|
|
665
|
-
|
|
666
|
-
for (const [key, queue] of state.queueByExecution.entries()) {
|
|
667
|
-
if (!queue.length) continue
|
|
668
|
-
const keep: QueueEntry[] = []
|
|
669
|
-
for (const entry of queue) {
|
|
670
|
-
const isHeartbeat = isInternalHeartbeatRun(entry.run.internal, entry.run.source)
|
|
671
|
-
if (!isHeartbeat) {
|
|
672
|
-
keep.push(entry)
|
|
673
|
-
continue
|
|
674
|
-
}
|
|
675
|
-
entry.run.status = 'cancelled'
|
|
676
|
-
entry.run.endedAt = now()
|
|
677
|
-
entry.run.error = reason
|
|
678
|
-
syncRunRecord(entry.run)
|
|
679
|
-
emitRunMeta(entry, 'cancelled', { reason })
|
|
680
|
-
entry.reject(new Error(reason))
|
|
681
|
-
cancelledQueued += 1
|
|
682
|
-
}
|
|
683
|
-
if (keep.length > 0) state.queueByExecution.set(key, keep)
|
|
684
|
-
else state.queueByExecution.delete(key)
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
for (const entry of state.runningByExecution.values()) {
|
|
688
|
-
const isHeartbeat = isInternalHeartbeatRun(entry.run.internal, entry.run.source)
|
|
689
|
-
if (!isHeartbeat) continue
|
|
690
|
-
abortedRunning += 1
|
|
691
|
-
abortSessionRuntime(entry, reason)
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
return { cancelledQueued, abortedRunning }
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
export function cancelAllRuns(reason = 'Cancelled'): { cancelledQueued: number; abortedRunning: number } {
|
|
698
|
-
ensureRecoveredPersistedRuns()
|
|
699
|
-
let cancelledQueued = 0
|
|
700
|
-
let abortedRunning = 0
|
|
701
|
-
|
|
702
|
-
for (const [key, queue] of state.queueByExecution.entries()) {
|
|
703
|
-
if (!queue.length) continue
|
|
704
|
-
for (const entry of queue) {
|
|
705
|
-
entry.run.status = 'cancelled'
|
|
706
|
-
entry.run.endedAt = now()
|
|
707
|
-
entry.run.error = reason
|
|
708
|
-
syncRunRecord(entry.run)
|
|
709
|
-
emitRunMeta(entry, 'cancelled', { reason })
|
|
710
|
-
entry.reject(new Error(reason))
|
|
711
|
-
cancelledQueued += 1
|
|
712
|
-
}
|
|
713
|
-
state.queueByExecution.delete(key)
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
for (const entry of state.runningByExecution.values()) {
|
|
717
|
-
abortedRunning += 1
|
|
718
|
-
abortSessionRuntime(entry, reason)
|
|
719
|
-
}
|
|
720
|
-
state.runningByExecution.clear()
|
|
721
|
-
state.nonHeartbeatWorkCount.clear()
|
|
722
|
-
|
|
723
|
-
return { cancelledQueued, abortedRunning }
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
async function drainExecution(executionKey: string): Promise<void> {
|
|
727
|
-
const depth = (state.drainDepth.get(executionKey) || 0) + 1
|
|
728
|
-
state.drainDepth.set(executionKey, depth)
|
|
729
|
-
if (depth > MAX_DRAIN_DEPTH) {
|
|
730
|
-
log.error('session-run', 'Drain recursion depth exceeded, deferring', { executionKey, depth, max: MAX_DRAIN_DEPTH })
|
|
731
|
-
state.drainDepth.delete(executionKey)
|
|
732
|
-
scheduleDeferredDrain(executionKey, 500)
|
|
733
|
-
return
|
|
734
|
-
}
|
|
735
|
-
try {
|
|
736
|
-
if (state.runningByExecution.has(executionKey)) return
|
|
737
|
-
const q = queueForExecution(executionKey)
|
|
738
|
-
// 3-tier drain priority: (1) user-facing, (2) internal non-heartbeat, (3) heartbeat
|
|
739
|
-
const userIdx = q.findIndex(e => !e.run.internal)
|
|
740
|
-
let next: QueueEntry | undefined
|
|
741
|
-
if (userIdx >= 0) {
|
|
742
|
-
next = q.splice(userIdx, 1)[0]
|
|
743
|
-
} else {
|
|
744
|
-
const internalIdx = q.findIndex(e => !isInternalHeartbeatRun(e.run.internal, e.run.source))
|
|
745
|
-
next = internalIdx >= 0 ? q.splice(internalIdx, 1)[0] : q.shift()
|
|
746
|
-
}
|
|
747
|
-
if (!next) {
|
|
748
|
-
clearDeferredDrain(executionKey)
|
|
749
|
-
return
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
if (isInternalHeartbeatRun(next.run.internal, next.run.source) && hasActiveNonHeartbeatSessionLease(next.run.sessionId)) {
|
|
753
|
-
q.unshift(next)
|
|
754
|
-
scheduleDeferredDrain(executionKey, HEARTBEAT_BUSY_RETRY_MS)
|
|
755
|
-
log.info('session-run', `Deferred heartbeat run ${next.run.id} for shared busy session`, {
|
|
756
|
-
sessionId: next.run.sessionId,
|
|
757
|
-
source: next.run.source,
|
|
758
|
-
leaseName: nonHeartbeatActivityLeaseName(next.run.sessionId),
|
|
759
|
-
})
|
|
760
|
-
return
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
if (hasExternalSessionExecutionHold(next.run.sessionId)) {
|
|
764
|
-
q.unshift(next)
|
|
765
|
-
scheduleDeferredDrain(executionKey, HEARTBEAT_BUSY_RETRY_MS)
|
|
766
|
-
log.info('session-run', `Deferred run ${next.run.id} for external session hold`, {
|
|
767
|
-
sessionId: next.run.sessionId,
|
|
768
|
-
source: next.run.source,
|
|
769
|
-
mode: next.run.mode,
|
|
770
|
-
})
|
|
771
|
-
return
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
clearDeferredDrain(executionKey)
|
|
775
|
-
state.runningByExecution.set(executionKey, next)
|
|
776
|
-
next.run.status = 'running'
|
|
777
|
-
next.run.startedAt = now()
|
|
778
|
-
syncRunRecord(next.run)
|
|
779
|
-
emitRunMeta(next, 'running')
|
|
780
|
-
log.info('session-run', `Run started ${next.run.id}`, {
|
|
781
|
-
sessionId: next.run.sessionId,
|
|
782
|
-
source: next.run.source,
|
|
783
|
-
internal: next.run.internal,
|
|
784
|
-
mode: next.run.mode,
|
|
785
|
-
timeoutMs: next.maxRuntimeMs || null,
|
|
786
|
-
})
|
|
787
|
-
|
|
788
|
-
let runtimeTimer: ReturnType<typeof setTimeout> | null = null
|
|
789
|
-
let finishedMissionId: string | null = null
|
|
790
|
-
if (next.maxRuntimeMs && next.maxRuntimeMs > 0) {
|
|
791
|
-
runtimeTimer = setTimeout(() => {
|
|
792
|
-
next.signalController.abort()
|
|
793
|
-
}, next.maxRuntimeMs)
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
try {
|
|
797
|
-
const result = await executeSessionChatTurn({
|
|
798
|
-
sessionId: next.run.sessionId,
|
|
799
|
-
message: next.message,
|
|
800
|
-
imagePath: next.imagePath,
|
|
801
|
-
imageUrl: next.imageUrl,
|
|
802
|
-
attachedFiles: next.attachedFiles,
|
|
803
|
-
internal: next.run.internal,
|
|
804
|
-
source: next.run.source,
|
|
805
|
-
runId: next.run.id,
|
|
806
|
-
signal: next.signalController.signal,
|
|
807
|
-
onEvent: (event) => emitToSubscribers(next, event),
|
|
808
|
-
modelOverride: next.modelOverride,
|
|
809
|
-
heartbeatConfig: next.heartbeatConfig,
|
|
810
|
-
replyToId: next.replyToId,
|
|
811
|
-
})
|
|
812
|
-
|
|
813
|
-
const failed = !!result.error
|
|
814
|
-
const aborted = next.signalController.signal.aborted
|
|
815
|
-
next.run.status = aborted ? 'cancelled' : (failed ? 'failed' : 'completed')
|
|
816
|
-
next.run.endedAt = next.run.endedAt || now()
|
|
817
|
-
next.run.error = aborted ? (next.run.error || 'Cancelled') : result.error
|
|
818
|
-
next.run.missionId = result.missionId || next.run.missionId || null
|
|
819
|
-
finishedMissionId = next.run.missionId || null
|
|
820
|
-
next.run.resultPreview = result.text?.slice(0, 280)
|
|
821
|
-
syncRunRecord(next.run)
|
|
822
|
-
emitRunMeta(next, next.run.status, {
|
|
823
|
-
persisted: result.persisted,
|
|
824
|
-
hasText: !!result.text,
|
|
825
|
-
error: next.run.error || null,
|
|
826
|
-
})
|
|
827
|
-
log.info('session-run', `Run finished ${next.run.id}`, {
|
|
828
|
-
sessionId: next.run.sessionId,
|
|
829
|
-
status: next.run.status,
|
|
830
|
-
persisted: result.persisted,
|
|
831
|
-
hasText: !!result.text,
|
|
832
|
-
error: next.run.error || null,
|
|
833
|
-
durationMs: (next.run.endedAt || now()) - (next.run.startedAt || now()),
|
|
834
|
-
})
|
|
835
|
-
const followup = handleMainLoopRunResult({
|
|
836
|
-
runId: next.run.id,
|
|
837
|
-
sessionId: next.run.sessionId,
|
|
838
|
-
message: next.message,
|
|
839
|
-
internal: next.run.internal,
|
|
840
|
-
source: next.run.source,
|
|
841
|
-
resultText: result.text,
|
|
842
|
-
error: next.run.error,
|
|
843
|
-
toolEvents: result.toolEvents,
|
|
844
|
-
inputTokens: result.inputTokens,
|
|
845
|
-
outputTokens: result.outputTokens,
|
|
846
|
-
estimatedCost: result.estimatedCost,
|
|
847
|
-
})
|
|
848
|
-
queueAutonomyObservation({
|
|
849
|
-
runId: next.run.id,
|
|
850
|
-
sessionId: next.run.sessionId,
|
|
851
|
-
source: next.run.source,
|
|
852
|
-
status: next.run.status,
|
|
853
|
-
resultText: result.text,
|
|
854
|
-
error: next.run.error || null,
|
|
855
|
-
toolEvents: result.toolEvents,
|
|
856
|
-
sourceMessage: next.message,
|
|
857
|
-
})
|
|
858
|
-
if (followup) {
|
|
859
|
-
setTimeout(() => {
|
|
860
|
-
try {
|
|
861
|
-
enqueueSessionRun({
|
|
862
|
-
sessionId: next.run.sessionId,
|
|
863
|
-
message: followup.message,
|
|
864
|
-
internal: true,
|
|
865
|
-
source: 'main-loop-followup',
|
|
866
|
-
mode: 'followup',
|
|
867
|
-
dedupeKey: followup.dedupeKey,
|
|
868
|
-
})
|
|
869
|
-
} catch (err: unknown) {
|
|
870
|
-
log.warn('session-run', `Main loop follow-up enqueue failed for ${next.run.sessionId}`, {
|
|
871
|
-
error: errorMessage(err),
|
|
872
|
-
})
|
|
873
|
-
}
|
|
874
|
-
}, Math.max(0, followup.delayMs || 0))
|
|
875
|
-
}
|
|
876
|
-
next.resolve(result)
|
|
877
|
-
} catch (err: unknown) {
|
|
878
|
-
const aborted = next.signalController.signal.aborted
|
|
879
|
-
next.run.status = aborted ? 'cancelled' : 'failed'
|
|
880
|
-
next.run.endedAt = now()
|
|
881
|
-
next.run.error = errorMessage(err)
|
|
882
|
-
finishedMissionId = next.run.missionId || null
|
|
883
|
-
syncRunRecord(next.run)
|
|
884
|
-
emitRunMeta(next, next.run.status, { error: next.run.error })
|
|
885
|
-
log.error('session-run', `Run failed ${next.run.id}`, {
|
|
886
|
-
sessionId: next.run.sessionId,
|
|
887
|
-
status: next.run.status,
|
|
888
|
-
error: next.run.error,
|
|
889
|
-
durationMs: (next.run.endedAt || now()) - (next.run.startedAt || now()),
|
|
890
|
-
})
|
|
891
|
-
if (err instanceof Error && err.stack) {
|
|
892
|
-
log.error('session-run', `Run failed stack trace ${next.run.id}`, {
|
|
893
|
-
sessionId: next.run.sessionId,
|
|
894
|
-
stack: err.stack,
|
|
895
|
-
})
|
|
896
|
-
}
|
|
897
|
-
queueAutonomyObservation({
|
|
898
|
-
runId: next.run.id,
|
|
899
|
-
sessionId: next.run.sessionId,
|
|
900
|
-
source: next.run.source,
|
|
901
|
-
status: next.run.status,
|
|
902
|
-
error: next.run.error || null,
|
|
903
|
-
sourceMessage: next.message,
|
|
904
|
-
})
|
|
905
|
-
next.reject(err instanceof Error ? err : new Error(next.run.error))
|
|
906
|
-
} finally {
|
|
907
|
-
if (runtimeTimer) clearTimeout(runtimeTimer)
|
|
908
|
-
state.runningByExecution.delete(executionKey)
|
|
909
|
-
decrementNonHeartbeatWork(next)
|
|
910
|
-
reconcileSessionActivityLease(next.run.sessionId)
|
|
911
|
-
notify(`stream-end:${next.run.sessionId}`)
|
|
912
|
-
if (finishedMissionId && next.run.source !== 'chat') {
|
|
913
|
-
queueMicrotask(() => {
|
|
914
|
-
import('@/lib/server/missions/mission-service')
|
|
915
|
-
.then(({ loadMissionById, requestMissionTick }) => {
|
|
916
|
-
const mission = loadMissionById(finishedMissionId)
|
|
917
|
-
if (!mission) return
|
|
918
|
-
if (mission.status !== 'active') return
|
|
919
|
-
if (mission.phase === 'dispatching' || mission.phase === 'executing') return
|
|
920
|
-
requestMissionTick(finishedMissionId as string, 'run_drained', {
|
|
921
|
-
runId: next.run.id,
|
|
922
|
-
source: next.run.source,
|
|
923
|
-
status: next.run.status,
|
|
924
|
-
})
|
|
925
|
-
})
|
|
926
|
-
.catch((err: unknown) => {
|
|
927
|
-
log.warn('session-run', 'Mission tick failed', { missionId: finishedMissionId, runId: next.run.id, error: errorMessage(err) })
|
|
928
|
-
})
|
|
929
|
-
})
|
|
930
|
-
}
|
|
931
|
-
void drainExecution(executionKey)
|
|
932
|
-
}
|
|
933
|
-
} finally {
|
|
934
|
-
state.drainDepth.delete(executionKey)
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
function findDedupeMatch(sessionId: string, dedupeKey?: string): QueueEntry | null {
|
|
939
|
-
if (!dedupeKey) return null
|
|
940
|
-
const executionKey = executionKeyForSession(sessionId)
|
|
941
|
-
const running = state.runningByExecution.get(executionKey)
|
|
942
|
-
if (running?.run.sessionId === sessionId && running?.run.dedupeKey === dedupeKey) return running
|
|
943
|
-
const q = queueForExecution(executionKey)
|
|
944
|
-
return q.find((e) => e.run.sessionId === sessionId && e.run.dedupeKey === dedupeKey) || null
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
export interface EnqueueSessionRunInput {
|
|
948
|
-
sessionId: string
|
|
949
|
-
message: string
|
|
950
|
-
missionId?: string | null
|
|
951
|
-
imagePath?: string
|
|
952
|
-
imageUrl?: string
|
|
953
|
-
attachedFiles?: string[]
|
|
954
|
-
internal?: boolean
|
|
955
|
-
source?: string
|
|
956
|
-
mode?: SessionQueueMode
|
|
957
|
-
onEvent?: (event: SSEEvent) => void
|
|
958
|
-
dedupeKey?: string
|
|
959
|
-
maxRuntimeMs?: number
|
|
960
|
-
modelOverride?: string
|
|
961
|
-
heartbeatConfig?: SessionRunHeartbeatConfig
|
|
962
|
-
replyToId?: string
|
|
963
|
-
/** Optional shared execution lane key. When set, multiple sessions can be serialized together. */
|
|
964
|
-
executionGroupKey?: string
|
|
965
|
-
/** External abort signal (e.g. from the HTTP request) — chained to the run's internal AbortController */
|
|
966
|
-
callerSignal?: AbortSignal
|
|
967
|
-
recoveredFromRestart?: boolean
|
|
968
|
-
recoveredFromRunId?: string
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
export interface EnqueueSessionRunResult {
|
|
972
|
-
runId: string
|
|
973
|
-
position: number
|
|
974
|
-
deduped?: boolean
|
|
975
|
-
coalesced?: boolean
|
|
976
|
-
promise: Promise<ExecuteChatTurnResult>
|
|
977
|
-
/** Abort the run's internal AbortController (cancels the LLM stream). */
|
|
978
|
-
abort: () => void
|
|
979
|
-
/** Remove this caller's onEvent listener from the run (call on client disconnect). */
|
|
980
|
-
unsubscribe: () => void
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli', 'opencode_cli'])
|
|
984
|
-
type SessionToolConfig = {
|
|
985
|
-
tools?: string[] | null
|
|
986
|
-
extensions?: string[] | null
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
function computeEffectiveRunTimeoutMs(
|
|
990
|
-
baseTimeoutMs: number,
|
|
991
|
-
sessionTools: string[],
|
|
992
|
-
runtime: { claudeCodeTimeoutMs: number },
|
|
993
|
-
): number {
|
|
994
|
-
const hasLongTool = sessionTools.some(t => LONG_TOOL_NAMES.has(t))
|
|
995
|
-
if (!hasLongTool) return baseTimeoutMs
|
|
996
|
-
const toolTimeout = runtime.claudeCodeTimeoutMs + 120_000
|
|
997
|
-
return Math.max(baseTimeoutMs, toolTimeout)
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
function isAutonomyManagedEnqueue(source: string, internal: boolean): boolean {
|
|
1001
|
-
return !(source === 'chat' && !internal)
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function buildRecoveryPayload(
|
|
1005
|
-
input: EnqueueSessionRunInput,
|
|
1006
|
-
source: string,
|
|
1007
|
-
mode: SessionQueueMode,
|
|
1008
|
-
maxRuntimeMs: number | undefined,
|
|
1009
|
-
executionKey: string,
|
|
1010
|
-
) {
|
|
1011
|
-
return {
|
|
1012
|
-
message: input.message,
|
|
1013
|
-
imagePath: input.imagePath,
|
|
1014
|
-
imageUrl: input.imageUrl,
|
|
1015
|
-
attachedFiles: input.attachedFiles,
|
|
1016
|
-
internal: input.internal === true,
|
|
1017
|
-
source,
|
|
1018
|
-
mode,
|
|
1019
|
-
maxRuntimeMs,
|
|
1020
|
-
modelOverride: input.modelOverride,
|
|
1021
|
-
heartbeatConfig: input.heartbeatConfig,
|
|
1022
|
-
replyToId: input.replyToId,
|
|
1023
|
-
executionGroupKey: executionKey.startsWith('session:') ? undefined : executionKey,
|
|
1024
|
-
}
|
|
64
|
+
return repairSessionRunQueueInternal(sessionId, drainExecution, opts)
|
|
1025
65
|
}
|
|
1026
66
|
|
|
1027
67
|
export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSessionRunResult {
|
|
1028
68
|
ensureRecoveredPersistedRuns()
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
if (isAllEstopEngaged()) {
|
|
1033
|
-
throw new Error('Execution is blocked because all estop is engaged.')
|
|
1034
|
-
}
|
|
1035
|
-
if (isAutonomyEstopEngaged() && isAutonomyManagedEnqueue(source, internal)) {
|
|
1036
|
-
throw new Error(`Autonomy estop is engaged. New ${source} runs are paused.`)
|
|
1037
|
-
}
|
|
1038
|
-
const executionKey = typeof input.executionGroupKey === 'string' && input.executionGroupKey.trim()
|
|
1039
|
-
? input.executionGroupKey.trim()
|
|
1040
|
-
: executionKeyForSession(input.sessionId)
|
|
1041
|
-
repairSessionRunQueue(input.sessionId, {
|
|
1042
|
-
executionKey,
|
|
1043
|
-
reason: 'Recovered stale queued run before enqueue',
|
|
1044
|
-
})
|
|
1045
|
-
const runtime = loadRuntimeSettings()
|
|
1046
|
-
const defaultMaxRuntimeMs = runtime.ongoingLoopMaxRuntimeMs ?? (10 * 60_000)
|
|
1047
|
-
const sessionData = loadSession(input.sessionId) as SessionToolConfig | null
|
|
1048
|
-
const sessionTools = getEnabledToolIds(sessionData)
|
|
1049
|
-
const adjustedDefaultMs = computeEffectiveRunTimeoutMs(defaultMaxRuntimeMs, sessionTools, runtime)
|
|
1050
|
-
const effectiveMaxRuntimeMs = typeof input.maxRuntimeMs === 'number'
|
|
1051
|
-
? input.maxRuntimeMs
|
|
1052
|
-
: adjustedDefaultMs
|
|
1053
|
-
|
|
1054
|
-
const dedupe = findDedupeMatch(input.sessionId, input.dedupeKey)
|
|
1055
|
-
if (dedupe) {
|
|
1056
|
-
const cb = input.onEvent
|
|
1057
|
-
if (cb) dedupe.onEvents.push(cb)
|
|
1058
|
-
if (input.callerSignal) chainCallerSignal(input.callerSignal, dedupe.signalController)
|
|
1059
|
-
return {
|
|
1060
|
-
runId: dedupe.run.id,
|
|
1061
|
-
position: 0,
|
|
1062
|
-
deduped: true,
|
|
1063
|
-
promise: dedupe.promise,
|
|
1064
|
-
abort: () => dedupe.signalController.abort(),
|
|
1065
|
-
unsubscribe: () => {
|
|
1066
|
-
if (!cb) return
|
|
1067
|
-
const idx = dedupe.onEvents.indexOf(cb)
|
|
1068
|
-
if (idx >= 0) dedupe.onEvents.splice(idx, 1)
|
|
1069
|
-
},
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
if (mode === 'steer') {
|
|
1074
|
-
const running = state.runningByExecution.get(executionKey)
|
|
1075
|
-
if (running && running.run.sessionId === input.sessionId) {
|
|
1076
|
-
running.signalController.abort()
|
|
1077
|
-
try { active.get(input.sessionId)?.kill?.() } catch { /* noop */ }
|
|
1078
|
-
}
|
|
1079
|
-
cancelPendingForSession(input.sessionId, 'Cancelled by steer mode')
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// Heartbeat preemption: if a user chat arrives while a heartbeat is running,
|
|
1083
|
-
// abort the heartbeat so the user doesn't wait. The heartbeat will retry
|
|
1084
|
-
// on the next tick.
|
|
1085
|
-
if (!internal && source === 'chat') {
|
|
1086
|
-
const running = state.runningByExecution.get(executionKey)
|
|
1087
|
-
if (running && isInternalHeartbeatRun(running.run.internal, running.run.source)) {
|
|
1088
|
-
log.info('session-run', `Preempting heartbeat ${running.run.id} for user chat on ${input.sessionId}`)
|
|
1089
|
-
abortSessionRuntime(running, 'Preempted by user chat')
|
|
1090
|
-
state.runningByExecution.delete(executionKey)
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const running = state.runningByExecution.get(executionKey)
|
|
1095
|
-
const q = queueForExecution(executionKey)
|
|
1096
|
-
if (mode === 'collect' && !input.imagePath && !input.imageUrl && !input.attachedFiles?.length) {
|
|
1097
|
-
const nowMs = nextQueuedAt()
|
|
1098
|
-
const candidate = q.at(-1)
|
|
1099
|
-
const canCoalesce = !!candidate
|
|
1100
|
-
&& candidate.run.mode === 'collect'
|
|
1101
|
-
&& candidate.run.internal === internal
|
|
1102
|
-
&& candidate.run.source === source
|
|
1103
|
-
&& !candidate.imagePath
|
|
1104
|
-
&& !candidate.imageUrl
|
|
1105
|
-
&& !candidate.attachedFiles?.length
|
|
1106
|
-
&& (nowMs - candidate.run.queuedAt) <= COLLECT_COALESCE_WINDOW_MS
|
|
1107
|
-
|
|
1108
|
-
if (candidate && canCoalesce) {
|
|
1109
|
-
const nextChunk = input.message.trim()
|
|
1110
|
-
if (nextChunk) {
|
|
1111
|
-
const current = candidate.message.trim()
|
|
1112
|
-
candidate.message = current
|
|
1113
|
-
? `${current}\n\n[Collected follow-up]\n${nextChunk}`
|
|
1114
|
-
: nextChunk
|
|
1115
|
-
candidate.run.messagePreview = messagePreview(candidate.message)
|
|
1116
|
-
candidate.run.queuedAt = nowMs
|
|
1117
|
-
syncRunRecord(candidate.run)
|
|
1118
|
-
}
|
|
1119
|
-
const coalesceCb = input.onEvent
|
|
1120
|
-
if (coalesceCb) candidate.onEvents.push(coalesceCb)
|
|
1121
|
-
if (input.callerSignal) chainCallerSignal(input.callerSignal, candidate.signalController)
|
|
1122
|
-
emitRunMeta(candidate, 'queued', { position: 0, coalesced: true, mergedIntoRunId: candidate.run.id })
|
|
1123
|
-
return {
|
|
1124
|
-
runId: candidate.run.id,
|
|
1125
|
-
position: 0,
|
|
1126
|
-
coalesced: true,
|
|
1127
|
-
promise: candidate.promise,
|
|
1128
|
-
abort: () => candidate.signalController.abort(),
|
|
1129
|
-
unsubscribe: () => {
|
|
1130
|
-
if (!coalesceCb) return
|
|
1131
|
-
const idx = candidate.onEvents.indexOf(coalesceCb)
|
|
1132
|
-
if (idx >= 0) candidate.onEvents.splice(idx, 1)
|
|
1133
|
-
},
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
const runId = genId(8)
|
|
1139
|
-
const run: SessionRunRecord = {
|
|
1140
|
-
id: runId,
|
|
1141
|
-
sessionId: input.sessionId,
|
|
1142
|
-
missionId: input.missionId ?? loadSession(input.sessionId)?.missionId ?? null,
|
|
1143
|
-
source,
|
|
1144
|
-
internal,
|
|
1145
|
-
mode,
|
|
1146
|
-
status: 'queued',
|
|
1147
|
-
messagePreview: messagePreview(input.message),
|
|
1148
|
-
dedupeKey: input.dedupeKey,
|
|
1149
|
-
queuedAt: nextQueuedAt(),
|
|
1150
|
-
recoveredFromRestart: input.recoveredFromRestart === true,
|
|
1151
|
-
recoveredFromRunId: input.recoveredFromRunId,
|
|
1152
|
-
recoveryPayload: buildRecoveryPayload(
|
|
1153
|
-
input,
|
|
1154
|
-
source,
|
|
1155
|
-
mode,
|
|
1156
|
-
effectiveMaxRuntimeMs > 0 ? effectiveMaxRuntimeMs : undefined,
|
|
1157
|
-
executionKey,
|
|
1158
|
-
),
|
|
1159
|
-
}
|
|
1160
|
-
registerRun(run)
|
|
1161
|
-
|
|
1162
|
-
let resolve!: (value: ExecuteChatTurnResult) => void
|
|
1163
|
-
let reject!: (error: Error) => void
|
|
1164
|
-
const promise = new Promise<ExecuteChatTurnResult>((res, rej) => {
|
|
1165
|
-
resolve = res
|
|
1166
|
-
reject = rej
|
|
69
|
+
return enqueueSessionRunInternal(input, {
|
|
70
|
+
repairSessionRunQueue: (sessionId, opts) => repairSessionRunQueue(sessionId, opts),
|
|
71
|
+
drainExecution,
|
|
1167
72
|
})
|
|
1168
|
-
promise.catch(() => {}) // prevent unhandledRejection when entry is cancelled
|
|
1169
|
-
state.promises.set(runId, promise)
|
|
1170
|
-
|
|
1171
|
-
const entry: QueueEntry = {
|
|
1172
|
-
executionKey,
|
|
1173
|
-
run,
|
|
1174
|
-
message: input.message,
|
|
1175
|
-
imagePath: input.imagePath,
|
|
1176
|
-
imageUrl: input.imageUrl,
|
|
1177
|
-
attachedFiles: input.attachedFiles,
|
|
1178
|
-
onEvents: input.onEvent ? [input.onEvent] : [],
|
|
1179
|
-
signalController: new AbortController(),
|
|
1180
|
-
maxRuntimeMs: effectiveMaxRuntimeMs > 0 ? effectiveMaxRuntimeMs : undefined,
|
|
1181
|
-
modelOverride: input.modelOverride,
|
|
1182
|
-
heartbeatConfig: input.heartbeatConfig,
|
|
1183
|
-
replyToId: input.replyToId,
|
|
1184
|
-
resolve,
|
|
1185
|
-
reject,
|
|
1186
|
-
promise,
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
if (input.callerSignal) chainCallerSignal(input.callerSignal, entry.signalController)
|
|
1190
|
-
|
|
1191
|
-
q.push(entry)
|
|
1192
|
-
incrementNonHeartbeatWork(entry)
|
|
1193
|
-
if (entry.nonHeartbeatCounted) {
|
|
1194
|
-
reconcileSessionActivityLease(input.sessionId)
|
|
1195
|
-
}
|
|
1196
|
-
const position = (running ? 1 : 0) + q.length - 1
|
|
1197
|
-
emitRunMeta(entry, 'queued', { position })
|
|
1198
|
-
void drainExecution(executionKey)
|
|
1199
|
-
|
|
1200
|
-
const entryCb = input.onEvent
|
|
1201
|
-
return {
|
|
1202
|
-
runId,
|
|
1203
|
-
position,
|
|
1204
|
-
promise,
|
|
1205
|
-
abort: () => entry.signalController.abort(),
|
|
1206
|
-
unsubscribe: () => {
|
|
1207
|
-
if (!entryCb) return
|
|
1208
|
-
const idx = entry.onEvents.indexOf(entryCb)
|
|
1209
|
-
if (idx >= 0) entry.onEvents.splice(idx, 1)
|
|
1210
|
-
},
|
|
1211
|
-
}
|
|
1212
73
|
}
|
|
1213
74
|
|
|
1214
|
-
export function getSessionRunState(sessionId: string)
|
|
1215
|
-
runningRunId?: string
|
|
1216
|
-
queueLength: number
|
|
1217
|
-
} {
|
|
75
|
+
export function getSessionRunState(sessionId: string) {
|
|
1218
76
|
ensureRecoveredPersistedRuns()
|
|
1219
|
-
|
|
1220
|
-
return {
|
|
1221
|
-
runningRunId: summary.runningRunId,
|
|
1222
|
-
queueLength: summary.queueLength,
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
function visibleQueuedEntriesForSession(sessionId: string): QueueEntry[] {
|
|
1227
|
-
return Array.from(state.queueByExecution.values())
|
|
1228
|
-
.flatMap((queue) => queue)
|
|
1229
|
-
.filter((entry) => entry.run.sessionId === sessionId && entry.run.internal !== true)
|
|
1230
|
-
.sort((left, right) => left.run.queuedAt - right.run.queuedAt)
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
function toQueuedTurn(entry: QueueEntry, index: number): SessionQueuedTurn {
|
|
1234
|
-
return {
|
|
1235
|
-
runId: entry.run.id,
|
|
1236
|
-
sessionId: entry.run.sessionId,
|
|
1237
|
-
missionId: entry.run.missionId || null,
|
|
1238
|
-
text: entry.message,
|
|
1239
|
-
queuedAt: entry.run.queuedAt,
|
|
1240
|
-
position: index + 1,
|
|
1241
|
-
imagePath: entry.imagePath,
|
|
1242
|
-
imageUrl: entry.imageUrl,
|
|
1243
|
-
attachedFiles: entry.attachedFiles,
|
|
1244
|
-
replyToId: entry.replyToId,
|
|
1245
|
-
source: entry.run.source,
|
|
1246
|
-
}
|
|
77
|
+
return getSessionRunStateInternal(sessionId)
|
|
1247
78
|
}
|
|
1248
79
|
|
|
1249
|
-
export function getSessionQueueSnapshot(sessionId: string)
|
|
80
|
+
export function getSessionQueueSnapshot(sessionId: string) {
|
|
1250
81
|
ensureRecoveredPersistedRuns()
|
|
1251
|
-
|
|
1252
|
-
const visibleQueued = visibleQueuedEntriesForSession(sessionId)
|
|
1253
|
-
return {
|
|
1254
|
-
sessionId,
|
|
1255
|
-
activeRunId: execution.runningRunId || null,
|
|
1256
|
-
queueLength: visibleQueued.length,
|
|
1257
|
-
items: visibleQueued.map((entry, index) => toQueuedTurn(entry, index)),
|
|
1258
|
-
}
|
|
82
|
+
return getSessionQueueSnapshotInternal(sessionId)
|
|
1259
83
|
}
|
|
1260
84
|
|
|
1261
|
-
export function getSessionExecutionState(sessionId: string)
|
|
1262
|
-
runningRunId?: string
|
|
1263
|
-
queueLength: number
|
|
1264
|
-
hasRunning: boolean
|
|
1265
|
-
hasQueued: boolean
|
|
1266
|
-
hasRunningHeartbeat: boolean
|
|
1267
|
-
hasQueuedHeartbeat: boolean
|
|
1268
|
-
hasRunningNonHeartbeat: boolean
|
|
1269
|
-
hasQueuedNonHeartbeat: boolean
|
|
1270
|
-
} {
|
|
85
|
+
export function getSessionExecutionState(sessionId: string) {
|
|
1271
86
|
ensureRecoveredPersistedRuns()
|
|
1272
|
-
|
|
1273
|
-
.find((entry) => entry.run.sessionId === sessionId)
|
|
1274
|
-
const runningMatchesSession = Boolean(running)
|
|
1275
|
-
const runningHeartbeat = Boolean(
|
|
1276
|
-
runningMatchesSession
|
|
1277
|
-
&& running
|
|
1278
|
-
&& isInternalHeartbeatRun(running.run.internal, running.run.source),
|
|
1279
|
-
)
|
|
1280
|
-
const runningNonHeartbeat = Boolean(runningMatchesSession && !runningHeartbeat)
|
|
1281
|
-
const queuedEntries = Array.from(state.queueByExecution.values())
|
|
1282
|
-
.flatMap((queue) => queue)
|
|
1283
|
-
.filter((entry) => entry.run.sessionId === sessionId)
|
|
1284
|
-
const queuedHeartbeat = queuedEntries.filter((entry) =>
|
|
1285
|
-
isInternalHeartbeatRun(entry.run.internal, entry.run.source),
|
|
1286
|
-
).length
|
|
1287
|
-
const queuedNonHeartbeat = queuedEntries.length - queuedHeartbeat
|
|
1288
|
-
return {
|
|
1289
|
-
runningRunId: (runningMatchesSession && running?.run.status === 'running')
|
|
1290
|
-
? running.run.id
|
|
1291
|
-
: undefined,
|
|
1292
|
-
queueLength: queuedEntries.length,
|
|
1293
|
-
hasRunning: Boolean(runningMatchesSession),
|
|
1294
|
-
hasQueued: queuedEntries.length > 0,
|
|
1295
|
-
hasRunningHeartbeat: runningHeartbeat,
|
|
1296
|
-
hasQueuedHeartbeat: queuedHeartbeat > 0,
|
|
1297
|
-
hasRunningNonHeartbeat: runningNonHeartbeat,
|
|
1298
|
-
hasQueuedNonHeartbeat: queuedNonHeartbeat > 0,
|
|
1299
|
-
}
|
|
87
|
+
return getSessionExecutionStateInternal(sessionId)
|
|
1300
88
|
}
|
|
1301
89
|
|
|
1302
|
-
export function getRunById(runId: string)
|
|
90
|
+
export function getRunById(runId: string) {
|
|
1303
91
|
ensureRecoveredPersistedRuns()
|
|
1304
|
-
return
|
|
92
|
+
return getRunByIdInternal(runId)
|
|
1305
93
|
}
|
|
1306
94
|
|
|
1307
|
-
export function listRuns(params?: {
|
|
1308
|
-
sessionId?: string
|
|
1309
|
-
status?: SessionRunStatus
|
|
1310
|
-
limit?: number
|
|
1311
|
-
}): SessionRunRecord[] {
|
|
95
|
+
export function listRuns(params?: Parameters<typeof listRunsInternal>[0]) {
|
|
1312
96
|
ensureRecoveredPersistedRuns()
|
|
1313
|
-
return
|
|
97
|
+
return listRunsInternal(params)
|
|
1314
98
|
}
|
|
1315
99
|
|
|
1316
|
-
export function listRunEvents(runId: string, limit?: number)
|
|
100
|
+
export function listRunEvents(runId: string, limit?: number) {
|
|
1317
101
|
ensureRecoveredPersistedRuns()
|
|
1318
|
-
return
|
|
102
|
+
return listRunEventsInternal(runId, limit)
|
|
1319
103
|
}
|
|
1320
104
|
|
|
1321
105
|
export function cancelQueuedRunById(runId: string, reason = 'Removed from queue'): boolean {
|
|
1322
106
|
ensureRecoveredPersistedRuns()
|
|
1323
|
-
|
|
1324
|
-
return result.cancelled > 0
|
|
107
|
+
return cancelQueuedRunByIdInternal(runId, reason)
|
|
1325
108
|
}
|
|
1326
109
|
|
|
1327
110
|
export function cancelQueuedRunsForSession(sessionId: string, reason = 'Cleared queued messages'): number {
|
|
1328
111
|
ensureRecoveredPersistedRuns()
|
|
1329
|
-
|
|
1330
|
-
return result.cancelled
|
|
112
|
+
return cancelQueuedRunsForSessionInternal(sessionId, reason)
|
|
1331
113
|
}
|
|
1332
114
|
|
|
1333
|
-
export function cancelSessionRuns(sessionId: string, reason = 'Cancelled')
|
|
115
|
+
export function cancelSessionRuns(sessionId: string, reason = 'Cancelled') {
|
|
1334
116
|
ensureRecoveredPersistedRuns()
|
|
1335
|
-
|
|
1336
|
-
.find((entry) => entry.run.sessionId === sessionId)
|
|
1337
|
-
let cancelledRunning = false
|
|
1338
|
-
if (running) {
|
|
1339
|
-
cancelledRunning = true
|
|
1340
|
-
abortSessionRuntime(running, reason)
|
|
1341
|
-
state.runningByExecution.delete(running.executionKey)
|
|
1342
|
-
decrementNonHeartbeatWork(running)
|
|
1343
|
-
}
|
|
1344
|
-
const cancelledQueued = cancelPendingForSession(sessionId, reason)
|
|
1345
|
-
reconcileSessionActivityLease(sessionId)
|
|
1346
|
-
return { cancelledQueued, cancelledRunning }
|
|
117
|
+
return cancelSessionRunsInternal(sessionId, reason)
|
|
1347
118
|
}
|
|
1348
119
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
120
|
+
export function cancelAllHeartbeatRuns(reason = 'Heartbeat disabled globally') {
|
|
121
|
+
ensureRecoveredPersistedRuns()
|
|
122
|
+
return cancelAllHeartbeatRunsInternal(reason)
|
|
123
|
+
}
|
|
1353
124
|
|
|
1354
|
-
|
|
125
|
+
export function cancelAllRuns(reason = 'Cancelled') {
|
|
126
|
+
ensureRecoveredPersistedRuns()
|
|
127
|
+
return cancelAllRunsInternal(reason)
|
|
128
|
+
}
|
|
1355
129
|
|
|
1356
130
|
export function sweepStuckRuns(): { aborted: number } {
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
// 1. In-memory running entries that have exceeded their timeout
|
|
1361
|
-
for (const [execKey, entry] of state.runningByExecution.entries()) {
|
|
1362
|
-
const age = deadline - (entry.run.startedAt || entry.run.queuedAt)
|
|
1363
|
-
// If the run has an explicit maxRuntimeMs, the existing setTimeout handles it;
|
|
1364
|
-
// the watchdog only kicks in at 1.5× as a safety net.
|
|
1365
|
-
if (entry.maxRuntimeMs && age < entry.maxRuntimeMs * 1.5) continue
|
|
1366
|
-
if (age < STUCK_RUN_THRESHOLD_MS) continue
|
|
1367
|
-
|
|
1368
|
-
abortSessionRuntime(entry, 'Watchdog: run exceeded maximum allowed duration')
|
|
1369
|
-
state.runningByExecution.delete(execKey)
|
|
1370
|
-
decrementNonHeartbeatWork(entry)
|
|
1371
|
-
reconcileSessionActivityLease(entry.run.sessionId)
|
|
1372
|
-
aborted++
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
// 2. Persisted runs marked running but with no in-memory entry (orphaned by HMR/crash)
|
|
1376
|
-
const persistedRunning = listPersistedRuns({ status: 'running' })
|
|
1377
|
-
for (const run of persistedRunning) {
|
|
1378
|
-
const execKey = run.recoveryPayload?.executionGroupKey || executionKeyForSession(run.sessionId)
|
|
1379
|
-
const inMemory = state.runningByExecution.get(execKey)
|
|
1380
|
-
if (inMemory && inMemory.run.id === run.id) continue // still tracked
|
|
1381
|
-
|
|
1382
|
-
const age = deadline - (run.startedAt || run.queuedAt)
|
|
1383
|
-
if (age < STUCK_RUN_THRESHOLD_MS) continue
|
|
1384
|
-
|
|
1385
|
-
markPersistedRunInterrupted(run, 'Watchdog: orphaned run detected after server restart or HMR')
|
|
1386
|
-
aborted++
|
|
1387
|
-
|
|
1388
|
-
// Re-enqueue if the source is recoverable and no other run is already in-flight for this session
|
|
1389
|
-
const alreadyRunning = state.runningByExecution.has(execKey)
|
|
1390
|
-
const alreadyQueued = (state.queueByExecution.get(execKey) || []).some(e => e.run.sessionId === run.sessionId)
|
|
1391
|
-
if (run.recoveryPayload && isRestartRecoverableSource(run.source) && !alreadyRunning && !alreadyQueued) {
|
|
1392
|
-
try {
|
|
1393
|
-
const payload = run.recoveryPayload
|
|
1394
|
-
enqueueSessionRun({
|
|
1395
|
-
sessionId: run.sessionId,
|
|
1396
|
-
message: payload.message,
|
|
1397
|
-
imagePath: payload.imagePath,
|
|
1398
|
-
imageUrl: payload.imageUrl,
|
|
1399
|
-
attachedFiles: payload.attachedFiles,
|
|
1400
|
-
internal: payload.internal,
|
|
1401
|
-
source: payload.source,
|
|
1402
|
-
mode: normalizeMode(payload.mode, payload.internal),
|
|
1403
|
-
dedupeKey: run.dedupeKey,
|
|
1404
|
-
maxRuntimeMs: payload.maxRuntimeMs,
|
|
1405
|
-
modelOverride: payload.modelOverride,
|
|
1406
|
-
heartbeatConfig: payload.heartbeatConfig,
|
|
1407
|
-
replyToId: payload.replyToId,
|
|
1408
|
-
executionGroupKey: payload.executionGroupKey,
|
|
1409
|
-
recoveredFromRestart: true,
|
|
1410
|
-
recoveredFromRunId: run.id,
|
|
1411
|
-
})
|
|
1412
|
-
} catch (err: unknown) {
|
|
1413
|
-
log.warn('session-run', `Watchdog: failed to re-enqueue orphaned run ${run.id}`, {
|
|
1414
|
-
sessionId: run.sessionId,
|
|
1415
|
-
error: errorMessage(err),
|
|
1416
|
-
})
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
return { aborted }
|
|
131
|
+
ensureRecoveredPersistedRuns()
|
|
132
|
+
return sweepStuckRunsInternal(enqueueSessionRun)
|
|
1422
133
|
}
|
|
1423
134
|
|
|
1424
135
|
export function resetSessionRunManagerForTests(): void {
|
|
1425
|
-
|
|
1426
|
-
for (const timer of state.deferredDrainTimers.values()) clearTimeout(timer)
|
|
1427
|
-
state.deferredDrainTimers.clear()
|
|
1428
|
-
for (const [sessionId, timer] of state.activityLeaseRenewTimers.entries()) {
|
|
1429
|
-
clearInterval(timer)
|
|
1430
|
-
releaseRuntimeLock(nonHeartbeatActivityLeaseName(sessionId), SHARED_ACTIVITY_LEASE_OWNER)
|
|
1431
|
-
}
|
|
1432
|
-
state.activityLeaseRenewTimers.clear()
|
|
1433
|
-
state.runningByExecution.clear()
|
|
1434
|
-
state.queueByExecution.clear()
|
|
1435
|
-
state.runs.clear()
|
|
1436
|
-
state.recentRunIds.length = 0
|
|
1437
|
-
state.promises.clear()
|
|
1438
|
-
state.externalSessionHolds.clear()
|
|
1439
|
-
state.nonHeartbeatWorkCount.clear()
|
|
1440
|
-
state.lastQueuedAt = 0
|
|
136
|
+
resetSessionRunManagerStateForTests()
|
|
1441
137
|
}
|
|
138
|
+
|
|
139
|
+
export { hasActiveNonHeartbeatSessionLease }
|