@gajae-code/coding-agent 0.11.3 → 0.11.5
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 +47 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/cli/worktree-cli.d.ts +8 -25
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/worktree.d.ts +7 -32
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +8 -3
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +77 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/input-controller.d.ts +8 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/broker/broker.d.ts +1 -0
- package/dist/types/sdk/broker/discovery.d.ts +10 -0
- package/dist/types/sdk/broker/ensure.d.ts +24 -1
- package/dist/types/sdk/broker/internal.d.ts +3 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -0
- package/dist/types/sdk/broker/state-version.d.ts +2 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +88 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +87 -12
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
- package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/path-utils.d.ts +9 -14
- package/dist/types/tools/render-utils.d.ts +1 -8
- package/dist/types/tools/resource-gc.d.ts +14 -0
- package/dist/types/utils/image-loading.d.ts +23 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/pasted-image-loading.d.ts +39 -0
- package/dist/types/utils/pasted-image-path.d.ts +21 -19
- package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
- package/dist/types/web/scrapers/types.d.ts +1 -5
- package/package.json +25 -9
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-threat-model-check.ts +60 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/cli/worktree-cli.ts +9 -287
- package/src/cli.ts +10 -9
- package/src/commands/gc.ts +11 -1
- package/src/commands/sdk.ts +10 -4
- package/src/commands/worktree.ts +10 -53
- package/src/config/model-profiles.ts +15 -15
- package/src/config/settings-schema.ts +17 -0
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/gc-render.ts +19 -1
- package/src/gjc-runtime/gc-runtime.ts +68 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-argv.ts +292 -0
- package/src/gjc-runtime/state-runtime.ts +80 -222
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +37 -13
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/custom-editor.ts +103 -32
- package/src/modes/components/custom-provider-wizard.ts +26 -1
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -37
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +393 -0
- package/src/modes/components/transcript-viewer-overlay.ts +63 -1
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/input-controller.ts +98 -37
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +71 -9
- package/src/modes/interactive-mode.ts +14 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/broker.ts +139 -32
- package/src/sdk/broker/discovery.ts +125 -0
- package/src/sdk/broker/ensure.ts +103 -19
- package/src/sdk/broker/internal.ts +10 -0
- package/src/sdk/broker/session-index.ts +309 -38
- package/src/sdk/broker/state-version.ts +5 -2
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +526 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +384 -92
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
- package/src/sdk/bus/telegram-daemon-control.ts +294 -76
- package/src/sdk/bus/telegram-daemon.ts +1842 -281
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +495 -253
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +215 -39
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1055 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask.ts +329 -57
- package/src/tools/bash-allowed-prefixes.ts +79 -29
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/browser/launch.ts +3 -1
- package/src/tools/browser/readable.ts +1 -1
- package/src/tools/browser/screenshot-format.ts +34 -0
- package/src/tools/browser/tab-protocol.ts +3 -0
- package/src/tools/browser/tab-supervisor.ts +125 -122
- package/src/tools/browser/tab-worker-entry.ts +33 -14
- package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
- package/src/tools/browser/tab-worker-smoke.ts +87 -0
- package/src/tools/browser/tab-worker.ts +7 -5
- package/src/tools/index.ts +4 -0
- package/src/tools/path-utils.ts +42 -35
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/read.ts +7 -1
- package/src/tools/render-utils.ts +1 -27
- package/src/tools/resource-gc.ts +123 -34
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
- package/src/utils/image-loading.ts +85 -34
- package/src/utils/image-resize.ts +16 -9
- package/src/utils/pasted-image-loading.ts +514 -0
- package/src/utils/pasted-image-path.ts +223 -98
- package/src/web/scrapers/html-to-markdown.ts +68 -0
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/types.ts +1 -68
- package/src/web/search/providers/openai-compatible.ts +76 -23
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawn as childProcessSpawn } from "node:child_process";
|
|
2
2
|
import * as crypto from "node:crypto";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
6
|
+
import * as native from "@gajae-code/natives";
|
|
7
|
+
import { Process } from "@gajae-code/natives";
|
|
5
8
|
import type { Settings } from "../../config/settings";
|
|
6
9
|
import type {
|
|
7
10
|
BuiltInDaemonController,
|
|
@@ -18,6 +21,20 @@ import { getNotificationConfig, isDiscordConfigured, isSlackConfigured } from ".
|
|
|
18
21
|
export type ChatDaemonKind = "discord" | "slack";
|
|
19
22
|
export type ChatDaemonAction = "stop" | "reload";
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Operational generations of the Discord/Slack daemon lifecycle contracts.
|
|
26
|
+
* These are intentionally separate from per-session endpoint generations.
|
|
27
|
+
* Generation 6 carries the retained managed filesystem authority boundary.
|
|
28
|
+
*/
|
|
29
|
+
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
30
|
+
discord: 6,
|
|
31
|
+
slack: 6,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|
|
35
|
+
return CHAT_DAEMON_GENERATIONS[kind];
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
export interface ChatDaemonState {
|
|
22
39
|
version: 1;
|
|
23
40
|
kind: ChatDaemonKind;
|
|
@@ -28,9 +45,111 @@ export interface ChatDaemonState {
|
|
|
28
45
|
startedAt: number;
|
|
29
46
|
heartbeatAt: number;
|
|
30
47
|
transportHealthy: boolean;
|
|
48
|
+
generation: number;
|
|
31
49
|
stoppedAt?: number;
|
|
32
50
|
}
|
|
33
51
|
|
|
52
|
+
/**
|
|
53
|
+
* State files are untrusted persisted input. A record must be completely valid
|
|
54
|
+
* before its PID can be treated as an owner, stopped, or safe to replace.
|
|
55
|
+
*/
|
|
56
|
+
/** A legacy owner is recognized only when the sole missing field is generation. */
|
|
57
|
+
export function isRecognizedLegacyGeneration(value: unknown): value is undefined {
|
|
58
|
+
return value === undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasProcessIncarnationAuthority(incarnation: unknown): incarnation is string {
|
|
62
|
+
return typeof incarnation === "string" && isProcessIncarnation(incarnation);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasSafeChatDaemonOwnerShape(
|
|
66
|
+
value: unknown,
|
|
67
|
+
): value is Omit<ChatDaemonState, "generation"> & { generation?: unknown } {
|
|
68
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
69
|
+
const state = value as Record<string, unknown>;
|
|
70
|
+
return (
|
|
71
|
+
state.version === 1 &&
|
|
72
|
+
(state.kind === "discord" || state.kind === "slack") &&
|
|
73
|
+
typeof state.pid === "number" &&
|
|
74
|
+
Number.isSafeInteger(state.pid) &&
|
|
75
|
+
state.pid > 0 &&
|
|
76
|
+
typeof state.ownerId === "string" &&
|
|
77
|
+
state.ownerId.length > 0 &&
|
|
78
|
+
typeof state.identity === "string" &&
|
|
79
|
+
state.identity.length > 0 &&
|
|
80
|
+
hasProcessIncarnationAuthority(state.incarnation) &&
|
|
81
|
+
typeof state.startedAt === "number" &&
|
|
82
|
+
Number.isFinite(state.startedAt) &&
|
|
83
|
+
typeof state.heartbeatAt === "number" &&
|
|
84
|
+
Number.isFinite(state.heartbeatAt) &&
|
|
85
|
+
typeof state.transportHealthy === "boolean" &&
|
|
86
|
+
(state.stoppedAt === undefined || (typeof state.stoppedAt === "number" && Number.isFinite(state.stoppedAt)))
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function hasSafeChatDaemonStateShape(value: unknown): value is ChatDaemonState {
|
|
91
|
+
if (!hasSafeChatDaemonOwnerShape(value)) return false;
|
|
92
|
+
const state = value as Record<string, unknown>;
|
|
93
|
+
return typeof state.generation === "number" && Number.isSafeInteger(state.generation) && state.generation >= 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Versions before immutable process provenance persisted this single sentinel.
|
|
98
|
+
* Recover it only after proving its recorded PID is dead; every other malformed
|
|
99
|
+
* record remains fail-closed.
|
|
100
|
+
*/
|
|
101
|
+
function isExactPreUpgradeUnavailableChatDaemonState(
|
|
102
|
+
value: unknown,
|
|
103
|
+
): value is Omit<ChatDaemonState, "generation" | "incarnation"> & { incarnation: "unavailable" } {
|
|
104
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
105
|
+
const state = value as Record<string, unknown>;
|
|
106
|
+
const keys = Object.keys(state);
|
|
107
|
+
if (
|
|
108
|
+
keys.some(
|
|
109
|
+
key =>
|
|
110
|
+
key !== "version" &&
|
|
111
|
+
key !== "kind" &&
|
|
112
|
+
key !== "pid" &&
|
|
113
|
+
key !== "ownerId" &&
|
|
114
|
+
key !== "identity" &&
|
|
115
|
+
key !== "incarnation" &&
|
|
116
|
+
key !== "startedAt" &&
|
|
117
|
+
key !== "heartbeatAt" &&
|
|
118
|
+
key !== "transportHealthy" &&
|
|
119
|
+
key !== "stoppedAt",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return false;
|
|
123
|
+
return (
|
|
124
|
+
state.version === 1 &&
|
|
125
|
+
(state.kind === "discord" || state.kind === "slack") &&
|
|
126
|
+
typeof state.pid === "number" &&
|
|
127
|
+
Number.isSafeInteger(state.pid) &&
|
|
128
|
+
state.pid > 0 &&
|
|
129
|
+
typeof state.ownerId === "string" &&
|
|
130
|
+
state.ownerId.length > 0 &&
|
|
131
|
+
typeof state.identity === "string" &&
|
|
132
|
+
state.identity.length > 0 &&
|
|
133
|
+
state.incarnation === "unavailable" &&
|
|
134
|
+
typeof state.startedAt === "number" &&
|
|
135
|
+
Number.isFinite(state.startedAt) &&
|
|
136
|
+
typeof state.heartbeatAt === "number" &&
|
|
137
|
+
Number.isFinite(state.heartbeatAt) &&
|
|
138
|
+
typeof state.transportHealthy === "boolean" &&
|
|
139
|
+
(state.stoppedAt === undefined || (typeof state.stoppedAt === "number" && Number.isFinite(state.stoppedAt)))
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasChatDaemonStatePid(value: unknown): value is { pid: number } {
|
|
144
|
+
return (
|
|
145
|
+
!!value &&
|
|
146
|
+
typeof value === "object" &&
|
|
147
|
+
typeof (value as { pid?: unknown }).pid === "number" &&
|
|
148
|
+
Number.isSafeInteger((value as { pid: number }).pid) &&
|
|
149
|
+
(value as { pid: number }).pid > 0
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
34
153
|
export interface ChatDaemonControlRequest {
|
|
35
154
|
version: 1;
|
|
36
155
|
requestId: string;
|
|
@@ -41,21 +160,52 @@ export interface ChatDaemonControlRequest {
|
|
|
41
160
|
incarnation: string;
|
|
42
161
|
}
|
|
43
162
|
|
|
163
|
+
export interface ChatDaemonProcessReference {
|
|
164
|
+
incarnation: string;
|
|
165
|
+
signalRoot(signal: NodeJS.Signals): void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function defaultProcessReference(pid: number, platform = os.platform()): ChatDaemonProcessReference | undefined {
|
|
169
|
+
// Process.fromPid on Darwin still resolves identity and then signals a numeric
|
|
170
|
+
// PID. A process can exit and its PID be reused in that interval, so daemon
|
|
171
|
+
// control must not use it as privileged signaling authority.
|
|
172
|
+
if (platform === "darwin") return undefined;
|
|
173
|
+
try {
|
|
174
|
+
const processRef = Process.fromPid(pid);
|
|
175
|
+
if (!processRef || !hasProcessIncarnationAuthority(processRef.incarnation)) return undefined;
|
|
176
|
+
return {
|
|
177
|
+
incarnation: processRef.incarnation,
|
|
178
|
+
signalRoot: signal => {
|
|
179
|
+
const nativeSignal = os.constants.signals[signal];
|
|
180
|
+
if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
|
|
181
|
+
const rootProcess = processRef as typeof processRef & { signalRoot(signal: number): boolean };
|
|
182
|
+
if (!rootProcess.signalRoot(nativeSignal)) throw new Error("Pinned process is already gone");
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
44
190
|
export interface ChatDaemonControlDeps {
|
|
45
191
|
pidAlive?: (pid: number) => boolean;
|
|
46
|
-
|
|
192
|
+
processReference?: (pid: number) => ChatDaemonProcessReference | undefined;
|
|
47
193
|
spawn?: (command: string, args: string[], opts: { detached: boolean; stdio: "ignore" }) => { unref?: () => void };
|
|
48
194
|
execPath?: string;
|
|
49
195
|
ownerPid?: number;
|
|
50
196
|
randomId?: () => string;
|
|
51
197
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
198
|
+
/** Test seam for platform-specific default stable-process authority. */
|
|
199
|
+
platform?: NodeJS.Platform;
|
|
52
200
|
sleep?: (ms: number) => Promise<void>;
|
|
201
|
+
spawnReadyTimeoutMs?: number;
|
|
53
202
|
}
|
|
54
203
|
|
|
55
204
|
const HEARTBEAT_TTL_MS = 20_000;
|
|
56
205
|
const DEFAULT_GRACEFUL_TIMEOUT_MS = 8_000;
|
|
57
206
|
const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
|
58
|
-
|
|
207
|
+
/** Covers Discord READY plus its first 5-second heartbeat; tests inject a smaller timeout. */
|
|
208
|
+
const DEFAULT_SPAWN_READY_TIMEOUT_MS = 8_000;
|
|
59
209
|
|
|
60
210
|
interface ChatDaemonOwnerLock {
|
|
61
211
|
pid: number;
|
|
@@ -63,6 +213,15 @@ interface ChatDaemonOwnerLock {
|
|
|
63
213
|
createdAt: number;
|
|
64
214
|
}
|
|
65
215
|
|
|
216
|
+
interface ChatDaemonOwnerLockLease {
|
|
217
|
+
content: string;
|
|
218
|
+
dev: bigint;
|
|
219
|
+
ino: bigint;
|
|
220
|
+
size: bigint;
|
|
221
|
+
mtimeNs: bigint;
|
|
222
|
+
sha256: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
66
225
|
interface ChatDaemonOwnershipProbe {
|
|
67
226
|
pidAlive(pid: number): boolean;
|
|
68
227
|
pidIncarnation(pid: number): string | undefined;
|
|
@@ -117,12 +276,11 @@ function defaultPidAlive(pid: number): boolean {
|
|
|
117
276
|
return false;
|
|
118
277
|
}
|
|
119
278
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
279
|
+
/** Windows process ownership uses immutable StartTime/FileTime provenance through
|
|
280
|
+
* the shared native/platform authority. Without authority, numeric PIDs are never trusted. */
|
|
281
|
+
function defaultPidIncarnation(pid: number): string | undefined {
|
|
282
|
+
return processIncarnation(pid);
|
|
124
283
|
}
|
|
125
|
-
|
|
126
284
|
function runtimeInfo(execPath?: string): DaemonRuntimeInfo {
|
|
127
285
|
const rt = resolveGjcRuntimeSpawnInfo(execPath ?? process.execPath);
|
|
128
286
|
return {
|
|
@@ -219,6 +377,15 @@ export function buildChatDaemonSpawnArgs(input: {
|
|
|
219
377
|
};
|
|
220
378
|
}
|
|
221
379
|
|
|
380
|
+
type ChatDaemonStateClassification =
|
|
381
|
+
| "absent"
|
|
382
|
+
| "replaceable"
|
|
383
|
+
| "compatible"
|
|
384
|
+
| "newer"
|
|
385
|
+
| "malformed"
|
|
386
|
+
| "unauthorized"
|
|
387
|
+
| "stopped";
|
|
388
|
+
|
|
222
389
|
export class ChatDaemonController implements BuiltInDaemonController {
|
|
223
390
|
readonly kind: ChatDaemonKind;
|
|
224
391
|
constructor(
|
|
@@ -237,20 +404,9 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
237
404
|
async status(): Promise<DaemonStatus> {
|
|
238
405
|
const runtime = runtimeInfo(this.deps.execPath);
|
|
239
406
|
const identity = this.identity();
|
|
240
|
-
if (!identity) return { kind: this.kind, configured: false, health: "not_configured", runtime };
|
|
241
407
|
const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
state.version === 1 &&
|
|
245
|
-
state.kind === this.kind &&
|
|
246
|
-
state.identity === identity &&
|
|
247
|
-
!state.stoppedAt &&
|
|
248
|
-
state.transportHealthy &&
|
|
249
|
-
Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
250
|
-
this.alive(state.pid) &&
|
|
251
|
-
this.incarnation(state.pid) === state.incarnation,
|
|
252
|
-
);
|
|
253
|
-
const health: DaemonHealth = live ? "running" : state && !state.stoppedAt ? "stale" : "stopped";
|
|
408
|
+
if (!identity) return { kind: this.kind, configured: false, health: "not_configured", runtime };
|
|
409
|
+
const health: DaemonHealth = this.stateHealth(state, identity);
|
|
254
410
|
return {
|
|
255
411
|
kind: this.kind,
|
|
256
412
|
configured: true,
|
|
@@ -272,14 +428,27 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
272
428
|
const identity = this.identity();
|
|
273
429
|
if (!identity) return "disabled";
|
|
274
430
|
const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
275
|
-
|
|
276
|
-
|
|
431
|
+
const classification = this.classify(existing, identity);
|
|
432
|
+
if (classification === "malformed" || classification === "unauthorized")
|
|
433
|
+
throw new Error(`Unable to replace unauthorized ${this.kind} daemon owner`);
|
|
434
|
+
if (existing && this.isSignalableMatchingOwner(existing)) {
|
|
435
|
+
if (classification === "compatible" || classification === "newer") {
|
|
436
|
+
// A compatible, physically-live owner may be mid-startup: a concurrent
|
|
437
|
+
// ensure can have just acquired ownership and published transportHealthy:false
|
|
438
|
+
// before its transport heartbeats healthy. Wait bounded for that owner to
|
|
439
|
+
// become attachable instead of failing a racing startup outright.
|
|
440
|
+
if (this.isHealthyFreshState(existing) || (await this.waitForOwnership(existing.ownerId, identity)))
|
|
441
|
+
return "attached";
|
|
442
|
+
if (classification === "newer")
|
|
443
|
+
throw new Error(`Unable to replace newer ${this.kind} daemon owner; upgrade this controller`);
|
|
444
|
+
throw new Error(`Unable to replace unhealthy ${this.kind} daemon owner`);
|
|
445
|
+
}
|
|
277
446
|
await this.stopForReplacement(existing);
|
|
278
447
|
}
|
|
279
448
|
const spawned = await this.spawn();
|
|
280
449
|
if (spawned) return "owner_spawned";
|
|
281
450
|
const replacement = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
282
|
-
if (replacement &&
|
|
451
|
+
if (replacement && this.isCurrentCompatibleState(replacement, identity)) return "attached";
|
|
283
452
|
throw new Error(`Unable to attach or spawn ${this.kind} daemon owner`);
|
|
284
453
|
}
|
|
285
454
|
|
|
@@ -288,24 +457,54 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
288
457
|
const warnings = before.runtime.warning ? [before.runtime.warning] : [];
|
|
289
458
|
if (!before.configured)
|
|
290
459
|
return this.result(action, false, `${this.kind} notifications are not configured`, before, before, warnings);
|
|
291
|
-
|
|
292
|
-
|
|
460
|
+
const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
461
|
+
const classification = this.classify(state, this.identity());
|
|
462
|
+
if (classification === "newer")
|
|
463
|
+
return this.result(
|
|
464
|
+
action,
|
|
465
|
+
false,
|
|
466
|
+
`${this.kind} daemon is newer than this controller; upgrade this controller before ${action}`,
|
|
467
|
+
before,
|
|
468
|
+
before,
|
|
469
|
+
warnings,
|
|
470
|
+
);
|
|
471
|
+
if (classification === "malformed" || classification === "unauthorized")
|
|
472
|
+
return this.result(
|
|
473
|
+
action,
|
|
474
|
+
false,
|
|
475
|
+
`${this.kind} daemon ownership changed; refusing to signal`,
|
|
476
|
+
before,
|
|
477
|
+
await this.status(),
|
|
478
|
+
warnings,
|
|
479
|
+
);
|
|
480
|
+
if (!state || !this.isSignalableMatchingOwner(state)) {
|
|
481
|
+
if (action === "stop") {
|
|
482
|
+
if (state && this.isAmbiguouslyLiveState(state))
|
|
483
|
+
return this.result(
|
|
484
|
+
action,
|
|
485
|
+
false,
|
|
486
|
+
`${this.kind} daemon ownership changed; refusing to signal`,
|
|
487
|
+
before,
|
|
488
|
+
before,
|
|
489
|
+
warnings,
|
|
490
|
+
);
|
|
293
491
|
return this.result(action, true, `no running ${this.kind} daemon`, before, before, warnings);
|
|
492
|
+
}
|
|
294
493
|
if (opts.spawnIfStopped === false)
|
|
295
494
|
return this.result(action, true, `no running ${this.kind} daemon to reload`, before, before, warnings);
|
|
296
495
|
const spawned = await this.spawn();
|
|
297
|
-
const after = await this.status();
|
|
298
496
|
return this.result(
|
|
299
497
|
action,
|
|
300
498
|
spawned,
|
|
301
|
-
spawned
|
|
499
|
+
spawned
|
|
500
|
+
? `spawned fresh ${this.kind} daemon`
|
|
501
|
+
: `${this.kind} daemon did not publish ownership after spawning`,
|
|
302
502
|
before,
|
|
303
|
-
|
|
503
|
+
await this.status(),
|
|
304
504
|
warnings,
|
|
305
505
|
);
|
|
306
506
|
}
|
|
307
|
-
|
|
308
|
-
if (!state || !this.ownsCapturedState(state, before))
|
|
507
|
+
if (state.identity !== this.identity() || !this.ownsCapturedState(state, before))
|
|
309
508
|
return this.result(
|
|
310
509
|
action,
|
|
311
510
|
false,
|
|
@@ -359,20 +558,75 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
359
558
|
private incarnation(pid: number): string | undefined {
|
|
360
559
|
return (this.deps.pidIncarnation ?? processIncarnation)(pid);
|
|
361
560
|
}
|
|
362
|
-
private
|
|
561
|
+
private processReference(pid: number): ChatDaemonProcessReference | undefined {
|
|
562
|
+
return this.deps.processReference
|
|
563
|
+
? this.deps.processReference(pid)
|
|
564
|
+
: defaultProcessReference(pid, this.deps.platform);
|
|
565
|
+
}
|
|
566
|
+
private isDefinitelyStoppedState(state: ChatDaemonState | undefined): boolean {
|
|
567
|
+
if (isExactPreUpgradeUnavailableChatDaemonState(state)) return !this.alive(state.pid);
|
|
568
|
+
if (!state || !hasSafeChatDaemonOwnerShape(state)) return false;
|
|
569
|
+
if (!this.alive(state.pid)) return true;
|
|
570
|
+
// Proven PID reuse means this persisted owner is gone. The distinct live PID
|
|
571
|
+
// remains nonsignalable because isSignalableMatchingOwner requires equality.
|
|
572
|
+
const incarnation = this.incarnation(state.pid);
|
|
573
|
+
return hasProcessIncarnationAuthority(incarnation) && incarnation !== state.incarnation;
|
|
574
|
+
}
|
|
575
|
+
private stateHealth(state: ChatDaemonState | undefined, identity: string): DaemonHealth {
|
|
576
|
+
if (!state || this.isDefinitelyStoppedState(state)) return "stopped";
|
|
577
|
+
if (this.isCurrentCompatibleState(state, identity)) return "running";
|
|
578
|
+
// A PID that is live but cannot prove a matching current incarnation is
|
|
579
|
+
// ambiguous: do not report it ready or overwrite it.
|
|
580
|
+
return "stale";
|
|
581
|
+
}
|
|
582
|
+
private isSignalableMatchingOwner(state: ChatDaemonState): boolean {
|
|
583
|
+
const incarnation = this.incarnation(state.pid);
|
|
363
584
|
return (
|
|
364
|
-
state
|
|
585
|
+
hasSafeChatDaemonOwnerShape(state) &&
|
|
365
586
|
state.kind === this.kind &&
|
|
366
|
-
|
|
367
|
-
state.transportHealthy &&
|
|
368
|
-
Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
587
|
+
state.stoppedAt === undefined &&
|
|
369
588
|
this.alive(state.pid) &&
|
|
370
|
-
|
|
371
|
-
|
|
589
|
+
hasProcessIncarnationAuthority(incarnation) &&
|
|
590
|
+
incarnation === state.incarnation
|
|
372
591
|
);
|
|
373
592
|
}
|
|
593
|
+
/** A live PID with an invalid ownership record is never safe to overwrite. */
|
|
594
|
+
private isAmbiguouslyLiveState(state: ChatDaemonState): boolean {
|
|
595
|
+
return !this.isDefinitelyStoppedState(state) && !this.isSignalableMatchingOwner(state);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private isHealthyFreshState(state: ChatDaemonState): boolean {
|
|
599
|
+
return (
|
|
600
|
+
hasSafeChatDaemonStateShape(state) &&
|
|
601
|
+
state.transportHealthy &&
|
|
602
|
+
Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
private classify(state: ChatDaemonState | undefined, identity: string | undefined): ChatDaemonStateClassification {
|
|
606
|
+
if (!state) return "absent";
|
|
607
|
+
if (isExactPreUpgradeUnavailableChatDaemonState(state)) return this.alive(state.pid) ? "malformed" : "stopped";
|
|
608
|
+
if (!hasSafeChatDaemonOwnerShape(state)) return "malformed";
|
|
609
|
+
if (this.isDefinitelyStoppedState(state)) return "stopped";
|
|
610
|
+
if (!identity || state.kind !== this.kind || state.identity !== identity) return "unauthorized";
|
|
611
|
+
if (hasSafeChatDaemonStateShape(state)) {
|
|
612
|
+
if (state.generation < chatDaemonGeneration(this.kind)) return "replaceable";
|
|
613
|
+
return state.generation > chatDaemonGeneration(this.kind) ? "newer" : "compatible";
|
|
614
|
+
}
|
|
615
|
+
const generation = (state as { generation?: unknown }).generation;
|
|
616
|
+
return isRecognizedLegacyGeneration(generation) ? "replaceable" : "malformed";
|
|
617
|
+
}
|
|
618
|
+
private isCurrentCompatibleState(state: ChatDaemonState, identity: string): boolean {
|
|
619
|
+
const classification = this.classify(state, identity);
|
|
620
|
+
return (
|
|
621
|
+
this.isSignalableMatchingOwner(state) &&
|
|
622
|
+
this.isHealthyFreshState(state) &&
|
|
623
|
+
(classification === "compatible" || classification === "newer")
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
374
627
|
private async stopForReplacement(state: ChatDaemonState): Promise<void> {
|
|
375
|
-
if (!this.
|
|
628
|
+
if (!this.isSignalableMatchingOwner(state)) return;
|
|
629
|
+
|
|
376
630
|
const requestId = this.deps.randomId?.() ?? crypto.randomUUID();
|
|
377
631
|
await writeChatDaemonControlRequest(this.settings.getAgentDir(), this.kind, {
|
|
378
632
|
version: 1,
|
|
@@ -403,21 +657,35 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
403
657
|
state.ownerId === before.ownerId &&
|
|
404
658
|
state.pid === before.pid &&
|
|
405
659
|
Boolean(state.incarnation) &&
|
|
406
|
-
this.
|
|
660
|
+
this.isSignalableMatchingOwner(state)
|
|
407
661
|
);
|
|
408
662
|
}
|
|
409
663
|
private async signalIfOwner(state: ChatDaemonState, signal: NodeJS.Signals): Promise<boolean> {
|
|
410
664
|
const current = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
665
|
+
const identity = this.identity();
|
|
666
|
+
const classification = this.classify(current, identity);
|
|
411
667
|
if (
|
|
668
|
+
!identity ||
|
|
412
669
|
!current ||
|
|
413
670
|
current.ownerId !== state.ownerId ||
|
|
414
671
|
current.pid !== state.pid ||
|
|
672
|
+
current.identity !== state.identity ||
|
|
415
673
|
current.incarnation !== state.incarnation ||
|
|
416
|
-
|
|
674
|
+
current.generation !== state.generation ||
|
|
675
|
+
(classification !== "compatible" && classification !== "replaceable") ||
|
|
676
|
+
!this.isSignalableMatchingOwner(current)
|
|
417
677
|
)
|
|
418
678
|
return false;
|
|
419
|
-
|
|
420
|
-
|
|
679
|
+
const processRef = this.processReference(state.pid);
|
|
680
|
+
// Numeric PIDs can be reused after the ordinary provenance recheck. Only the
|
|
681
|
+
// native stable reference may perform this privileged signal operation.
|
|
682
|
+
if (!processRef || processRef.incarnation !== state.incarnation) return false;
|
|
683
|
+
try {
|
|
684
|
+
processRef.signalRoot(signal);
|
|
685
|
+
return true;
|
|
686
|
+
} catch {
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
421
689
|
}
|
|
422
690
|
private async ownerChanged(
|
|
423
691
|
action: ChatDaemonAction,
|
|
@@ -459,15 +727,10 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
459
727
|
const paths = chatDaemonPaths(this.settings.getAgentDir(), this.kind);
|
|
460
728
|
await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
461
729
|
const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
Date.now() - existing.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
467
|
-
this.alive(existing.pid) &&
|
|
468
|
-
this.incarnation(existing.pid) === existing.incarnation
|
|
469
|
-
)
|
|
470
|
-
return false;
|
|
730
|
+
const classification = this.classify(existing, identity);
|
|
731
|
+
if (classification === "malformed" || classification === "unauthorized") return false;
|
|
732
|
+
if (existing && (this.isSignalableMatchingOwner(existing) || this.isAmbiguouslyLiveState(existing))) return false;
|
|
733
|
+
|
|
471
734
|
const ownerId = `${this.deps.ownerPid ?? process.ppid}-${this.deps.randomId?.() ?? crypto.randomUUID()}`;
|
|
472
735
|
const { command, args } = buildChatDaemonSpawnArgs({
|
|
473
736
|
kind: this.kind,
|
|
@@ -479,7 +742,26 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
479
742
|
detached: true,
|
|
480
743
|
stdio: "ignore",
|
|
481
744
|
}).unref?.();
|
|
482
|
-
return
|
|
745
|
+
return await this.waitForOwnership(ownerId, identity);
|
|
746
|
+
}
|
|
747
|
+
private async waitForOwnership(ownerId: string, identity: string): Promise<boolean> {
|
|
748
|
+
const timeoutMs = Math.max(this.deps.spawnReadyTimeoutMs ?? DEFAULT_SPAWN_READY_TIMEOUT_MS, 0);
|
|
749
|
+
const until = Date.now() + timeoutMs;
|
|
750
|
+
const maxPolls = Math.ceil(timeoutMs / 25);
|
|
751
|
+
for (let poll = 0; poll <= maxPolls; poll++) {
|
|
752
|
+
const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
753
|
+
const classification = state ? this.classify(state, identity) : undefined;
|
|
754
|
+
if (
|
|
755
|
+
state &&
|
|
756
|
+
state.ownerId === ownerId &&
|
|
757
|
+
(classification === "compatible" || classification === "newer") &&
|
|
758
|
+
this.isCurrentCompatibleState(state, identity)
|
|
759
|
+
)
|
|
760
|
+
return true;
|
|
761
|
+
if (Date.now() >= until || poll === maxPolls) return false;
|
|
762
|
+
await this.sleep(25);
|
|
763
|
+
}
|
|
764
|
+
return false;
|
|
483
765
|
}
|
|
484
766
|
}
|
|
485
767
|
|
|
@@ -523,44 +805,132 @@ export async function acquireChatDaemonOwnership(input: {
|
|
|
523
805
|
pidAlive: input.pidAlive ?? defaultPidAlive,
|
|
524
806
|
pidIncarnation: input.pidIncarnation ?? processIncarnation,
|
|
525
807
|
};
|
|
526
|
-
const incarnation = input.incarnation ?? probe.pidIncarnation(pid)
|
|
808
|
+
const incarnation = input.incarnation ?? probe.pidIncarnation(pid);
|
|
809
|
+
if (!hasProcessIncarnationAuthority(incarnation)) return false;
|
|
810
|
+
|
|
527
811
|
await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
528
|
-
|
|
812
|
+
const existing = await readJson<unknown>(paths.state);
|
|
813
|
+
// A live record remains fenced unless authoritative provenance proves the PID
|
|
814
|
+
// was reused. This permits recovery without ever signaling that replacement.
|
|
815
|
+
if (hasChatDaemonStatePid(existing) && probe.pidAlive(existing.pid)) {
|
|
816
|
+
const current = probe.pidIncarnation(existing.pid);
|
|
817
|
+
if (
|
|
818
|
+
!hasSafeChatDaemonOwnerShape(existing) ||
|
|
819
|
+
!hasProcessIncarnationAuthority(current) ||
|
|
820
|
+
current === existing.incarnation
|
|
821
|
+
)
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
const owner = { pid, incarnation, createdAt: Date.now() };
|
|
825
|
+
let lock = await createChatDaemonOwnerLock(paths.lock, owner);
|
|
826
|
+
if (!lock) {
|
|
529
827
|
if (!(await reclaimChatDaemonOwnerLock(paths.lock, paths.state, probe))) return false;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
await withStateWriteLock(
|
|
534
|
-
paths.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
828
|
+
lock = await createChatDaemonOwnerLock(paths.lock, owner);
|
|
829
|
+
if (!lock) return false;
|
|
830
|
+
}
|
|
831
|
+
return await withStateWriteLock(paths.state, async () => {
|
|
832
|
+
if (!(await ownsChatDaemonOwnerLock(paths.lock, lock))) return false;
|
|
833
|
+
await writeJson(paths.state, {
|
|
834
|
+
version: 1,
|
|
835
|
+
kind: input.kind,
|
|
836
|
+
pid,
|
|
837
|
+
ownerId: input.ownerId,
|
|
838
|
+
identity: input.identity,
|
|
839
|
+
incarnation,
|
|
840
|
+
startedAt: Date.now(),
|
|
841
|
+
heartbeatAt: Date.now(),
|
|
842
|
+
transportHealthy: false,
|
|
843
|
+
generation: chatDaemonGeneration(input.kind),
|
|
844
|
+
} satisfies ChatDaemonState);
|
|
845
|
+
return true;
|
|
846
|
+
});
|
|
549
847
|
}
|
|
550
848
|
|
|
551
|
-
async function createChatDaemonOwnerLock(
|
|
849
|
+
async function createChatDaemonOwnerLock(
|
|
850
|
+
lock: string,
|
|
851
|
+
owner: ChatDaemonOwnerLock,
|
|
852
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
853
|
+
const content = `${JSON.stringify(owner)}\n`;
|
|
854
|
+
const temporary = `${lock}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
552
855
|
try {
|
|
553
|
-
const handle = await fs.promises.open(
|
|
856
|
+
const handle = await fs.promises.open(temporary, "wx", 0o600);
|
|
554
857
|
try {
|
|
555
|
-
await handle.writeFile(
|
|
858
|
+
await handle.writeFile(content, "utf8");
|
|
556
859
|
await handle.sync();
|
|
557
860
|
} finally {
|
|
558
861
|
await handle.close();
|
|
559
862
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
863
|
+
try {
|
|
864
|
+
await fs.promises.link(temporary, lock);
|
|
865
|
+
} catch (error) {
|
|
866
|
+
if (isAlreadyExists(error)) return undefined;
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
return await captureChatDaemonOwnerLockLease(lock);
|
|
870
|
+
} finally {
|
|
871
|
+
await fs.promises.unlink(temporary).catch(() => undefined);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function captureChatDaemonOwnerLockLease(lock: string): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
876
|
+
try {
|
|
877
|
+
const handle = await fs.promises.open(lock, "r");
|
|
878
|
+
try {
|
|
879
|
+
const before = await handle.stat({ bigint: true });
|
|
880
|
+
if (!before.isFile()) return undefined;
|
|
881
|
+
const content = await handle.readFile({ encoding: "utf8" });
|
|
882
|
+
const after = await handle.stat({ bigint: true });
|
|
883
|
+
const pathname = await fs.promises.lstat(lock, { bigint: true });
|
|
884
|
+
if (
|
|
885
|
+
!after.isFile() ||
|
|
886
|
+
!pathname.isFile() ||
|
|
887
|
+
pathname.isSymbolicLink() ||
|
|
888
|
+
before.dev !== after.dev ||
|
|
889
|
+
before.ino !== after.ino ||
|
|
890
|
+
before.size !== after.size ||
|
|
891
|
+
before.mtimeNs !== after.mtimeNs ||
|
|
892
|
+
before.dev !== pathname.dev ||
|
|
893
|
+
before.ino !== pathname.ino ||
|
|
894
|
+
before.size !== pathname.size ||
|
|
895
|
+
before.mtimeNs !== pathname.mtimeNs
|
|
896
|
+
)
|
|
897
|
+
return undefined;
|
|
898
|
+
return {
|
|
899
|
+
content,
|
|
900
|
+
dev: before.dev,
|
|
901
|
+
ino: before.ino,
|
|
902
|
+
size: before.size,
|
|
903
|
+
mtimeNs: before.mtimeNs,
|
|
904
|
+
sha256: crypto.createHash("sha256").update(content).digest("hex"),
|
|
905
|
+
};
|
|
906
|
+
} finally {
|
|
907
|
+
await handle.close();
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function ownsChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): Promise<boolean> {
|
|
915
|
+
const current = await captureChatDaemonOwnerLockLease(lock);
|
|
916
|
+
return (
|
|
917
|
+
current?.dev === lease.dev &&
|
|
918
|
+
current.ino === lease.ino &&
|
|
919
|
+
current.size === lease.size &&
|
|
920
|
+
current.mtimeNs === lease.mtimeNs &&
|
|
921
|
+
current.content === lease.content
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Deletes only the exact lease observed by this contender; a successor is retained. */
|
|
926
|
+
function unlinkExactChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): boolean {
|
|
927
|
+
try {
|
|
928
|
+
return native.exactUnlink(lock, {
|
|
929
|
+
...lease,
|
|
930
|
+
quarantineName: `.gjc-delete-chat-daemon-lock-${crypto.randomUUID()}`,
|
|
931
|
+
}).ok;
|
|
932
|
+
} catch {
|
|
933
|
+
return false;
|
|
564
934
|
}
|
|
565
935
|
}
|
|
566
936
|
|
|
@@ -570,64 +940,68 @@ async function reclaimChatDaemonOwnerLock(
|
|
|
570
940
|
probe: ChatDaemonOwnershipProbe,
|
|
571
941
|
): Promise<boolean> {
|
|
572
942
|
if (!(await canReclaimChatDaemonOwnerLock(lock, stateFile, probe))) return false;
|
|
573
|
-
|
|
574
943
|
const reclaimFile = `${lock}.reclaim`;
|
|
575
944
|
const reclaimLock = await acquireChatDaemonReclaimLock(reclaimFile, probe);
|
|
576
|
-
|
|
577
945
|
if (!reclaimLock) return false;
|
|
578
946
|
try {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
await fs.promises.unlink(lock).catch(() => undefined);
|
|
582
|
-
return true;
|
|
947
|
+
const ownerLock = await canReclaimChatDaemonOwnerLock(lock, stateFile, probe);
|
|
948
|
+
return !!ownerLock && unlinkExactChatDaemonOwnerLock(lock, ownerLock);
|
|
583
949
|
} finally {
|
|
584
|
-
|
|
585
|
-
await fs.promises.unlink(reclaimFile).catch(() => undefined);
|
|
950
|
+
unlinkExactChatDaemonOwnerLock(reclaimFile, reclaimLock);
|
|
586
951
|
}
|
|
587
952
|
}
|
|
588
953
|
|
|
589
954
|
async function acquireChatDaemonReclaimLock(
|
|
590
955
|
reclaimFile: string,
|
|
591
956
|
probe: ChatDaemonOwnershipProbe,
|
|
592
|
-
): Promise<
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
if (!
|
|
600
|
-
await
|
|
601
|
-
if (!(await createChatDaemonOwnerLock(reclaimFile, owner))) return undefined;
|
|
602
|
-
return await fs.promises.open(reclaimFile, "r+");
|
|
957
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
958
|
+
const incarnation = probe.pidIncarnation(process.pid);
|
|
959
|
+
if (!hasProcessIncarnationAuthority(incarnation)) return undefined;
|
|
960
|
+
const owner: ChatDaemonOwnerLock = { pid: process.pid, incarnation, createdAt: Date.now() };
|
|
961
|
+
const created = await createChatDaemonOwnerLock(reclaimFile, owner);
|
|
962
|
+
if (created) return created;
|
|
963
|
+
const stale = await staleChatDaemonLockLease(reclaimFile, probe);
|
|
964
|
+
if (!stale || !unlinkExactChatDaemonOwnerLock(reclaimFile, stale)) return undefined;
|
|
965
|
+
return await createChatDaemonOwnerLock(reclaimFile, owner);
|
|
603
966
|
}
|
|
604
967
|
|
|
605
|
-
async function
|
|
968
|
+
async function staleChatDaemonLockLease(
|
|
969
|
+
lock: string,
|
|
970
|
+
probe: ChatDaemonOwnershipProbe,
|
|
971
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
972
|
+
const lease = await captureChatDaemonOwnerLockLease(lock);
|
|
973
|
+
if (!lease) return undefined;
|
|
606
974
|
let owner: unknown;
|
|
607
975
|
try {
|
|
608
|
-
owner = JSON.parse(
|
|
609
|
-
} catch {
|
|
610
|
-
|
|
611
|
-
const currentIncarnation = probe.pidIncarnation(owner.pid);
|
|
612
|
-
return (
|
|
613
|
-
!probe.pidAlive(owner.pid) ||
|
|
614
|
-
(owner.incarnation !== "unavailable" &&
|
|
615
|
-
(!isProcessIncarnation(owner.incarnation) ||
|
|
616
|
-
(currentIncarnation !== undefined && currentIncarnation !== owner.incarnation)))
|
|
617
|
-
);
|
|
976
|
+
owner = JSON.parse(lease.content);
|
|
977
|
+
} catch {
|
|
978
|
+
return undefined;
|
|
618
979
|
}
|
|
619
|
-
|
|
620
|
-
|
|
980
|
+
if (!isChatDaemonOwnerLock(owner)) return undefined;
|
|
981
|
+
if (!probe.pidAlive(owner.pid)) return lease;
|
|
982
|
+
if (!hasProcessIncarnationAuthority(owner.incarnation)) return undefined;
|
|
983
|
+
const currentIncarnation = probe.pidIncarnation(owner.pid);
|
|
984
|
+
return hasProcessIncarnationAuthority(currentIncarnation) && currentIncarnation !== owner.incarnation
|
|
985
|
+
? lease
|
|
986
|
+
: undefined;
|
|
621
987
|
}
|
|
622
988
|
|
|
623
989
|
async function canReclaimChatDaemonOwnerLock(
|
|
624
990
|
lock: string,
|
|
625
991
|
stateFile: string,
|
|
626
992
|
probe: ChatDaemonOwnershipProbe,
|
|
627
|
-
): Promise<
|
|
628
|
-
const state = await readJson<
|
|
629
|
-
if (
|
|
630
|
-
|
|
993
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
994
|
+
const state = await readJson<unknown>(stateFile);
|
|
995
|
+
if (hasChatDaemonStatePid(state) && probe.pidAlive(state.pid)) {
|
|
996
|
+
const current = probe.pidIncarnation(state.pid);
|
|
997
|
+
if (
|
|
998
|
+
!hasSafeChatDaemonOwnerShape(state) ||
|
|
999
|
+
!hasProcessIncarnationAuthority(current) ||
|
|
1000
|
+
current === state.incarnation
|
|
1001
|
+
)
|
|
1002
|
+
return undefined;
|
|
1003
|
+
}
|
|
1004
|
+
return await staleChatDaemonLockLease(lock, probe);
|
|
631
1005
|
}
|
|
632
1006
|
|
|
633
1007
|
function isChatDaemonOwnerLock(value: unknown): value is ChatDaemonOwnerLock {
|
|
@@ -652,17 +1026,25 @@ export async function renewChatDaemonHeartbeat(input: {
|
|
|
652
1026
|
pid?: number;
|
|
653
1027
|
incarnation?: string;
|
|
654
1028
|
transportHealthy: boolean;
|
|
1029
|
+
pidAlive?: (pid: number) => boolean;
|
|
1030
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
655
1031
|
}): Promise<boolean> {
|
|
656
1032
|
const paths = chatDaemonPaths(input.agentDir, input.kind);
|
|
1033
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1034
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
657
1035
|
return await withStateWriteLock(paths.state, async () => {
|
|
658
|
-
const state = await readJson<
|
|
659
|
-
|
|
1036
|
+
const state = await readJson<unknown>(paths.state);
|
|
1037
|
+
if (!hasSafeChatDaemonStateShape(state)) return false;
|
|
1038
|
+
const pid = input.pid ?? state.pid;
|
|
1039
|
+
const currentIncarnation = pidIncarnation(pid);
|
|
660
1040
|
if (
|
|
661
|
-
!state ||
|
|
662
1041
|
state.ownerId !== input.ownerId ||
|
|
663
1042
|
pid !== state.pid ||
|
|
664
|
-
!input.incarnation ||
|
|
665
|
-
state.incarnation !== input.incarnation
|
|
1043
|
+
!hasProcessIncarnationAuthority(input.incarnation) ||
|
|
1044
|
+
state.incarnation !== input.incarnation ||
|
|
1045
|
+
!pidAlive(pid) ||
|
|
1046
|
+
!hasProcessIncarnationAuthority(currentIncarnation) ||
|
|
1047
|
+
currentIncarnation !== input.incarnation
|
|
666
1048
|
)
|
|
667
1049
|
return false;
|
|
668
1050
|
await writeJson(paths.state, { ...state, heartbeatAt: Date.now(), transportHealthy: input.transportHealthy });
|
|
@@ -673,21 +1055,35 @@ export async function releaseChatDaemonOwnership(input: {
|
|
|
673
1055
|
agentDir: string;
|
|
674
1056
|
kind: ChatDaemonKind;
|
|
675
1057
|
ownerId: string;
|
|
676
|
-
pid
|
|
677
|
-
incarnation
|
|
1058
|
+
pid: number;
|
|
1059
|
+
incarnation: string;
|
|
1060
|
+
pidAlive?: (pid: number) => boolean;
|
|
1061
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
678
1062
|
}): Promise<void> {
|
|
679
1063
|
const paths = chatDaemonPaths(input.agentDir, input.kind);
|
|
1064
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1065
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
680
1066
|
await withStateWriteLock(paths.state, async () => {
|
|
681
|
-
const state = await readJson<
|
|
1067
|
+
const state = await readJson<unknown>(paths.state);
|
|
1068
|
+
const currentIncarnation = pidIncarnation(input.pid);
|
|
682
1069
|
if (
|
|
683
|
-
state
|
|
684
|
-
(input.
|
|
685
|
-
|
|
1070
|
+
!hasSafeChatDaemonStateShape(state) ||
|
|
1071
|
+
!hasProcessIncarnationAuthority(input.incarnation) ||
|
|
1072
|
+
state.ownerId !== input.ownerId ||
|
|
1073
|
+
state.pid !== input.pid ||
|
|
1074
|
+
state.incarnation !== input.incarnation ||
|
|
1075
|
+
!pidAlive(input.pid) ||
|
|
1076
|
+
!hasProcessIncarnationAuthority(currentIncarnation) ||
|
|
1077
|
+
currentIncarnation !== input.incarnation
|
|
686
1078
|
)
|
|
687
1079
|
return;
|
|
688
1080
|
await writeJson(paths.state, { ...state, stoppedAt: Date.now(), transportHealthy: false });
|
|
689
|
-
const lock = await
|
|
690
|
-
|
|
691
|
-
|
|
1081
|
+
const lock = await captureChatDaemonOwnerLockLease(paths.lock);
|
|
1082
|
+
let owner: unknown;
|
|
1083
|
+
try {
|
|
1084
|
+
owner = lock && JSON.parse(lock.content);
|
|
1085
|
+
} catch {}
|
|
1086
|
+
if (lock && isChatDaemonOwnerLock(owner) && owner.pid === state.pid && owner.incarnation === state.incarnation)
|
|
1087
|
+
unlinkExactChatDaemonOwnerLock(paths.lock, lock);
|
|
692
1088
|
});
|
|
693
1089
|
}
|