@oar-sh/oar 0.9.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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function normalizeSessionId(value) {
|
|
4
|
+
const text = String(value || '').trim();
|
|
5
|
+
return text || null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const ACTIVE_WORKER_STATUSES = new Set(['ready', 'processing', 'starting']);
|
|
9
|
+
|
|
10
|
+
function hasWorkerIdentity(worker) {
|
|
11
|
+
if (!worker || typeof worker !== 'object') return false;
|
|
12
|
+
const workerId = String(worker.workerId || '').trim();
|
|
13
|
+
const pid = Number(worker.pid);
|
|
14
|
+
return workerId.length > 0 || (Number.isInteger(pid) && pid > 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toSafeNowMs(nowFn) {
|
|
18
|
+
const value = Number(nowFn());
|
|
19
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clampInt(value, fallback = 0) {
|
|
23
|
+
const n = Number(value);
|
|
24
|
+
if (!Number.isFinite(n)) return fallback;
|
|
25
|
+
return Math.max(0, Math.floor(n));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function defaultIsPidAlive(pidValue) {
|
|
29
|
+
const pid = Number(pidValue);
|
|
30
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const code = String(error?.code || '').trim().toUpperCase();
|
|
36
|
+
if (code === 'EPERM') return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizePendingQuestionSessionIds(value) {
|
|
42
|
+
if (!value) return new Set();
|
|
43
|
+
if (value instanceof Set) {
|
|
44
|
+
return new Set(Array.from(value)
|
|
45
|
+
.map((sid) => normalizeSessionId(sid))
|
|
46
|
+
.filter(Boolean));
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(value)) return new Set();
|
|
49
|
+
return new Set(value
|
|
50
|
+
.map((sid) => normalizeSessionId(sid))
|
|
51
|
+
.filter(Boolean));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasStartupFailureReason(lifecycle) {
|
|
55
|
+
const reason = String(lifecycle?.degradedReason || '').trim().toLowerCase();
|
|
56
|
+
return reason === 'startup-heartbeat-timeout' || reason === 'stale-pid';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createSessionWorkerSupervisor({
|
|
60
|
+
registry,
|
|
61
|
+
spawnWorker = null,
|
|
62
|
+
now = () => Date.now(),
|
|
63
|
+
maxRestartRetries = 5,
|
|
64
|
+
restartBackoffBaseMs = 1_000,
|
|
65
|
+
restartBackoffMaxMs = 30_000,
|
|
66
|
+
idleEvictionMs = 0,
|
|
67
|
+
// Copilot-kind workers boot a full CLI (auth, model catalog, workspace scan)
|
|
68
|
+
// before their extension can heartbeat; measured median is ~35s. A 30s
|
|
69
|
+
// timeout marked those still-booting workers as startup failures and killed
|
|
70
|
+
// them mid-boot, so the turn paid a second cold start. Pid liveness is still
|
|
71
|
+
// the fast path for genuinely dead workers.
|
|
72
|
+
heartbeatTimeoutMs = 90_000,
|
|
73
|
+
degradedRecoveryGraceMs = 10_000,
|
|
74
|
+
// Long enough to keep a kill from racing an in-flight ensureWorker respawn,
|
|
75
|
+
// short enough that stopping a turn and immediately sending a new message
|
|
76
|
+
// does not add 30s in front of the worker's boot.
|
|
77
|
+
killBlockGraceMs = 3_000,
|
|
78
|
+
isPidAlive = defaultIsPidAlive,
|
|
79
|
+
diagnosticPlanReference = null,
|
|
80
|
+
log = null,
|
|
81
|
+
} = {}) {
|
|
82
|
+
if (!registry) {
|
|
83
|
+
throw new Error('createSessionWorkerSupervisor requires registry');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pendingStarts = new Map();
|
|
87
|
+
const lifecycleBySession = new Map();
|
|
88
|
+
const retryLimit = Math.max(0, clampInt(maxRestartRetries, 5));
|
|
89
|
+
const backoffBaseMs = Math.max(1, clampInt(restartBackoffBaseMs, 1_000));
|
|
90
|
+
const backoffMaxMs = Math.max(backoffBaseMs, clampInt(restartBackoffMaxMs, 30_000));
|
|
91
|
+
const idleTimeoutMs = clampInt(idleEvictionMs, 0);
|
|
92
|
+
const heartbeatStaleAfterMs = Math.max(1_000, clampInt(heartbeatTimeoutMs, 90_000));
|
|
93
|
+
const recoveryGraceMs = Math.max(0, clampInt(degradedRecoveryGraceMs, 10_000));
|
|
94
|
+
const killGraceMs = Math.max(0, clampInt(killBlockGraceMs, 3_000));
|
|
95
|
+
function resolvePlanReference() {
|
|
96
|
+
const value = typeof diagnosticPlanReference === 'function'
|
|
97
|
+
? diagnosticPlanReference()
|
|
98
|
+
: diagnosticPlanReference;
|
|
99
|
+
return normalizeSessionId(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function emitMonitorLog(message) {
|
|
103
|
+
if (typeof log === 'function') {
|
|
104
|
+
log(message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function nowMs() {
|
|
109
|
+
return toSafeNowMs(now);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function nowIso() {
|
|
113
|
+
return new Date(nowMs()).toISOString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function computeBackoffMs(retryCount) {
|
|
117
|
+
const count = Math.max(1, clampInt(retryCount, 1));
|
|
118
|
+
const exponent = Math.max(0, count - 1);
|
|
119
|
+
return Math.min(backoffMaxMs, backoffBaseMs * (2 ** exponent));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getOrCreateLifecycle(sessionId) {
|
|
123
|
+
const existing = lifecycleBySession.get(sessionId);
|
|
124
|
+
if (existing) return existing;
|
|
125
|
+
const seed = registry.getWorker(sessionId);
|
|
126
|
+
const nowAtMs = nowMs();
|
|
127
|
+
const state = {
|
|
128
|
+
retryCount: Math.max(0, clampInt(seed?.retryCount, 0)),
|
|
129
|
+
backoffMs: 0,
|
|
130
|
+
nextRestartAtMs: null,
|
|
131
|
+
restartExhausted: false,
|
|
132
|
+
lastError: null,
|
|
133
|
+
lastFailureAtMs: null,
|
|
134
|
+
lastActivityAtMs: nowAtMs,
|
|
135
|
+
uiState: 'white',
|
|
136
|
+
degradedReason: null,
|
|
137
|
+
degradedAtMs: null,
|
|
138
|
+
lastHeartbeatAtMs: null,
|
|
139
|
+
failureCount: 0,
|
|
140
|
+
stalePidDetected: false,
|
|
141
|
+
recoveryCandidateAtMs: null,
|
|
142
|
+
questionPending: false,
|
|
143
|
+
launchMode: null,
|
|
144
|
+
launchPid: null,
|
|
145
|
+
launchAtMs: null,
|
|
146
|
+
awaitingHeartbeat: false,
|
|
147
|
+
firstObservedHeartbeatAtMs: null,
|
|
148
|
+
monitorLoggedAtMs: null,
|
|
149
|
+
killedAtMs: null,
|
|
150
|
+
};
|
|
151
|
+
lifecycleBySession.set(sessionId, state);
|
|
152
|
+
return state;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setLifecycle(sessionId, patch = {}) {
|
|
156
|
+
const current = getOrCreateLifecycle(sessionId);
|
|
157
|
+
const next = { ...current, ...patch };
|
|
158
|
+
lifecycleBySession.set(sessionId, next);
|
|
159
|
+
return next;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toLifecycleSnapshot(sessionId, state = null) {
|
|
163
|
+
const lifecycle = state || lifecycleBySession.get(sessionId) || null;
|
|
164
|
+
if (!lifecycle) return null;
|
|
165
|
+
return {
|
|
166
|
+
retryCount: clampInt(lifecycle.retryCount, 0),
|
|
167
|
+
backoffMs: clampInt(lifecycle.backoffMs, 0),
|
|
168
|
+
nextRestartAt: lifecycle.nextRestartAtMs ? new Date(lifecycle.nextRestartAtMs).toISOString() : null,
|
|
169
|
+
restartExhausted: Boolean(lifecycle.restartExhausted),
|
|
170
|
+
lastError: lifecycle.lastError || null,
|
|
171
|
+
lastFailureAt: lifecycle.lastFailureAtMs ? new Date(lifecycle.lastFailureAtMs).toISOString() : null,
|
|
172
|
+
lastActivityAt: lifecycle.lastActivityAtMs ? new Date(lifecycle.lastActivityAtMs).toISOString() : null,
|
|
173
|
+
uiState: String(lifecycle.uiState || 'white'),
|
|
174
|
+
degradedReason: lifecycle.degradedReason || null,
|
|
175
|
+
lastHeartbeatAt: lifecycle.lastHeartbeatAtMs ? new Date(lifecycle.lastHeartbeatAtMs).toISOString() : null,
|
|
176
|
+
failureCount: clampInt(lifecycle.failureCount, 0),
|
|
177
|
+
stalePidDetected: Boolean(lifecycle.stalePidDetected),
|
|
178
|
+
questionPending: Boolean(lifecycle.questionPending),
|
|
179
|
+
launchMode: lifecycle.launchMode || null,
|
|
180
|
+
launchPid: Number.isInteger(Number(lifecycle.launchPid)) ? Number(lifecycle.launchPid) : null,
|
|
181
|
+
launchAt: lifecycle.launchAtMs ? new Date(lifecycle.launchAtMs).toISOString() : null,
|
|
182
|
+
awaitingHeartbeat: Boolean(lifecycle.awaitingHeartbeat),
|
|
183
|
+
firstObservedHeartbeatAt: lifecycle.firstObservedHeartbeatAtMs ? new Date(lifecycle.firstObservedHeartbeatAtMs).toISOString() : null,
|
|
184
|
+
readyWithoutHeartbeatMs: lifecycle.awaitingHeartbeat && lifecycle.launchAtMs
|
|
185
|
+
? Math.max(0, nowMs() - lifecycle.launchAtMs)
|
|
186
|
+
: 0,
|
|
187
|
+
diagnosticPlanReference: resolvePlanReference() || null,
|
|
188
|
+
killedAt: lifecycle.killedAtMs ? new Date(lifecycle.killedAtMs).toISOString() : null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function touchActivity(sessionId, atMs = nowMs()) {
|
|
193
|
+
return setLifecycle(sessionId, { lastActivityAtMs: atMs });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function noteHeartbeat(sessionId, atMs = nowMs()) {
|
|
197
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
198
|
+
const patch = {
|
|
199
|
+
lastHeartbeatAtMs: atMs,
|
|
200
|
+
lastActivityAtMs: atMs,
|
|
201
|
+
};
|
|
202
|
+
if (lifecycle.awaitingHeartbeat) {
|
|
203
|
+
patch.awaitingHeartbeat = false;
|
|
204
|
+
patch.firstObservedHeartbeatAtMs = lifecycle.firstObservedHeartbeatAtMs || atMs;
|
|
205
|
+
patch.monitorLoggedAtMs = null;
|
|
206
|
+
}
|
|
207
|
+
return setLifecycle(sessionId, patch);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function noteLaunch(sessionId, { mode = null, pid = null, atMs = nowMs() } = {}) {
|
|
211
|
+
return setLifecycle(sessionId, {
|
|
212
|
+
launchMode: String(mode || '').trim() || null,
|
|
213
|
+
launchPid: Number.isInteger(Number(pid)) ? Number(pid) : null,
|
|
214
|
+
launchAtMs: atMs,
|
|
215
|
+
awaitingHeartbeat: true,
|
|
216
|
+
firstObservedHeartbeatAtMs: null,
|
|
217
|
+
monitorLoggedAtMs: null,
|
|
218
|
+
uiState: 'white',
|
|
219
|
+
degradedReason: null,
|
|
220
|
+
degradedAtMs: null,
|
|
221
|
+
stalePidDetected: false,
|
|
222
|
+
recoveryCandidateAtMs: null,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function maybeLogStartupIssue(sessionId, reason, worker, nowAtMs) {
|
|
227
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
228
|
+
if (lifecycle.monitorLoggedAtMs) return;
|
|
229
|
+
const pid = Number.isInteger(Number(worker?.pid)) ? Number(worker.pid) : null;
|
|
230
|
+
const readyWithoutHeartbeatMs = lifecycle.launchAtMs
|
|
231
|
+
? Math.max(0, nowAtMs - lifecycle.launchAtMs)
|
|
232
|
+
: 0;
|
|
233
|
+
const plan = resolvePlanReference();
|
|
234
|
+
emitMonitorLog(
|
|
235
|
+
`[worker-monitor] session=${sessionId} reason=${String(reason || 'unknown')} mode=${String(lifecycle.launchMode || 'unknown')} pid=${pid || 'none'} readyWithoutHeartbeatMs=${readyWithoutHeartbeatMs}${plan ? ` plan=${plan}` : ''}`,
|
|
236
|
+
);
|
|
237
|
+
setLifecycle(sessionId, { monitorLoggedAtMs: nowAtMs });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function registerDegradedState(sessionId, reason, {
|
|
241
|
+
atMs = nowMs(),
|
|
242
|
+
stalePidDetected = false,
|
|
243
|
+
} = {}) {
|
|
244
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
245
|
+
const normalizedReason = String(reason || 'degraded').trim() || 'degraded';
|
|
246
|
+
const reasonChanged = lifecycle.uiState !== 'yellow' || lifecycle.degradedReason !== normalizedReason;
|
|
247
|
+
const nextFailureCount = reasonChanged ? lifecycle.failureCount + 1 : lifecycle.failureCount;
|
|
248
|
+
const next = setLifecycle(sessionId, {
|
|
249
|
+
uiState: 'yellow',
|
|
250
|
+
degradedReason: normalizedReason,
|
|
251
|
+
degradedAtMs: lifecycle.degradedAtMs || atMs,
|
|
252
|
+
lastFailureAtMs: reasonChanged ? atMs : lifecycle.lastFailureAtMs,
|
|
253
|
+
failureCount: nextFailureCount,
|
|
254
|
+
stalePidDetected: stalePidDetected || lifecycle.stalePidDetected,
|
|
255
|
+
recoveryCandidateAtMs: null,
|
|
256
|
+
});
|
|
257
|
+
return next;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function clearDegradedState(sessionId) {
|
|
261
|
+
return setLifecycle(sessionId, {
|
|
262
|
+
uiState: 'white',
|
|
263
|
+
degradedReason: null,
|
|
264
|
+
degradedAtMs: null,
|
|
265
|
+
stalePidDetected: false,
|
|
266
|
+
recoveryCandidateAtMs: null,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function markKilled(sessionId) {
|
|
271
|
+
if (!normalizeSessionId(sessionId)) return null;
|
|
272
|
+
const killed = setLifecycle(sessionId, { killedAtMs: nowMs() });
|
|
273
|
+
return toLifecycleSnapshot(sessionId, killed);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isKillBlocked(sessionId) {
|
|
277
|
+
if (killGraceMs <= 0) return false;
|
|
278
|
+
const lifecycle = lifecycleBySession.get(sessionId);
|
|
279
|
+
if (!lifecycle?.killedAtMs) return false;
|
|
280
|
+
return (nowMs() - lifecycle.killedAtMs) < killGraceMs;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function checkPidLiveness(worker) {
|
|
284
|
+
const pid = Number(worker?.pid);
|
|
285
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
286
|
+
return { checked: false, alive: false };
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
return { checked: true, alive: isPidAlive(pid) === true };
|
|
290
|
+
} catch {
|
|
291
|
+
return { checked: true, alive: false };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function shouldReuseLiveWorker(worker) {
|
|
296
|
+
if (!worker || !hasWorkerIdentity(worker)) return false;
|
|
297
|
+
const pidProbe = checkPidLiveness(worker);
|
|
298
|
+
if (pidProbe.checked) return pidProbe.alive;
|
|
299
|
+
const status = String(worker?.status || '').trim().toLowerCase();
|
|
300
|
+
return ACTIVE_WORKER_STATUSES.has(status);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function assessSessionHealth(sessionId, worker, {
|
|
304
|
+
nowAtMs = nowMs(),
|
|
305
|
+
questionPending = false,
|
|
306
|
+
} = {}) {
|
|
307
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
308
|
+
const workerStatus = String(worker?.status || '').trim().toLowerCase();
|
|
309
|
+
const workerPresent = Boolean(worker);
|
|
310
|
+
const expectedAlive = workerPresent && ACTIVE_WORKER_STATUSES.has(workerStatus) && hasWorkerIdentity(worker);
|
|
311
|
+
const hasErrorStatus = workerStatus === 'error';
|
|
312
|
+
const expectingReply = Boolean(questionPending) || workerStatus === 'processing';
|
|
313
|
+
const pidProbe = checkPidLiveness(worker);
|
|
314
|
+
const awaitingHeartbeat = Boolean(lifecycle.awaitingHeartbeat);
|
|
315
|
+
const startupHeartbeatOverdue = awaitingHeartbeat
|
|
316
|
+
&& lifecycle.launchAtMs
|
|
317
|
+
&& (nowAtMs - lifecycle.launchAtMs) >= heartbeatStaleAfterMs;
|
|
318
|
+
const healthyCandidate = workerPresent
|
|
319
|
+
&& ACTIVE_WORKER_STATUSES.has(workerStatus)
|
|
320
|
+
&& hasWorkerIdentity(worker)
|
|
321
|
+
&& (!pidProbe.checked || pidProbe.alive)
|
|
322
|
+
&& !hasErrorStatus
|
|
323
|
+
&& !awaitingHeartbeat
|
|
324
|
+
&& !questionPending;
|
|
325
|
+
|
|
326
|
+
if (lifecycle.restartExhausted && healthyCandidate) {
|
|
327
|
+
clearRestartSchedule(sessionId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const refreshedLifecycle = getOrCreateLifecycle(sessionId);
|
|
331
|
+
|
|
332
|
+
if (refreshedLifecycle.restartExhausted) {
|
|
333
|
+
registerDegradedState(sessionId, 'restart-exhausted', { atMs: nowAtMs });
|
|
334
|
+
} else if (hasErrorStatus) {
|
|
335
|
+
registerDegradedState(sessionId, String(worker?.lastError || lifecycle.lastError || 'worker-error'), { atMs: nowAtMs });
|
|
336
|
+
} else if (expectedAlive && pidProbe.checked && !pidProbe.alive && (expectingReply || awaitingHeartbeat)) {
|
|
337
|
+
registerDegradedState(sessionId, 'stale-pid', { atMs: nowAtMs, stalePidDetected: true });
|
|
338
|
+
maybeLogStartupIssue(sessionId, 'stale-pid', worker, nowAtMs);
|
|
339
|
+
} else if (startupHeartbeatOverdue) {
|
|
340
|
+
registerDegradedState(sessionId, 'startup-heartbeat-timeout', { atMs: nowAtMs });
|
|
341
|
+
maybeLogStartupIssue(sessionId, 'startup-heartbeat-timeout', worker, nowAtMs);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const afterDetection = getOrCreateLifecycle(sessionId);
|
|
345
|
+
if (!expectingReply && afterDetection.uiState === 'yellow' && afterDetection.degradedReason === 'stale-pid') {
|
|
346
|
+
clearDegradedState(sessionId);
|
|
347
|
+
}
|
|
348
|
+
const afterRecovery = getOrCreateLifecycle(sessionId);
|
|
349
|
+
let stickyYellow = afterRecovery.uiState === 'yellow';
|
|
350
|
+
const effectiveHealthyCandidate = expectedAlive
|
|
351
|
+
&& (!pidProbe.checked || pidProbe.alive)
|
|
352
|
+
&& !hasErrorStatus
|
|
353
|
+
&& !awaitingHeartbeat
|
|
354
|
+
&& !afterRecovery.restartExhausted;
|
|
355
|
+
|
|
356
|
+
if (stickyYellow) {
|
|
357
|
+
if (effectiveHealthyCandidate) {
|
|
358
|
+
const recoveryCandidateAtMs = afterRecovery.recoveryCandidateAtMs || nowAtMs;
|
|
359
|
+
const graceSatisfied = (nowAtMs - recoveryCandidateAtMs) >= recoveryGraceMs;
|
|
360
|
+
const noNewFailures = !afterRecovery.lastFailureAtMs || afterRecovery.lastFailureAtMs <= recoveryCandidateAtMs;
|
|
361
|
+
if (graceSatisfied && noNewFailures) {
|
|
362
|
+
clearDegradedState(sessionId);
|
|
363
|
+
stickyYellow = false;
|
|
364
|
+
} else {
|
|
365
|
+
setLifecycle(sessionId, {
|
|
366
|
+
recoveryCandidateAtMs,
|
|
367
|
+
questionPending: Boolean(questionPending),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
setLifecycle(sessionId, {
|
|
372
|
+
questionPending: Boolean(questionPending),
|
|
373
|
+
recoveryCandidateAtMs: null,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const current = getOrCreateLifecycle(sessionId);
|
|
379
|
+
const effectiveYellow = current.uiState === 'yellow';
|
|
380
|
+
const effectiveHealthy = effectiveHealthyCandidate && !effectiveYellow;
|
|
381
|
+
const finalUiState = effectiveYellow
|
|
382
|
+
? 'yellow'
|
|
383
|
+
: (questionPending ? 'red' : (effectiveHealthy ? 'green' : 'white'));
|
|
384
|
+
return setLifecycle(sessionId, {
|
|
385
|
+
uiState: finalUiState,
|
|
386
|
+
questionPending: Boolean(questionPending),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function scheduleRestart(sessionId, error = null, { incrementRetry = true } = {}) {
|
|
391
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
392
|
+
const nextRetryCount = incrementRetry ? lifecycle.retryCount + 1 : lifecycle.retryCount;
|
|
393
|
+
const message = String(error?.message || error || lifecycle.lastError || 'worker failure').trim();
|
|
394
|
+
const failedAtMs = nowMs();
|
|
395
|
+
|
|
396
|
+
if (nextRetryCount > retryLimit) {
|
|
397
|
+
const exhausted = setLifecycle(sessionId, {
|
|
398
|
+
retryCount: nextRetryCount,
|
|
399
|
+
backoffMs: 0,
|
|
400
|
+
nextRestartAtMs: null,
|
|
401
|
+
restartExhausted: true,
|
|
402
|
+
lastError: message,
|
|
403
|
+
lastFailureAtMs: failedAtMs,
|
|
404
|
+
lastActivityAtMs: failedAtMs,
|
|
405
|
+
});
|
|
406
|
+
registerDegradedState(sessionId, 'restart-exhausted', { atMs: failedAtMs });
|
|
407
|
+
return { scheduled: false, exhausted: true, lifecycle: toLifecycleSnapshot(sessionId, exhausted) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const backoffMs = computeBackoffMs(nextRetryCount);
|
|
411
|
+
const scheduled = setLifecycle(sessionId, {
|
|
412
|
+
retryCount: nextRetryCount,
|
|
413
|
+
backoffMs,
|
|
414
|
+
nextRestartAtMs: failedAtMs + backoffMs,
|
|
415
|
+
restartExhausted: false,
|
|
416
|
+
lastError: message,
|
|
417
|
+
lastFailureAtMs: failedAtMs,
|
|
418
|
+
lastActivityAtMs: failedAtMs,
|
|
419
|
+
});
|
|
420
|
+
registerDegradedState(sessionId, 'spawn-failed', { atMs: failedAtMs });
|
|
421
|
+
return { scheduled: true, exhausted: false, lifecycle: toLifecycleSnapshot(sessionId, scheduled) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function clearRestartSchedule(sessionId, {
|
|
425
|
+
resetDegradedState = true,
|
|
426
|
+
resetKilledMarker = false,
|
|
427
|
+
} = {}) {
|
|
428
|
+
if (!normalizeSessionId(sessionId)) return null;
|
|
429
|
+
const nowAtMs = nowMs();
|
|
430
|
+
const patch = {
|
|
431
|
+
retryCount: 0,
|
|
432
|
+
backoffMs: 0,
|
|
433
|
+
nextRestartAtMs: null,
|
|
434
|
+
restartExhausted: false,
|
|
435
|
+
lastError: null,
|
|
436
|
+
lastFailureAtMs: null,
|
|
437
|
+
lastActivityAtMs: nowAtMs,
|
|
438
|
+
};
|
|
439
|
+
if (resetDegradedState) {
|
|
440
|
+
patch.recoveryCandidateAtMs = null;
|
|
441
|
+
patch.stalePidDetected = false;
|
|
442
|
+
patch.degradedReason = null;
|
|
443
|
+
patch.degradedAtMs = null;
|
|
444
|
+
patch.uiState = 'white';
|
|
445
|
+
}
|
|
446
|
+
if (resetKilledMarker) {
|
|
447
|
+
patch.killedAtMs = null;
|
|
448
|
+
}
|
|
449
|
+
const cleared = setLifecycle(sessionId, patch);
|
|
450
|
+
return toLifecycleSnapshot(sessionId, cleared);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function canAttemptRestart(sessionId) {
|
|
454
|
+
const lifecycle = lifecycleBySession.get(sessionId);
|
|
455
|
+
if (!lifecycle) return { allowed: true, reason: null, lifecycle: null };
|
|
456
|
+
if (lifecycle.restartExhausted) {
|
|
457
|
+
return { allowed: false, reason: 'restart-exhausted', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
|
|
458
|
+
}
|
|
459
|
+
if (lifecycle.nextRestartAtMs && nowMs() < lifecycle.nextRestartAtMs) {
|
|
460
|
+
return { allowed: false, reason: 'restart-delayed', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
|
|
461
|
+
}
|
|
462
|
+
return { allowed: true, reason: null, lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function isTransitionAllowed(fromStatus, toStatus) {
|
|
466
|
+
const from = String(fromStatus || 'new').trim().toLowerCase();
|
|
467
|
+
const to = String(toStatus || '').trim().toLowerCase();
|
|
468
|
+
if (!to || from === to) return true;
|
|
469
|
+
const allowed = {
|
|
470
|
+
new: new Set(['starting', 'ready', 'error', 'processing']),
|
|
471
|
+
starting: new Set(['ready', 'error']),
|
|
472
|
+
ready: new Set(['processing', 'starting', 'error']),
|
|
473
|
+
processing: new Set(['ready', 'error']),
|
|
474
|
+
error: new Set(['starting', 'ready']),
|
|
475
|
+
};
|
|
476
|
+
return Boolean(allowed[from]?.has(to));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function setWorkerState(sessionId, nextState = {}) {
|
|
480
|
+
const current = registry.getWorker(sessionId);
|
|
481
|
+
const requestedStatus = String(nextState.status || current?.status || 'new').trim().toLowerCase();
|
|
482
|
+
if (current && !isTransitionAllowed(current.status, requestedStatus)) {
|
|
483
|
+
return current;
|
|
484
|
+
}
|
|
485
|
+
const next = registry.upsertWorker({
|
|
486
|
+
...(current || {}),
|
|
487
|
+
sdkSessionId: sessionId,
|
|
488
|
+
...nextState,
|
|
489
|
+
status: requestedStatus,
|
|
490
|
+
updatedAt: nowIso(),
|
|
491
|
+
});
|
|
492
|
+
touchActivity(sessionId, nowMs());
|
|
493
|
+
return next;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function getWorkerState(sdkSessionId) {
|
|
497
|
+
return registry.getWorker(normalizeSessionId(sdkSessionId));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function listWorkerStates() {
|
|
501
|
+
return registry.listWorkers();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function ensureWorker(sdkSessionId, { allowProcessReuse = true } = {}) {
|
|
505
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
506
|
+
if (!sessionId) {
|
|
507
|
+
return { ok: false, error: 'missing-session-id', worker: null };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (isKillBlocked(sessionId)) {
|
|
511
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
error: 'session-killed',
|
|
515
|
+
worker: registry.getWorker(sessionId) || null,
|
|
516
|
+
lifecycle: toLifecycleSnapshot(sessionId, lifecycle),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const existing = registry.getWorker(sessionId);
|
|
521
|
+
const existingLifecycle = getOrCreateLifecycle(sessionId);
|
|
522
|
+
const blockedByStartupFailure = hasStartupFailureReason(existingLifecycle);
|
|
523
|
+
if (allowProcessReuse && !blockedByStartupFailure && shouldReuseLiveWorker(existing)) {
|
|
524
|
+
const nowAtMs = nowMs();
|
|
525
|
+
const reusedWorker = setWorkerState(sessionId, {
|
|
526
|
+
...existing,
|
|
527
|
+
sdkSessionId: sessionId,
|
|
528
|
+
status: 'ready',
|
|
529
|
+
});
|
|
530
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
531
|
+
if (!lifecycle.awaitingHeartbeat && !lifecycle.firstObservedHeartbeatAtMs) {
|
|
532
|
+
noteLaunch(sessionId, {
|
|
533
|
+
mode: 'reused',
|
|
534
|
+
pid: existing?.pid || null,
|
|
535
|
+
atMs: nowAtMs,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
// A live worker should always be allowed to serve turns even if stale restart
|
|
539
|
+
// backoff/exhausted lifecycle state was left behind by earlier failures.
|
|
540
|
+
clearRestartSchedule(sessionId, { resetDegradedState: false });
|
|
541
|
+
assessSessionHealth(sessionId, reusedWorker, { nowAtMs, questionPending: false });
|
|
542
|
+
return { ok: true, reused: true, worker: reusedWorker, lifecycle: toLifecycleSnapshot(sessionId) };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const restartGate = canAttemptRestart(sessionId);
|
|
546
|
+
if (!restartGate.allowed) {
|
|
547
|
+
return { ok: false, error: restartGate.reason, worker: existing, lifecycle: restartGate.lifecycle };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const inFlight = pendingStarts.get(sessionId);
|
|
551
|
+
if (inFlight?.promise) {
|
|
552
|
+
return inFlight.promise;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const startToken = Symbol(`start-${sessionId}`);
|
|
556
|
+
const isStartActive = () => pendingStarts.get(sessionId)?.token === startToken;
|
|
557
|
+
const buildStartBlockedResult = () => {
|
|
558
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
559
|
+
const blockedError = isKillBlocked(sessionId) ? 'session-killed' : 'start-cancelled';
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
error: blockedError,
|
|
563
|
+
worker: registry.getWorker(sessionId) || null,
|
|
564
|
+
lifecycle: toLifecycleSnapshot(sessionId, lifecycle),
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const startPromise = Promise.resolve().then(async () => {
|
|
569
|
+
if (!isStartActive() || isKillBlocked(sessionId)) {
|
|
570
|
+
return buildStartBlockedResult();
|
|
571
|
+
}
|
|
572
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
573
|
+
const startingState = setWorkerState(sessionId, {
|
|
574
|
+
sdkSessionId: sessionId,
|
|
575
|
+
status: 'starting',
|
|
576
|
+
queueDepth: existing?.queueDepth || 0,
|
|
577
|
+
retryCount: lifecycle.retryCount || existing?.retryCount || 0,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
if (typeof spawnWorker === 'function') {
|
|
582
|
+
if (!isStartActive() || isKillBlocked(sessionId)) {
|
|
583
|
+
return buildStartBlockedResult();
|
|
584
|
+
}
|
|
585
|
+
const spawned = await spawnWorker(sessionId, {
|
|
586
|
+
allowProcessReuse: allowProcessReuse && !blockedByStartupFailure,
|
|
587
|
+
});
|
|
588
|
+
if (!isStartActive() || isKillBlocked(sessionId)) {
|
|
589
|
+
return buildStartBlockedResult();
|
|
590
|
+
}
|
|
591
|
+
const readyState = setWorkerState(sessionId, {
|
|
592
|
+
...startingState,
|
|
593
|
+
workerId: String(spawned?.workerId || startingState.workerId || `worker-${sessionId.slice(0, 8)}`).trim(),
|
|
594
|
+
pid: Number.isInteger(Number(spawned?.pid)) ? Number(spawned.pid) : null,
|
|
595
|
+
status: 'ready',
|
|
596
|
+
retryCount: 0,
|
|
597
|
+
lastError: null,
|
|
598
|
+
});
|
|
599
|
+
noteLaunch(sessionId, {
|
|
600
|
+
mode: 'spawned',
|
|
601
|
+
pid: spawned?.pid || null,
|
|
602
|
+
atMs: nowMs(),
|
|
603
|
+
});
|
|
604
|
+
clearRestartSchedule(sessionId);
|
|
605
|
+
assessSessionHealth(sessionId, readyState, { questionPending: false });
|
|
606
|
+
return { ok: true, reused: false, worker: readyState, lifecycle: toLifecycleSnapshot(sessionId) };
|
|
607
|
+
}
|
|
608
|
+
const simulatedReady = setWorkerState(sessionId, {
|
|
609
|
+
...startingState,
|
|
610
|
+
workerId: startingState.workerId || `worker-${sessionId.slice(0, 8)}`,
|
|
611
|
+
status: 'ready',
|
|
612
|
+
retryCount: 0,
|
|
613
|
+
lastError: null,
|
|
614
|
+
});
|
|
615
|
+
noteLaunch(sessionId, {
|
|
616
|
+
mode: 'spawned',
|
|
617
|
+
pid: simulatedReady?.pid || null,
|
|
618
|
+
atMs: nowMs(),
|
|
619
|
+
});
|
|
620
|
+
clearRestartSchedule(sessionId);
|
|
621
|
+
assessSessionHealth(sessionId, simulatedReady, { questionPending: false });
|
|
622
|
+
return { ok: true, reused: false, worker: simulatedReady, lifecycle: toLifecycleSnapshot(sessionId) };
|
|
623
|
+
} catch (error) {
|
|
624
|
+
const scheduled = scheduleRestart(sessionId, error, { incrementRetry: true });
|
|
625
|
+
const failed = setWorkerState(sessionId, {
|
|
626
|
+
...startingState,
|
|
627
|
+
status: 'error',
|
|
628
|
+
retryCount: scheduled.lifecycle?.retryCount || Number(startingState.retryCount || 0) + 1,
|
|
629
|
+
lastError: String(error?.message || error || 'unknown spawn failure').trim(),
|
|
630
|
+
});
|
|
631
|
+
assessSessionHealth(sessionId, failed, { questionPending: false });
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
error: scheduled.exhausted ? 'restart-exhausted' : 'spawn-failed',
|
|
635
|
+
worker: failed,
|
|
636
|
+
lifecycle: toLifecycleSnapshot(sessionId),
|
|
637
|
+
};
|
|
638
|
+
} finally {
|
|
639
|
+
if (isStartActive()) {
|
|
640
|
+
pendingStarts.delete(sessionId);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
pendingStarts.set(sessionId, {
|
|
646
|
+
token: startToken,
|
|
647
|
+
promise: startPromise,
|
|
648
|
+
});
|
|
649
|
+
return startPromise;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function cancelPendingStart(sdkSessionId, { wait = false } = {}) {
|
|
653
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
654
|
+
if (!sessionId) {
|
|
655
|
+
return { cancelled: false, hadPending: false, waited: false };
|
|
656
|
+
}
|
|
657
|
+
const pending = pendingStarts.get(sessionId);
|
|
658
|
+
if (!pending?.promise) {
|
|
659
|
+
return { cancelled: true, hadPending: false, waited: false };
|
|
660
|
+
}
|
|
661
|
+
pendingStarts.delete(sessionId);
|
|
662
|
+
if (!wait) {
|
|
663
|
+
return { cancelled: true, hadPending: true, waited: false };
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
await pending.promise;
|
|
667
|
+
} catch {
|
|
668
|
+
// Ignore start promise errors while cancelling in-flight starts.
|
|
669
|
+
}
|
|
670
|
+
return { cancelled: true, hadPending: true, waited: true };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function markProcessing(sdkSessionId, queueDepth = 0) {
|
|
674
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
675
|
+
if (!sessionId) return null;
|
|
676
|
+
const next = setWorkerState(sessionId, {
|
|
677
|
+
sdkSessionId: sessionId,
|
|
678
|
+
status: 'processing',
|
|
679
|
+
queueDepth: Math.max(0, Number(queueDepth || 0)),
|
|
680
|
+
});
|
|
681
|
+
assessSessionHealth(sessionId, next, { questionPending: false });
|
|
682
|
+
return next;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function markIdle(sdkSessionId, queueDepth = 0) {
|
|
686
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
687
|
+
if (!sessionId) return null;
|
|
688
|
+
const next = setWorkerState(sessionId, {
|
|
689
|
+
sdkSessionId: sessionId,
|
|
690
|
+
status: 'ready',
|
|
691
|
+
queueDepth: Math.max(0, Number(queueDepth || 0)),
|
|
692
|
+
});
|
|
693
|
+
assessSessionHealth(sessionId, next, { questionPending: false });
|
|
694
|
+
return next;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function markError(sdkSessionId, error) {
|
|
698
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
699
|
+
if (!sessionId) return null;
|
|
700
|
+
const current = registry.getWorker(sessionId) || { retryCount: 0 };
|
|
701
|
+
const scheduled = scheduleRestart(sessionId, error, { incrementRetry: true });
|
|
702
|
+
const next = setWorkerState(sessionId, {
|
|
703
|
+
...current,
|
|
704
|
+
sdkSessionId: sessionId,
|
|
705
|
+
status: 'error',
|
|
706
|
+
retryCount: scheduled.lifecycle?.retryCount ?? (Number(current.retryCount || 0) + 1),
|
|
707
|
+
lastError: String(error?.message || error || 'unknown worker error').trim(),
|
|
708
|
+
});
|
|
709
|
+
assessSessionHealth(sessionId, next, { questionPending: false });
|
|
710
|
+
return next;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function noteSessionHeartbeat(sdkSessionId, atMs = nowMs()) {
|
|
714
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
715
|
+
if (!sessionId) return null;
|
|
716
|
+
const lifecycle = noteHeartbeat(sessionId, atMs);
|
|
717
|
+
const worker = registry.getWorker(sessionId);
|
|
718
|
+
assessSessionHealth(sessionId, worker, { nowAtMs: atMs, questionPending: Boolean(lifecycle.questionPending) });
|
|
719
|
+
return toLifecycleSnapshot(sessionId);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function resetHealth(sdkSessionId, { clearFailureCount = false } = {}) {
|
|
723
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
724
|
+
if (!sessionId) return null;
|
|
725
|
+
const patch = {
|
|
726
|
+
uiState: 'white',
|
|
727
|
+
degradedReason: null,
|
|
728
|
+
degradedAtMs: null,
|
|
729
|
+
stalePidDetected: false,
|
|
730
|
+
recoveryCandidateAtMs: null,
|
|
731
|
+
questionPending: false,
|
|
732
|
+
lastFailureAtMs: null,
|
|
733
|
+
};
|
|
734
|
+
if (clearFailureCount) patch.failureCount = 0;
|
|
735
|
+
const lifecycle = setLifecycle(sessionId, patch);
|
|
736
|
+
return toLifecycleSnapshot(sessionId, lifecycle);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function getLifecycleState(sdkSessionId) {
|
|
740
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
741
|
+
if (!sessionId) return null;
|
|
742
|
+
const worker = registry.getWorker(sessionId);
|
|
743
|
+
assessSessionHealth(sessionId, worker, {
|
|
744
|
+
nowAtMs: nowMs(),
|
|
745
|
+
questionPending: Boolean(getOrCreateLifecycle(sessionId).questionPending),
|
|
746
|
+
});
|
|
747
|
+
return toLifecycleSnapshot(sessionId, lifecycleBySession.get(sessionId));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function evictIdleWorkers({ idleForMs = idleTimeoutMs } = {}) {
|
|
751
|
+
const timeoutMs = clampInt(idleForMs, idleTimeoutMs);
|
|
752
|
+
if (timeoutMs <= 0) return [];
|
|
753
|
+
const cutoff = nowMs() - timeoutMs;
|
|
754
|
+
const evicted = [];
|
|
755
|
+
for (const worker of registry.listWorkers()) {
|
|
756
|
+
const sessionId = normalizeSessionId(worker.sdkSessionId);
|
|
757
|
+
if (!sessionId) continue;
|
|
758
|
+
if (worker.status !== 'ready') continue;
|
|
759
|
+
if (pendingStarts.has(sessionId)) continue;
|
|
760
|
+
const lifecycle = getOrCreateLifecycle(sessionId);
|
|
761
|
+
const lastActivityAtMs = lifecycle.lastActivityAtMs || Date.parse(worker.updatedAt) || 0;
|
|
762
|
+
if (lastActivityAtMs > cutoff) continue;
|
|
763
|
+
const removed = registry.removeWorker(sessionId);
|
|
764
|
+
lifecycleBySession.delete(sessionId);
|
|
765
|
+
if (!removed) continue;
|
|
766
|
+
evicted.push({
|
|
767
|
+
sdkSessionId: sessionId,
|
|
768
|
+
workerId: worker.workerId || null,
|
|
769
|
+
idleForMs: Math.max(0, nowMs() - lastActivityAtMs),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return evicted;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function snapshot({ pendingQuestionSessionIds = null } = {}) {
|
|
776
|
+
const workers = registry.listWorkers();
|
|
777
|
+
const questionPendingSet = normalizePendingQuestionSessionIds(pendingQuestionSessionIds);
|
|
778
|
+
const workerBySession = new Map();
|
|
779
|
+
for (const worker of workers) {
|
|
780
|
+
const sid = normalizeSessionId(worker?.sdkSessionId);
|
|
781
|
+
if (!sid) continue;
|
|
782
|
+
workerBySession.set(sid, worker);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const knownSessionIds = new Set([
|
|
786
|
+
...Array.from(workerBySession.keys()),
|
|
787
|
+
...Array.from(lifecycleBySession.keys()),
|
|
788
|
+
...Array.from(questionPendingSet.values()),
|
|
789
|
+
]);
|
|
790
|
+
|
|
791
|
+
const workerRows = [];
|
|
792
|
+
const lifecycleRows = [];
|
|
793
|
+
const healthCounts = { white: 0, green: 0, red: 0, yellow: 0 };
|
|
794
|
+
|
|
795
|
+
for (const sdkSessionId of knownSessionIds) {
|
|
796
|
+
const worker = workerBySession.get(sdkSessionId) || null;
|
|
797
|
+
const lifecycle = assessSessionHealth(sdkSessionId, worker, {
|
|
798
|
+
nowAtMs: nowMs(),
|
|
799
|
+
questionPending: questionPendingSet.has(sdkSessionId),
|
|
800
|
+
});
|
|
801
|
+
const lifecycleSnapshot = toLifecycleSnapshot(sdkSessionId, lifecycle);
|
|
802
|
+
lifecycleRows.push({
|
|
803
|
+
sdkSessionId,
|
|
804
|
+
...lifecycleSnapshot,
|
|
805
|
+
});
|
|
806
|
+
const uiState = String(lifecycleSnapshot?.uiState || 'white');
|
|
807
|
+
if (Object.hasOwn(healthCounts, uiState)) {
|
|
808
|
+
healthCounts[uiState] += 1;
|
|
809
|
+
}
|
|
810
|
+
if (worker) {
|
|
811
|
+
workerRows.push({
|
|
812
|
+
...worker,
|
|
813
|
+
uiState,
|
|
814
|
+
degradedReason: lifecycleSnapshot?.degradedReason || null,
|
|
815
|
+
lastHeartbeatAt: lifecycleSnapshot?.lastHeartbeatAt || null,
|
|
816
|
+
lastFailureAt: lifecycleSnapshot?.lastFailureAt || null,
|
|
817
|
+
failureCount: lifecycleSnapshot?.failureCount || 0,
|
|
818
|
+
stalePidDetected: lifecycleSnapshot?.stalePidDetected === true,
|
|
819
|
+
questionPending: lifecycleSnapshot?.questionPending === true,
|
|
820
|
+
launchMode: lifecycleSnapshot?.launchMode || null,
|
|
821
|
+
launchPid: lifecycleSnapshot?.launchPid || null,
|
|
822
|
+
launchAt: lifecycleSnapshot?.launchAt || null,
|
|
823
|
+
awaitingHeartbeat: lifecycleSnapshot?.awaitingHeartbeat === true,
|
|
824
|
+
firstObservedHeartbeatAt: lifecycleSnapshot?.firstObservedHeartbeatAt || null,
|
|
825
|
+
readyWithoutHeartbeatMs: lifecycleSnapshot?.readyWithoutHeartbeatMs ?? 0,
|
|
826
|
+
diagnosticPlanReference: lifecycleSnapshot?.diagnosticPlanReference || null,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const counts = workers.reduce((acc, worker) => {
|
|
832
|
+
const key = String(worker.status || 'unknown');
|
|
833
|
+
acc[key] = Number(acc[key] || 0) + 1;
|
|
834
|
+
return acc;
|
|
835
|
+
}, {});
|
|
836
|
+
|
|
837
|
+
const overallUiState = healthCounts.yellow > 0
|
|
838
|
+
? 'yellow'
|
|
839
|
+
: (healthCounts.red > 0 ? 'red' : (healthCounts.green > 0 ? 'green' : 'white'));
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
workerCount: workers.length,
|
|
843
|
+
counts,
|
|
844
|
+
workers: workerRows,
|
|
845
|
+
pendingStarts: pendingStarts.size,
|
|
846
|
+
lifecycle: lifecycleRows,
|
|
847
|
+
health: {
|
|
848
|
+
uiState: overallUiState,
|
|
849
|
+
degradedReason: overallUiState === 'yellow'
|
|
850
|
+
? (lifecycleRows.find((item) => item.uiState === 'yellow')?.degradedReason || 'degraded')
|
|
851
|
+
: null,
|
|
852
|
+
counts: healthCounts,
|
|
853
|
+
heartbeatTimeoutMs: heartbeatStaleAfterMs,
|
|
854
|
+
degradedRecoveryGraceMs: recoveryGraceMs,
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
ensureWorker,
|
|
861
|
+
getWorkerState,
|
|
862
|
+
listWorkerStates,
|
|
863
|
+
markProcessing,
|
|
864
|
+
markIdle,
|
|
865
|
+
markError,
|
|
866
|
+
noteSessionHeartbeat,
|
|
867
|
+
resetHealth,
|
|
868
|
+
scheduleRestart: (sdkSessionId, error, options) => {
|
|
869
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
870
|
+
if (!sessionId) return null;
|
|
871
|
+
return scheduleRestart(sessionId, error, options);
|
|
872
|
+
},
|
|
873
|
+
clearRestartSchedule: (sdkSessionId, options) => {
|
|
874
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
875
|
+
if (!sessionId) return null;
|
|
876
|
+
return clearRestartSchedule(sessionId, options);
|
|
877
|
+
},
|
|
878
|
+
isKillBlocked: (sdkSessionId) => {
|
|
879
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
880
|
+
if (!sessionId) return false;
|
|
881
|
+
return isKillBlocked(sessionId);
|
|
882
|
+
},
|
|
883
|
+
markKilled: (sdkSessionId) => {
|
|
884
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
885
|
+
if (!sessionId) return null;
|
|
886
|
+
return markKilled(sessionId);
|
|
887
|
+
},
|
|
888
|
+
cancelPendingStart,
|
|
889
|
+
getLifecycleState,
|
|
890
|
+
canAttemptRestart: (sdkSessionId) => {
|
|
891
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
892
|
+
if (!sessionId) return { allowed: false, reason: 'missing-session-id', lifecycle: null };
|
|
893
|
+
return canAttemptRestart(sessionId);
|
|
894
|
+
},
|
|
895
|
+
evictIdleWorkers,
|
|
896
|
+
snapshot,
|
|
897
|
+
};
|
|
898
|
+
}
|