@gajae-code/coding-agent 0.12.11 → 0.12.12
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 +38 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +23 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
- package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
- package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
- package/dist/types/sdk/bus/notification-service.d.ts +10 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
- package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
- package/dist/types/sdk/host/host.d.ts +52 -0
- package/dist/types/sdk/lifecycle-session.d.ts +6 -0
- package/dist/types/sdk/session-activation.d.ts +41 -0
- package/dist/types/sdk/session.d.ts +18 -0
- package/dist/types/sdk/startup-capability.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +7 -0
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +23 -5
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/utils/npm-registry.d.ts +71 -0
- package/examples/extensions/README.md +38 -4
- package/examples/extensions/ooo-bridge.ts +15 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/async/job-manager.ts +9 -2
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/update-cli.ts +90 -23
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +18 -0
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +31 -15
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +2 -0
- package/src/coordinator-mcp/server.ts +358 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
- package/src/extensibility/extensions/runner.ts +15 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/main.ts +203 -67
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/session-selector.ts +26 -3
- package/src/modes/components/status-line/gh.ts +23 -2
- package/src/modes/controllers/event-controller.ts +3 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +11 -0
- package/src/modes/controllers/selector-controller.ts +8 -2
- package/src/modes/interactive-mode.ts +7 -1
- package/src/modes/utils/host-status.ts +38 -0
- package/src/runtime-mcp/types.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +60 -3
- package/src/sdk/bus/chat-daemon-cli.ts +42 -0
- package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
- package/src/sdk/bus/chat-daemon-control.ts +16 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
- package/src/sdk/bus/control-plane-frames.ts +51 -0
- package/src/sdk/bus/conversation-store.ts +29 -1
- package/src/sdk/bus/existing-thread-readiness.ts +155 -0
- package/src/sdk/bus/index.ts +70 -0
- package/src/sdk/bus/notification-service.ts +28 -1
- package/src/sdk/bus/rich-render.ts +3 -2
- package/src/sdk/bus/slack-daemon.ts +99 -4
- package/src/sdk/bus/slack-live-provider.ts +32 -2
- package/src/sdk/bus/slack-provider.ts +11 -0
- package/src/sdk/bus/slack-thread-binding.ts +641 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
- package/src/sdk/bus/telegram-daemon.ts +1961 -497
- package/src/sdk/bus/telegram-reference.ts +42 -6
- package/src/sdk/bus/topic-registry.ts +674 -181
- package/src/sdk/host/host.ts +180 -1
- package/src/sdk/lifecycle-session.ts +8 -2
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session-activation.ts +127 -0
- package/src/sdk/session.ts +139 -28
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -9
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +206 -7
- package/src/session/session-manager.ts +148 -38
- package/src/setup/provider-presets.json +1 -1
- package/src/task/index.ts +92 -44
- package/src/tools/index.ts +8 -0
- package/src/utils/npm-registry.ts +660 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request/response control channel for chat-daemon commands that must be
|
|
3
|
+
* executed *by* the running owner instead of terminating it.
|
|
4
|
+
*
|
|
5
|
+
* `control.json` is the lifecycle channel: every request the owner recognizes
|
|
6
|
+
* there ends its serving loop (stop/reload). Adopting an existing Slack root
|
|
7
|
+
* must not stop a healthy daemon, so operator commands travel on this separate
|
|
8
|
+
* per-request channel and are answered in place.
|
|
9
|
+
*
|
|
10
|
+
* Invariants:
|
|
11
|
+
* - Every request is addressed to an exact owner (`ownerId`, `pid`,
|
|
12
|
+
* `incarnation`, daemon `generation`). A daemon that is not that exact owner
|
|
13
|
+
* answers `owner_changed` and performs no work, so a replaced or restarted
|
|
14
|
+
* owner can never satisfy a request captured against its predecessor. The
|
|
15
|
+
* same tuple is re-proven inside the commit fence and echoed in the answer,
|
|
16
|
+
* so the submitter can verify who actually acted.
|
|
17
|
+
* - `<id>.response.json` is a single-winner arbitration object created with
|
|
18
|
+
* `O_CREAT|O_EXCL`. The serving daemon must win it *before* it commits, and a
|
|
19
|
+
* submitter that has given up must win it before reporting failure. Whoever
|
|
20
|
+
* loses learns so definitively, which removes the commit-vs-timeout race
|
|
21
|
+
* without any sleep: a definitive `cancelled` answer proves no dispatch can
|
|
22
|
+
* follow within this submission's race, and a lost cancellation is reported
|
|
23
|
+
* as `unknown` rather than as a failure the caller could act on. The
|
|
24
|
+
* arbitration is scoped to that race under the trust boundary below; it is
|
|
25
|
+
* not durable settlement across a crashed and resurrected daemon.
|
|
26
|
+
* - Documents live in an owner-only (`0700`) directory below the agent
|
|
27
|
+
* directory, are bounded, and are rejected unless they are ordinary regular
|
|
28
|
+
* files. This matches the product's existing same-UID local trust boundary;
|
|
29
|
+
* it is not a defence against a hostile process running as the same user.
|
|
30
|
+
* - Only identifiers travel through the channel. Tokens, message bodies, and
|
|
31
|
+
* control secrets are never written here.
|
|
32
|
+
*
|
|
33
|
+
* The channel proves *correlation*, never authorship: every field a response
|
|
34
|
+
* echoes is copied verbatim out of the plaintext request published beside it,
|
|
35
|
+
* so a definitive answer is only actionable once the caller corroborates the
|
|
36
|
+
* mutation against an authority outside this directory.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import * as crypto from "node:crypto";
|
|
40
|
+
import * as fs from "node:fs/promises";
|
|
41
|
+
import * as path from "node:path";
|
|
42
|
+
import { isEnoent } from "@gajae-code/utils";
|
|
43
|
+
import { type ChatDaemonKind, chatDaemonPaths } from "./chat-daemon-control";
|
|
44
|
+
|
|
45
|
+
export const CHAT_DAEMON_COMMAND_VERSION = 1;
|
|
46
|
+
export const DEFAULT_CHAT_DAEMON_COMMAND_TIMEOUT_MS = 15_000;
|
|
47
|
+
const DEFAULT_POLL_INTERVAL_MS = 25;
|
|
48
|
+
/** How long a completed response and an expired request stay readable before sweeping. */
|
|
49
|
+
const COMMAND_RETENTION_MS = 60_000;
|
|
50
|
+
const DEFAULT_REQUEST_TTL_MS = 30_000;
|
|
51
|
+
/** Bounded wait for a daemon that won the response claim before the submitter gave up. */
|
|
52
|
+
const DEFAULT_SETTLE_GRACE_MS = 5_000;
|
|
53
|
+
const REQUEST_SUFFIX = ".request.json";
|
|
54
|
+
const RESPONSE_SUFFIX = ".response.json";
|
|
55
|
+
const REQUEST_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
56
|
+
const MAX_COMMAND_FIELD_LENGTH = 256;
|
|
57
|
+
/** Command documents are identifiers only; anything larger is not ours. */
|
|
58
|
+
const MAX_COMMAND_ENTRY_BYTES = 8 * 1024;
|
|
59
|
+
const OWNER_ONLY_DIRECTORY_MODE = 0o700;
|
|
60
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
61
|
+
|
|
62
|
+
export type ChatDaemonCommandName = "bind-thread";
|
|
63
|
+
export type ChatDaemonCommandStatus = "ok" | "rejected" | "owner_changed" | "expired" | "outcome_unknown";
|
|
64
|
+
|
|
65
|
+
/** Exact owner authority a request is addressed to. */
|
|
66
|
+
export interface ChatDaemonCommandOwner {
|
|
67
|
+
ownerId: string;
|
|
68
|
+
pid: number;
|
|
69
|
+
incarnation: string;
|
|
70
|
+
generation: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ChatDaemonCommandRequest extends ChatDaemonCommandOwner {
|
|
74
|
+
version: typeof CHAT_DAEMON_COMMAND_VERSION;
|
|
75
|
+
requestId: string;
|
|
76
|
+
kind: ChatDaemonKind;
|
|
77
|
+
command: ChatDaemonCommandName;
|
|
78
|
+
sessionId: string;
|
|
79
|
+
rootTs: string;
|
|
80
|
+
createdAt: number;
|
|
81
|
+
expiresAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The answer to exactly one request.
|
|
86
|
+
*
|
|
87
|
+
* The owner tuple, session, and root are the *addressed* request's, echoed
|
|
88
|
+
* verbatim. A response therefore carries the complete request envelope, and a
|
|
89
|
+
* submitter can prove that the document in front of it is correlated with its
|
|
90
|
+
* own request rather than with a replayed or concurrent one.
|
|
91
|
+
*
|
|
92
|
+
* That is correlation, not authentication. Every echoed field is public: it is
|
|
93
|
+
* copied out of the plaintext request published in the same command directory,
|
|
94
|
+
* so a stale or planted document can satisfy the envelope without the addressed
|
|
95
|
+
* daemon ever running. A caller that needs a definitive outcome must corroborate
|
|
96
|
+
* it against an authority outside this directory (the durable mutation itself).
|
|
97
|
+
*/
|
|
98
|
+
export interface ChatDaemonCommandResponse extends ChatDaemonCommandOwner {
|
|
99
|
+
version: typeof CHAT_DAEMON_COMMAND_VERSION;
|
|
100
|
+
requestId: string;
|
|
101
|
+
kind: ChatDaemonKind;
|
|
102
|
+
command: ChatDaemonCommandName;
|
|
103
|
+
sessionId: string;
|
|
104
|
+
rootTs: string;
|
|
105
|
+
status: ChatDaemonCommandStatus;
|
|
106
|
+
/** Machine-readable rejection category; never a message body or credential. */
|
|
107
|
+
code?: string;
|
|
108
|
+
endpointGeneration?: number;
|
|
109
|
+
teamId?: string;
|
|
110
|
+
channelId?: string;
|
|
111
|
+
completedAt: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Result of one handler dispatch, carrying explicit commit certainty.
|
|
116
|
+
*
|
|
117
|
+
* A failure is never just a code. `rejected` asserts that no mapping changed, so
|
|
118
|
+
* the caller may be told the binding definitively failed. `unknown` asserts the
|
|
119
|
+
* opposite: commit authority was exercised and the mapping may already be
|
|
120
|
+
* applied, so no definitive rejection may be reported for it. Handlers state
|
|
121
|
+
* this directly instead of leaving the channel to infer it from error text.
|
|
122
|
+
*/
|
|
123
|
+
export type ChatDaemonCommandOutcome =
|
|
124
|
+
| {
|
|
125
|
+
ok: true;
|
|
126
|
+
sessionId: string;
|
|
127
|
+
endpointGeneration: number;
|
|
128
|
+
teamId: string;
|
|
129
|
+
channelId: string;
|
|
130
|
+
rootTs: string;
|
|
131
|
+
}
|
|
132
|
+
| { ok: false; certainty: "rejected"; code: string }
|
|
133
|
+
| { ok: false; certainty: "unknown"; code: string };
|
|
134
|
+
|
|
135
|
+
export interface ChatDaemonCommandBindInput {
|
|
136
|
+
sessionId: string;
|
|
137
|
+
rootTs: string;
|
|
138
|
+
/**
|
|
139
|
+
* Terminal authority for this exact request. It must be awaited inside the
|
|
140
|
+
* store fence, immediately before the commit, and the commit must be
|
|
141
|
+
* abandoned when it answers `false`. It re-proves the exact daemon owner
|
|
142
|
+
* tuple, that the request is still published and unexpired, and takes the
|
|
143
|
+
* single-winner response claim, after which no cancellation can succeed.
|
|
144
|
+
*/
|
|
145
|
+
commitAuthority?: () => Promise<boolean>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Daemon-side executor. Implemented by the runtime that owns the live transports. */
|
|
149
|
+
export interface ChatDaemonCommandHandler {
|
|
150
|
+
bindExistingRoot(input: ChatDaemonCommandBindInput): Promise<ChatDaemonCommandOutcome>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function requestEntry(requestId: string): string {
|
|
154
|
+
return `${requestId}${REQUEST_SUFFIX}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function responseEntry(requestId: string): string {
|
|
158
|
+
return `${requestId}${RESPONSE_SUFFIX}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
162
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function boundedString(value: unknown): value is string {
|
|
166
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_COMMAND_FIELD_LENGTH;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function positiveInteger(value: unknown): value is number {
|
|
170
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isChatDaemonCommandRequest(value: unknown): value is ChatDaemonCommandRequest {
|
|
174
|
+
if (!isRecord(value)) return false;
|
|
175
|
+
return (
|
|
176
|
+
value.version === CHAT_DAEMON_COMMAND_VERSION &&
|
|
177
|
+
typeof value.requestId === "string" &&
|
|
178
|
+
REQUEST_ID_PATTERN.test(value.requestId) &&
|
|
179
|
+
(value.kind === "discord" || value.kind === "slack") &&
|
|
180
|
+
value.command === "bind-thread" &&
|
|
181
|
+
boundedString(value.ownerId) &&
|
|
182
|
+
positiveInteger(value.pid) &&
|
|
183
|
+
boundedString(value.incarnation) &&
|
|
184
|
+
typeof value.generation === "number" &&
|
|
185
|
+
Number.isSafeInteger(value.generation) &&
|
|
186
|
+
value.generation >= 0 &&
|
|
187
|
+
boundedString(value.sessionId) &&
|
|
188
|
+
boundedString(value.rootTs) &&
|
|
189
|
+
typeof value.createdAt === "number" &&
|
|
190
|
+
Number.isFinite(value.createdAt) &&
|
|
191
|
+
typeof value.expiresAt === "number" &&
|
|
192
|
+
Number.isFinite(value.expiresAt)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isChatDaemonCommandResponse(value: unknown): value is ChatDaemonCommandResponse {
|
|
197
|
+
if (!isRecord(value)) return false;
|
|
198
|
+
return (
|
|
199
|
+
value.version === CHAT_DAEMON_COMMAND_VERSION &&
|
|
200
|
+
typeof value.requestId === "string" &&
|
|
201
|
+
REQUEST_ID_PATTERN.test(value.requestId) &&
|
|
202
|
+
(value.kind === "discord" || value.kind === "slack") &&
|
|
203
|
+
value.command === "bind-thread" &&
|
|
204
|
+
boundedString(value.ownerId) &&
|
|
205
|
+
positiveInteger(value.pid) &&
|
|
206
|
+
boundedString(value.incarnation) &&
|
|
207
|
+
typeof value.generation === "number" &&
|
|
208
|
+
Number.isSafeInteger(value.generation) &&
|
|
209
|
+
(value.status === "ok" ||
|
|
210
|
+
value.status === "rejected" ||
|
|
211
|
+
value.status === "owner_changed" ||
|
|
212
|
+
value.status === "expired" ||
|
|
213
|
+
value.status === "outcome_unknown") &&
|
|
214
|
+
(value.code === undefined || boundedString(value.code)) &&
|
|
215
|
+
boundedString(value.sessionId) &&
|
|
216
|
+
boundedString(value.rootTs) &&
|
|
217
|
+
(value.endpointGeneration === undefined || positiveInteger(value.endpointGeneration)) &&
|
|
218
|
+
(value.teamId === undefined || boundedString(value.teamId)) &&
|
|
219
|
+
(value.channelId === undefined || boundedString(value.channelId)) &&
|
|
220
|
+
typeof value.completedAt === "number" &&
|
|
221
|
+
Number.isFinite(value.completedAt)
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Two request documents describe the same authorization only when every field agrees. */
|
|
226
|
+
function isSameChatDaemonCommandRequest(left: ChatDaemonCommandRequest, right: ChatDaemonCommandRequest): boolean {
|
|
227
|
+
return (
|
|
228
|
+
left.version === right.version &&
|
|
229
|
+
left.requestId === right.requestId &&
|
|
230
|
+
left.kind === right.kind &&
|
|
231
|
+
left.command === right.command &&
|
|
232
|
+
left.ownerId === right.ownerId &&
|
|
233
|
+
left.pid === right.pid &&
|
|
234
|
+
left.incarnation === right.incarnation &&
|
|
235
|
+
left.generation === right.generation &&
|
|
236
|
+
left.sessionId === right.sessionId &&
|
|
237
|
+
left.rootTs === right.rootTs &&
|
|
238
|
+
left.createdAt === right.createdAt &&
|
|
239
|
+
left.expiresAt === right.expiresAt
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A response may influence caller behaviour only when its complete envelope is
|
|
245
|
+
* the request's own.
|
|
246
|
+
*
|
|
247
|
+
* This runs *before* any status is interpreted. A document that agrees on the
|
|
248
|
+
* request id but disagrees on the addressed daemon tuple, the command, or the
|
|
249
|
+
* session/root binding is stale or planted material: it is neither a success
|
|
250
|
+
* nor a trusted business rejection, and it is never allowed to settle the
|
|
251
|
+
* submission as an answer.
|
|
252
|
+
*/
|
|
253
|
+
function answersRequest(response: ChatDaemonCommandResponse, request: ChatDaemonCommandRequest): boolean {
|
|
254
|
+
return (
|
|
255
|
+
response.version === request.version &&
|
|
256
|
+
response.requestId === request.requestId &&
|
|
257
|
+
response.kind === request.kind &&
|
|
258
|
+
response.command === request.command &&
|
|
259
|
+
response.ownerId === request.ownerId &&
|
|
260
|
+
response.pid === request.pid &&
|
|
261
|
+
response.incarnation === request.incarnation &&
|
|
262
|
+
response.generation === request.generation &&
|
|
263
|
+
response.sessionId === request.sessionId &&
|
|
264
|
+
response.rootTs === request.rootTs
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Entry names are built from validated request ids; reject anything that could traverse. */
|
|
269
|
+
function assertSafeEntryName(name: string): string {
|
|
270
|
+
if (
|
|
271
|
+
name.length === 0 ||
|
|
272
|
+
name.length > 128 ||
|
|
273
|
+
name.includes("/") ||
|
|
274
|
+
name.includes("\\") ||
|
|
275
|
+
name.includes("\0") ||
|
|
276
|
+
name === "." ||
|
|
277
|
+
name === ".."
|
|
278
|
+
)
|
|
279
|
+
throw new Error("chat daemon command entry name is not addressable");
|
|
280
|
+
return name;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The owner-only directory the channel exchanges its documents in.
|
|
285
|
+
*
|
|
286
|
+
* `directory` is a plain pathname: this codebase already trusts same-UID local
|
|
287
|
+
* processes, and every document below it is bounded and validated, so the
|
|
288
|
+
* channel does not attempt hostile-filesystem hardening it cannot honestly
|
|
289
|
+
* provide from TypeScript.
|
|
290
|
+
*/
|
|
291
|
+
export interface ChatDaemonCommandScope {
|
|
292
|
+
readonly directory: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface OpenChatDaemonCommandScopeInput {
|
|
296
|
+
agentDir: string;
|
|
297
|
+
kind: ChatDaemonKind;
|
|
298
|
+
/** Creates the daemon and command directories owner-only when absent. */
|
|
299
|
+
create?: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Capture the command directory, or report the channel unusable. */
|
|
303
|
+
export async function openChatDaemonCommandScope(
|
|
304
|
+
input: OpenChatDaemonCommandScopeInput,
|
|
305
|
+
): Promise<ChatDaemonCommandScope | undefined> {
|
|
306
|
+
const directory = path.join(chatDaemonPaths(input.agentDir, input.kind).dir, "commands");
|
|
307
|
+
try {
|
|
308
|
+
if (input.create === true) {
|
|
309
|
+
await fs.mkdir(directory, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
310
|
+
await fs.chmod(directory, OWNER_ONLY_DIRECTORY_MODE);
|
|
311
|
+
}
|
|
312
|
+
const stats = await fs.lstat(directory);
|
|
313
|
+
if (!stats.isDirectory()) return undefined;
|
|
314
|
+
return { directory };
|
|
315
|
+
} catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function entryPath(scope: ChatDaemonCommandScope, name: string): string {
|
|
321
|
+
return path.join(scope.directory, assertSafeEntryName(name));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function listScopedEntries(scope: ChatDaemonCommandScope): Promise<string[]> {
|
|
325
|
+
try {
|
|
326
|
+
return await fs.readdir(scope.directory);
|
|
327
|
+
} catch {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Read one bounded regular-file document.
|
|
334
|
+
*
|
|
335
|
+
* A symlink, directory, FIFO, device, or oversized entry is not a command
|
|
336
|
+
* document and never becomes one: it is reported as absent rather than opened.
|
|
337
|
+
*/
|
|
338
|
+
async function readScopedJson(scope: ChatDaemonCommandScope, name: string): Promise<unknown> {
|
|
339
|
+
const file = entryPath(scope, name);
|
|
340
|
+
try {
|
|
341
|
+
const stats = await fs.lstat(file);
|
|
342
|
+
if (!stats.isFile() || stats.size > MAX_COMMAND_ENTRY_BYTES) return undefined;
|
|
343
|
+
const text = await fs.readFile(file, "utf8");
|
|
344
|
+
if (text.length === 0) return undefined;
|
|
345
|
+
return JSON.parse(text);
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Age of an entry by its own (non-dereferenced) mtime, used only for retention sweeps. */
|
|
352
|
+
async function scopedEntryAgeMs(scope: ChatDaemonCommandScope, name: string, now: number): Promise<number | undefined> {
|
|
353
|
+
try {
|
|
354
|
+
return now - (await fs.lstat(entryPath(scope, name))).mtimeMs;
|
|
355
|
+
} catch {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function scopedEntryExists(scope: ChatDaemonCommandScope, name: string): Promise<boolean> {
|
|
361
|
+
try {
|
|
362
|
+
await fs.lstat(entryPath(scope, name));
|
|
363
|
+
return true;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
// An entry that cannot be classified is treated as present so nothing
|
|
366
|
+
// downstream proceeds on an unproven path.
|
|
367
|
+
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Create an entry exclusively.
|
|
373
|
+
*
|
|
374
|
+
* `O_CREAT|O_EXCL` is the arbitration primitive of this channel: exactly one of
|
|
375
|
+
* the submitter and the serving daemon can create a given response object, and
|
|
376
|
+
* the loser learns it definitively through `exists`.
|
|
377
|
+
*/
|
|
378
|
+
async function claimScopedEntry(scope: ChatDaemonCommandScope, name: string): Promise<boolean> {
|
|
379
|
+
try {
|
|
380
|
+
const handle = await fs.open(entryPath(scope, name), "wx", OWNER_ONLY_FILE_MODE);
|
|
381
|
+
await handle.close();
|
|
382
|
+
return true;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
385
|
+
if (code === "EEXIST") return false;
|
|
386
|
+
throw new Error(`chat daemon command entry could not be claimed (${code ?? "unknown"})`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function writeTemporary(scope: ChatDaemonCommandScope, value: unknown): Promise<string> {
|
|
391
|
+
const temporary = path.join(scope.directory, `${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
392
|
+
const text = `${JSON.stringify(value)}\n`;
|
|
393
|
+
if (new TextEncoder().encode(text).byteLength > MAX_COMMAND_ENTRY_BYTES)
|
|
394
|
+
throw new Error("chat daemon command document is not bounded");
|
|
395
|
+
await fs.writeFile(temporary, text, { mode: OWNER_ONLY_FILE_MODE, flag: "wx" });
|
|
396
|
+
return temporary;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Replace an entry atomically. The document is complete before it is ever visible. */
|
|
400
|
+
async function writeScopedJson(scope: ChatDaemonCommandScope, name: string, value: unknown): Promise<void> {
|
|
401
|
+
const target = entryPath(scope, name);
|
|
402
|
+
const temporary = await writeTemporary(scope, value);
|
|
403
|
+
try {
|
|
404
|
+
await fs.rename(temporary, target);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
await fs.unlink(temporary).catch(() => undefined);
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Publish an entry that must not already exist.
|
|
413
|
+
*
|
|
414
|
+
* `link()` from a fully written temporary makes the document atomically visible
|
|
415
|
+
* under its final name and fails `exists` when the name is taken, so a replayed
|
|
416
|
+
* or planted identifier can never be silently overwritten.
|
|
417
|
+
*/
|
|
418
|
+
async function publishScopedJsonExclusive(
|
|
419
|
+
scope: ChatDaemonCommandScope,
|
|
420
|
+
name: string,
|
|
421
|
+
value: unknown,
|
|
422
|
+
): Promise<"published" | "exists"> {
|
|
423
|
+
const target = entryPath(scope, name);
|
|
424
|
+
const temporary = await writeTemporary(scope, value);
|
|
425
|
+
try {
|
|
426
|
+
await fs.link(temporary, target);
|
|
427
|
+
return "published";
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
|
|
430
|
+
throw error;
|
|
431
|
+
} finally {
|
|
432
|
+
await fs.unlink(temporary).catch(() => undefined);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Returns whether the entry is gone: unlinked now, or already absent. */
|
|
437
|
+
async function unlinkScopedEntry(scope: ChatDaemonCommandScope, name: string): Promise<boolean> {
|
|
438
|
+
try {
|
|
439
|
+
await fs.unlink(entryPath(scope, name));
|
|
440
|
+
return true;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
return isEnoent(error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Single-winner terminal authority for one request.
|
|
448
|
+
*
|
|
449
|
+
* The claim is the exclusive creation of `<id>.response.json`. Acquiring it is
|
|
450
|
+
* idempotent within a serve, and losing it is permanent for that request: the
|
|
451
|
+
* submitter cancelled, so nothing may be committed or published afterwards.
|
|
452
|
+
*
|
|
453
|
+
* A holder that then discovers it must not act releases the claim instead of
|
|
454
|
+
* publishing, so an abandoned command leaves no orphan behind.
|
|
455
|
+
*/
|
|
456
|
+
class ChatDaemonResponseClaim {
|
|
457
|
+
#scope: ChatDaemonCommandScope;
|
|
458
|
+
#entry: string;
|
|
459
|
+
#state: "unclaimed" | "held" | "lost" = "unclaimed";
|
|
460
|
+
|
|
461
|
+
constructor(scope: ChatDaemonCommandScope, requestId: string) {
|
|
462
|
+
this.#scope = scope;
|
|
463
|
+
this.#entry = responseEntry(requestId);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
get held(): boolean {
|
|
467
|
+
return this.#state === "held";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async acquire(): Promise<boolean> {
|
|
471
|
+
if (this.#state !== "unclaimed") return this.#state === "held";
|
|
472
|
+
try {
|
|
473
|
+
this.#state = (await claimScopedEntry(this.#scope, this.#entry)) ? "held" : "lost";
|
|
474
|
+
} catch {
|
|
475
|
+
this.#state = "lost";
|
|
476
|
+
}
|
|
477
|
+
return this.#state === "held";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async release(): Promise<void> {
|
|
481
|
+
if (this.#state !== "held") return;
|
|
482
|
+
this.#state = "lost";
|
|
483
|
+
await unlinkScopedEntry(this.#scope, this.#entry);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async publish(response: ChatDaemonCommandResponse): Promise<void> {
|
|
487
|
+
if (!(await this.acquire())) return;
|
|
488
|
+
await writeScopedJson(this.#scope, this.#entry, response);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Build a request addressed to one exact owner. */
|
|
493
|
+
export function buildChatDaemonCommandRequest(input: {
|
|
494
|
+
kind: ChatDaemonKind;
|
|
495
|
+
command: ChatDaemonCommandName;
|
|
496
|
+
owner: ChatDaemonCommandOwner;
|
|
497
|
+
sessionId: string;
|
|
498
|
+
rootTs: string;
|
|
499
|
+
now?: number;
|
|
500
|
+
ttlMs?: number;
|
|
501
|
+
requestId?: string;
|
|
502
|
+
}): ChatDaemonCommandRequest {
|
|
503
|
+
const createdAt = input.now ?? Date.now();
|
|
504
|
+
return {
|
|
505
|
+
version: CHAT_DAEMON_COMMAND_VERSION,
|
|
506
|
+
requestId: input.requestId ?? crypto.randomUUID(),
|
|
507
|
+
kind: input.kind,
|
|
508
|
+
command: input.command,
|
|
509
|
+
ownerId: input.owner.ownerId,
|
|
510
|
+
pid: input.owner.pid,
|
|
511
|
+
incarnation: input.owner.incarnation,
|
|
512
|
+
generation: input.owner.generation,
|
|
513
|
+
sessionId: input.sessionId,
|
|
514
|
+
rootTs: input.rootTs,
|
|
515
|
+
createdAt,
|
|
516
|
+
expiresAt: createdAt + (input.ttlMs ?? DEFAULT_REQUEST_TTL_MS),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface SubmitChatDaemonCommandInput {
|
|
521
|
+
agentDir: string;
|
|
522
|
+
kind: ChatDaemonKind;
|
|
523
|
+
owner: ChatDaemonCommandOwner;
|
|
524
|
+
command: ChatDaemonCommandName;
|
|
525
|
+
sessionId: string;
|
|
526
|
+
rootTs: string;
|
|
527
|
+
timeoutMs?: number;
|
|
528
|
+
pollIntervalMs?: number;
|
|
529
|
+
/** Bounded wait after a lost cancellation, before the outcome is reported unknown. */
|
|
530
|
+
settleGraceMs?: number;
|
|
531
|
+
ttlMs?: number;
|
|
532
|
+
now?: () => number;
|
|
533
|
+
sleep?: (ms: number) => Promise<void>;
|
|
534
|
+
requestId?: string;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Terminal result of one submission.
|
|
539
|
+
*
|
|
540
|
+
* `cancelled` is the only definitive failure: the submitter won the response
|
|
541
|
+
* claim, so the addressed owner cannot dispatch this request from here on within
|
|
542
|
+
* this submission's race. `unknown` means the daemon won that claim first and its
|
|
543
|
+
* answer was not observed; the caller must not report a definitive failure,
|
|
544
|
+
* because a commit may already be applied.
|
|
545
|
+
*
|
|
546
|
+
* `untrusted` means the response object under this identifier is a complete,
|
|
547
|
+
* well-formed document that does not carry this request's envelope. It is
|
|
548
|
+
* channel corruption — stale or planted — so it may not be read as an answer of
|
|
549
|
+
* any status, and because the identifier is now occupied the real outcome is
|
|
550
|
+
* unknowable from here; the caller must treat it exactly like `unknown`.
|
|
551
|
+
*/
|
|
552
|
+
export type ChatDaemonCommandSubmission =
|
|
553
|
+
| { outcome: "answered"; response: ChatDaemonCommandResponse }
|
|
554
|
+
| { outcome: "cancelled" }
|
|
555
|
+
| { outcome: "unknown" }
|
|
556
|
+
| { outcome: "untrusted"; code: "response_envelope_mismatch" }
|
|
557
|
+
| { outcome: "unavailable"; code: "command_channel_unavailable" | "request_id_unavailable" };
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Publish a command for the addressed owner and wait for its answer.
|
|
561
|
+
*
|
|
562
|
+
* The wait ends in exactly one of four states, and the commit-vs-timeout race is
|
|
563
|
+
* arbitrated by the exclusive creation of the response object rather than by any
|
|
564
|
+
* timing assumption.
|
|
565
|
+
*/
|
|
566
|
+
export async function submitChatDaemonCommand(
|
|
567
|
+
input: SubmitChatDaemonCommandInput,
|
|
568
|
+
): Promise<ChatDaemonCommandSubmission> {
|
|
569
|
+
const scope = await openChatDaemonCommandScope({ agentDir: input.agentDir, kind: input.kind, create: true });
|
|
570
|
+
if (!scope) return { outcome: "unavailable", code: "command_channel_unavailable" };
|
|
571
|
+
return await submitAgainstScope(scope, input);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function submitAgainstScope(
|
|
575
|
+
scope: ChatDaemonCommandScope,
|
|
576
|
+
input: SubmitChatDaemonCommandInput,
|
|
577
|
+
): Promise<ChatDaemonCommandSubmission> {
|
|
578
|
+
const now = input.now ?? Date.now;
|
|
579
|
+
const sleep = input.sleep ?? (async ms => await Bun.sleep(ms));
|
|
580
|
+
const timeoutMs = Math.max(input.timeoutMs ?? DEFAULT_CHAT_DAEMON_COMMAND_TIMEOUT_MS, 0);
|
|
581
|
+
const pollIntervalMs = Math.max(input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 1);
|
|
582
|
+
const settleGraceMs = Math.max(input.settleGraceMs ?? DEFAULT_SETTLE_GRACE_MS, 0);
|
|
583
|
+
const request = buildChatDaemonCommandRequest({
|
|
584
|
+
kind: input.kind,
|
|
585
|
+
command: input.command,
|
|
586
|
+
owner: input.owner,
|
|
587
|
+
sessionId: input.sessionId,
|
|
588
|
+
rootTs: input.rootTs,
|
|
589
|
+
now: now(),
|
|
590
|
+
ttlMs: input.ttlMs ?? Math.max(timeoutMs * 2, DEFAULT_REQUEST_TTL_MS),
|
|
591
|
+
requestId: input.requestId,
|
|
592
|
+
});
|
|
593
|
+
const requestName = requestEntry(request.requestId);
|
|
594
|
+
const responseName = responseEntry(request.requestId);
|
|
595
|
+
// Stale or planted material under this identifier must neither authorize an
|
|
596
|
+
// answer nor silently suppress the command: the submission fails closed and
|
|
597
|
+
// the caller re-runs with a fresh identifier.
|
|
598
|
+
if (await scopedEntryExists(scope, responseName)) return { outcome: "unavailable", code: "request_id_unavailable" };
|
|
599
|
+
let published: "published" | "exists";
|
|
600
|
+
try {
|
|
601
|
+
published = await publishScopedJsonExclusive(scope, requestName, request);
|
|
602
|
+
} catch {
|
|
603
|
+
return { outcome: "unavailable", code: "command_channel_unavailable" };
|
|
604
|
+
}
|
|
605
|
+
if (published === "exists") return { outcome: "unavailable", code: "request_id_unavailable" };
|
|
606
|
+
try {
|
|
607
|
+
const answered = await awaitChatDaemonCommandResponse({
|
|
608
|
+
scope,
|
|
609
|
+
request,
|
|
610
|
+
responseName,
|
|
611
|
+
deadline: now() + timeoutMs,
|
|
612
|
+
now,
|
|
613
|
+
sleep,
|
|
614
|
+
pollIntervalMs,
|
|
615
|
+
});
|
|
616
|
+
if (answered.kind === "untrusted") return { outcome: "untrusted", code: "response_envelope_mismatch" };
|
|
617
|
+
if (answered.kind === "answer") return { outcome: "answered", response: answered.response };
|
|
618
|
+
// Winning the response claim makes a dispatch by the addressed owner
|
|
619
|
+
// impossible for the rest of this submission's race, which is what turns a
|
|
620
|
+
// timeout into a definitive, mutation-free failure without a sleep.
|
|
621
|
+
if (await claimScopedEntry(scope, responseName)) return { outcome: "cancelled" };
|
|
622
|
+
const settled = await awaitChatDaemonCommandResponse({
|
|
623
|
+
scope,
|
|
624
|
+
request,
|
|
625
|
+
responseName,
|
|
626
|
+
deadline: now() + settleGraceMs,
|
|
627
|
+
now,
|
|
628
|
+
sleep,
|
|
629
|
+
pollIntervalMs,
|
|
630
|
+
});
|
|
631
|
+
if (settled.kind === "untrusted") return { outcome: "untrusted", code: "response_envelope_mismatch" };
|
|
632
|
+
return settled.kind === "answer" ? { outcome: "answered", response: settled.response } : { outcome: "unknown" };
|
|
633
|
+
} finally {
|
|
634
|
+
// Order matters: the request is retired before the settled response
|
|
635
|
+
// object. A daemon that re-creates a removed response claim then proves
|
|
636
|
+
// the request is gone and abandons, so no *later dispatch* can follow a
|
|
637
|
+
// settled submission.
|
|
638
|
+
//
|
|
639
|
+
// The scope of that guarantee is exactly this submission's race against a
|
|
640
|
+
// concurrent serve, under the same-UID trusted-pathname model documented
|
|
641
|
+
// at the top of this module. It is not durable settlement: it says nothing
|
|
642
|
+
// about a daemon that crashed mid-commit and is resurrected against
|
|
643
|
+
// replayed material, and nothing about a same-UID process that rewrites
|
|
644
|
+
// these documents. Both are out of scope here, and neither is what a
|
|
645
|
+
// caller may rely on for commit certainty — that comes only from
|
|
646
|
+
// corroborating the mutation itself.
|
|
647
|
+
// The response claim is what fences an already-settled request: a serve
|
|
648
|
+
// that finds no response entry re-claims the request and commits it. So the
|
|
649
|
+
// claim may only be released once the request is *proven* gone. When the
|
|
650
|
+
// request unlink fails the claim is deliberately left behind — a leaked
|
|
651
|
+
// response object is inert, whereas releasing it would let a submission the
|
|
652
|
+
// caller was told was `cancelled` commit later.
|
|
653
|
+
if (await unlinkScopedEntry(scope, requestName)) await unlinkScopedEntry(scope, responseName);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** What one poll of the response object proved: an answer, corruption, or nothing yet. */
|
|
658
|
+
type ChatDaemonResponseObservation =
|
|
659
|
+
| { kind: "answer"; response: ChatDaemonCommandResponse }
|
|
660
|
+
| { kind: "untrusted" }
|
|
661
|
+
| { kind: "pending" };
|
|
662
|
+
|
|
663
|
+
async function awaitChatDaemonCommandResponse(input: {
|
|
664
|
+
scope: ChatDaemonCommandScope;
|
|
665
|
+
request: ChatDaemonCommandRequest;
|
|
666
|
+
responseName: string;
|
|
667
|
+
deadline: number;
|
|
668
|
+
now: () => number;
|
|
669
|
+
sleep: (ms: number) => Promise<void>;
|
|
670
|
+
pollIntervalMs: number;
|
|
671
|
+
}): Promise<ChatDaemonResponseObservation> {
|
|
672
|
+
for (;;) {
|
|
673
|
+
const document = await readScopedJson(input.scope, input.responseName);
|
|
674
|
+
if (document !== undefined) {
|
|
675
|
+
// An empty or unclassifiable entry is the in-flight claim placeholder and
|
|
676
|
+
// must keep the caller waiting. Any *complete* document is terminal
|
|
677
|
+
// material: either it is this request's exact answer, or it is stale or
|
|
678
|
+
// planted content that can never become one, and waiting for the deadline
|
|
679
|
+
// would only delay a fail-closed report.
|
|
680
|
+
if (!isChatDaemonCommandResponse(document) || !answersRequest(document, input.request))
|
|
681
|
+
return { kind: "untrusted" };
|
|
682
|
+
return { kind: "answer", response: document };
|
|
683
|
+
}
|
|
684
|
+
if (input.now() >= input.deadline) return { kind: "pending" };
|
|
685
|
+
await input.sleep(input.pollIntervalMs);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export interface ServeChatDaemonCommandsInput {
|
|
690
|
+
agentDir: string;
|
|
691
|
+
kind: ChatDaemonKind;
|
|
692
|
+
/** The serving daemon's own proven authority. */
|
|
693
|
+
ownerId: string;
|
|
694
|
+
pid: number;
|
|
695
|
+
incarnation: string;
|
|
696
|
+
generation: number;
|
|
697
|
+
handler: ChatDaemonCommandHandler;
|
|
698
|
+
/**
|
|
699
|
+
* Re-proves that this process still holds the persisted owner record. It is
|
|
700
|
+
* checked before any work is dispatched *and again* inside the commit fence,
|
|
701
|
+
* so a daemon that lost ownership after dispatch mutates nothing.
|
|
702
|
+
*/
|
|
703
|
+
verifyOwnership?: () => Promise<boolean>;
|
|
704
|
+
now?: () => number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Answer every pending request addressed to this exact owner. Requests aimed at
|
|
709
|
+
* any other owner identity are answered `owner_changed` without doing work, and
|
|
710
|
+
* expired requests are answered `expired`; neither performs a mutation.
|
|
711
|
+
*/
|
|
712
|
+
export async function serveChatDaemonCommandsOnce(input: ServeChatDaemonCommandsInput): Promise<number> {
|
|
713
|
+
const scope = await openChatDaemonCommandScope({ agentDir: input.agentDir, kind: input.kind });
|
|
714
|
+
if (!scope) return 0;
|
|
715
|
+
return await serveChatDaemonCommandsAgainstScope(scope, input);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Answer every pending request against an already-captured command scope.
|
|
720
|
+
*
|
|
721
|
+
* Exposed so a caller that already holds the directory authority drives exactly
|
|
722
|
+
* the production path.
|
|
723
|
+
*/
|
|
724
|
+
export async function serveChatDaemonCommandsAgainstScope(
|
|
725
|
+
scope: ChatDaemonCommandScope,
|
|
726
|
+
input: ServeChatDaemonCommandsInput,
|
|
727
|
+
): Promise<number> {
|
|
728
|
+
const now = input.now ?? Date.now;
|
|
729
|
+
let served = 0;
|
|
730
|
+
for (const name of await listScopedEntries(scope)) {
|
|
731
|
+
if (name.endsWith(RESPONSE_SUFFIX)) {
|
|
732
|
+
await sweepCompletedResponse(scope, name, now());
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (!name.endsWith(REQUEST_SUFFIX)) continue;
|
|
736
|
+
const requestId = name.slice(0, -REQUEST_SUFFIX.length);
|
|
737
|
+
if (!REQUEST_ID_PATTERN.test(requestId)) continue;
|
|
738
|
+
const request = await readScopedJson(scope, name);
|
|
739
|
+
if (!isChatDaemonCommandRequest(request) || request.requestId !== requestId || request.kind !== input.kind) {
|
|
740
|
+
await unlinkScopedEntry(scope, name);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
// A response object already exists for this identifier: the request is a
|
|
744
|
+
// replay of settled material, the submitter cancelled, or a stale/planted
|
|
745
|
+
// answer occupies the name. Either way this request may never authorize
|
|
746
|
+
// work, and a definitive answer for it was already reported elsewhere.
|
|
747
|
+
if (await scopedEntryExists(scope, responseEntry(requestId))) {
|
|
748
|
+
if (request.expiresAt + COMMAND_RETENTION_MS <= now()) await unlinkScopedEntry(scope, name);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const claim = new ChatDaemonResponseClaim(scope, requestId);
|
|
752
|
+
const answered = await answerChatDaemonCommand({ request, input, scope, claim, now: now() });
|
|
753
|
+
await claim.publish(answered);
|
|
754
|
+
await unlinkScopedEntry(scope, name);
|
|
755
|
+
served++;
|
|
756
|
+
}
|
|
757
|
+
return served;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Why the commit fence refused, so the answer names the real authority failure. */
|
|
761
|
+
type CommitFenceFailure = "owner_changed" | "cancelled" | "abandoned";
|
|
762
|
+
|
|
763
|
+
async function answerChatDaemonCommand(context: {
|
|
764
|
+
request: ChatDaemonCommandRequest;
|
|
765
|
+
input: ServeChatDaemonCommandsInput;
|
|
766
|
+
scope: ChatDaemonCommandScope;
|
|
767
|
+
claim: ChatDaemonResponseClaim;
|
|
768
|
+
now: number;
|
|
769
|
+
}): Promise<ChatDaemonCommandResponse> {
|
|
770
|
+
const { request, input, scope, claim } = context;
|
|
771
|
+
const now = input.now ?? Date.now;
|
|
772
|
+
// Every answer echoes the *addressed* request envelope, not the responding
|
|
773
|
+
// process's own identity. That is what lets a submitter validate the complete
|
|
774
|
+
// tuple before it interprets any status: an `owner_changed` answer published
|
|
775
|
+
// by a replacement daemon is still provably the answer to this request, while
|
|
776
|
+
// a document carrying some other tuple is provably not.
|
|
777
|
+
const envelope = {
|
|
778
|
+
version: CHAT_DAEMON_COMMAND_VERSION,
|
|
779
|
+
requestId: request.requestId,
|
|
780
|
+
kind: request.kind,
|
|
781
|
+
command: request.command,
|
|
782
|
+
ownerId: request.ownerId,
|
|
783
|
+
pid: request.pid,
|
|
784
|
+
incarnation: request.incarnation,
|
|
785
|
+
generation: request.generation,
|
|
786
|
+
sessionId: request.sessionId,
|
|
787
|
+
rootTs: request.rootTs,
|
|
788
|
+
completedAt: context.now,
|
|
789
|
+
} as const;
|
|
790
|
+
if (
|
|
791
|
+
request.ownerId !== input.ownerId ||
|
|
792
|
+
request.pid !== input.pid ||
|
|
793
|
+
request.incarnation !== input.incarnation ||
|
|
794
|
+
request.generation !== input.generation
|
|
795
|
+
)
|
|
796
|
+
return { ...envelope, status: "owner_changed" };
|
|
797
|
+
if (request.expiresAt <= context.now) return { ...envelope, status: "expired" };
|
|
798
|
+
// Ownership can lapse between capture and execution. Re-prove it here so a
|
|
799
|
+
// daemon that no longer holds the owner record performs no mutation.
|
|
800
|
+
if (input.verifyOwnership && !(await input.verifyOwnership())) return { ...envelope, status: "owner_changed" };
|
|
801
|
+
let fenceFailure: CommitFenceFailure | undefined;
|
|
802
|
+
/**
|
|
803
|
+
* The commit fence. It runs inside the caller's store lock, immediately
|
|
804
|
+
* before the mutation, and every check is local: the persisted owner record,
|
|
805
|
+
* the single-winner claim, then the still-published request material.
|
|
806
|
+
*
|
|
807
|
+
* The claim is taken *before* the request is re-read on purpose. A submitter
|
|
808
|
+
* that gives up claims the response, then removes its request and its own
|
|
809
|
+
* claim; taking the claim first and only then proving the request is still
|
|
810
|
+
* published closes the window in which a daemon could re-create the removed
|
|
811
|
+
* claim and commit behind a caller that already reported cancellation.
|
|
812
|
+
*/
|
|
813
|
+
const commitAuthority = async (): Promise<boolean> => {
|
|
814
|
+
if (input.verifyOwnership && !(await input.verifyOwnership())) {
|
|
815
|
+
fenceFailure = "owner_changed";
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
if (!(await claim.acquire())) {
|
|
819
|
+
fenceFailure = "cancelled";
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
const current = await readScopedJson(scope, requestEntry(request.requestId));
|
|
823
|
+
if (
|
|
824
|
+
!isChatDaemonCommandRequest(current) ||
|
|
825
|
+
!isSameChatDaemonCommandRequest(current, request) ||
|
|
826
|
+
current.expiresAt <= now()
|
|
827
|
+
) {
|
|
828
|
+
fenceFailure = "abandoned";
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
return true;
|
|
832
|
+
};
|
|
833
|
+
const answer = await dispatchChatDaemonCommand({
|
|
834
|
+
input,
|
|
835
|
+
request,
|
|
836
|
+
envelope,
|
|
837
|
+
commitAuthority,
|
|
838
|
+
claim,
|
|
839
|
+
fenceFailure: () => fenceFailure,
|
|
840
|
+
});
|
|
841
|
+
// An abandoned request has no reader left: its submitter already settled.
|
|
842
|
+
// Release the claim rather than publishing an answer nobody can consume.
|
|
843
|
+
if (fenceFailure === "abandoned") await claim.release();
|
|
844
|
+
return answer;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function dispatchChatDaemonCommand(context: {
|
|
848
|
+
input: ServeChatDaemonCommandsInput;
|
|
849
|
+
request: ChatDaemonCommandRequest;
|
|
850
|
+
envelope: Omit<ChatDaemonCommandResponse, "status">;
|
|
851
|
+
commitAuthority: () => Promise<boolean>;
|
|
852
|
+
claim: ChatDaemonResponseClaim;
|
|
853
|
+
fenceFailure: () => CommitFenceFailure | undefined;
|
|
854
|
+
}): Promise<ChatDaemonCommandResponse> {
|
|
855
|
+
const { envelope, claim } = context;
|
|
856
|
+
// Whether the fence ever handed out commit authority. Once it has, the
|
|
857
|
+
// handler may have applied a mapping, and an exception carries no evidence
|
|
858
|
+
// either way — so a thrown failure after that point is indeterminate, not a
|
|
859
|
+
// rejection the caller may act on.
|
|
860
|
+
let authorized = false;
|
|
861
|
+
const commitAuthority = async (): Promise<boolean> => {
|
|
862
|
+
const granted = await context.commitAuthority();
|
|
863
|
+
authorized = authorized || granted;
|
|
864
|
+
return granted;
|
|
865
|
+
};
|
|
866
|
+
let outcome: ChatDaemonCommandOutcome;
|
|
867
|
+
try {
|
|
868
|
+
outcome = await context.input.handler.bindExistingRoot({
|
|
869
|
+
sessionId: context.request.sessionId,
|
|
870
|
+
rootTs: context.request.rootTs,
|
|
871
|
+
commitAuthority,
|
|
872
|
+
});
|
|
873
|
+
} catch {
|
|
874
|
+
outcome = authorized
|
|
875
|
+
? { ok: false, certainty: "unknown", code: "binding_outcome_unknown" }
|
|
876
|
+
: { ok: false, certainty: "rejected", code: "binding_failed" };
|
|
877
|
+
}
|
|
878
|
+
if (!outcome.ok) {
|
|
879
|
+
// A handler that reports an indeterminate commit outranks everything: the
|
|
880
|
+
// mapping may already be applied, so no definitive answer may be published.
|
|
881
|
+
if (outcome.certainty === "unknown") return { ...envelope, status: "outcome_unknown", code: outcome.code };
|
|
882
|
+
// The fence is the authority on *why* a refused commit was refused: a
|
|
883
|
+
// handler-level error text can never outrank a proven authority change.
|
|
884
|
+
const failure = context.fenceFailure();
|
|
885
|
+
if (failure === "owner_changed") return { ...envelope, status: "owner_changed" };
|
|
886
|
+
if (failure !== undefined) return { ...envelope, status: "expired" };
|
|
887
|
+
return { ...envelope, status: "rejected", code: outcome.code };
|
|
888
|
+
}
|
|
889
|
+
// A success is only reportable when this serve holds the terminal claim; a
|
|
890
|
+
// handler that never passed the fence has not proven exact commit authority.
|
|
891
|
+
if (!claim.held) return { ...envelope, status: "rejected", code: "commit_authority_missing" };
|
|
892
|
+
// The answer must describe the binding that was asked for. A handler that
|
|
893
|
+
// reports some other session or root has not answered this request, and
|
|
894
|
+
// publishing it would produce a document the submitter must reject as
|
|
895
|
+
// untrusted rather than a usable outcome.
|
|
896
|
+
if (outcome.sessionId !== context.request.sessionId || outcome.rootTs !== context.request.rootTs)
|
|
897
|
+
return { ...envelope, status: "rejected", code: "binding_mismatch" };
|
|
898
|
+
return {
|
|
899
|
+
...envelope,
|
|
900
|
+
status: "ok",
|
|
901
|
+
endpointGeneration: outcome.endpointGeneration,
|
|
902
|
+
teamId: outcome.teamId,
|
|
903
|
+
channelId: outcome.channelId,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Retire one completed response once its retention window has passed.
|
|
909
|
+
*
|
|
910
|
+
* An unparseable entry can be a live claim placeholder held by an in-flight
|
|
911
|
+
* commit, so only age retires it; deleting it early would break arbitration.
|
|
912
|
+
*/
|
|
913
|
+
async function sweepCompletedResponse(scope: ChatDaemonCommandScope, name: string, now: number): Promise<void> {
|
|
914
|
+
const document = await readScopedJson(scope, name);
|
|
915
|
+
if (isChatDaemonCommandResponse(document)) {
|
|
916
|
+
if (now - document.completedAt >= COMMAND_RETENTION_MS) await unlinkScopedEntry(scope, name);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const age = await scopedEntryAgeMs(scope, name, now);
|
|
920
|
+
if (age !== undefined && age >= COMMAND_RETENTION_MS) await unlinkScopedEntry(scope, name);
|
|
921
|
+
}
|