@gajae-code/coding-agent 0.16.7 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
|
@@ -21,6 +21,12 @@ import { DiscordNotificationDaemon } from "./discord-daemon";
|
|
|
21
21
|
|
|
22
22
|
import { DiscordLiveProvider } from "./discord-live-provider";
|
|
23
23
|
import type { DiscordProvider } from "./discord-provider";
|
|
24
|
+
import {
|
|
25
|
+
type DoctorDaemonIdentity,
|
|
26
|
+
type DoctorDaemonOccupancy,
|
|
27
|
+
type DoctorDaemonStatus,
|
|
28
|
+
doctorDaemonOccupancySettled,
|
|
29
|
+
} from "./doctor-daemon-restart";
|
|
24
30
|
import { type NotificationEvent, NotificationPresentationEngine } from "./engine";
|
|
25
31
|
import { SlackNotificationDaemon } from "./slack-daemon";
|
|
26
32
|
import { SlackLiveProvider } from "./slack-live-provider";
|
|
@@ -299,6 +305,11 @@ export class ChatDaemonRuntime {
|
|
|
299
305
|
#slack: SlackNotificationDaemon | undefined;
|
|
300
306
|
#presentation: NotificationPresentationEngine | undefined;
|
|
301
307
|
#transportHealthy: (() => boolean) | undefined;
|
|
308
|
+
#doctorPrepared = false;
|
|
309
|
+
/** Real inbound `/sdk` command dispatches currently admitted and running. */
|
|
310
|
+
readonly #inboundInFlight = new Set<Promise<unknown>>();
|
|
311
|
+
/** Real outbound provider notify/resume deliveries currently in flight. */
|
|
312
|
+
readonly #outboundInFlight = new Set<Promise<unknown>>();
|
|
302
313
|
|
|
303
314
|
constructor(
|
|
304
315
|
private readonly input: { kind: ChatDaemonKind; agentDir: string; config: ChatDaemonRuntimeConfig },
|
|
@@ -317,6 +328,24 @@ export class ChatDaemonRuntime {
|
|
|
317
328
|
});
|
|
318
329
|
}
|
|
319
330
|
|
|
331
|
+
#trackInbound<T>(work: Promise<T>): Promise<T> {
|
|
332
|
+
this.#inboundInFlight.add(work);
|
|
333
|
+
void work.then(
|
|
334
|
+
() => this.#inboundInFlight.delete(work),
|
|
335
|
+
() => this.#inboundInFlight.delete(work),
|
|
336
|
+
);
|
|
337
|
+
return work;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#trackOutbound<T>(work: Promise<T>): Promise<T> {
|
|
341
|
+
this.#outboundInFlight.add(work);
|
|
342
|
+
void work.then(
|
|
343
|
+
() => this.#outboundInFlight.delete(work),
|
|
344
|
+
() => this.#outboundInFlight.delete(work),
|
|
345
|
+
);
|
|
346
|
+
return work;
|
|
347
|
+
}
|
|
348
|
+
|
|
320
349
|
async start(): Promise<void> {
|
|
321
350
|
const replayReady = Promise.withResolvers<void>();
|
|
322
351
|
this.#replayReady = replayReady.promise;
|
|
@@ -443,6 +472,51 @@ export class ChatDaemonRuntime {
|
|
|
443
472
|
return this.#transportHealthy?.() ?? false;
|
|
444
473
|
}
|
|
445
474
|
|
|
475
|
+
/** Doctor reservation hook: stop admitting new `/sdk` command dispatch. Synchronous fence. */
|
|
476
|
+
prepareDoctorRestart(): boolean {
|
|
477
|
+
this.#doctorPrepared = true;
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
cancelDoctorRestart(): void {
|
|
482
|
+
if (!this.#stopping) this.#doctorPrepared = false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Real runtime occupancy, not synthetic placeholders. `attached` is live
|
|
487
|
+
* session attachments; `inflight` is attachment (re)publication barriers
|
|
488
|
+
* currently resolving; `inbound` is admitted `/sdk` command dispatches
|
|
489
|
+
* (including operator `bindExistingRoot` binds) actually executing;
|
|
490
|
+
* `outbound` is real Discord/Slack notify/resume provider deliveries in
|
|
491
|
+
* flight; `cleanup` is provider close/retire work that has not yet
|
|
492
|
+
* settled.
|
|
493
|
+
*/
|
|
494
|
+
doctorOccupancy(): DoctorDaemonOccupancy {
|
|
495
|
+
return {
|
|
496
|
+
attached: this.#attachments.size,
|
|
497
|
+
inflight: this.#attachmentBarriers.size,
|
|
498
|
+
inbound: this.#inboundInFlight.size,
|
|
499
|
+
outbound: this.#outboundInFlight.size,
|
|
500
|
+
cleanup: this.#cleanupWork.size + this.#retirementWork.size,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
doctorRestartStatus(identity: DoctorDaemonIdentity, requestId: string, leaseExpiresAt: number): DoctorDaemonStatus {
|
|
505
|
+
const occupancy = this.doctorOccupancy();
|
|
506
|
+
return {
|
|
507
|
+
...identity,
|
|
508
|
+
requestId,
|
|
509
|
+
phase: this.#doctorPrepared ? "prepared" : "idle",
|
|
510
|
+
admitting: !this.#doctorPrepared && !this.#stopping,
|
|
511
|
+
occupancy,
|
|
512
|
+
leaseExpiresAt,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
doctorRestartReady(): boolean {
|
|
517
|
+
return doctorDaemonOccupancySettled(this.doctorOccupancy());
|
|
518
|
+
}
|
|
519
|
+
|
|
446
520
|
/** Reconcile indexed attachments and optionally wait for their replay tails. */
|
|
447
521
|
async reconcile(options: { waitForReplay?: boolean } = {}): Promise<void> {
|
|
448
522
|
await this.#router.reconcile(options);
|
|
@@ -487,8 +561,19 @@ export class ChatDaemonRuntime {
|
|
|
487
561
|
|
|
488
562
|
/** Adopt an operator-supplied Slack root through the provider's presentation store. */
|
|
489
563
|
async bindExistingRoot(request: ChatDaemonCommandBindInput): Promise<ChatDaemonCommandOutcome> {
|
|
564
|
+
// Operator binds admit new provider-facing work exactly like /sdk command
|
|
565
|
+
// dispatch: they must be refused under the same doctor admission fence, not
|
|
566
|
+
// only the slash-command path.
|
|
567
|
+
if (this.#doctorPrepared) return { ok: false, certainty: "rejected", code: "target_not_configured" };
|
|
490
568
|
const slack = this.#slack;
|
|
491
569
|
if (!slack) return { ok: false, certainty: "rejected", code: "target_not_configured" };
|
|
570
|
+
return await this.#trackInbound(this.#bindExistingRoot(slack, request));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async #bindExistingRoot(
|
|
574
|
+
slack: SlackNotificationDaemon,
|
|
575
|
+
request: ChatDaemonCommandBindInput,
|
|
576
|
+
): Promise<ChatDaemonCommandOutcome> {
|
|
492
577
|
try {
|
|
493
578
|
const bound = await slack.bindExistingRoot(request.sessionId, request.rootTs, request.commitAuthority);
|
|
494
579
|
if (!bound.rootTs || bound.endpointGeneration === undefined)
|
|
@@ -662,23 +747,27 @@ export class ChatDaemonRuntime {
|
|
|
662
747
|
: undefined;
|
|
663
748
|
if (typeof content !== "string") return;
|
|
664
749
|
if (this.#discord)
|
|
665
|
-
await this.#
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
750
|
+
await this.#trackOutbound(
|
|
751
|
+
this.#discord.notify({
|
|
752
|
+
sessionId,
|
|
753
|
+
endpointGeneration: attachment.generation,
|
|
754
|
+
attachmentAuthorityId: attachment.authorityId,
|
|
755
|
+
content,
|
|
756
|
+
...(publicationId === undefined ? {} : { publicationId }),
|
|
757
|
+
...(notification.type === "action_needed"
|
|
758
|
+
? { actionId: notification.id, options: notification.options }
|
|
759
|
+
: {}),
|
|
760
|
+
}),
|
|
761
|
+
);
|
|
675
762
|
if (this.#slack)
|
|
676
|
-
await this.#
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
763
|
+
await this.#trackOutbound(
|
|
764
|
+
this.#slack.notify(
|
|
765
|
+
sessionId,
|
|
766
|
+
content,
|
|
767
|
+
notification.type === "action_needed" && notification.kind === "ask" ? notification.id : undefined,
|
|
768
|
+
attachment.generation,
|
|
769
|
+
publicationId,
|
|
770
|
+
),
|
|
682
771
|
);
|
|
683
772
|
}
|
|
684
773
|
|
|
@@ -721,16 +810,18 @@ export class ChatDaemonRuntime {
|
|
|
721
810
|
publicationId?: string,
|
|
722
811
|
): Promise<void> {
|
|
723
812
|
if (this.#discord) {
|
|
724
|
-
await this.#discord.resume(sessionId, generation, attachmentAuthorityId);
|
|
725
|
-
await this.#
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
813
|
+
await this.#trackOutbound(this.#discord.resume(sessionId, generation, attachmentAuthorityId));
|
|
814
|
+
await this.#trackOutbound(
|
|
815
|
+
this.#discord.notify({
|
|
816
|
+
sessionId,
|
|
817
|
+
endpointGeneration: generation,
|
|
818
|
+
attachmentAuthorityId,
|
|
819
|
+
content,
|
|
820
|
+
...(publicationId === undefined ? {} : { publicationId }),
|
|
821
|
+
}),
|
|
822
|
+
);
|
|
732
823
|
}
|
|
733
|
-
if (this.#slack) await this.#slack.resume(sessionId, content, generation, publicationId);
|
|
824
|
+
if (this.#slack) await this.#trackOutbound(this.#slack.resume(sessionId, content, generation, publicationId));
|
|
734
825
|
}
|
|
735
826
|
|
|
736
827
|
async #runChatCommand(
|
|
@@ -741,6 +832,31 @@ export class ChatDaemonRuntime {
|
|
|
741
832
|
idempotencyKey: string = randomUUID(),
|
|
742
833
|
beforeDispatch?: () => void,
|
|
743
834
|
dispatchFence?: <T>(dispatch: () => Promise<T>) => Promise<T>,
|
|
835
|
+
): Promise<boolean> {
|
|
836
|
+
// Fence BEFORE any admitted async work: a prepared doctor restart must
|
|
837
|
+
// refuse this dispatch synchronously, never merely self-report admitting.
|
|
838
|
+
if (this.#doctorPrepared) throw new ChatDeliveryError("pre_send");
|
|
839
|
+
return await this.#trackInbound(
|
|
840
|
+
this.#runChatCommandBody(
|
|
841
|
+
transport,
|
|
842
|
+
sessionId,
|
|
843
|
+
content,
|
|
844
|
+
expectedAttachment,
|
|
845
|
+
idempotencyKey,
|
|
846
|
+
beforeDispatch,
|
|
847
|
+
dispatchFence,
|
|
848
|
+
),
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async #runChatCommandBody(
|
|
853
|
+
transport: ChatTransport,
|
|
854
|
+
sessionId: string,
|
|
855
|
+
content: string,
|
|
856
|
+
expectedAttachment: SessionAttachment,
|
|
857
|
+
idempotencyKey: string,
|
|
858
|
+
beforeDispatch: (() => void) | undefined,
|
|
859
|
+
dispatchFence: (<T>(dispatch: () => Promise<T>) => Promise<T>) | undefined,
|
|
744
860
|
): Promise<boolean> {
|
|
745
861
|
const match = /^\/sdk\s+(control|query|global)\s+([^\s]+)(?:\s+(.+))?\s*$/.exec(content);
|
|
746
862
|
if (!match) return false;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, hold-only cross-process startup/maintenance exclusion for the D8/D9
|
|
3
|
+
* doctor daemon-service flows.
|
|
4
|
+
*
|
|
5
|
+
* This guard NEVER publishes ownership or service state and NEVER starts,
|
|
6
|
+
* signals, or claims a daemon owner. It is purely a mutual-exclusion fence,
|
|
7
|
+
* built on the same authoritative file-lock machinery every other durable
|
|
8
|
+
* daemon lock in this codebase uses ({@link withFileLock}): bounded retry with
|
|
9
|
+
* backoff, honoring an optional `AbortSignal`, and exact-lease release on the
|
|
10
|
+
* way out (never a blind recursive `rm`, and never a PID kill).
|
|
11
|
+
*
|
|
12
|
+
* Why a separate guard file: `withFileLock` serializes on `<path>.lock`, a
|
|
13
|
+
* name distinct from every existing canonical artifact for a service
|
|
14
|
+
* (`telegram-daemon.lock`/`.state.json`/`.steal`, or `owner.lock`/`state.json`
|
|
15
|
+
* for Discord/Slack). Acquiring this guard therefore can never collide with,
|
|
16
|
+
* be satisfied by, or be mistaken for an ordinary ownership/publication lock,
|
|
17
|
+
* and ordinary publication code paths are free to acquire their own locks
|
|
18
|
+
* independently — this primitive only fences bulk-maintenance and startup
|
|
19
|
+
* repair work (e.g. detaching a stale Telegram `.steal` transition marker)
|
|
20
|
+
* against a concurrent ordinary `ensure`, and vice versa.
|
|
21
|
+
*
|
|
22
|
+
* If a caller already holds this guard, it must route any further
|
|
23
|
+
* `withDaemonStartupExclusion` acquisition for the SAME `(agentDir, owner)`
|
|
24
|
+
* pair through nested async work that never itself re-enters this function —
|
|
25
|
+
* `withFileLock`'s underlying acquisition has no reentrant/owner-token
|
|
26
|
+
* concept visible to callers, so a second acquisition attempt from the same
|
|
27
|
+
* async context is indistinguishable from unrelated contention and will
|
|
28
|
+
* deadlock against itself. Nothing in this module grants, threads, or
|
|
29
|
+
* consults any bypass flag or ambient/AsyncLocalStorage authority to avoid
|
|
30
|
+
* that; the caller is responsible for not awaiting a child operation that
|
|
31
|
+
* itself needs this same guard while still holding it.
|
|
32
|
+
*/
|
|
33
|
+
import * as path from "node:path";
|
|
34
|
+
import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
|
|
35
|
+
import { CHAT_DAEMON_DIRECTORY } from "../service-artifact-paths";
|
|
36
|
+
import { daemonPaths } from "./daemon-paths";
|
|
37
|
+
|
|
38
|
+
export type DaemonStartupExclusionOwner = "telegram" | "discord" | "slack";
|
|
39
|
+
|
|
40
|
+
export interface DaemonStartupExclusionOptions {
|
|
41
|
+
/** Abort acquisition early; never releases a lease this call did not itself acquire. */
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
/** Upper bound on total wait while contended. Defaults to {@link DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS}. */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Matches the production wall-time bound already established for other durable daemon locks. */
|
|
48
|
+
export const DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS = 5_000;
|
|
49
|
+
const RETRY_DELAY_MS = 100;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The canonical per-service guard path. Lives inside the SAME per-service
|
|
53
|
+
* directory as the service's ordinary owner/state artifacts (so it inherits
|
|
54
|
+
* that directory's existing parent-permission posture) but under a filename
|
|
55
|
+
* reserved exclusively for this guard — never a name any ownership,
|
|
56
|
+
* publication, or transition-marker code path reads or writes.
|
|
57
|
+
*
|
|
58
|
+
* This function only computes a path; it performs no filesystem I/O and
|
|
59
|
+
* creates nothing. (`withFileLock` itself creates the missing parent
|
|
60
|
+
* directory chain, mode 0o700, purely as lock-acquisition scaffolding — an
|
|
61
|
+
* empty directory carries no service state.)
|
|
62
|
+
*/
|
|
63
|
+
export function daemonStartupExclusionPath(agentDir: string, owner: DaemonStartupExclusionOwner): string {
|
|
64
|
+
const root = path.resolve(agentDir);
|
|
65
|
+
const dir = owner === "telegram" ? daemonPaths(root).dir : path.join(root, CHAT_DAEMON_DIRECTORY, owner);
|
|
66
|
+
return path.join(dir, ".startup-exclusion");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run `operation` while holding the cross-process startup/maintenance
|
|
71
|
+
* exclusion for `(agentDir, owner)`. Bounded: acquisition retries back off
|
|
72
|
+
* for at most `timeoutMs` (default {@link DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS})
|
|
73
|
+
* and additionally honors `signal` if supplied — whichever bound is hit first
|
|
74
|
+
* rejects the acquisition. A rejected/timed-out/aborted acquisition never held
|
|
75
|
+
* a lease and therefore never releases one; it cannot touch a foreign owner's
|
|
76
|
+
* lock. On success, the lease this call acquired is released exactly once,
|
|
77
|
+
* through the same identity-bound release primitive `withFileLock` uses for
|
|
78
|
+
* every other durable lock in this codebase — never a recursive directory
|
|
79
|
+
* removal, and never a process signal.
|
|
80
|
+
*
|
|
81
|
+
* This primitive itself never reads, writes, or removes any owner/state/lock
|
|
82
|
+
* file belonging to the service; it only fences concurrent callers of this
|
|
83
|
+
* same function (and, by construction, is never consulted by ordinary
|
|
84
|
+
* ownership-acquisition or publication code, which uses its own distinct
|
|
85
|
+
* lock file).
|
|
86
|
+
*/
|
|
87
|
+
export async function withDaemonStartupExclusion<T>(
|
|
88
|
+
agentDir: string,
|
|
89
|
+
owner: DaemonStartupExclusionOwner,
|
|
90
|
+
operation: () => Promise<T>,
|
|
91
|
+
options: DaemonStartupExclusionOptions = {},
|
|
92
|
+
): Promise<T> {
|
|
93
|
+
return await withFileLock(daemonStartupExclusionPath(agentDir, owner), operation, exclusionOptions(options));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function exclusionOptions(options: DaemonStartupExclusionOptions): FileLockOptions {
|
|
97
|
+
const requested = options.timeoutMs ?? DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS;
|
|
98
|
+
if (!Number.isFinite(requested) || requested <= 0 || requested > 120_000)
|
|
99
|
+
throw new RangeError("invalid startup exclusion timeout");
|
|
100
|
+
const timeoutMs = Math.ceil(requested);
|
|
101
|
+
const retries = Math.max(Math.ceil(timeoutMs / RETRY_DELAY_MS), 1);
|
|
102
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
103
|
+
const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
|
|
104
|
+
const lockOptions: FileLockOptions = {
|
|
105
|
+
signal,
|
|
106
|
+
retries,
|
|
107
|
+
retryDelayMs: RETRY_DELAY_MS,
|
|
108
|
+
};
|
|
109
|
+
return lockOptions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Retain the same exclusion across an existing acquire/release lifecycle. */
|
|
113
|
+
export async function acquireDaemonStartupExclusion(
|
|
114
|
+
agentDir: string,
|
|
115
|
+
owner: DaemonStartupExclusionOwner,
|
|
116
|
+
options: DaemonStartupExclusionOptions = {},
|
|
117
|
+
): Promise<() => Promise<void>> {
|
|
118
|
+
return await acquireFileLock(daemonStartupExclusionPath(agentDir, owner), exclusionOptions(options));
|
|
119
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor-side caller for the owner-driven daemon restart protocol (D8).
|
|
3
|
+
*
|
|
4
|
+
* The owner already implements prepare/commit/cancel against its own admission
|
|
5
|
+
* fence and occupancy (`chat-daemon-cli.ts`, `telegram-daemon-cli.ts`); this
|
|
6
|
+
* module is the counterpart that a `gjc doctor --fix --repair
|
|
7
|
+
* service.restart-owned` invocation drives. It never signals, kills, or reloads
|
|
8
|
+
* anything: the only lever is the durable control request the owner polls, and
|
|
9
|
+
* the only proof of the owner's exit is a positive kernel observation of its
|
|
10
|
+
* exact incarnation.
|
|
11
|
+
*
|
|
12
|
+
* The successor is started through the product's own sanctioned start path,
|
|
13
|
+
* never a doctor-private spawn, so the existing startup exclusion stays the
|
|
14
|
+
* single authority for who may own the slot.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import type { Settings } from "../../config/settings";
|
|
18
|
+
import { observeProcessIncarnation } from "../broker/process-incarnation";
|
|
19
|
+
import {
|
|
20
|
+
type ChatDaemonKind,
|
|
21
|
+
type ChatDaemonState,
|
|
22
|
+
chatDaemonGeneration,
|
|
23
|
+
clearChatDoctorControlRequest,
|
|
24
|
+
ensureDiscordDaemon,
|
|
25
|
+
ensureSlackDaemon,
|
|
26
|
+
readChatDaemonState,
|
|
27
|
+
readChatDoctorControlRequest,
|
|
28
|
+
writeChatDoctorControlRequest,
|
|
29
|
+
} from "./chat-daemon-control";
|
|
30
|
+
import type { DoctorDaemonControlRequest, DoctorDaemonIdentity, DoctorDaemonOwner } from "./doctor-daemon-restart";
|
|
31
|
+
import { ensureTelegramDaemonRunning, readDaemonState } from "./telegram-daemon";
|
|
32
|
+
import { DAEMON_GENERATION } from "./telegram-daemon-contract";
|
|
33
|
+
import {
|
|
34
|
+
clearTelegramDoctorControlRequest,
|
|
35
|
+
readTelegramDoctorControlRequest,
|
|
36
|
+
writeTelegramDoctorControlRequest,
|
|
37
|
+
} from "./telegram-daemon-control";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-owner adapter over the two control-file surfaces. Both owners implement
|
|
41
|
+
* the same prepare/commit/cancel protocol; only the accessor signatures and the
|
|
42
|
+
* sanctioned start path differ, so this is a shape adapter, never a second
|
|
43
|
+
* protocol.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* A published owner this doctor can drive, an owner that predates the current
|
|
47
|
+
* restart protocol (which requires the disclosed one-time manual transition and
|
|
48
|
+
* must never be signalled), or no record at all. The three are distinct: a
|
|
49
|
+
* pre-protocol incumbent is present and must not be reported as absent.
|
|
50
|
+
*/
|
|
51
|
+
type OwnerObservation =
|
|
52
|
+
| { readonly kind: "current"; readonly pid: number; readonly identity: DoctorDaemonIdentity }
|
|
53
|
+
| { readonly kind: "pre_protocol" }
|
|
54
|
+
| { readonly kind: "absent" };
|
|
55
|
+
|
|
56
|
+
interface OwnerAdapter {
|
|
57
|
+
readonly owner: DoctorDaemonOwner;
|
|
58
|
+
observe(): Promise<OwnerObservation>;
|
|
59
|
+
read(): Promise<DoctorDaemonControlRequest | undefined>;
|
|
60
|
+
write(request: DoctorDaemonControlRequest): Promise<void>;
|
|
61
|
+
clear(requestId: string): Promise<void>;
|
|
62
|
+
/** The product's own sanctioned start path; never a doctor-private spawn. */
|
|
63
|
+
start(): Promise<boolean>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const POLL_MS = 100;
|
|
67
|
+
const DEFAULT_DEADLINE_MS = 30_000;
|
|
68
|
+
|
|
69
|
+
export interface DoctorDaemonRestartOptions {
|
|
70
|
+
readonly agentDir: string;
|
|
71
|
+
readonly owner: DoctorDaemonOwner;
|
|
72
|
+
readonly settings: Settings;
|
|
73
|
+
readonly requestId?: string;
|
|
74
|
+
readonly deadlineMs?: number;
|
|
75
|
+
/** Bounded natural-completion wait; idle attached sessions still count as workload. */
|
|
76
|
+
readonly drainSeconds?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Typed before/after-effect outcome. Every refusal names the exact stage it
|
|
81
|
+
* stopped at, so a caller never has to infer whether an effect began.
|
|
82
|
+
*/
|
|
83
|
+
export type DoctorDaemonRestartOutcome =
|
|
84
|
+
| {
|
|
85
|
+
kind: "restarted";
|
|
86
|
+
requestId: string;
|
|
87
|
+
oldOwner: DoctorDaemonIdentity;
|
|
88
|
+
successor: DoctorDaemonIdentity;
|
|
89
|
+
adopted: boolean;
|
|
90
|
+
}
|
|
91
|
+
| { kind: "owner_unavailable"; reason: "no_state" | "owner_not_confirmed_live" | "unsupported_incumbent_protocol" }
|
|
92
|
+
| { kind: "prepare_refused"; reason: "control_write_failed" | "not_acknowledged" }
|
|
93
|
+
// The owner enforces occupancy itself and simply does not exit while work
|
|
94
|
+
// remains, which this client observes as `old_owner_exit_timeout`. A failed
|
|
95
|
+
// commit here therefore means only that the durable request could not be
|
|
96
|
+
// written, and must not claim to describe occupancy.
|
|
97
|
+
| { kind: "commit_refused"; reason: "control_write_failed" }
|
|
98
|
+
| { kind: "old_owner_exit_timeout"; requestId: string; oldOwner: DoctorDaemonIdentity }
|
|
99
|
+
| {
|
|
100
|
+
kind: "successor_refused";
|
|
101
|
+
requestId: string;
|
|
102
|
+
oldOwner: DoctorDaemonIdentity;
|
|
103
|
+
reason: "start_refused" | "publication_timeout";
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function identityOf(owner: DoctorDaemonOwner, state: ChatDaemonState): DoctorDaemonIdentity {
|
|
107
|
+
return { owner, ownerId: state.ownerId, generation: state.generation, incarnation: state.incarnation };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function chatAdapter(options: DoctorDaemonRestartOptions, kind: ChatDaemonKind): OwnerAdapter {
|
|
111
|
+
return {
|
|
112
|
+
owner: kind,
|
|
113
|
+
async observe(): Promise<OwnerObservation> {
|
|
114
|
+
const state = await readChatDaemonState(options.agentDir, kind);
|
|
115
|
+
if (!state || state.stoppedAt !== undefined) return { kind: "absent" };
|
|
116
|
+
// Present but older than the current restart protocol: the operator must
|
|
117
|
+
// perform the disclosed one-time manual transition; doctor never signals it.
|
|
118
|
+
if (state.generation !== chatDaemonGeneration(kind)) return { kind: "pre_protocol" };
|
|
119
|
+
return { kind: "current", pid: state.pid, identity: identityOf(kind, state) };
|
|
120
|
+
},
|
|
121
|
+
read: () => readChatDoctorControlRequest(options.agentDir, kind),
|
|
122
|
+
write: request => writeChatDoctorControlRequest(options.agentDir, kind, request),
|
|
123
|
+
clear: requestId => clearChatDoctorControlRequest(options.agentDir, kind, requestId),
|
|
124
|
+
async start() {
|
|
125
|
+
const ensured =
|
|
126
|
+
kind === "discord"
|
|
127
|
+
? await ensureDiscordDaemon(options.settings)
|
|
128
|
+
: await ensureSlackDaemon(options.settings);
|
|
129
|
+
return ensured !== "disabled";
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function telegramAdapter(options: DoctorDaemonRestartOptions): OwnerAdapter {
|
|
135
|
+
const settings = options.settings;
|
|
136
|
+
return {
|
|
137
|
+
owner: "telegram",
|
|
138
|
+
async observe(): Promise<OwnerObservation> {
|
|
139
|
+
const state = await readDaemonState(settings);
|
|
140
|
+
if (!state || state.stoppedAt !== undefined) return { kind: "absent" };
|
|
141
|
+
if (state.generation !== DAEMON_GENERATION) return { kind: "pre_protocol" };
|
|
142
|
+
// A provisional owner is physically live but not attachable as ready, so it
|
|
143
|
+
// is not a restartable incumbent.
|
|
144
|
+
if (state.ownershipPhase !== undefined && state.ownershipPhase !== "ready") return { kind: "absent" };
|
|
145
|
+
return {
|
|
146
|
+
kind: "current",
|
|
147
|
+
pid: state.pid,
|
|
148
|
+
identity: {
|
|
149
|
+
owner: "telegram",
|
|
150
|
+
ownerId: state.ownerId,
|
|
151
|
+
generation: state.generation,
|
|
152
|
+
incarnation: state.incarnation,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
read: () => readTelegramDoctorControlRequest(settings),
|
|
157
|
+
write: request => writeTelegramDoctorControlRequest(settings, request),
|
|
158
|
+
clear: requestId => clearTelegramDoctorControlRequest(settings, requestId),
|
|
159
|
+
async start() {
|
|
160
|
+
// The product's own ensure path resolves the configured destination and
|
|
161
|
+
// holds the existing setup lease; doctor never supplies a token or chat and
|
|
162
|
+
// never refreshes a credential.
|
|
163
|
+
const ensured = await ensureTelegramDaemonRunning({ settings });
|
|
164
|
+
return ensured === "owner_spawned" || ensured === "attached";
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Positive kernel absence only: a changed or missing record is never death proof. */
|
|
170
|
+
function confirmedExited(pid: number, incarnation: string): boolean {
|
|
171
|
+
const observation = observeProcessIncarnation(pid);
|
|
172
|
+
if (observation.status === "absent") return true;
|
|
173
|
+
// A live pid whose incarnation differs is a reused pid, so the original
|
|
174
|
+
// incarnation is genuinely gone; `unknown` stays inconclusive.
|
|
175
|
+
return observation.status === "present" && observation.incarnation !== incarnation;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function request(
|
|
179
|
+
adapter: OwnerAdapter,
|
|
180
|
+
identity: DoctorDaemonIdentity,
|
|
181
|
+
requestId: string,
|
|
182
|
+
action: DoctorDaemonControlRequest["action"],
|
|
183
|
+
leaseExpiresAt: number,
|
|
184
|
+
): Promise<boolean> {
|
|
185
|
+
try {
|
|
186
|
+
await adapter.write({ version: 1, requestId, action, leaseExpiresAt, createdAt: Date.now(), ...identity });
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Owner-side prepare/commit, positive-exit-confirmed cleanup, then exactly one
|
|
195
|
+
* successor started through the product's own ensure path.
|
|
196
|
+
*
|
|
197
|
+
* Ordering is fixed: prepare (owner closes admission) → occupancy settles →
|
|
198
|
+
* commit → owner exits under its own gate → positive absence proof → successor
|
|
199
|
+
* start or adoption. A commit is never issued before the owner acknowledged the
|
|
200
|
+
* prepare, and a successor is never started before the predecessor is proven
|
|
201
|
+
* gone.
|
|
202
|
+
*/
|
|
203
|
+
export async function restartDaemonForDoctor(options: DoctorDaemonRestartOptions): Promise<DoctorDaemonRestartOutcome> {
|
|
204
|
+
const adapter = options.owner === "telegram" ? telegramAdapter(options) : chatAdapter(options, options.owner);
|
|
205
|
+
const deadlineAt = Date.now() + (options.deadlineMs ?? DEFAULT_DEADLINE_MS);
|
|
206
|
+
const before = await adapter.observe();
|
|
207
|
+
if (before.kind === "pre_protocol") return { kind: "owner_unavailable", reason: "unsupported_incumbent_protocol" };
|
|
208
|
+
if (before.kind === "absent") return { kind: "owner_unavailable", reason: "no_state" };
|
|
209
|
+
if (confirmedExited(before.pid, before.identity.incarnation))
|
|
210
|
+
return { kind: "owner_unavailable", reason: "owner_not_confirmed_live" };
|
|
211
|
+
const identity = before.identity;
|
|
212
|
+
const requestId = options.requestId ?? randomUUID();
|
|
213
|
+
if (!(await request(adapter, identity, requestId, "prepare", deadlineAt)))
|
|
214
|
+
return { kind: "prepare_refused", reason: "control_write_failed" };
|
|
215
|
+
|
|
216
|
+
// The owner consumes a prepare without rewriting it, so the request simply
|
|
217
|
+
// staying present proves nothing: this process wrote it. What IS observable to
|
|
218
|
+
// a non-parent caller is that the owner remains the same live incarnation that
|
|
219
|
+
// can still act on it. A request that outlives its own owner, or that a third
|
|
220
|
+
// party removed, is never treated as accepted.
|
|
221
|
+
const drainUntil = Date.now() + (options.drainSeconds ?? 0) * 1_000;
|
|
222
|
+
let acknowledged = false;
|
|
223
|
+
while (Date.now() < deadlineAt) {
|
|
224
|
+
const current = await adapter.read();
|
|
225
|
+
if (current?.requestId !== requestId) break;
|
|
226
|
+
const owner = await adapter.observe();
|
|
227
|
+
if (owner.kind !== "current" || owner.identity.incarnation !== identity.incarnation) break;
|
|
228
|
+
if (!confirmedExited(before.pid, identity.incarnation)) {
|
|
229
|
+
acknowledged = true;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
await Bun.sleep(POLL_MS);
|
|
233
|
+
}
|
|
234
|
+
if (!acknowledged) {
|
|
235
|
+
// An owner that already consumed the prepare has closed admission, and only a
|
|
236
|
+
// `cancel` under the same request id reopens it — deleting the control file
|
|
237
|
+
// reopens nothing and would strand a healthy daemon refusing all work. Cancel
|
|
238
|
+
// when the addressed owner is still there (it unlinks the file itself);
|
|
239
|
+
// otherwise there is nobody to reopen and the stale file is just removed.
|
|
240
|
+
const owner = await adapter.observe();
|
|
241
|
+
if (owner.kind === "current" && owner.identity.incarnation === identity.incarnation)
|
|
242
|
+
await request(adapter, identity, requestId, "cancel", deadlineAt);
|
|
243
|
+
else await adapter.clear(requestId);
|
|
244
|
+
return { kind: "prepare_refused", reason: "not_acknowledged" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// `--drain` is bounded natural completion only. The owner itself refuses the
|
|
248
|
+
// commit while any workload remains (an idle attached session counts), so this
|
|
249
|
+
// wait never detaches or retires anything on its behalf.
|
|
250
|
+
if (Date.now() < drainUntil)
|
|
251
|
+
await Bun.sleep(Math.min(drainUntil - Date.now(), Math.max(0, deadlineAt - Date.now())));
|
|
252
|
+
|
|
253
|
+
if (!(await request(adapter, identity, requestId, "commit", deadlineAt))) {
|
|
254
|
+
await adapter.clear(requestId);
|
|
255
|
+
return { kind: "commit_refused", reason: "control_write_failed" };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
while (Date.now() < deadlineAt) {
|
|
259
|
+
if (confirmedExited(before.pid, identity.incarnation)) break;
|
|
260
|
+
await Bun.sleep(POLL_MS);
|
|
261
|
+
}
|
|
262
|
+
if (!confirmedExited(before.pid, identity.incarnation))
|
|
263
|
+
// The commit is durable and the owner may still retire under its own gate;
|
|
264
|
+
// never cancel it here and never force the exit.
|
|
265
|
+
return { kind: "old_owner_exit_timeout", requestId, oldOwner: identity };
|
|
266
|
+
|
|
267
|
+
// An already-published successor of the same generation is adopted rather than
|
|
268
|
+
// starting a second one.
|
|
269
|
+
const published = await adapter.observe();
|
|
270
|
+
if (published.kind === "current" && published.identity.incarnation !== identity.incarnation) {
|
|
271
|
+
await adapter.clear(requestId);
|
|
272
|
+
return { kind: "restarted", requestId, oldOwner: identity, successor: published.identity, adopted: true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
if (!(await adapter.start()))
|
|
277
|
+
return { kind: "successor_refused", requestId, oldOwner: identity, reason: "start_refused" };
|
|
278
|
+
} catch {
|
|
279
|
+
return { kind: "successor_refused", requestId, oldOwner: identity, reason: "start_refused" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
while (Date.now() < deadlineAt) {
|
|
283
|
+
const successor = await adapter.observe();
|
|
284
|
+
if (successor.kind === "current" && successor.identity.incarnation !== identity.incarnation) {
|
|
285
|
+
await adapter.clear(requestId);
|
|
286
|
+
return { kind: "restarted", requestId, oldOwner: identity, successor: successor.identity, adopted: false };
|
|
287
|
+
}
|
|
288
|
+
await Bun.sleep(POLL_MS);
|
|
289
|
+
}
|
|
290
|
+
return { kind: "successor_refused", requestId, oldOwner: identity, reason: "publication_timeout" };
|
|
291
|
+
}
|