@gajae-code/coding-agent 0.9.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/dist/types/cli/classify-install-target.d.ts +0 -10
- package/dist/types/cli/daemon-cli.d.ts +2 -0
- package/dist/types/cli/initial-message.d.ts +1 -1
- package/dist/types/cli/malloc-env-guard.d.ts +65 -0
- package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +3 -1
- package/dist/types/cli/session-picker.d.ts +4 -3
- package/dist/types/cli/update-cli.d.ts +22 -3
- package/dist/types/cli.d.ts +4 -0
- package/dist/types/commands/daemon.d.ts +6 -2
- package/dist/types/commands/launch.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/commit/analysis/conventional.d.ts +1 -1
- package/dist/types/commit/analysis/summary.d.ts +1 -1
- package/dist/types/commit/changelog/generate.d.ts +1 -1
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +47 -7
- package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
- package/dist/types/coordinator-mcp/server.d.ts +67 -6
- package/dist/types/daemon/control-types.d.ts +10 -0
- package/dist/types/daemon/operator-contract.d.ts +47 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
- package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
- package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
- package/dist/types/internal-urls/local-protocol.d.ts +13 -9
- package/dist/types/main.d.ts +66 -2
- package/dist/types/modes/acp/acp-agent.d.ts +19 -2
- package/dist/types/modes/acp/acp-mode.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
- package/dist/types/modes/components/pet-selector.d.ts +11 -0
- package/dist/types/modes/components/session-selector.d.ts +22 -9
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +2 -1
- package/dist/types/modes/controllers/event-controller.d.ts +4 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +24 -2
- package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
- package/dist/types/modes/theme/defaults/index.d.ts +568 -568
- package/dist/types/modes/types.d.ts +28 -2
- package/dist/types/modes/utils/abort-message.d.ts +1 -1
- package/dist/types/modes/utils/irc-message.d.ts +38 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
- package/dist/types/notifications/config.d.ts +34 -0
- package/dist/types/notifications/daemon-paths.d.ts +7 -0
- package/dist/types/notifications/index.d.ts +27 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
- package/dist/types/notifications/notification-service.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
- package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +130 -3
- package/dist/types/notifications/telegram-reference.d.ts +19 -2
- package/dist/types/notifications/topic-registry.d.ts +24 -4
- package/dist/types/registry/agent-registry.d.ts +2 -0
- package/dist/types/rlm/index.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +26 -2
- package/dist/types/session/cache-economics.d.ts +79 -0
- package/dist/types/session/provider-safety-stop.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +124 -4
- package/dist/types/session/session-storage.d.ts +155 -13
- package/dist/types/setup/credential-auto-import.d.ts +2 -2
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +8 -0
- package/dist/types/tools/ask-answer-registry.d.ts +5 -0
- package/dist/types/tools/ask.d.ts +25 -1
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
- package/dist/types/tools/index.d.ts +58 -6
- package/dist/types/tools/telegram-send.d.ts +7 -0
- package/dist/types/utils/external-editor.d.ts +2 -0
- package/package.json +9 -9
- package/scripts/compile-args.ts +8 -2
- package/scripts/g004-tmux-smoke.ts +196 -36
- package/src/cli/daemon-cli.ts +19 -29
- package/src/cli/malloc-env-guard.test.ts +104 -0
- package/src/cli/malloc-env-guard.ts +149 -0
- package/src/cli/notify-cli.ts +78 -14
- package/src/cli/session-picker.ts +23 -40
- package/src/cli/update-cli.ts +56 -37
- package/src/cli.ts +50 -10
- package/src/commands/daemon.ts +19 -5
- package/src/commands/harness.ts +515 -47
- package/src/commands/launch.ts +11 -3
- package/src/commands/notify.ts +5 -1
- package/src/commands/session.ts +6 -1
- package/src/config/keybindings.ts +5 -0
- package/src/config/model-profile-activation.ts +1 -1
- package/src/config/model-profiles.ts +23 -23
- package/src/config/settings-schema.ts +41 -1
- package/src/coordinator-mcp/model-preset.ts +100 -0
- package/src/coordinator-mcp/server.ts +2653 -425
- package/src/daemon/control-types.ts +11 -0
- package/src/daemon/operator-contract.ts +121 -0
- package/src/deep-interview/render-middleware.ts +67 -2
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
- package/src/gjc-runtime/launch-tmux.ts +702 -135
- package/src/gjc-runtime/session-state-sidecar.ts +756 -135
- package/src/gjc-runtime/team-runtime.ts +6 -0
- package/src/gjc-runtime/tmux-common.ts +26 -7
- package/src/gjc-runtime/tmux-gc.ts +52 -9
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
- package/src/gjc-runtime/tmux-sessions.ts +871 -68
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/internal-urls/local-protocol.ts +29 -10
- package/src/main.ts +298 -75
- package/src/modes/acp/acp-agent.ts +728 -98
- package/src/modes/acp/acp-mode.ts +29 -2
- package/src/modes/components/assistant-message.ts +32 -3
- package/src/modes/components/bash-execution.ts +43 -13
- package/src/modes/components/custom-editor.ts +5 -0
- package/src/modes/components/gajae-pet-widget.ts +360 -0
- package/src/modes/components/hook-selector.ts +12 -0
- package/src/modes/components/irc-sidebar.ts +206 -0
- package/src/modes/components/pet-selector.ts +55 -0
- package/src/modes/components/session-selector.ts +222 -221
- package/src/modes/components/settings-selector.ts +10 -0
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/components/user-message.ts +26 -9
- package/src/modes/controllers/command-controller.ts +28 -2
- package/src/modes/controllers/event-controller.ts +159 -36
- package/src/modes/controllers/extension-ui-controller.ts +36 -17
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +62 -15
- package/src/modes/interactive-mode.ts +183 -7
- package/src/modes/irc-observation-ledger.ts +127 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
- package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
- package/src/modes/types.ts +34 -2
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/irc-message.ts +138 -0
- package/src/modes/utils/ui-helpers.ts +216 -50
- package/src/notifications/config.ts +44 -0
- package/src/notifications/daemon-paths.ts +8 -0
- package/src/notifications/index.ts +535 -75
- package/src/notifications/lifecycle-control-runtime.ts +930 -77
- package/src/notifications/notification-service.ts +710 -0
- package/src/notifications/rate-limit-pool.ts +46 -12
- package/src/notifications/telegram-daemon-cli.ts +2 -0
- package/src/notifications/telegram-daemon-control.ts +27 -2
- package/src/notifications/telegram-daemon.ts +635 -94
- package/src/notifications/telegram-reference.ts +73 -13
- package/src/notifications/topic-registry.ts +86 -9
- package/src/prompts/system/irc-peer-roster.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/registry/agent-registry.ts +3 -0
- package/src/sdk.ts +52 -1
- package/src/secrets/obfuscator.ts +4 -2
- package/src/session/agent-session.ts +396 -145
- package/src/session/cache-economics.ts +275 -0
- package/src/session/provider-safety-stop.ts +9 -0
- package/src/session/session-dump-format.ts +6 -0
- package/src/session/session-manager.ts +809 -39
- package/src/session/session-storage.ts +582 -38
- package/src/slash-commands/builtin-registry.ts +143 -9
- package/src/task/executor.ts +12 -3
- package/src/task/index.ts +8 -0
- package/src/task/receipt.ts +7 -2
- package/src/task/types.ts +26 -0
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/ask.ts +355 -33
- package/src/tools/browser/tab-supervisor.ts +63 -6
- package/src/tools/browser.ts +8 -3
- package/src/tools/index.ts +81 -6
- package/src/tools/sqlite-reader.ts +8 -4
- package/src/tui/output-block.ts +18 -6
- package/src/utils/external-editor.ts +6 -1
|
@@ -11,8 +11,31 @@ import {
|
|
|
11
11
|
} from "../coordinator/contract";
|
|
12
12
|
import {
|
|
13
13
|
GJC_COORDINATOR_SESSION_ID_ENV,
|
|
14
|
+
GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV,
|
|
15
|
+
GJC_COORDINATOR_SESSION_READINESS_FILE_ENV,
|
|
14
16
|
GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
|
|
17
|
+
GJC_TMUX_OWNER_GENERATION_ENV,
|
|
18
|
+
GJC_TMUX_OWNER_SERVER_KEY_ENV,
|
|
19
|
+
GJC_TMUX_OWNER_STATE_DIR_ENV,
|
|
15
20
|
} from "../gjc-runtime/session-state-sidecar";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
captureOwnerGenerationBaselineSync,
|
|
24
|
+
classifyCgroup,
|
|
25
|
+
isExactScopedBootstrapSuccessReceipt,
|
|
26
|
+
type OwnerIsolationProbe,
|
|
27
|
+
ownerProcessStartTime,
|
|
28
|
+
planTmuxOwnerIsolation,
|
|
29
|
+
replaceOwnerGenerationSync,
|
|
30
|
+
type TmuxServerProof,
|
|
31
|
+
} from "../gjc-runtime/tmux-owner-isolation";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
type CoordinatorModelProfileLoader,
|
|
35
|
+
loadCoordinatorModelProfiles,
|
|
36
|
+
resolveCoordinatorMpreset,
|
|
37
|
+
} from "./model-preset";
|
|
38
|
+
|
|
16
39
|
import {
|
|
17
40
|
assertCoordinatorArtifactPath,
|
|
18
41
|
assertCoordinatorWorkdir,
|
|
@@ -44,8 +67,15 @@ interface JsonRpcResponse {
|
|
|
44
67
|
interface SessionStartInput {
|
|
45
68
|
cwd: string;
|
|
46
69
|
prompt?: string;
|
|
70
|
+
mpreset?: string | null;
|
|
47
71
|
namespace: { profile: string | null; repo: string | null };
|
|
48
72
|
worktree: true;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
launchId: string;
|
|
75
|
+
/** Canonical, coordinator-owned immutable readiness marker path. */
|
|
76
|
+
readinessMarkerFile: string;
|
|
77
|
+
/** @deprecated Compatibility alias for injected launch providers. */
|
|
78
|
+
readinessFile: string;
|
|
49
79
|
}
|
|
50
80
|
|
|
51
81
|
interface SessionRegisterInput {
|
|
@@ -79,10 +109,20 @@ interface RuntimeSessionStatePayload extends CoordinatorSessionState {
|
|
|
79
109
|
error?: { code: string; message: string; recoverable: boolean } | null;
|
|
80
110
|
}
|
|
81
111
|
|
|
112
|
+
interface CommandResult {
|
|
113
|
+
exitCode: number;
|
|
114
|
+
stdout: string;
|
|
115
|
+
stderr: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type CommandRunner = (command: string[], stdinLine?: string) => Promise<CommandResult>;
|
|
119
|
+
|
|
82
120
|
interface CoordinatorServices {
|
|
83
121
|
listSessions?: () => unknown[] | Promise<unknown[]>;
|
|
84
122
|
startSession?: (input: SessionStartInput) => unknown | Promise<unknown>;
|
|
85
|
-
commandRunner?:
|
|
123
|
+
commandRunner?: CommandRunner;
|
|
124
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
125
|
+
resolveModelProfiles?: CoordinatorModelProfileLoader;
|
|
86
126
|
}
|
|
87
127
|
|
|
88
128
|
interface CoordinatorMcpServerOptions {
|
|
@@ -92,7 +132,7 @@ interface CoordinatorMcpServerOptions {
|
|
|
92
132
|
|
|
93
133
|
interface LegacyHandlerOptions {
|
|
94
134
|
env?: NodeJS.ProcessEnv;
|
|
95
|
-
createSession?: () => unknown;
|
|
135
|
+
createSession?: (input: SessionStartInput) => unknown;
|
|
96
136
|
}
|
|
97
137
|
|
|
98
138
|
type TurnStatus =
|
|
@@ -112,16 +152,28 @@ interface TurnRecord {
|
|
|
112
152
|
session_id: string;
|
|
113
153
|
namespace: { profile: string | null; repo: string | null };
|
|
114
154
|
status: TurnStatus;
|
|
115
|
-
prompt: {
|
|
155
|
+
prompt: {
|
|
156
|
+
text: string;
|
|
157
|
+
created_at: string;
|
|
158
|
+
source: "mcp" | "question_answer";
|
|
159
|
+
};
|
|
116
160
|
delivery: {
|
|
117
161
|
delivered: boolean;
|
|
118
162
|
queued: boolean;
|
|
119
163
|
target: string | null;
|
|
120
164
|
tmux_keys_sent?: boolean;
|
|
121
165
|
prompt_acknowledged?: boolean;
|
|
122
|
-
state?:
|
|
166
|
+
state?:
|
|
167
|
+
| "queued"
|
|
168
|
+
| "awaiting_runtime_readiness"
|
|
169
|
+
| "delivering"
|
|
170
|
+
| "tmux_keys_sent"
|
|
171
|
+
| "acknowledged"
|
|
172
|
+
| "unavailable"
|
|
173
|
+
| "unacknowledged";
|
|
123
174
|
attempts: Array<{
|
|
124
175
|
delivered: boolean;
|
|
176
|
+
attempt_id?: string;
|
|
125
177
|
created_at: string;
|
|
126
178
|
reason: string | null;
|
|
127
179
|
channel?: "tmux_keys" | "runtime_ack";
|
|
@@ -138,7 +190,11 @@ interface TurnRecord {
|
|
|
138
190
|
};
|
|
139
191
|
evidence: Array<Record<string, unknown>>;
|
|
140
192
|
error: { code: string; message: string; recoverable: boolean } | null;
|
|
141
|
-
liveness: {
|
|
193
|
+
liveness: {
|
|
194
|
+
checked_at: string | null;
|
|
195
|
+
live: boolean | null;
|
|
196
|
+
reason: string | null;
|
|
197
|
+
};
|
|
142
198
|
created_at: string;
|
|
143
199
|
updated_at: string;
|
|
144
200
|
started_at: string | null;
|
|
@@ -163,9 +219,22 @@ interface CoordinatorSessionState {
|
|
|
163
219
|
current_turn_id: string | null;
|
|
164
220
|
last_turn_id: string | null;
|
|
165
221
|
updated_at: string;
|
|
166
|
-
source: "coordinator" | "agent_session_event";
|
|
222
|
+
source: "coordinator" | "agent_session_event" | "process_postmortem";
|
|
223
|
+
|
|
167
224
|
live: boolean | null;
|
|
168
225
|
reason: string | null;
|
|
226
|
+
cwd?: string;
|
|
227
|
+
workdir?: string;
|
|
228
|
+
session_file?: string | null;
|
|
229
|
+
owner_generation?: string;
|
|
230
|
+
event?: string;
|
|
231
|
+
owner_terminal?: {
|
|
232
|
+
generation: string;
|
|
233
|
+
socket_key: string;
|
|
234
|
+
classification: string;
|
|
235
|
+
observer: string;
|
|
236
|
+
observed_at: string;
|
|
237
|
+
};
|
|
169
238
|
}
|
|
170
239
|
|
|
171
240
|
type CoordinatorEventKind =
|
|
@@ -218,6 +287,10 @@ const MISSING_FINAL_RESPONSE_ADVISORY = "completion_missing_final_response";
|
|
|
218
287
|
const PROMPT_ACK_TIMEOUT_REASON = "runtime_prompt_ack_timeout";
|
|
219
288
|
const DEFAULT_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 10_000;
|
|
220
289
|
const MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
290
|
+
const RUNTIME_READINESS_TIMEOUT_REASON = "runtime_readiness_timeout";
|
|
291
|
+
const DEFAULT_RUNTIME_READINESS_TIMEOUT_MS = 10_000;
|
|
292
|
+
const MAX_RUNTIME_READINESS_TIMEOUT_MS = 5 * 60 * 1000;
|
|
293
|
+
|
|
221
294
|
const ACTIVE_TURN_STATUSES = new Set<TurnStatus>(["delivering", "active", "waiting_for_answer", "completing"]);
|
|
222
295
|
const TERMINAL_TURN_STATUSES = new Set<TurnStatus>(["completed", "failed", "cancelled", "superseded"]);
|
|
223
296
|
const TURN_ID_PATTERN = /^turn-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -233,7 +306,12 @@ function textResult(
|
|
|
233
306
|
isError = false,
|
|
234
307
|
): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
|
|
235
308
|
return {
|
|
236
|
-
content: [
|
|
309
|
+
content: [
|
|
310
|
+
{
|
|
311
|
+
type: "text",
|
|
312
|
+
text: typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
313
|
+
},
|
|
314
|
+
],
|
|
237
315
|
isError,
|
|
238
316
|
};
|
|
239
317
|
}
|
|
@@ -243,13 +321,27 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
243
321
|
description: string;
|
|
244
322
|
inputSchema: Record<string, unknown>;
|
|
245
323
|
} {
|
|
246
|
-
const allowMutation = {
|
|
324
|
+
const allowMutation = {
|
|
325
|
+
type: "boolean",
|
|
326
|
+
description: "Required and must be true for mutating tools.",
|
|
327
|
+
};
|
|
247
328
|
const cwd = {
|
|
248
329
|
type: "string",
|
|
249
330
|
description: "Canonicalized GJC worktree or project directory inside configured roots.",
|
|
250
331
|
};
|
|
251
|
-
const sessionId = {
|
|
252
|
-
|
|
332
|
+
const sessionId = {
|
|
333
|
+
type: "string",
|
|
334
|
+
description: "GJC coordinator bridge session id.",
|
|
335
|
+
};
|
|
336
|
+
const pathField = {
|
|
337
|
+
type: "string",
|
|
338
|
+
description: "Artifact path inside configured safe roots.",
|
|
339
|
+
};
|
|
340
|
+
const mpreset = {
|
|
341
|
+
type: "string",
|
|
342
|
+
description:
|
|
343
|
+
"Optional GJC model profile (`gjc --mpreset <profile>`) to authoritatively activate for a fresh session; resolved through the merged built-in/custom profile registry and applied from the first turn. Unknown names are rejected with the available-profile listing.",
|
|
344
|
+
};
|
|
253
345
|
const common = { type: "object", properties: {} as Record<string, unknown> };
|
|
254
346
|
if (name === "gjc_coordinator_register_session") {
|
|
255
347
|
return {
|
|
@@ -278,7 +370,12 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
278
370
|
description: "Start a GJC worktree/tmux oriented session through the coordinator bridge.",
|
|
279
371
|
inputSchema: {
|
|
280
372
|
type: "object",
|
|
281
|
-
properties: {
|
|
373
|
+
properties: {
|
|
374
|
+
cwd,
|
|
375
|
+
prompt: { type: "string" },
|
|
376
|
+
mpreset,
|
|
377
|
+
allow_mutation: allowMutation,
|
|
378
|
+
},
|
|
282
379
|
required: ["cwd", "allow_mutation"],
|
|
283
380
|
},
|
|
284
381
|
};
|
|
@@ -307,7 +404,11 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
307
404
|
description: "Read authoritative durable turn state plus bounded advisory tmux status.",
|
|
308
405
|
inputSchema: {
|
|
309
406
|
type: "object",
|
|
310
|
-
properties: {
|
|
407
|
+
properties: {
|
|
408
|
+
session_id: sessionId,
|
|
409
|
+
turn_id: { type: "string" },
|
|
410
|
+
lines: { type: "number" },
|
|
411
|
+
},
|
|
311
412
|
required: ["turn_id"],
|
|
312
413
|
},
|
|
313
414
|
};
|
|
@@ -376,7 +477,11 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
376
477
|
return {
|
|
377
478
|
name,
|
|
378
479
|
description: "Read one bounded artifact from configured safe roots.",
|
|
379
|
-
inputSchema: {
|
|
480
|
+
inputSchema: {
|
|
481
|
+
type: "object",
|
|
482
|
+
properties: { path: pathField },
|
|
483
|
+
required: ["path"],
|
|
484
|
+
},
|
|
380
485
|
};
|
|
381
486
|
}
|
|
382
487
|
if (name === "gjc_coordinator_read_status") {
|
|
@@ -390,21 +495,35 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
390
495
|
return {
|
|
391
496
|
name,
|
|
392
497
|
description: "Read a bounded structured session tail, not tmux scrollback.",
|
|
393
|
-
inputSchema: {
|
|
498
|
+
inputSchema: {
|
|
499
|
+
type: "object",
|
|
500
|
+
properties: { session_id: sessionId, lines: { type: "number" } },
|
|
501
|
+
},
|
|
394
502
|
};
|
|
395
503
|
}
|
|
396
504
|
if (name === "gjc_coordinator_list_questions") {
|
|
397
505
|
return {
|
|
398
506
|
name,
|
|
399
507
|
description: "List bounded structured questions for coordinator coordination.",
|
|
400
|
-
inputSchema: {
|
|
508
|
+
inputSchema: {
|
|
509
|
+
type: "object",
|
|
510
|
+
properties: { session_id: sessionId, status: { type: "string" } },
|
|
511
|
+
},
|
|
401
512
|
};
|
|
402
513
|
}
|
|
403
514
|
if (name === "gjc_coordinator_list_artifacts") {
|
|
404
|
-
return {
|
|
515
|
+
return {
|
|
516
|
+
name,
|
|
517
|
+
description: "List known safe artifact roots for coordinator coordination.",
|
|
518
|
+
inputSchema: common,
|
|
519
|
+
};
|
|
405
520
|
}
|
|
406
521
|
if (name === "gjc_coordinator_read_coordination_status") {
|
|
407
|
-
return {
|
|
522
|
+
return {
|
|
523
|
+
name,
|
|
524
|
+
description: "Read coordinator coordination reports.",
|
|
525
|
+
inputSchema: common,
|
|
526
|
+
};
|
|
408
527
|
}
|
|
409
528
|
if (name === "gjc_coordinator_watch_events") {
|
|
410
529
|
return {
|
|
@@ -438,7 +557,10 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
438
557
|
type: "string",
|
|
439
558
|
description: "Delegated task or objective to run through the selected GJC workflow.",
|
|
440
559
|
},
|
|
441
|
-
prompt: {
|
|
560
|
+
prompt: {
|
|
561
|
+
type: "string",
|
|
562
|
+
description: "Alias for task; accepted when task is absent.",
|
|
563
|
+
},
|
|
442
564
|
allow_mutation: allowMutation,
|
|
443
565
|
session_id: {
|
|
444
566
|
type: "string",
|
|
@@ -453,24 +575,38 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
453
575
|
type: "boolean",
|
|
454
576
|
description: "When reusing a session with an active turn, supersede it before sending.",
|
|
455
577
|
},
|
|
578
|
+
mpreset,
|
|
456
579
|
model: {
|
|
457
580
|
type: "string",
|
|
458
581
|
description: "Optional model hint passed in prompt metadata; no provider default is implied.",
|
|
459
582
|
},
|
|
460
|
-
await_completion: {
|
|
583
|
+
await_completion: {
|
|
584
|
+
type: "boolean",
|
|
585
|
+
description: "If true, poll the turn until terminal or timeout.",
|
|
586
|
+
},
|
|
461
587
|
timeout_ms: {
|
|
462
588
|
type: "number",
|
|
463
589
|
description:
|
|
464
590
|
"Bounded await timeout in milliseconds, capped at 30 minutes like gjc_coordinator_await_turn.",
|
|
465
591
|
},
|
|
466
|
-
poll_interval_ms: {
|
|
467
|
-
|
|
592
|
+
poll_interval_ms: {
|
|
593
|
+
type: "number",
|
|
594
|
+
description: "Bounded await polling interval.",
|
|
595
|
+
},
|
|
596
|
+
lines: {
|
|
597
|
+
type: "number",
|
|
598
|
+
description: "Bounded advisory tail lines returned with await/read payloads.",
|
|
599
|
+
},
|
|
468
600
|
},
|
|
469
601
|
required: ["cwd", "allow_mutation"],
|
|
470
602
|
},
|
|
471
603
|
};
|
|
472
604
|
}
|
|
473
|
-
return {
|
|
605
|
+
return {
|
|
606
|
+
name,
|
|
607
|
+
description: "List known scoped GJC coordinator bridge sessions.",
|
|
608
|
+
inputSchema: common,
|
|
609
|
+
};
|
|
474
610
|
}
|
|
475
611
|
|
|
476
612
|
type DelegateWorkflow = "plan" | "execute" | "team";
|
|
@@ -536,13 +672,80 @@ function workflowPrompt(
|
|
|
536
672
|
].join("\n");
|
|
537
673
|
}
|
|
538
674
|
|
|
675
|
+
const PRIVATE_SESSION_CONTROL_FIELDS = new Set([
|
|
676
|
+
"tmux_socket_key",
|
|
677
|
+
"tmuxSocketKey",
|
|
678
|
+
"tmux_owner_generation",
|
|
679
|
+
"tmuxOwnerGeneration",
|
|
680
|
+
"tmux_owner_state_dir",
|
|
681
|
+
"tmuxOwnerStateDir",
|
|
682
|
+
"tmux_owner_server_key",
|
|
683
|
+
"tmuxOwnerServerKey",
|
|
684
|
+
"tmux_owner_server_pid",
|
|
685
|
+
"tmuxOwnerServerPid",
|
|
686
|
+
"tmux_owner_server_start_time",
|
|
687
|
+
"tmuxOwnerServerStartTime",
|
|
688
|
+
"tmux_native_session_id",
|
|
689
|
+
"tmuxNativeSessionId",
|
|
690
|
+
"pane_id",
|
|
691
|
+
"paneId",
|
|
692
|
+
"socket_key",
|
|
693
|
+
"socketKey",
|
|
694
|
+
"owner_generation",
|
|
695
|
+
"ownerGeneration",
|
|
696
|
+
"state_dir",
|
|
697
|
+
"ownerStateDir",
|
|
698
|
+
"owner_server_key",
|
|
699
|
+
"ownerServerKey",
|
|
700
|
+
"owner_server_pid",
|
|
701
|
+
"ownerServerPid",
|
|
702
|
+
"owner_server_start_time",
|
|
703
|
+
"ownerServerStartTime",
|
|
704
|
+
"owner_terminal",
|
|
705
|
+
"ownerTerminal",
|
|
706
|
+
"generation",
|
|
707
|
+
"server_key",
|
|
708
|
+
"intent_id",
|
|
709
|
+
"dedupe_key",
|
|
710
|
+
"__coordinatorOwnerTransaction",
|
|
711
|
+
]);
|
|
712
|
+
|
|
713
|
+
function publicSessionProjection(session: Record<string, unknown>): Record<string, unknown> {
|
|
714
|
+
return Object.fromEntries(Object.entries(session).filter(([key]) => !PRIVATE_SESSION_CONTROL_FIELDS.has(key)));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function publicCoordinatorResponse(value: unknown): unknown {
|
|
718
|
+
if (Array.isArray(value)) return value.map(publicCoordinatorResponse);
|
|
719
|
+
const record = asRecord(value);
|
|
720
|
+
if (!record) return value;
|
|
721
|
+
return Object.fromEntries(
|
|
722
|
+
Object.entries(publicSessionProjection(record)).map(([key, item]) => [key, publicCoordinatorResponse(item)]),
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
539
726
|
function normalizeSession(session: Record<string, unknown>): Record<string, unknown> {
|
|
727
|
+
const suppliedIds = [session.session_id, session.sessionId, session.name].filter(
|
|
728
|
+
(value): value is string => typeof value === "string",
|
|
729
|
+
);
|
|
730
|
+
if (suppliedIds.length === 0) throw new Error("invalid_session_id");
|
|
731
|
+
const sessionId = safeExternalId("session", suppliedIds[0]);
|
|
732
|
+
if (suppliedIds.some(value => value !== sessionId)) throw new Error("session_id_conflict");
|
|
540
733
|
return {
|
|
541
|
-
session_id: session.sessionId ?? session.session_id ?? session.name ?? "unknown",
|
|
542
734
|
...(session.tmuxSession ? { tmux_session: session.tmuxSession } : {}),
|
|
735
|
+
...(session.tmuxTarget ? { tmux_target: session.tmuxTarget } : {}),
|
|
736
|
+
...(session.tmuxSocketKey ? { tmux_socket_key: session.tmuxSocketKey } : {}),
|
|
737
|
+
...(session.tmuxOwnerGeneration ? { tmux_owner_generation: session.tmuxOwnerGeneration } : {}),
|
|
738
|
+
...(session.tmuxOwnerStateDir ? { tmux_owner_state_dir: session.tmuxOwnerStateDir } : {}),
|
|
739
|
+
...(session.tmuxOwnerServerKey ? { tmux_owner_server_key: session.tmuxOwnerServerKey } : {}),
|
|
740
|
+
...(session.tmuxOwnerServerPid ? { tmux_owner_server_pid: session.tmuxOwnerServerPid } : {}),
|
|
741
|
+
...(session.tmuxOwnerServerStartTime ? { tmux_owner_server_start_time: session.tmuxOwnerServerStartTime } : {}),
|
|
742
|
+
...(session.tmuxNativeSessionId ? { tmux_native_session_id: session.tmuxNativeSessionId } : {}),
|
|
743
|
+
...(session.paneId ? { pane_id: session.paneId } : {}),
|
|
543
744
|
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
544
745
|
...(session.createdAt ? { created_at: session.createdAt } : {}),
|
|
545
746
|
...session,
|
|
747
|
+
session_id: sessionId,
|
|
748
|
+
sessionId,
|
|
546
749
|
};
|
|
547
750
|
}
|
|
548
751
|
|
|
@@ -554,15 +757,379 @@ async function canonicalizePath(value: string): Promise<string> {
|
|
|
554
757
|
}
|
|
555
758
|
}
|
|
556
759
|
|
|
760
|
+
interface RuntimeReadinessMarker {
|
|
761
|
+
schema_version: 1;
|
|
762
|
+
session_id: string;
|
|
763
|
+
launch_id: string;
|
|
764
|
+
state: "ready_for_input";
|
|
765
|
+
event: "interactive_input_ready";
|
|
766
|
+
source: "gjc_interactive_runtime";
|
|
767
|
+
ready_for_input: true;
|
|
768
|
+
created_at: string;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function readRuntimeReadinessMarker(
|
|
772
|
+
markerFile: string,
|
|
773
|
+
sessionId: string,
|
|
774
|
+
launchId: string,
|
|
775
|
+
readinessRoot?: string,
|
|
776
|
+
): Promise<RuntimeReadinessMarker | null> {
|
|
777
|
+
try {
|
|
778
|
+
const expectedFile = path.resolve(markerFile);
|
|
779
|
+
if (readinessRoot) {
|
|
780
|
+
const canonicalRoot = await fs.realpath(readinessRoot);
|
|
781
|
+
const canonicalParent = await fs.realpath(path.dirname(expectedFile));
|
|
782
|
+
if (
|
|
783
|
+
!expectedFile.startsWith(`${canonicalRoot}${path.sep}`) ||
|
|
784
|
+
!canonicalParent.startsWith(`${canonicalRoot}${path.sep}`)
|
|
785
|
+
)
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
const stat = await fs.lstat(expectedFile);
|
|
789
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
|
790
|
+
const marker = JSON.parse(await fs.readFile(expectedFile, "utf8")) as RuntimeReadinessMarker;
|
|
791
|
+
return marker.schema_version === 1 &&
|
|
792
|
+
marker.session_id === sessionId &&
|
|
793
|
+
marker.launch_id === launchId &&
|
|
794
|
+
marker.state === "ready_for_input" &&
|
|
795
|
+
marker.event === "interactive_input_ready" &&
|
|
796
|
+
marker.source === "gjc_interactive_runtime" &&
|
|
797
|
+
marker.ready_for_input === true &&
|
|
798
|
+
typeof marker.created_at === "string" &&
|
|
799
|
+
marker.created_at.length > 0 &&
|
|
800
|
+
Number.isFinite(Date.parse(marker.created_at))
|
|
801
|
+
? marker
|
|
802
|
+
: null;
|
|
803
|
+
} catch {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function waitForRuntimeReadinessMarker(
|
|
809
|
+
markerFile: string,
|
|
810
|
+
sessionId: string,
|
|
811
|
+
launchId: string,
|
|
812
|
+
readinessRoot: string,
|
|
813
|
+
timeoutMs: number,
|
|
814
|
+
): Promise<boolean> {
|
|
815
|
+
const ready = async (): Promise<boolean> =>
|
|
816
|
+
(await readRuntimeReadinessMarker(markerFile, sessionId, launchId, readinessRoot)) !== null;
|
|
817
|
+
if (await ready()) return true;
|
|
818
|
+
|
|
819
|
+
const markerDir = path.dirname(markerFile);
|
|
820
|
+
await ensureDir(markerDir);
|
|
821
|
+
const deadline = Date.now() + Math.max(timeoutMs, 0);
|
|
822
|
+
let wake = Promise.withResolvers<void>();
|
|
823
|
+
let watcher: nodeFs.FSWatcher | null = null;
|
|
824
|
+
try {
|
|
825
|
+
watcher = nodeFs.watch(markerDir, (_eventType, filename) => {
|
|
826
|
+
if (filename === path.basename(markerFile)) wake.resolve();
|
|
827
|
+
});
|
|
828
|
+
// A marker may be installed between the first read and watch creation.
|
|
829
|
+
if (await ready()) return true;
|
|
830
|
+
while (Date.now() < deadline) {
|
|
831
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
832
|
+
let timer: NodeJS.Timeout | null = null;
|
|
833
|
+
const fallback = new Promise<void>(resolve => {
|
|
834
|
+
timer = setTimeout(resolve, Math.min(50, remainingMs));
|
|
835
|
+
timer.unref?.();
|
|
836
|
+
});
|
|
837
|
+
await Promise.race([wake.promise, fallback]);
|
|
838
|
+
if (timer) clearTimeout(timer);
|
|
839
|
+
wake = Promise.withResolvers<void>();
|
|
840
|
+
if (await ready()) return true;
|
|
841
|
+
}
|
|
842
|
+
return false;
|
|
843
|
+
} finally {
|
|
844
|
+
watcher?.close();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
557
848
|
async function ensureDir(dir: string): Promise<void> {
|
|
558
849
|
await fs.mkdir(dir, { recursive: true });
|
|
559
850
|
}
|
|
560
851
|
|
|
852
|
+
class CoordinatorStateError extends Error {
|
|
853
|
+
constructor(readonly reason: "invalid" | "unreadable") {
|
|
854
|
+
super(`coordinator_state_${reason}`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const COORDINATOR_SESSION_STATES = new Set<CoordinatorSessionStateValue>([
|
|
859
|
+
"booting",
|
|
860
|
+
"ready_for_input",
|
|
861
|
+
"running",
|
|
862
|
+
"needs_user_input",
|
|
863
|
+
"completed",
|
|
864
|
+
"errored",
|
|
865
|
+
"stale",
|
|
866
|
+
"unknown",
|
|
867
|
+
]);
|
|
868
|
+
const COORDINATOR_EVENT_KINDS = new Set<CoordinatorEventKind>([
|
|
869
|
+
"session.registered",
|
|
870
|
+
"session.started",
|
|
871
|
+
"session.state_changed",
|
|
872
|
+
"turn.queued",
|
|
873
|
+
"turn.delivering",
|
|
874
|
+
"turn.active",
|
|
875
|
+
"turn.acknowledged",
|
|
876
|
+
"turn.waiting_for_answer",
|
|
877
|
+
"turn.completed",
|
|
878
|
+
"turn.failed",
|
|
879
|
+
"turn.cancelled",
|
|
880
|
+
"turn.superseded",
|
|
881
|
+
"question.opened",
|
|
882
|
+
"question.answered",
|
|
883
|
+
"report.written",
|
|
884
|
+
"tmux.delivery_succeeded",
|
|
885
|
+
"tmux.delivery_failed",
|
|
886
|
+
"delegation.started",
|
|
887
|
+
]);
|
|
888
|
+
|
|
889
|
+
function coordinatorStateError(reason: "invalid" | "unreadable"): CoordinatorStateError {
|
|
890
|
+
return new CoordinatorStateError(reason);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function isValidCoordinatorSessionState(value: unknown, sessionId: string): value is CoordinatorSessionState {
|
|
894
|
+
const record = asRecord(value);
|
|
895
|
+
return (
|
|
896
|
+
record?.schema_version === 1 &&
|
|
897
|
+
record.session_id === sessionId &&
|
|
898
|
+
typeof record.state === "string" &&
|
|
899
|
+
COORDINATOR_SESSION_STATES.has(record.state as CoordinatorSessionStateValue) &&
|
|
900
|
+
typeof record.ready_for_input === "boolean" &&
|
|
901
|
+
record.ready_for_input === (record.state === "ready_for_input" || record.state === "completed") &&
|
|
902
|
+
(record.current_turn_id === null ||
|
|
903
|
+
(typeof record.current_turn_id === "string" && TURN_ID_PATTERN.test(record.current_turn_id))) &&
|
|
904
|
+
(record.last_turn_id === null ||
|
|
905
|
+
(typeof record.last_turn_id === "string" && TURN_ID_PATTERN.test(record.last_turn_id))) &&
|
|
906
|
+
typeof record.updated_at === "string" &&
|
|
907
|
+
Number.isFinite(Date.parse(record.updated_at)) &&
|
|
908
|
+
(record.source === "coordinator" ||
|
|
909
|
+
record.source === "agent_session_event" ||
|
|
910
|
+
record.source === "process_postmortem") &&
|
|
911
|
+
(record.live === null || typeof record.live === "boolean") &&
|
|
912
|
+
(record.reason === null || typeof record.reason === "string")
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH = 16_384;
|
|
917
|
+
const MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH = 256;
|
|
918
|
+
const MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH = 1_024;
|
|
919
|
+
const MAX_RUNTIME_ERROR_CODE_LENGTH = 128;
|
|
920
|
+
const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 4_096;
|
|
921
|
+
|
|
922
|
+
function isBoundedNullableString(value: unknown, maxLength: number): value is string | null {
|
|
923
|
+
return value === null || (typeof value === "string" && value.length <= maxLength);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function isValidRuntimeFinalResponse(value: unknown): value is CoordinatorFinalResponse {
|
|
927
|
+
const record = asRecord(value);
|
|
928
|
+
return (
|
|
929
|
+
record !== null &&
|
|
930
|
+
Object.keys(record).every(key => ["text", "format", "source", "artifact_path", "truncated"].includes(key)) &&
|
|
931
|
+
isBoundedNullableString(record.text, MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH) &&
|
|
932
|
+
record.format === "markdown" &&
|
|
933
|
+
(record.source === null ||
|
|
934
|
+
(typeof record.source === "string" &&
|
|
935
|
+
record.source.length > 0 &&
|
|
936
|
+
record.source.length <= MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH)) &&
|
|
937
|
+
(record.artifact_path === null ||
|
|
938
|
+
(typeof record.artifact_path === "string" &&
|
|
939
|
+
record.artifact_path.length > 0 &&
|
|
940
|
+
record.artifact_path.length <= MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH &&
|
|
941
|
+
!path.isAbsolute(record.artifact_path) &&
|
|
942
|
+
!record.artifact_path.split(/[\\/]/).includes(".."))) &&
|
|
943
|
+
typeof record.truncated === "boolean"
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function isValidRuntimeError(value: unknown): value is NonNullable<RuntimeSessionStatePayload["error"]> {
|
|
948
|
+
const record = asRecord(value);
|
|
949
|
+
return (
|
|
950
|
+
record !== null &&
|
|
951
|
+
Object.keys(record).every(key => ["code", "message", "recoverable"].includes(key)) &&
|
|
952
|
+
typeof record.code === "string" &&
|
|
953
|
+
record.code.length > 0 &&
|
|
954
|
+
record.code.length <= MAX_RUNTIME_ERROR_CODE_LENGTH &&
|
|
955
|
+
typeof record.message === "string" &&
|
|
956
|
+
record.message.length > 0 &&
|
|
957
|
+
record.message.length <= MAX_RUNTIME_ERROR_MESSAGE_LENGTH &&
|
|
958
|
+
typeof record.recoverable === "boolean"
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function isValidOwnerTerminalRuntimeReceipt(value: unknown, sessionId: string): boolean {
|
|
963
|
+
const record = asRecord(value);
|
|
964
|
+
if (!record) return false;
|
|
965
|
+
const baseKeys = [
|
|
966
|
+
"generation",
|
|
967
|
+
"socket_key",
|
|
968
|
+
"signal",
|
|
969
|
+
"result",
|
|
970
|
+
"classification",
|
|
971
|
+
"observer",
|
|
972
|
+
"observed_at",
|
|
973
|
+
"dedupe_key",
|
|
974
|
+
];
|
|
975
|
+
const keys = Object.keys(record);
|
|
976
|
+
if (!keys.every(key => [...baseKeys, "intent_id"].includes(key)) || baseKeys.some(key => !keys.includes(key)))
|
|
977
|
+
return false;
|
|
978
|
+
if (
|
|
979
|
+
!validRuntimeOwnerGeneration(record.generation) ||
|
|
980
|
+
!validRuntimeOwnerGeneration(record.socket_key) ||
|
|
981
|
+
typeof record.signal !== "string" ||
|
|
982
|
+
!["SIGTERM", "SIGHUP", "SIGINT", "SIGKILL", "EXIT", "MANUAL", "UNKNOWN"].includes(record.signal) ||
|
|
983
|
+
typeof record.result !== "string" ||
|
|
984
|
+
!record.result ||
|
|
985
|
+
record.result.length > 64 ||
|
|
986
|
+
!["expected_operator_shutdown", "unexpected_owner_loss", "non_operator_cleanup"].includes(
|
|
987
|
+
String(record.classification),
|
|
988
|
+
) ||
|
|
989
|
+
!["sidecar", "raw_monitor"].includes(String(record.observer)) ||
|
|
990
|
+
typeof record.observed_at !== "string" ||
|
|
991
|
+
!Number.isFinite(Date.parse(record.observed_at)) ||
|
|
992
|
+
record.dedupe_key !== `owner-loss:${sessionId}:${record.generation}`
|
|
993
|
+
)
|
|
994
|
+
return false;
|
|
995
|
+
if (record.classification === "expected_operator_shutdown")
|
|
996
|
+
return (
|
|
997
|
+
record.signal === "SIGTERM" &&
|
|
998
|
+
record.result === "owner_term_then_session_cleanup" &&
|
|
999
|
+
typeof record.intent_id === "string" &&
|
|
1000
|
+
record.intent_id.length > 0
|
|
1001
|
+
);
|
|
1002
|
+
if (record.intent_id !== undefined) return false;
|
|
1003
|
+
if (record.classification === "non_operator_cleanup") return record.result === "cleanup";
|
|
1004
|
+
return ["owner_lost", "cleanup", "process_postmortem", "exit", "unknown_terminal"].includes(record.result);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function isValidRuntimeSessionState(value: unknown, sessionId: string): value is RuntimeSessionStatePayload {
|
|
1008
|
+
if (!isValidCoordinatorSessionState(value, sessionId)) return false;
|
|
1009
|
+
const record = asRecord(value);
|
|
1010
|
+
if (!record) return false;
|
|
1011
|
+
const finalResponse = record.final_response;
|
|
1012
|
+
const error = record.error;
|
|
1013
|
+
if (finalResponse !== undefined && !isValidRuntimeFinalResponse(finalResponse)) return false;
|
|
1014
|
+
if (record.source !== "coordinator" && record.live !== (record.state === "running")) return false;
|
|
1015
|
+
if (error !== undefined && error !== null && !isValidRuntimeError(error)) return false;
|
|
1016
|
+
if (record.owner_generation !== undefined && !validRuntimeOwnerGeneration(record.owner_generation)) return false;
|
|
1017
|
+
if (record.source === "coordinator") return finalResponse === undefined && (error === undefined || error === null);
|
|
1018
|
+
if (record.source === "process_postmortem") {
|
|
1019
|
+
if (record.state !== "completed" && record.state !== "errored") return false;
|
|
1020
|
+
if (record.event === "owner_terminal") {
|
|
1021
|
+
if (!isValidOwnerTerminalRuntimeReceipt(record.owner_terminal, sessionId)) return false;
|
|
1022
|
+
const ownerTerminal = asRecord(record.owner_terminal)!;
|
|
1023
|
+
const expected = ownerTerminal.classification === "expected_operator_shutdown";
|
|
1024
|
+
if (
|
|
1025
|
+
record.reason !== ownerTerminal.classification ||
|
|
1026
|
+
record.state !== (expected ? "completed" : "errored") ||
|
|
1027
|
+
(expected
|
|
1028
|
+
? error !== undefined && error !== null
|
|
1029
|
+
: error === undefined ||
|
|
1030
|
+
error === null ||
|
|
1031
|
+
error.code !== ownerTerminal.classification ||
|
|
1032
|
+
error.recoverable !== true)
|
|
1033
|
+
)
|
|
1034
|
+
return false;
|
|
1035
|
+
} else if (record.event === "process_exit") {
|
|
1036
|
+
if (
|
|
1037
|
+
record.owner_terminal !== undefined ||
|
|
1038
|
+
typeof record.exit_kind !== "string" ||
|
|
1039
|
+
record.exit_kind.length === 0 ||
|
|
1040
|
+
record.exit_kind.length > 128 ||
|
|
1041
|
+
(record.exit_code !== null &&
|
|
1042
|
+
(typeof record.exit_code !== "number" || !Number.isSafeInteger(record.exit_code))) ||
|
|
1043
|
+
(record.signal !== null &&
|
|
1044
|
+
record.signal !== "SIGINT" &&
|
|
1045
|
+
record.signal !== "SIGTERM" &&
|
|
1046
|
+
record.signal !== "SIGHUP") ||
|
|
1047
|
+
typeof record.prompt_accepted !== "boolean" ||
|
|
1048
|
+
typeof record.observed_recoverable_worktree_changes !== "boolean" ||
|
|
1049
|
+
(record.worktree_baseline_dirty !== null && typeof record.worktree_baseline_dirty !== "boolean") ||
|
|
1050
|
+
typeof record.worktree_changed_since_baseline !== "boolean"
|
|
1051
|
+
)
|
|
1052
|
+
return false;
|
|
1053
|
+
} else {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (record.state === "completed") return error === undefined || error === null;
|
|
1058
|
+
if (record.state === "errored") return error !== undefined && error !== null;
|
|
1059
|
+
return finalResponse === undefined && (error === undefined || error === null);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const PUBLIC_COORDINATOR_ERRORS = new Set([
|
|
1063
|
+
"coordinator_workdir_required",
|
|
1064
|
+
"coordinator_workdir_roots_required",
|
|
1065
|
+
"coordinator_workdir_outside_allowed_roots",
|
|
1066
|
+
"coordinator_artifact_path_required",
|
|
1067
|
+
"coordinator_artifact_roots_required",
|
|
1068
|
+
"coordinator_artifact_outside_allowed_roots",
|
|
1069
|
+
"coordinator_mutation_class_disabled",
|
|
1070
|
+
"coordinator_mutation_call_not_allowed",
|
|
1071
|
+
"invalid_session_id",
|
|
1072
|
+
"invalid_tmux_session",
|
|
1073
|
+
"invalid_tmux_target",
|
|
1074
|
+
"invalid_turn_id",
|
|
1075
|
+
"invalid_question_id",
|
|
1076
|
+
"session_id_conflict",
|
|
1077
|
+
"tmux_session_unavailable",
|
|
1078
|
+
"tmux_target_unavailable",
|
|
1079
|
+
"coordinator_session_command_required",
|
|
1080
|
+
"coordinator_launch_attestation_missing",
|
|
1081
|
+
"coordinator_launch_attestation_mismatch",
|
|
1082
|
+
"coordinator_launch_metadata_missing",
|
|
1083
|
+
"coordinator_readiness_path_invalid",
|
|
1084
|
+
"session_origin_unverified",
|
|
1085
|
+
"coordinator_prompt_buffer_privacy_unverified",
|
|
1086
|
+
"coordinator_tmux_start_failed",
|
|
1087
|
+
"coordinator_tmux_owner_server_unsafe",
|
|
1088
|
+
"coordinator_tmux_owner_server_unverifiable",
|
|
1089
|
+
"coordinator_tmux_owner_server_race",
|
|
1090
|
+
"coordinator_evidence_paths_must_be_array",
|
|
1091
|
+
]);
|
|
1092
|
+
|
|
1093
|
+
class CoordinatorStartError extends Error {
|
|
1094
|
+
constructor(
|
|
1095
|
+
message: string,
|
|
1096
|
+
readonly cleanupStatus: "cleaned" | "failed" | "unverifiable",
|
|
1097
|
+
) {
|
|
1098
|
+
super(message);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function publicCleanupStatus(error: unknown): "cleaned" | "failed" | "unverifiable" | undefined {
|
|
1103
|
+
return error instanceof CoordinatorStartError ? error.cleanupStatus : undefined;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function publicCoordinatorError(error: unknown): string {
|
|
1107
|
+
if (error instanceof CoordinatorStateError) return `coordinator_state_${error.reason}`;
|
|
1108
|
+
const message = error instanceof Error ? error.message : "";
|
|
1109
|
+
const normalized = message
|
|
1110
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
1111
|
+
.replace(/\s+/g, " ")
|
|
1112
|
+
.trim();
|
|
1113
|
+
const code = normalized.split(":", 1)[0];
|
|
1114
|
+
return PUBLIC_COORDINATOR_ERRORS.has(code) ? code : "coordinator_request_failed";
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function isExpectedStateAbsence(error: unknown): boolean {
|
|
1118
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
1119
|
+
}
|
|
1120
|
+
|
|
561
1121
|
async function readJsonFile(file: string): Promise<unknown | null> {
|
|
1122
|
+
let content: string;
|
|
1123
|
+
try {
|
|
1124
|
+
content = await fs.readFile(file, "utf8");
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
if (isExpectedStateAbsence(error)) return null;
|
|
1127
|
+
throw coordinatorStateError("unreadable");
|
|
1128
|
+
}
|
|
562
1129
|
try {
|
|
563
|
-
return JSON.parse(
|
|
1130
|
+
return JSON.parse(content);
|
|
564
1131
|
} catch {
|
|
565
|
-
|
|
1132
|
+
throw coordinatorStateError("invalid");
|
|
566
1133
|
}
|
|
567
1134
|
}
|
|
568
1135
|
|
|
@@ -572,15 +1139,16 @@ async function writeJsonFile(file: string, value: unknown): Promise<void> {
|
|
|
572
1139
|
}
|
|
573
1140
|
|
|
574
1141
|
async function listJsonFiles(dir: string): Promise<unknown[]> {
|
|
1142
|
+
let entries: string[];
|
|
575
1143
|
try {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
);
|
|
580
|
-
return values.filter(value => value !== null);
|
|
581
|
-
} catch {
|
|
582
|
-
return [];
|
|
1144
|
+
entries = await fs.readdir(dir);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1147
|
+
throw coordinatorStateError("unreadable");
|
|
583
1148
|
}
|
|
1149
|
+
return await Promise.all(
|
|
1150
|
+
entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
|
|
1151
|
+
);
|
|
584
1152
|
}
|
|
585
1153
|
|
|
586
1154
|
const COORDINATOR_STATUS_EVENT_LIMIT = 100;
|
|
@@ -668,22 +1236,39 @@ function boundSummary(value: string): string {
|
|
|
668
1236
|
}
|
|
669
1237
|
|
|
670
1238
|
async function readLatestEventSeq(namespaceDir: string): Promise<number> {
|
|
671
|
-
const
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1239
|
+
const rawSequence = await readJsonFile(eventSequenceFile(namespaceDir));
|
|
1240
|
+
const events = await readCoordinatorEvents(namespaceDir);
|
|
1241
|
+
const journalLatestSeq = events.at(-1)?.seq ?? 0;
|
|
1242
|
+
if (rawSequence === null) return journalLatestSeq;
|
|
1243
|
+
const sequence = asRecord(rawSequence);
|
|
1244
|
+
if (
|
|
1245
|
+
!sequence ||
|
|
1246
|
+
Object.keys(sequence).some(key => key !== "seq" && key !== "updated_at") ||
|
|
1247
|
+
typeof sequence.seq !== "number" ||
|
|
1248
|
+
!Number.isSafeInteger(sequence.seq) ||
|
|
1249
|
+
sequence.seq < 0 ||
|
|
1250
|
+
typeof sequence.updated_at !== "string" ||
|
|
1251
|
+
!Number.isFinite(Date.parse(sequence.updated_at)) ||
|
|
1252
|
+
sequence.seq > journalLatestSeq
|
|
1253
|
+
)
|
|
1254
|
+
throw coordinatorStateError("invalid");
|
|
1255
|
+
return journalLatestSeq;
|
|
677
1256
|
}
|
|
678
1257
|
|
|
679
1258
|
const eventAppendQueues = new Map<string, Promise<unknown>>();
|
|
680
1259
|
|
|
1260
|
+
/** Lock order: namespace transaction lock, then event journal lock. */
|
|
1261
|
+
function coordinatorTransactionLockFile(namespaceDir: string): string {
|
|
1262
|
+
return path.join(namespaceDir, "locks", "mutation.lock");
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function withCoordinatorTransaction<T>(namespaceDir: string, operation: () => Promise<T>): Promise<T> {
|
|
1266
|
+
return await withSessionStateLock(coordinatorTransactionLockFile(namespaceDir), operation);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
681
1269
|
async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEventInput): Promise<CoordinatorEvent> {
|
|
682
1270
|
const previous = eventAppendQueues.get(namespaceDir) ?? Promise.resolve();
|
|
683
|
-
|
|
684
|
-
const current = new Promise<void>(resolve => {
|
|
685
|
-
release = resolve;
|
|
686
|
-
});
|
|
1271
|
+
const { promise: current, resolve: release } = Promise.withResolvers<void>();
|
|
687
1272
|
const queued = previous.then(
|
|
688
1273
|
() => current,
|
|
689
1274
|
() => current,
|
|
@@ -692,57 +1277,148 @@ async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEv
|
|
|
692
1277
|
|
|
693
1278
|
await previous.catch(() => undefined);
|
|
694
1279
|
try {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1280
|
+
return await withSessionStateLock(eventJournalFile(namespaceDir), async () => {
|
|
1281
|
+
const latestSeq = await readLatestEventSeq(namespaceDir);
|
|
1282
|
+
const seq = latestSeq + 1;
|
|
1283
|
+
const timestamp = new Date().toISOString();
|
|
1284
|
+
const event: CoordinatorEvent = {
|
|
1285
|
+
schema_version: 1,
|
|
1286
|
+
seq,
|
|
1287
|
+
id: `event-${seq.toString().padStart(12, "0")}`,
|
|
1288
|
+
timestamp,
|
|
1289
|
+
kind: input.kind,
|
|
1290
|
+
summary: boundSummary(input.summary),
|
|
1291
|
+
...(input.sessionId ? { session_id: input.sessionId } : {}),
|
|
1292
|
+
...(input.turnId ? { turn_id: input.turnId } : {}),
|
|
1293
|
+
...(input.questionId ? { question_id: input.questionId } : {}),
|
|
1294
|
+
...(input.reportId ? { report_id: input.reportId } : {}),
|
|
1295
|
+
...(input.payloadRef ? { payload_ref: input.payloadRef } : {}),
|
|
1296
|
+
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
1297
|
+
};
|
|
1298
|
+
await ensureDir(eventsDir(namespaceDir));
|
|
1299
|
+
await fs.appendFile(eventJournalFile(namespaceDir), `${JSON.stringify(event)}\n`);
|
|
1300
|
+
await writeJsonFile(eventSequenceFile(namespaceDir), {
|
|
1301
|
+
seq,
|
|
1302
|
+
updated_at: timestamp,
|
|
1303
|
+
});
|
|
1304
|
+
return event;
|
|
1305
|
+
});
|
|
716
1306
|
} finally {
|
|
717
1307
|
release();
|
|
718
1308
|
if (eventAppendQueues.get(namespaceDir) === queued) eventAppendQueues.delete(namespaceDir);
|
|
719
1309
|
}
|
|
720
1310
|
}
|
|
721
1311
|
|
|
722
|
-
|
|
1312
|
+
// Per-session mutation lock. Concurrent stdio dispatch (see pumpCoordinatorMcpStream) makes
|
|
1313
|
+
// same-session read-modify-write tool calls (notably send_prompt: read active turn → decide →
|
|
1314
|
+
// write new turn) interleave, which could persist two "active" turns while only one active-turn
|
|
1315
|
+
// pointer survives. Serializing per session — same promise-chain shape as eventAppendQueues —
|
|
1316
|
+
// restores the atomicity the former serial read loop provided, without serializing across
|
|
1317
|
+
// sessions or blocking long read-only polls (await_turn/watch_events). This is an in-process
|
|
1318
|
+
// lock distinct from the file-based withSessionStateLock/withCoordinatorTransaction, so it can
|
|
1319
|
+
// never deadlock by nesting inside them.
|
|
1320
|
+
const sessionMutationQueues = new Map<string, Promise<unknown>>();
|
|
1321
|
+
|
|
1322
|
+
async function withSessionMutation<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
1323
|
+
const previous = sessionMutationQueues.get(key) ?? Promise.resolve();
|
|
1324
|
+
const { promise: current, resolve: release } = Promise.withResolvers<void>();
|
|
1325
|
+
const queued = previous.then(
|
|
1326
|
+
() => current,
|
|
1327
|
+
() => current,
|
|
1328
|
+
);
|
|
1329
|
+
sessionMutationQueues.set(key, queued);
|
|
1330
|
+
await previous.catch(() => undefined);
|
|
1331
|
+
try {
|
|
1332
|
+
return await fn();
|
|
1333
|
+
} finally {
|
|
1334
|
+
release();
|
|
1335
|
+
if (sessionMutationQueues.get(key) === queued) sessionMutationQueues.delete(key);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function parseCoordinatorEvent(line: string): CoordinatorEvent {
|
|
1340
|
+
let event: unknown;
|
|
723
1341
|
try {
|
|
724
|
-
|
|
725
|
-
if (typeof event.seq !== "number" || typeof event.kind !== "string") return null;
|
|
726
|
-
return event;
|
|
1342
|
+
event = JSON.parse(line);
|
|
727
1343
|
} catch {
|
|
728
|
-
|
|
1344
|
+
throw coordinatorStateError("invalid");
|
|
1345
|
+
}
|
|
1346
|
+
const record = asRecord(event);
|
|
1347
|
+
const allowed = new Set([
|
|
1348
|
+
"schema_version",
|
|
1349
|
+
"seq",
|
|
1350
|
+
"id",
|
|
1351
|
+
"timestamp",
|
|
1352
|
+
"kind",
|
|
1353
|
+
"session_id",
|
|
1354
|
+
"turn_id",
|
|
1355
|
+
"question_id",
|
|
1356
|
+
"report_id",
|
|
1357
|
+
"summary",
|
|
1358
|
+
"payload_ref",
|
|
1359
|
+
"metadata",
|
|
1360
|
+
]);
|
|
1361
|
+
const metadata = asRecord(record?.metadata);
|
|
1362
|
+
const validMetadata =
|
|
1363
|
+
record?.metadata === undefined && metadata === null
|
|
1364
|
+
? true
|
|
1365
|
+
: metadata !== null &&
|
|
1366
|
+
Object.values(metadata).every(
|
|
1367
|
+
value => value === null || ["string", "number", "boolean"].includes(typeof value),
|
|
1368
|
+
);
|
|
1369
|
+
const validOptionalExternalId = (value: unknown) =>
|
|
1370
|
+
value === undefined || (typeof value === "string" && SAFE_EXTERNAL_ID_PATTERN.test(value));
|
|
1371
|
+
const validPayloadRef =
|
|
1372
|
+
record?.payload_ref === undefined ||
|
|
1373
|
+
(typeof record.payload_ref === "string" &&
|
|
1374
|
+
record.payload_ref.length > 0 &&
|
|
1375
|
+
!path.isAbsolute(record.payload_ref) &&
|
|
1376
|
+
!record.payload_ref.split(/[\\/]/).includes(".."));
|
|
1377
|
+
if (
|
|
1378
|
+
!record ||
|
|
1379
|
+
Object.keys(record).some(key => !allowed.has(key)) ||
|
|
1380
|
+
record.schema_version !== 1 ||
|
|
1381
|
+
typeof record.seq !== "number" ||
|
|
1382
|
+
!Number.isSafeInteger(record.seq) ||
|
|
1383
|
+
record.seq <= 0 ||
|
|
1384
|
+
record.id !== `event-${record.seq.toString().padStart(12, "0")}` ||
|
|
1385
|
+
typeof record.timestamp !== "string" ||
|
|
1386
|
+
!Number.isFinite(Date.parse(record.timestamp)) ||
|
|
1387
|
+
typeof record.kind !== "string" ||
|
|
1388
|
+
!COORDINATOR_EVENT_KINDS.has(record.kind as CoordinatorEventKind) ||
|
|
1389
|
+
typeof record.summary !== "string" ||
|
|
1390
|
+
record.summary.length > 240 ||
|
|
1391
|
+
!validOptionalExternalId(record.session_id) ||
|
|
1392
|
+
(record.turn_id !== undefined && (typeof record.turn_id !== "string" || !TURN_ID_PATTERN.test(record.turn_id))) ||
|
|
1393
|
+
!validOptionalExternalId(record.question_id) ||
|
|
1394
|
+
!validOptionalExternalId(record.report_id) ||
|
|
1395
|
+
!validPayloadRef ||
|
|
1396
|
+
!validMetadata
|
|
1397
|
+
) {
|
|
1398
|
+
throw coordinatorStateError("invalid");
|
|
729
1399
|
}
|
|
1400
|
+
return record as unknown as CoordinatorEvent;
|
|
730
1401
|
}
|
|
731
1402
|
|
|
732
1403
|
async function readCoordinatorEvents(namespaceDir: string): Promise<CoordinatorEvent[]> {
|
|
1404
|
+
const journalFile = eventJournalFile(namespaceDir);
|
|
1405
|
+
let content: string;
|
|
733
1406
|
try {
|
|
734
|
-
|
|
735
|
-
return content
|
|
736
|
-
.split("\n")
|
|
737
|
-
.map(line => line.trim())
|
|
738
|
-
.filter(Boolean)
|
|
739
|
-
.map(parseCoordinatorEvent)
|
|
740
|
-
.filter((event): event is CoordinatorEvent => event !== null)
|
|
741
|
-
.sort((left, right) => left.seq - right.seq);
|
|
1407
|
+
content = await fs.readFile(journalFile, "utf8");
|
|
742
1408
|
} catch (error) {
|
|
743
|
-
if ((error
|
|
744
|
-
throw
|
|
1409
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1410
|
+
throw coordinatorStateError("unreadable");
|
|
745
1411
|
}
|
|
1412
|
+
const events = content
|
|
1413
|
+
.split("\n")
|
|
1414
|
+
.map(line => line.trim())
|
|
1415
|
+
.filter(Boolean)
|
|
1416
|
+
.map(parseCoordinatorEvent);
|
|
1417
|
+
for (let index = 1; index < events.length; index++) {
|
|
1418
|
+
if (events[index].seq !== events[index - 1].seq + 1) throw coordinatorStateError("invalid");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
return events;
|
|
746
1422
|
}
|
|
747
1423
|
|
|
748
1424
|
function boundedEventLimit(value: unknown): number {
|
|
@@ -819,6 +1495,33 @@ function safeTmuxTarget(value: unknown): string {
|
|
|
819
1495
|
return value;
|
|
820
1496
|
}
|
|
821
1497
|
|
|
1498
|
+
function safeTmuxRuntimeIdentity(value: string): boolean {
|
|
1499
|
+
return value.length > 0 && value.length <= 160 && !/[\s\u0000-\u001f\u007f]/.test(value);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function coordinatorCleanupPredicate(expectedPid: number, nativeSessionId: string, attemptSession: string): string {
|
|
1503
|
+
return `#{&&:#{==:#{pid},${expectedPid}},#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_name},${attemptSession}}}}`;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function parseTmuxNewSessionRecord(
|
|
1507
|
+
stdout: string,
|
|
1508
|
+
sessionName: string,
|
|
1509
|
+
): { tmuxTarget: string; paneId: string; nativeSessionId: string } | undefined {
|
|
1510
|
+
const match = /^(\S+) (%\d+) (\$\d+)\n?$/.exec(stdout);
|
|
1511
|
+
if (!match) return undefined;
|
|
1512
|
+
const [tmuxTarget, paneId, nativeSessionId] = match.slice(1);
|
|
1513
|
+
const targetSuffix = tmuxTarget!.slice(`${sessionName}:`.length);
|
|
1514
|
+
if (
|
|
1515
|
+
!tmuxTarget!.startsWith(`${sessionName}:`) ||
|
|
1516
|
+
!/^\d+\.\d+$/.test(targetSuffix) ||
|
|
1517
|
+
!safeTmuxRuntimeIdentity(tmuxTarget!) ||
|
|
1518
|
+
!safeTmuxRuntimeIdentity(paneId!) ||
|
|
1519
|
+
!safeTmuxRuntimeIdentity(nativeSessionId!)
|
|
1520
|
+
)
|
|
1521
|
+
return undefined;
|
|
1522
|
+
return { tmuxTarget: tmuxTarget!, paneId: paneId!, nativeSessionId: nativeSessionId! };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
822
1525
|
function optionalString(value: unknown): string | null {
|
|
823
1526
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
824
1527
|
}
|
|
@@ -847,8 +1550,150 @@ function sessionStateFile(namespaceDir: string, sessionId: string): string {
|
|
|
847
1550
|
return path.join(namespaceDir, "session-states", `${safeExternalId("session", sessionId)}.json`);
|
|
848
1551
|
}
|
|
849
1552
|
|
|
1553
|
+
function isTimestamp(value: unknown): value is string {
|
|
1554
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function isValidTurnRecord(value: unknown): value is TurnRecord {
|
|
1558
|
+
const turn = asRecord(value);
|
|
1559
|
+
const delivery = asRecord(turn?.delivery);
|
|
1560
|
+
const prompt = asRecord(turn?.prompt);
|
|
1561
|
+
const response = asRecord(turn?.final_response);
|
|
1562
|
+
const liveness = asRecord(turn?.liveness);
|
|
1563
|
+
const namespace = asRecord(turn?.namespace);
|
|
1564
|
+
const statuses: readonly TurnStatus[] = [
|
|
1565
|
+
"queued",
|
|
1566
|
+
"delivering",
|
|
1567
|
+
"active",
|
|
1568
|
+
"waiting_for_answer",
|
|
1569
|
+
"completing",
|
|
1570
|
+
"completed",
|
|
1571
|
+
"failed",
|
|
1572
|
+
"cancelled",
|
|
1573
|
+
"superseded",
|
|
1574
|
+
];
|
|
1575
|
+
if (
|
|
1576
|
+
turn?.schema_version !== 1 ||
|
|
1577
|
+
typeof turn.turn_id !== "string" ||
|
|
1578
|
+
!TURN_ID_PATTERN.test(turn.turn_id) ||
|
|
1579
|
+
typeof turn.session_id !== "string" ||
|
|
1580
|
+
!SAFE_EXTERNAL_ID_PATTERN.test(turn.session_id) ||
|
|
1581
|
+
!namespace ||
|
|
1582
|
+
(namespace.profile !== null && typeof namespace.profile !== "string") ||
|
|
1583
|
+
(namespace.repo !== null && typeof namespace.repo !== "string") ||
|
|
1584
|
+
!statuses.includes(turn.status as TurnStatus)
|
|
1585
|
+
)
|
|
1586
|
+
return false;
|
|
1587
|
+
if (
|
|
1588
|
+
!prompt ||
|
|
1589
|
+
typeof prompt.text !== "string" ||
|
|
1590
|
+
!isTimestamp(prompt.created_at) ||
|
|
1591
|
+
(prompt.source !== "mcp" && prompt.source !== "question_answer")
|
|
1592
|
+
)
|
|
1593
|
+
return false;
|
|
1594
|
+
if (
|
|
1595
|
+
!delivery ||
|
|
1596
|
+
typeof delivery.delivered !== "boolean" ||
|
|
1597
|
+
typeof delivery.queued !== "boolean" ||
|
|
1598
|
+
(delivery.target !== null && typeof delivery.target !== "string") ||
|
|
1599
|
+
(delivery.tmux_keys_sent !== undefined && typeof delivery.tmux_keys_sent !== "boolean") ||
|
|
1600
|
+
(delivery.prompt_acknowledged !== undefined && typeof delivery.prompt_acknowledged !== "boolean") ||
|
|
1601
|
+
(delivery.state !== undefined &&
|
|
1602
|
+
![
|
|
1603
|
+
"queued",
|
|
1604
|
+
"awaiting_runtime_readiness",
|
|
1605
|
+
"delivering",
|
|
1606
|
+
"tmux_keys_sent",
|
|
1607
|
+
"acknowledged",
|
|
1608
|
+
"unavailable",
|
|
1609
|
+
"unacknowledged",
|
|
1610
|
+
].includes(delivery.state as string)) ||
|
|
1611
|
+
!Array.isArray(delivery.attempts) ||
|
|
1612
|
+
!delivery.attempts.every(attempt => {
|
|
1613
|
+
const item = asRecord(attempt);
|
|
1614
|
+
return (
|
|
1615
|
+
item !== null &&
|
|
1616
|
+
typeof item.delivered === "boolean" &&
|
|
1617
|
+
isTimestamp(item.created_at) &&
|
|
1618
|
+
(item.reason === null || typeof item.reason === "string") &&
|
|
1619
|
+
(item.channel === undefined || item.channel === "tmux_keys" || item.channel === "runtime_ack") &&
|
|
1620
|
+
(item.tmux_keys_sent === undefined || typeof item.tmux_keys_sent === "boolean")
|
|
1621
|
+
);
|
|
1622
|
+
})
|
|
1623
|
+
)
|
|
1624
|
+
return false;
|
|
1625
|
+
if (
|
|
1626
|
+
!Array.isArray(turn.question_ids) ||
|
|
1627
|
+
!turn.question_ids.every(id => typeof id === "string" && SAFE_EXTERNAL_ID_PATTERN.test(id)) ||
|
|
1628
|
+
!isValidRuntimeFinalResponse(response) ||
|
|
1629
|
+
!Array.isArray(turn.evidence) ||
|
|
1630
|
+
!turn.evidence.every(item => asRecord(item) !== null) ||
|
|
1631
|
+
(turn.error !== null && !isValidRuntimeError(turn.error))
|
|
1632
|
+
)
|
|
1633
|
+
return false;
|
|
1634
|
+
if (
|
|
1635
|
+
!liveness ||
|
|
1636
|
+
(liveness.checked_at !== null && !isTimestamp(liveness.checked_at)) ||
|
|
1637
|
+
(liveness.live !== null && typeof liveness.live !== "boolean") ||
|
|
1638
|
+
(liveness.reason !== null && typeof liveness.reason !== "string") ||
|
|
1639
|
+
!isTimestamp(turn.created_at) ||
|
|
1640
|
+
!isTimestamp(turn.updated_at) ||
|
|
1641
|
+
(turn.started_at !== null && !isTimestamp(turn.started_at)) ||
|
|
1642
|
+
(turn.completed_at !== null && !isTimestamp(turn.completed_at))
|
|
1643
|
+
)
|
|
1644
|
+
return false;
|
|
1645
|
+
return TERMINAL_TURN_STATUSES.has(turn.status as TurnStatus)
|
|
1646
|
+
? turn.completed_at !== null && (turn.status === "failed" ? turn.error !== null : turn.error === null)
|
|
1647
|
+
: turn.completed_at === null && turn.error === null;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function isValidActiveTurnRecord(
|
|
1651
|
+
value: unknown,
|
|
1652
|
+
sessionId: string,
|
|
1653
|
+
): value is {
|
|
1654
|
+
session_id: string;
|
|
1655
|
+
turn_id: string;
|
|
1656
|
+
status: TurnStatus;
|
|
1657
|
+
updated_at: string;
|
|
1658
|
+
} {
|
|
1659
|
+
const active = asRecord(value);
|
|
1660
|
+
return (
|
|
1661
|
+
active !== null &&
|
|
1662
|
+
Object.keys(active).every(key => ["session_id", "turn_id", "status", "updated_at"].includes(key)) &&
|
|
1663
|
+
active.session_id === sessionId &&
|
|
1664
|
+
typeof active.turn_id === "string" &&
|
|
1665
|
+
TURN_ID_PATTERN.test(active.turn_id) &&
|
|
1666
|
+
ACTIVE_TURN_STATUSES.has(active.status as TurnStatus) &&
|
|
1667
|
+
isTimestamp(active.updated_at)
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
850
1671
|
async function readTurnRecord(namespaceDir: string, turnId: unknown): Promise<TurnRecord | null> {
|
|
851
|
-
|
|
1672
|
+
const requestedTurnId = safeTurnId(turnId);
|
|
1673
|
+
const value = await readJsonFile(turnFile(namespaceDir, requestedTurnId));
|
|
1674
|
+
if (value === null) return null;
|
|
1675
|
+
if (!isValidTurnRecord(value) || value.turn_id !== requestedTurnId) throw coordinatorStateError("invalid");
|
|
1676
|
+
return value;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
async function listTurnRecords(namespaceDir: string): Promise<TurnRecord[]> {
|
|
1680
|
+
let entries: string[];
|
|
1681
|
+
try {
|
|
1682
|
+
entries = await fs.readdir(turnsDir(namespaceDir));
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1685
|
+
throw coordinatorStateError("unreadable");
|
|
1686
|
+
}
|
|
1687
|
+
const records = await Promise.all(
|
|
1688
|
+
entries
|
|
1689
|
+
.filter(entry => entry.endsWith(".json"))
|
|
1690
|
+
.map(async entry => {
|
|
1691
|
+
const turnId = entry.slice(0, -".json".length);
|
|
1692
|
+
if (!TURN_ID_PATTERN.test(turnId)) throw coordinatorStateError("invalid");
|
|
1693
|
+
return await readTurnRecord(namespaceDir, turnId);
|
|
1694
|
+
}),
|
|
1695
|
+
);
|
|
1696
|
+
return records.filter((record): record is TurnRecord => record !== null);
|
|
852
1697
|
}
|
|
853
1698
|
|
|
854
1699
|
function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
|
|
@@ -864,7 +1709,8 @@ function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
|
|
|
864
1709
|
}
|
|
865
1710
|
|
|
866
1711
|
async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
867
|
-
|
|
1712
|
+
if (!isValidTurnRecord(turn)) throw coordinatorStateError("invalid");
|
|
1713
|
+
const previous = await readTurnRecord(namespaceDir, turn.turn_id);
|
|
868
1714
|
await writeJsonFile(turnFile(namespaceDir, turn.turn_id), turn);
|
|
869
1715
|
const kind = previous?.status === turn.status ? null : turnEventKind(turn.status);
|
|
870
1716
|
if (kind) {
|
|
@@ -884,14 +1730,17 @@ async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<
|
|
|
884
1730
|
}
|
|
885
1731
|
|
|
886
1732
|
async function readActiveTurn(namespaceDir: string, sessionId: string): Promise<TurnRecord | null> {
|
|
887
|
-
const
|
|
888
|
-
if (
|
|
889
|
-
|
|
890
|
-
|
|
1733
|
+
const value = await readJsonFile(activeTurnFile(namespaceDir, sessionId));
|
|
1734
|
+
if (value === null) return null;
|
|
1735
|
+
if (!isValidActiveTurnRecord(value, sessionId)) throw coordinatorStateError("invalid");
|
|
1736
|
+
const turn = await readTurnRecord(namespaceDir, value.turn_id);
|
|
1737
|
+
if (!turn || turn.session_id !== sessionId || turn.status !== value.status || !ACTIVE_TURN_STATUSES.has(turn.status))
|
|
1738
|
+
throw coordinatorStateError("invalid");
|
|
891
1739
|
return turn;
|
|
892
1740
|
}
|
|
893
1741
|
|
|
894
1742
|
async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
1743
|
+
if (!ACTIVE_TURN_STATUSES.has(turn.status)) throw coordinatorStateError("invalid");
|
|
895
1744
|
await writeJsonFile(activeTurnFile(namespaceDir, turn.session_id), {
|
|
896
1745
|
session_id: turn.session_id,
|
|
897
1746
|
turn_id: turn.turn_id,
|
|
@@ -901,15 +1750,84 @@ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<
|
|
|
901
1750
|
}
|
|
902
1751
|
|
|
903
1752
|
async function clearActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
904
|
-
const
|
|
905
|
-
if (
|
|
1753
|
+
const value = await readJsonFile(activeTurnFile(namespaceDir, turn.session_id));
|
|
1754
|
+
if (value === null) return;
|
|
1755
|
+
if (!isValidActiveTurnRecord(value, turn.session_id)) throw coordinatorStateError("invalid");
|
|
1756
|
+
if (value.turn_id === turn.turn_id) await fs.rm(activeTurnFile(namespaceDir, turn.session_id), { force: true });
|
|
906
1757
|
}
|
|
907
1758
|
|
|
908
1759
|
async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
|
|
909
|
-
|
|
1760
|
+
const value = await readJsonFile(sessionStateFile(namespaceDir, sessionId));
|
|
1761
|
+
if (value === null) return null;
|
|
1762
|
+
if (!isValidRuntimeSessionState(value, sessionId)) throw coordinatorStateError("invalid");
|
|
1763
|
+
return value;
|
|
910
1764
|
}
|
|
911
1765
|
|
|
912
|
-
|
|
1766
|
+
function validRuntimeOwnerGeneration(value: unknown): value is string {
|
|
1767
|
+
return (
|
|
1768
|
+
typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/.test(value)
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function runtimeStateMatchesPrivateOwnerGeneration(
|
|
1773
|
+
sessionState: CoordinatorSessionState,
|
|
1774
|
+
session: Record<string, unknown> | null,
|
|
1775
|
+
): boolean {
|
|
1776
|
+
const expectedGeneration = session?.tmux_owner_generation ?? session?.tmuxOwnerGeneration;
|
|
1777
|
+
if (expectedGeneration === undefined)
|
|
1778
|
+
return !(sessionState.source === "process_postmortem" && sessionState.event === "owner_terminal");
|
|
1779
|
+
if (!validRuntimeOwnerGeneration(expectedGeneration) || sessionState.owner_generation !== expectedGeneration)
|
|
1780
|
+
return false;
|
|
1781
|
+
if (sessionState.source !== "process_postmortem") return true;
|
|
1782
|
+
if (sessionState.event !== "owner_terminal") return false;
|
|
1783
|
+
const expectedSocketKey = session?.tmux_owner_server_key ?? session?.tmuxOwnerServerKey;
|
|
1784
|
+
return (
|
|
1785
|
+
typeof expectedSocketKey === "string" &&
|
|
1786
|
+
sessionState.owner_terminal?.generation === expectedGeneration &&
|
|
1787
|
+
sessionState.owner_terminal.socket_key === expectedSocketKey
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
async function reconcileTerminalRuntimeEvidence(namespaceDir: string, sessionId: string): Promise<boolean> {
|
|
1792
|
+
const sessionState = await readSessionState(namespaceDir, sessionId);
|
|
1793
|
+
if (
|
|
1794
|
+
!sessionState ||
|
|
1795
|
+
sessionState.source === "coordinator" ||
|
|
1796
|
+
(sessionState.state !== "completed" && sessionState.state !== "errored")
|
|
1797
|
+
)
|
|
1798
|
+
return false;
|
|
1799
|
+
const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
|
|
1800
|
+
if (!runtimeStateMatchesPrivateOwnerGeneration(sessionState, session)) return false;
|
|
1801
|
+
|
|
1802
|
+
const activeTurn = await readActiveTurn(namespaceDir, sessionId);
|
|
1803
|
+
if (activeTurn && (sessionState.current_turn_id === null || sessionState.current_turn_id === activeTurn.turn_id)) {
|
|
1804
|
+
await markTurnTerminalFromSessionState(namespaceDir, activeTurn, sessionState);
|
|
1805
|
+
}
|
|
1806
|
+
return true;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
async function preflightCoordinatorMutation(
|
|
1810
|
+
namespaceDir: string,
|
|
1811
|
+
sessionId?: string,
|
|
1812
|
+
options: { rejectTerminalRuntimeEvidence?: boolean } = {},
|
|
1813
|
+
): Promise<void> {
|
|
1814
|
+
await readLatestEventSeq(namespaceDir);
|
|
1815
|
+
if (!sessionId) return;
|
|
1816
|
+
const [sessionState, session] = await Promise.all([
|
|
1817
|
+
readSessionState(namespaceDir, sessionId),
|
|
1818
|
+
readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)),
|
|
1819
|
+
]);
|
|
1820
|
+
const sessionRecord = asRecord(session);
|
|
1821
|
+
if (sessionRecord && normalizeSession(sessionRecord).session_id !== sessionId)
|
|
1822
|
+
throw coordinatorStateError("invalid");
|
|
1823
|
+
if (sessionState && sessionState.session_id !== sessionId) throw coordinatorStateError("invalid");
|
|
1824
|
+
await readActiveTurn(namespaceDir, sessionId);
|
|
1825
|
+
const terminalRuntimeEvidence = await reconcileTerminalRuntimeEvidence(namespaceDir, sessionId);
|
|
1826
|
+
if (terminalRuntimeEvidence && options.rejectTerminalRuntimeEvidence !== false)
|
|
1827
|
+
throw coordinatorStateError("invalid");
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
async function writeSessionStateUnlocked(
|
|
913
1831
|
namespaceDir: string,
|
|
914
1832
|
sessionId: string,
|
|
915
1833
|
state: CoordinatorSessionStateValue,
|
|
@@ -922,6 +1840,20 @@ async function writeSessionState(
|
|
|
922
1840
|
} = {},
|
|
923
1841
|
): Promise<CoordinatorSessionState> {
|
|
924
1842
|
const previous = await readSessionState(namespaceDir, sessionId);
|
|
1843
|
+
if (previous && previous.source !== "coordinator") return previous;
|
|
1844
|
+
const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
|
|
1845
|
+
const previousCwd = typeof previous?.cwd === "string" && previous.cwd.trim() ? previous.cwd : null;
|
|
1846
|
+
const cwd =
|
|
1847
|
+
typeof session?.cwd === "string" && session.cwd.trim()
|
|
1848
|
+
? path.resolve(session.cwd)
|
|
1849
|
+
: previousCwd
|
|
1850
|
+
? path.resolve(previousCwd)
|
|
1851
|
+
: null;
|
|
1852
|
+
if (!cwd) throw coordinatorStateError("invalid");
|
|
1853
|
+
const sessionFile =
|
|
1854
|
+
typeof session?.session_file === "string" && session.session_file.trim()
|
|
1855
|
+
? path.resolve(session.session_file)
|
|
1856
|
+
: null;
|
|
925
1857
|
const payload: CoordinatorSessionState = {
|
|
926
1858
|
schema_version: 1,
|
|
927
1859
|
session_id: sessionId,
|
|
@@ -933,6 +1865,9 @@ async function writeSessionState(
|
|
|
933
1865
|
source: options.source ?? "coordinator",
|
|
934
1866
|
live: options.live ?? previous?.live ?? null,
|
|
935
1867
|
reason: options.reason ?? null,
|
|
1868
|
+
cwd,
|
|
1869
|
+
workdir: cwd,
|
|
1870
|
+
session_file: sessionFile,
|
|
936
1871
|
};
|
|
937
1872
|
await writeJsonFile(sessionStateFile(namespaceDir, sessionId), payload);
|
|
938
1873
|
if (
|
|
@@ -960,17 +1895,149 @@ async function writeSessionState(
|
|
|
960
1895
|
return payload;
|
|
961
1896
|
}
|
|
962
1897
|
|
|
963
|
-
|
|
1898
|
+
interface SessionStateLockOwner {
|
|
1899
|
+
pid: number;
|
|
1900
|
+
start_time: string;
|
|
1901
|
+
token: string;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
function processStartTime(pid: number): string | null {
|
|
1905
|
+
try {
|
|
1906
|
+
const stat = nodeFs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1907
|
+
const close = stat.lastIndexOf(")");
|
|
1908
|
+
const fields = stat
|
|
1909
|
+
.slice(close + 1)
|
|
1910
|
+
.trim()
|
|
1911
|
+
.split(/\s+/);
|
|
1912
|
+
return fields[19] ?? null;
|
|
1913
|
+
} catch {
|
|
1914
|
+
return null;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function validLockOwner(value: unknown): value is SessionStateLockOwner {
|
|
1919
|
+
if (!value || typeof value !== "object") return false;
|
|
1920
|
+
const owner = value as Partial<SessionStateLockOwner>;
|
|
964
1921
|
return (
|
|
965
|
-
|
|
966
|
-
(
|
|
1922
|
+
typeof owner.pid === "number" &&
|
|
1923
|
+
Number.isSafeInteger(owner.pid) &&
|
|
1924
|
+
owner.pid > 0 &&
|
|
1925
|
+
typeof owner.start_time === "string" &&
|
|
1926
|
+
typeof owner.token === "string" &&
|
|
1927
|
+
owner.token.length > 0
|
|
967
1928
|
);
|
|
968
1929
|
}
|
|
969
1930
|
|
|
970
|
-
function
|
|
971
|
-
if (
|
|
972
|
-
|
|
973
|
-
|
|
1931
|
+
function lockOwnerIsAlive(value: unknown): boolean {
|
|
1932
|
+
if (!validLockOwner(value)) return false;
|
|
1933
|
+
const owner = value;
|
|
1934
|
+
try {
|
|
1935
|
+
process.kill(owner.pid, 0);
|
|
1936
|
+
} catch (error) {
|
|
1937
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
|
|
1938
|
+
return true;
|
|
1939
|
+
}
|
|
1940
|
+
const currentStartTime = processStartTime(owner.pid);
|
|
1941
|
+
return currentStartTime === null || currentStartTime === owner.start_time;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
|
|
1945
|
+
let raw: string;
|
|
1946
|
+
try {
|
|
1947
|
+
raw = await fs.readFile(lockFile, "utf8");
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
1950
|
+
throw error;
|
|
1951
|
+
}
|
|
1952
|
+
let owner: unknown;
|
|
1953
|
+
try {
|
|
1954
|
+
owner = JSON.parse(raw);
|
|
1955
|
+
} catch {
|
|
1956
|
+
owner = null;
|
|
1957
|
+
}
|
|
1958
|
+
if (!validLockOwner(owner)) {
|
|
1959
|
+
const stat = await fs.stat(lockFile);
|
|
1960
|
+
if (Date.now() - stat.mtimeMs < 30_000) return;
|
|
1961
|
+
} else if (lockOwnerIsAlive(owner)) return;
|
|
1962
|
+
try {
|
|
1963
|
+
if ((await fs.readFile(lockFile, "utf8")) === raw) await fs.rm(lockFile);
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
async function withSessionStateLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
1970
|
+
const lockFile = `${stateFile}.lock`;
|
|
1971
|
+
const owner: SessionStateLockOwner = {
|
|
1972
|
+
pid: process.pid,
|
|
1973
|
+
start_time: processStartTime(process.pid) ?? "unknown",
|
|
1974
|
+
token: randomUUID(),
|
|
1975
|
+
};
|
|
1976
|
+
await ensureDir(path.dirname(stateFile));
|
|
1977
|
+
for (let attempt = 0; attempt < 12_000; attempt++) {
|
|
1978
|
+
let handle: fs.FileHandle | undefined;
|
|
1979
|
+
try {
|
|
1980
|
+
handle = await fs.open(lockFile, "wx");
|
|
1981
|
+
try {
|
|
1982
|
+
await handle.writeFile(JSON.stringify(owner));
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
await handle.close().catch(() => undefined);
|
|
1985
|
+
handle = undefined;
|
|
1986
|
+
await fs.rm(lockFile, { force: true }).catch(() => undefined);
|
|
1987
|
+
throw error;
|
|
1988
|
+
}
|
|
1989
|
+
const outcome = await operation().then(
|
|
1990
|
+
value => ({ ok: true as const, value }),
|
|
1991
|
+
error => ({ ok: false as const, error }),
|
|
1992
|
+
);
|
|
1993
|
+
await handle.close();
|
|
1994
|
+
try {
|
|
1995
|
+
if ((await fs.readFile(lockFile, "utf8")) === JSON.stringify(owner)) await fs.rm(lockFile);
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1998
|
+
}
|
|
1999
|
+
if (!outcome.ok) throw outcome.error;
|
|
2000
|
+
return outcome.value;
|
|
2001
|
+
} catch (error) {
|
|
2002
|
+
if (handle) throw error;
|
|
2003
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw coordinatorStateError("unreadable");
|
|
2004
|
+
await reclaimStaleSessionStateLock(lockFile);
|
|
2005
|
+
await Bun.sleep(5);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
throw coordinatorStateError("unreadable");
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
async function writeSessionState(
|
|
2012
|
+
namespaceDir: string,
|
|
2013
|
+
sessionId: string,
|
|
2014
|
+
state: CoordinatorSessionStateValue,
|
|
2015
|
+
options: {
|
|
2016
|
+
currentTurnId?: string | null;
|
|
2017
|
+
lastTurnId?: string | null;
|
|
2018
|
+
live?: boolean | null;
|
|
2019
|
+
reason?: string | null;
|
|
2020
|
+
source?: CoordinatorSessionState["source"];
|
|
2021
|
+
} = {},
|
|
2022
|
+
): Promise<CoordinatorSessionState> {
|
|
2023
|
+
const file = sessionStateFile(namespaceDir, sessionId);
|
|
2024
|
+
return await withSessionStateLock(
|
|
2025
|
+
file,
|
|
2026
|
+
async () => await writeSessionStateUnlocked(namespaceDir, sessionId, state, options),
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function hasTmuxIdentity(session: Record<string, unknown>): boolean {
|
|
2031
|
+
return (
|
|
2032
|
+
(typeof session.tmux_session === "string" && session.tmux_session.length > 0) ||
|
|
2033
|
+
(typeof session.tmuxSession === "string" && session.tmuxSession.length > 0)
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function unavailableSessionReason(turn: TurnRecord, reason: string): string {
|
|
2038
|
+
if (
|
|
2039
|
+
reason === "tmux_session_missing" &&
|
|
2040
|
+
turn.delivery.tmux_keys_sent === true &&
|
|
974
2041
|
turn.delivery.prompt_acknowledged === true
|
|
975
2042
|
) {
|
|
976
2043
|
return "tmux_session_missing_after_prompt_acknowledgement";
|
|
@@ -1012,7 +2079,11 @@ async function markTurnFailedForUnavailableSession(
|
|
|
1012
2079
|
truncated: false,
|
|
1013
2080
|
},
|
|
1014
2081
|
evidence: unavailableSessionEvidence(turn, durableReason, timestamp),
|
|
1015
|
-
error: {
|
|
2082
|
+
error: {
|
|
2083
|
+
code: "session_unavailable",
|
|
2084
|
+
message: durableReason,
|
|
2085
|
+
recoverable: true,
|
|
2086
|
+
},
|
|
1016
2087
|
liveness: { checked_at: timestamp, live: false, reason: durableReason },
|
|
1017
2088
|
updated_at: timestamp,
|
|
1018
2089
|
completed_at: timestamp,
|
|
@@ -1193,7 +2264,11 @@ async function markTurnFailedForUnacknowledgedDelivery(
|
|
|
1193
2264
|
created_at: timestamp,
|
|
1194
2265
|
},
|
|
1195
2266
|
],
|
|
1196
|
-
liveness: {
|
|
2267
|
+
liveness: {
|
|
2268
|
+
checked_at: timestamp,
|
|
2269
|
+
live: turn.liveness.live,
|
|
2270
|
+
reason: PROMPT_ACK_TIMEOUT_REASON,
|
|
2271
|
+
},
|
|
1197
2272
|
updated_at: timestamp,
|
|
1198
2273
|
completed_at: timestamp,
|
|
1199
2274
|
};
|
|
@@ -1214,6 +2289,16 @@ async function reconcileRuntimeAcknowledgement(
|
|
|
1214
2289
|
ackTimeoutMs: number,
|
|
1215
2290
|
options: { failOnTimeout: boolean } = { failOnTimeout: true },
|
|
1216
2291
|
): Promise<TurnRecord> {
|
|
2292
|
+
const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${turn.session_id}.json`)));
|
|
2293
|
+
if (
|
|
2294
|
+
sessionState &&
|
|
2295
|
+
runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
|
|
2296
|
+
sessionState.source !== "coordinator" &&
|
|
2297
|
+
(sessionState.state === "completed" || sessionState.state === "errored") &&
|
|
2298
|
+
(sessionState.current_turn_id === null || sessionState.current_turn_id === turn.turn_id)
|
|
2299
|
+
) {
|
|
2300
|
+
return await markTurnTerminalFromSessionState(namespaceDir, turn, sessionState);
|
|
2301
|
+
}
|
|
1217
2302
|
if (sessionState && runtimeStateAcknowledgesTurn(turn, sessionState)) {
|
|
1218
2303
|
return await markTurnAcknowledgedFromRuntimeState(namespaceDir, turn, sessionState);
|
|
1219
2304
|
}
|
|
@@ -1226,6 +2311,27 @@ async function reconcileRuntimeAcknowledgement(
|
|
|
1226
2311
|
function shellQuote(value: string): string {
|
|
1227
2312
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1228
2313
|
}
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* Append an authoritative `--mpreset <profile>` to the coordinator child launch
|
|
2317
|
+
* command so the spawned GJC session activates the selected profile from its
|
|
2318
|
+
* first turn. The profile name is validated against the merged registry before
|
|
2319
|
+
* reaching here; it is still shell-quoted as defense in depth.
|
|
2320
|
+
*
|
|
2321
|
+
* Precedence note: this matches `gjc --mpreset <profile>` semantics exactly, so
|
|
2322
|
+
* an explicit model-affecting flag already present in the operator-configured
|
|
2323
|
+
* `sessionCommand` (e.g. a `--model`/`--thinking` baked into the launch command)
|
|
2324
|
+
* follows the same CLI precedence as a direct `gjc --mpreset ... --model ...`
|
|
2325
|
+
* invocation. The effective profile recorded on the session is the
|
|
2326
|
+
* coordinator-resolved request, not a child-attested activation receipt; a
|
|
2327
|
+
* cross-process activation receipt is a possible future enhancement and is out
|
|
2328
|
+
* of scope for the per-session selection contract here.
|
|
2329
|
+
*/
|
|
2330
|
+
export function buildCoordinatorSessionCommand(sessionCommand: string, mpreset: string | null | undefined): string {
|
|
2331
|
+
if (!mpreset) return sessionCommand;
|
|
2332
|
+
return `${sessionCommand} --mpreset ${shellQuote(mpreset)}`;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
1229
2335
|
function makeTurnRecord(
|
|
1230
2336
|
config: CoordinatorMcpConfig,
|
|
1231
2337
|
sessionId: string,
|
|
@@ -1250,7 +2356,13 @@ function makeTurnRecord(
|
|
|
1250
2356
|
attempts: [],
|
|
1251
2357
|
},
|
|
1252
2358
|
question_ids: [],
|
|
1253
|
-
final_response: {
|
|
2359
|
+
final_response: {
|
|
2360
|
+
text: null,
|
|
2361
|
+
format: "markdown",
|
|
2362
|
+
source: null,
|
|
2363
|
+
artifact_path: null,
|
|
2364
|
+
truncated: false,
|
|
2365
|
+
},
|
|
1254
2366
|
evidence: [],
|
|
1255
2367
|
error: null,
|
|
1256
2368
|
liveness: { checked_at: null, live: null, reason: null },
|
|
@@ -1272,6 +2384,8 @@ function asTerminalTurnStatus(status: unknown): TurnStatus | null {
|
|
|
1272
2384
|
|
|
1273
2385
|
export const COORDINATOR_AWAIT_TURN_TIMEOUT_MAX_MS = 30 * 60 * 1000;
|
|
1274
2386
|
export const COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS = MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS;
|
|
2387
|
+
export const COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS = MAX_RUNTIME_READINESS_TIMEOUT_MS;
|
|
2388
|
+
|
|
1275
2389
|
export const COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS = 30_000;
|
|
1276
2390
|
export const COORDINATOR_POLL_INTERVAL_MAX_MS = 10_000;
|
|
1277
2391
|
|
|
@@ -1290,6 +2404,12 @@ export function boundedRuntimePromptAckTimeoutMs(value: unknown): number {
|
|
|
1290
2404
|
COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS,
|
|
1291
2405
|
);
|
|
1292
2406
|
}
|
|
2407
|
+
export function boundedRuntimeReadinessTimeoutMs(value: unknown): number {
|
|
2408
|
+
return Math.min(
|
|
2409
|
+
parsePositiveIntegerMs(value, DEFAULT_RUNTIME_READINESS_TIMEOUT_MS),
|
|
2410
|
+
COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS,
|
|
2411
|
+
);
|
|
2412
|
+
}
|
|
1293
2413
|
|
|
1294
2414
|
export function boundedEventWatchTimeoutMs(value: unknown): number {
|
|
1295
2415
|
return Math.min(parsePositiveIntegerMs(value, 1000), COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS);
|
|
@@ -1298,8 +2418,21 @@ export function boundedEventWatchTimeoutMs(value: unknown): number {
|
|
|
1298
2418
|
export function boundedPollIntervalMs(value: unknown): number {
|
|
1299
2419
|
return Math.min(Math.max(parsePositiveIntegerMs(value, 100), 10), COORDINATOR_POLL_INTERVAL_MAX_MS);
|
|
1300
2420
|
}
|
|
1301
|
-
async function runCommand(
|
|
1302
|
-
|
|
2421
|
+
async function runCommand(
|
|
2422
|
+
command: string[],
|
|
2423
|
+
stdinLine?: string,
|
|
2424
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
2425
|
+
const proc = Bun.spawn(command, {
|
|
2426
|
+
stdin: stdinLine === undefined ? "ignore" : "pipe",
|
|
2427
|
+
stdout: "pipe",
|
|
2428
|
+
stderr: "pipe",
|
|
2429
|
+
});
|
|
2430
|
+
if (stdinLine !== undefined) {
|
|
2431
|
+
const stdin = proc.stdin;
|
|
2432
|
+
if (!stdin) throw new Error("coordinator_command_stdin_unavailable");
|
|
2433
|
+
stdin.write(`${stdinLine}\n`);
|
|
2434
|
+
stdin.end();
|
|
2435
|
+
}
|
|
1303
2436
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1304
2437
|
new Response(proc.stdout).text(),
|
|
1305
2438
|
new Response(proc.stderr).text(),
|
|
@@ -1308,29 +2441,39 @@ async function runCommand(command: string[]): Promise<{ exitCode: number; stdout
|
|
|
1308
2441
|
return { exitCode, stdout, stderr };
|
|
1309
2442
|
}
|
|
1310
2443
|
|
|
1311
|
-
type CommandRunner = (command: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
1312
|
-
|
|
1313
2444
|
async function sendTmuxPromptKeys(
|
|
1314
2445
|
target: string,
|
|
1315
2446
|
prompt: string,
|
|
1316
|
-
runner: CommandRunner
|
|
2447
|
+
runner: CommandRunner,
|
|
2448
|
+
socketKey: string,
|
|
2449
|
+
proveCurrentServer: () => Promise<boolean>,
|
|
1317
2450
|
): Promise<boolean> {
|
|
2451
|
+
const runMutation = async (argv: string[]): Promise<CommandResult | null> => {
|
|
2452
|
+
if (!(await proveCurrentServer())) return null;
|
|
2453
|
+
return await runner(argv);
|
|
2454
|
+
};
|
|
1318
2455
|
const bufferName = `gjc-coordinator-prompt-${randomUUID()}`;
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
2456
|
+
const tmux = ["tmux", "-L", socketKey];
|
|
2457
|
+
const zeroize = async (): Promise<boolean> => {
|
|
2458
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2459
|
+
const cleared = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", ""]);
|
|
2460
|
+
const deleted =
|
|
2461
|
+
cleared?.exitCode === 0 ? await runMutation([...tmux, "delete-buffer", "-b", bufferName]) : null;
|
|
2462
|
+
if (cleared?.exitCode === 0 && deleted?.exitCode === 0) return true;
|
|
2463
|
+
}
|
|
2464
|
+
return false;
|
|
2465
|
+
};
|
|
2466
|
+
const buffered = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", prompt]);
|
|
2467
|
+
if (buffered?.exitCode !== 0) return false;
|
|
2468
|
+
const pasted = await runMutation([...tmux, "paste-buffer", "-d", "-b", bufferName, "-t", target]);
|
|
2469
|
+
if (pasted?.exitCode !== 0) {
|
|
2470
|
+
if (!(await zeroize())) throw new Error("coordinator_prompt_buffer_privacy_unverified");
|
|
1324
2471
|
return false;
|
|
1325
2472
|
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
const dismissedAutocomplete = await runner(["tmux", "send-keys", "-t", target, "Escape"]);
|
|
1331
|
-
if (dismissedAutocomplete.exitCode !== 0) return false;
|
|
1332
|
-
const submitted = await runner(["tmux", "send-keys", "-t", target, "Enter"]);
|
|
1333
|
-
return submitted.exitCode === 0;
|
|
2473
|
+
const dismissedAutocomplete = await runMutation([...tmux, "send-keys", "-t", target, "Escape"]);
|
|
2474
|
+
if (dismissedAutocomplete?.exitCode !== 0) return false;
|
|
2475
|
+
const submitted = await runMutation([...tmux, "send-keys", "-t", target, "Enter"]);
|
|
2476
|
+
return submitted?.exitCode === 0;
|
|
1334
2477
|
}
|
|
1335
2478
|
|
|
1336
2479
|
function boundedLineCount(value: unknown): number {
|
|
@@ -1355,7 +2498,10 @@ async function registerExistingTmuxSession(
|
|
|
1355
2498
|
namespaceDir: string,
|
|
1356
2499
|
sessionFilePath: string,
|
|
1357
2500
|
runner: CommandRunner = runCommand,
|
|
1358
|
-
): Promise<{
|
|
2501
|
+
): Promise<{
|
|
2502
|
+
session: Record<string, unknown>;
|
|
2503
|
+
sessionState: CoordinatorSessionState;
|
|
2504
|
+
}> {
|
|
1359
2505
|
await assertTmuxTargetAvailable(input.tmuxSession, input.tmuxTarget, runner);
|
|
1360
2506
|
const existing = asRecord(await readJsonFile(sessionFilePath));
|
|
1361
2507
|
if (existing) {
|
|
@@ -1381,6 +2527,7 @@ async function registerExistingTmuxSession(
|
|
|
1381
2527
|
authoritative: true,
|
|
1382
2528
|
warp_attached: input.warpAttached,
|
|
1383
2529
|
source: input.source,
|
|
2530
|
+
origin: "registered",
|
|
1384
2531
|
model: input.model,
|
|
1385
2532
|
};
|
|
1386
2533
|
await writeJsonFile(sessionFilePath, session);
|
|
@@ -1391,52 +2538,406 @@ async function registerExistingTmuxSession(
|
|
|
1391
2538
|
return { session, sessionState: state };
|
|
1392
2539
|
}
|
|
1393
2540
|
|
|
2541
|
+
async function portableProcessStartTime(pid: number, runner: CommandRunner): Promise<string | null> {
|
|
2542
|
+
const result = await runner(["ps", "-o", "lstart=", "-p", String(pid)]);
|
|
2543
|
+
const startTime = result.stdout.trim();
|
|
2544
|
+
return result.exitCode === 0 &&
|
|
2545
|
+
startTime.length > 0 &&
|
|
2546
|
+
startTime.length <= 160 &&
|
|
2547
|
+
!/[\r\n\u0000-\u001f\u007f]/.test(startTime)
|
|
2548
|
+
? startTime
|
|
2549
|
+
: null;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
export async function coordinatorOwnerIsolationProbe(runner: CommandRunner): Promise<OwnerIsolationProbe> {
|
|
2553
|
+
return {
|
|
2554
|
+
readCallerCgroup: async () => await fs.readFile("/proc/self/cgroup", "utf8").catch(() => null),
|
|
2555
|
+
probeServer: async (socketKey): Promise<TmuxServerProof> => {
|
|
2556
|
+
try {
|
|
2557
|
+
const result = await runner(["tmux", "-L", socketKey, "list-sessions", "-F", "#{pid} #{session_name}"]);
|
|
2558
|
+
if (result.exitCode !== 0) {
|
|
2559
|
+
const diagnostic = `${result.stdout}\n${result.stderr}`.slice(0, 512);
|
|
2560
|
+
return /(?:no server running|failed to connect to server|no sessions|error connecting to)/i.test(
|
|
2561
|
+
diagnostic,
|
|
2562
|
+
)
|
|
2563
|
+
? { state: "absent" }
|
|
2564
|
+
: { state: "unverifiable" };
|
|
2565
|
+
}
|
|
2566
|
+
const rows = result.stdout
|
|
2567
|
+
.split("\n")
|
|
2568
|
+
.map(line => line.trim().split(/\s+/, 2))
|
|
2569
|
+
.filter(([pid, name]) => Boolean(pid && name));
|
|
2570
|
+
const pid = Number(rows[0]?.[0]);
|
|
2571
|
+
const sessionNames = rows.map(([, name]) => name as string);
|
|
2572
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || rows.some(([rowPid]) => Number(rowPid) !== pid))
|
|
2573
|
+
return { state: "unverifiable" };
|
|
2574
|
+
const stat =
|
|
2575
|
+
process.platform === "linux" ? await fs.readFile(`/proc/${pid}/stat`, "utf8").catch(() => null) : null;
|
|
2576
|
+
const startTime =
|
|
2577
|
+
process.platform === "linux"
|
|
2578
|
+
? ownerProcessStartTime(process.platform, stat)
|
|
2579
|
+
: await portableProcessStartTime(pid, runner);
|
|
2580
|
+
if (!startTime) return { state: "unverifiable", pid, sessionNames };
|
|
2581
|
+
if (process.platform !== "linux")
|
|
2582
|
+
return {
|
|
2583
|
+
state: "safe",
|
|
2584
|
+
pid,
|
|
2585
|
+
startTime,
|
|
2586
|
+
sessionNames,
|
|
2587
|
+
cgroup: { classification: "not_applicable" },
|
|
2588
|
+
};
|
|
2589
|
+
const cgroupText = await fs.readFile(`/proc/${pid}/cgroup`, "utf8").catch(() => null);
|
|
2590
|
+
const cgroup = classifyCgroup({ platform: process.platform, cgroupText });
|
|
2591
|
+
return {
|
|
2592
|
+
state:
|
|
2593
|
+
cgroup.classification === "safe"
|
|
2594
|
+
? "safe"
|
|
2595
|
+
: cgroup.classification === "unsafe_service"
|
|
2596
|
+
? "unsafe"
|
|
2597
|
+
: "unverifiable",
|
|
2598
|
+
pid,
|
|
2599
|
+
startTime,
|
|
2600
|
+
sessionNames,
|
|
2601
|
+
cgroup,
|
|
2602
|
+
};
|
|
2603
|
+
} catch {
|
|
2604
|
+
// A transient spawn failure (e.g. EAGAIN under load, or a momentary tmux/ps hiccup)
|
|
2605
|
+
// must not fatally abort the owner-server proof. Surfacing it as an unverifiable state
|
|
2606
|
+
// lets proveCoordinatorOwnerServer's bounded retry loop re-probe once the transient
|
|
2607
|
+
// clears, instead of propagating the throw and failing the entire delegate outright.
|
|
2608
|
+
return { state: "unverifiable" };
|
|
2609
|
+
}
|
|
2610
|
+
},
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
async function proveCoordinatorOwnerServer(probe: OwnerIsolationProbe, socketKey: string): Promise<TmuxServerProof> {
|
|
2615
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
2616
|
+
const proof = await probe.probeServer(socketKey);
|
|
2617
|
+
if (proof.state === "unsafe" || (proof.state === "safe" && proof.pid && proof.startTime)) return proof;
|
|
2618
|
+
if (attempt < 49) await Bun.sleep(10);
|
|
2619
|
+
}
|
|
2620
|
+
return { state: "unverifiable" };
|
|
2621
|
+
}
|
|
2622
|
+
|
|
1394
2623
|
async function startTmuxSession(
|
|
1395
2624
|
config: CoordinatorMcpConfig,
|
|
1396
2625
|
input: SessionStartInput,
|
|
1397
2626
|
namespaceDir: string,
|
|
1398
2627
|
runner: CommandRunner = runCommand,
|
|
2628
|
+
ownerIsolationProbe?: OwnerIsolationProbe,
|
|
1399
2629
|
): Promise<Record<string, unknown>> {
|
|
1400
2630
|
if (!config.sessionCommand) throw new Error("coordinator_session_command_required");
|
|
1401
|
-
const sessionName =
|
|
2631
|
+
const sessionName = input.sessionId;
|
|
2632
|
+
const socketKey = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
|
|
2633
|
+
const ownerStateDir = namespaceDir;
|
|
2634
|
+
const ownerGeneration = randomUUID();
|
|
2635
|
+
const generationBaseline = captureOwnerGenerationBaselineSync(ownerStateDir, sessionName);
|
|
1402
2636
|
const runtimeStateFile = sessionStateFile(namespaceDir, sessionName);
|
|
1403
2637
|
const sessionCommand = [
|
|
1404
2638
|
"exec env",
|
|
1405
2639
|
`${GJC_COORDINATOR_SESSION_STATE_FILE_ENV}=${shellQuote(runtimeStateFile)}`,
|
|
1406
2640
|
`${GJC_COORDINATOR_SESSION_ID_ENV}=${shellQuote(sessionName)}`,
|
|
1407
|
-
|
|
2641
|
+
`${GJC_TMUX_OWNER_GENERATION_ENV}=${shellQuote(ownerGeneration)}`,
|
|
2642
|
+
`${GJC_TMUX_OWNER_STATE_DIR_ENV}=${shellQuote(ownerStateDir)}`,
|
|
2643
|
+
`${GJC_TMUX_OWNER_SERVER_KEY_ENV}=${shellQuote(socketKey)}`,
|
|
2644
|
+
`${GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV}=${shellQuote(input.launchId)}`,
|
|
2645
|
+
`${GJC_COORDINATOR_SESSION_READINESS_FILE_ENV}=${shellQuote(input.readinessMarkerFile)}`,
|
|
2646
|
+
buildCoordinatorSessionCommand(config.sessionCommand, input.mpreset),
|
|
1408
2647
|
].join(" ");
|
|
1409
|
-
const
|
|
2648
|
+
const tmuxArgv = [
|
|
1410
2649
|
"tmux",
|
|
2650
|
+
"-L",
|
|
2651
|
+
socketKey,
|
|
1411
2652
|
"new-session",
|
|
1412
2653
|
"-d",
|
|
1413
2654
|
"-P",
|
|
1414
2655
|
"-F",
|
|
1415
|
-
"#{session_name}:#{window_index}.#{pane_index} #{pane_id}",
|
|
2656
|
+
"#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
|
|
1416
2657
|
"-s",
|
|
1417
2658
|
sessionName,
|
|
1418
2659
|
"-c",
|
|
1419
2660
|
input.cwd,
|
|
1420
2661
|
sessionCommand,
|
|
1421
|
-
]
|
|
1422
|
-
|
|
1423
|
-
|
|
2662
|
+
];
|
|
2663
|
+
const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
|
|
2664
|
+
let plan = await planTmuxOwnerIsolation(
|
|
2665
|
+
{
|
|
2666
|
+
schema_version: 1,
|
|
2667
|
+
op: "plan",
|
|
2668
|
+
platform: process.platform,
|
|
2669
|
+
session_id: sessionName,
|
|
2670
|
+
owner_generation: ownerGeneration,
|
|
2671
|
+
baseline: generationBaseline,
|
|
2672
|
+
cwd: input.cwd,
|
|
2673
|
+
state_dir: ownerStateDir,
|
|
2674
|
+
socket_key: socketKey,
|
|
2675
|
+
tmux_argv: tmuxArgv,
|
|
2676
|
+
},
|
|
2677
|
+
probe,
|
|
2678
|
+
);
|
|
2679
|
+
if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
|
|
2680
|
+
if (plan.execution.mode === "direct" && plan.execution.server_absent_before) {
|
|
2681
|
+
plan = await planTmuxOwnerIsolation(
|
|
2682
|
+
{
|
|
2683
|
+
schema_version: 1,
|
|
2684
|
+
op: "plan",
|
|
2685
|
+
platform: process.platform,
|
|
2686
|
+
session_id: sessionName,
|
|
2687
|
+
owner_generation: ownerGeneration,
|
|
2688
|
+
baseline: generationBaseline,
|
|
2689
|
+
cwd: input.cwd,
|
|
2690
|
+
state_dir: ownerStateDir,
|
|
2691
|
+
socket_key: socketKey,
|
|
2692
|
+
tmux_argv: tmuxArgv,
|
|
2693
|
+
},
|
|
2694
|
+
{
|
|
2695
|
+
...probe,
|
|
2696
|
+
readCallerCgroup: async () => "0::/gjc-coordinator-bootstrap.service",
|
|
2697
|
+
},
|
|
2698
|
+
);
|
|
2699
|
+
if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
|
|
2700
|
+
}
|
|
2701
|
+
const preSpawnProof =
|
|
2702
|
+
plan.execution.mode === "direct" && plan.server_state === "safe"
|
|
2703
|
+
? await proveCoordinatorOwnerServer(probe, socketKey)
|
|
2704
|
+
: null;
|
|
2705
|
+
if (
|
|
2706
|
+
preSpawnProof &&
|
|
2707
|
+
(preSpawnProof.state !== "safe" ||
|
|
2708
|
+
!preSpawnProof.pid ||
|
|
2709
|
+
!preSpawnProof.startTime ||
|
|
2710
|
+
(process.platform === "linux" && preSpawnProof.cgroup?.classification !== "safe"))
|
|
2711
|
+
)
|
|
2712
|
+
throw new Error("coordinator_tmux_owner_server_unverifiable");
|
|
2713
|
+
const started = await runner(
|
|
2714
|
+
plan.execution.argv,
|
|
2715
|
+
plan.execution.mode === "scoped" ? plan.execution.stdin_line : undefined,
|
|
2716
|
+
);
|
|
2717
|
+
|
|
2718
|
+
const rawScopedReceipt =
|
|
2719
|
+
plan.execution.mode === "scoped" && isExactScopedBootstrapSuccessReceipt(started.stdout)
|
|
2720
|
+
? (JSON.parse(started.stdout.trim()) as {
|
|
2721
|
+
native_session_id: string;
|
|
2722
|
+
server_pid: number;
|
|
2723
|
+
server_start_time: string;
|
|
2724
|
+
session_name: string;
|
|
2725
|
+
})
|
|
2726
|
+
: undefined;
|
|
2727
|
+
const scopedReceipt =
|
|
2728
|
+
rawScopedReceipt?.session_name === plan.execution.attempt_session ? rawScopedReceipt : undefined;
|
|
2729
|
+
const directReceipt =
|
|
2730
|
+
plan.execution.mode === "direct"
|
|
2731
|
+
? parseTmuxNewSessionRecord(started.stdout, plan.execution.attempt_session)
|
|
2732
|
+
: undefined;
|
|
2733
|
+
const directNativeSessionId = directReceipt?.nativeSessionId;
|
|
2734
|
+
const spawnedNativeSessionId = scopedReceipt?.native_session_id ?? directNativeSessionId;
|
|
2735
|
+
let expectedServer = scopedReceipt
|
|
2736
|
+
? {
|
|
2737
|
+
pid: scopedReceipt.server_pid,
|
|
2738
|
+
startTime: scopedReceipt.server_start_time,
|
|
2739
|
+
}
|
|
2740
|
+
: preSpawnProof && preSpawnProof.state === "safe" && preSpawnProof.pid && preSpawnProof.startTime
|
|
2741
|
+
? { pid: preSpawnProof.pid, startTime: preSpawnProof.startTime }
|
|
2742
|
+
: undefined;
|
|
2743
|
+
const cleanupFailedAttempt = async (): Promise<"cleaned" | "failed" | "unverifiable"> => {
|
|
2744
|
+
if (!spawnedNativeSessionId || !expectedServer) return "unverifiable";
|
|
2745
|
+
try {
|
|
2746
|
+
const result = await runner([
|
|
2747
|
+
"tmux",
|
|
2748
|
+
"-L",
|
|
2749
|
+
socketKey,
|
|
2750
|
+
"if-shell",
|
|
2751
|
+
"-t",
|
|
2752
|
+
spawnedNativeSessionId,
|
|
2753
|
+
"-F",
|
|
2754
|
+
coordinatorCleanupPredicate(expectedServer.pid, spawnedNativeSessionId, plan.execution.attempt_session),
|
|
2755
|
+
`kill-session -t '${spawnedNativeSessionId}' ; display-message -p __gjc_coordinator_cleanup_ok__`,
|
|
2756
|
+
"display-message -p __gjc_coordinator_cleanup_refused__",
|
|
2757
|
+
]);
|
|
2758
|
+
if (result.exitCode !== 0) return "failed";
|
|
2759
|
+
return result.stdout.trim() === "__gjc_coordinator_cleanup_ok__" ? "cleaned" : "unverifiable";
|
|
2760
|
+
} catch {
|
|
2761
|
+
return "unverifiable";
|
|
2762
|
+
}
|
|
2763
|
+
};
|
|
2764
|
+
const failAfterCleanup = async (reason: string): Promise<never> => {
|
|
2765
|
+
throw new CoordinatorStartError(reason, await cleanupFailedAttempt());
|
|
2766
|
+
};
|
|
2767
|
+
if (started.exitCode !== 0) return await failAfterCleanup("coordinator_tmux_start_failed");
|
|
2768
|
+
const bootstrapReceiptValid = plan.execution.mode !== "scoped" || scopedReceipt !== undefined;
|
|
2769
|
+
const initialProof = await proveCoordinatorOwnerServer(probe, socketKey).catch(error =>
|
|
2770
|
+
failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_owner_server_unverifiable"),
|
|
2771
|
+
);
|
|
2772
|
+
if (
|
|
2773
|
+
initialProof.state !== "safe" ||
|
|
2774
|
+
!initialProof.pid ||
|
|
2775
|
+
!initialProof.startTime ||
|
|
2776
|
+
(process.platform === "linux" && initialProof.cgroup?.classification !== "safe")
|
|
2777
|
+
) {
|
|
2778
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_unverifiable");
|
|
2779
|
+
}
|
|
2780
|
+
if (
|
|
2781
|
+
scopedReceipt &&
|
|
2782
|
+
(initialProof.pid !== scopedReceipt.server_pid || initialProof.startTime !== scopedReceipt.server_start_time)
|
|
2783
|
+
) {
|
|
2784
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2785
|
+
}
|
|
2786
|
+
if (
|
|
2787
|
+
preSpawnProof &&
|
|
2788
|
+
(initialProof.pid !== preSpawnProof.pid || initialProof.startTime !== preSpawnProof.startTime)
|
|
2789
|
+
) {
|
|
2790
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2791
|
+
}
|
|
2792
|
+
if (!expectedServer)
|
|
2793
|
+
expectedServer = {
|
|
2794
|
+
pid: initialProof.pid,
|
|
2795
|
+
startTime: initialProof.startTime,
|
|
2796
|
+
};
|
|
2797
|
+
if (!bootstrapReceiptValid) {
|
|
2798
|
+
return await failAfterCleanup("coordinator_tmux_start_failed");
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
const startRecord = await (async () => {
|
|
2802
|
+
const queried = await runner([
|
|
2803
|
+
"tmux",
|
|
2804
|
+
"-L",
|
|
2805
|
+
socketKey,
|
|
2806
|
+
"display-message",
|
|
2807
|
+
"-p",
|
|
2808
|
+
"-t",
|
|
2809
|
+
sessionName,
|
|
2810
|
+
"#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
|
|
2811
|
+
]);
|
|
2812
|
+
if (queried.exitCode !== 0) throw new Error("coordinator_tmux_start_failed");
|
|
2813
|
+
return queried.stdout.trim();
|
|
2814
|
+
})().catch(error => failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_start_failed"));
|
|
2815
|
+
const postLaunchProof = await probe.probeServer(socketKey);
|
|
2816
|
+
if (
|
|
2817
|
+
postLaunchProof.state !== "safe" ||
|
|
2818
|
+
postLaunchProof.pid !== initialProof.pid ||
|
|
2819
|
+
postLaunchProof.startTime !== initialProof.startTime
|
|
2820
|
+
) {
|
|
2821
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2822
|
+
}
|
|
2823
|
+
const parsedStartRecord = parseTmuxNewSessionRecord(startRecord, plan.execution.attempt_session);
|
|
2824
|
+
if (!parsedStartRecord) {
|
|
2825
|
+
return await failAfterCleanup("coordinator_tmux_start_failed");
|
|
2826
|
+
}
|
|
2827
|
+
const { tmuxTarget, paneId, nativeSessionId } = parsedStartRecord;
|
|
2828
|
+
if (
|
|
2829
|
+
(scopedReceipt && nativeSessionId !== scopedReceipt.native_session_id) ||
|
|
2830
|
+
(plan.execution.mode === "direct" && nativeSessionId !== directNativeSessionId)
|
|
2831
|
+
) {
|
|
2832
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2833
|
+
}
|
|
2834
|
+
const ownerTransaction = {
|
|
2835
|
+
commit: () => replaceOwnerGenerationSync(ownerStateDir, sessionName, ownerGeneration, generationBaseline),
|
|
2836
|
+
rollback: cleanupFailedAttempt,
|
|
2837
|
+
};
|
|
1424
2838
|
return {
|
|
1425
2839
|
sessionId: sessionName,
|
|
1426
2840
|
tmuxSession: sessionName,
|
|
1427
|
-
tmuxTarget
|
|
2841
|
+
tmuxTarget,
|
|
1428
2842
|
paneId,
|
|
2843
|
+
tmuxNativeSessionId: nativeSessionId,
|
|
2844
|
+
tmuxSocketKey: socketKey,
|
|
2845
|
+
tmuxOwnerGeneration: ownerGeneration,
|
|
2846
|
+
tmuxOwnerStateDir: ownerStateDir,
|
|
2847
|
+
tmuxOwnerServerKey: socketKey,
|
|
2848
|
+
tmuxOwnerServerPid: initialProof.pid,
|
|
2849
|
+
tmuxOwnerServerStartTime: initialProof.startTime,
|
|
2850
|
+
|
|
1429
2851
|
cwd: input.cwd,
|
|
1430
2852
|
createdAt: new Date().toISOString(),
|
|
1431
2853
|
sessionCommand: config.sessionCommand,
|
|
2854
|
+
...(input.mpreset ? { mpreset: input.mpreset } : {}),
|
|
1432
2855
|
runtimeStateFile,
|
|
2856
|
+
__coordinatorOwnerTransaction: ownerTransaction,
|
|
2857
|
+
launchId: input.launchId,
|
|
2858
|
+
readinessMarkerFile: input.readinessMarkerFile,
|
|
2859
|
+
readinessFile: input.readinessMarkerFile,
|
|
2860
|
+
origin: "coordinator_created",
|
|
2861
|
+
runtime_readiness_latched: false,
|
|
1433
2862
|
};
|
|
1434
2863
|
}
|
|
1435
2864
|
|
|
1436
|
-
|
|
2865
|
+
function hasPrivateTmuxOwnerIdentity(session: Record<string, unknown>): boolean {
|
|
2866
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2867
|
+
const serverKey =
|
|
2868
|
+
typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
|
|
2869
|
+
const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
|
|
2870
|
+
const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
|
|
2871
|
+
const nativeSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
|
|
2872
|
+
const paneId = session.pane_id ?? session.paneId;
|
|
2873
|
+
return (
|
|
2874
|
+
typeof socketKey === "string" &&
|
|
2875
|
+
socketKey.length > 0 &&
|
|
2876
|
+
socketKey === serverKey &&
|
|
2877
|
+
Number.isSafeInteger(pid) &&
|
|
2878
|
+
typeof startTime === "string" &&
|
|
2879
|
+
startTime.length > 0 &&
|
|
2880
|
+
typeof nativeSessionId === "string" &&
|
|
2881
|
+
nativeSessionId.length > 0 &&
|
|
2882
|
+
typeof paneId === "string" &&
|
|
2883
|
+
paneId.length > 0
|
|
2884
|
+
);
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
async function proveImmutableTmuxTarget(session: Record<string, unknown>, runner: CommandRunner): Promise<boolean> {
|
|
2888
|
+
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
2889
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2890
|
+
const expectedSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
|
|
2891
|
+
const expectedPaneId = session.pane_id ?? session.paneId;
|
|
2892
|
+
if (
|
|
2893
|
+
typeof target !== "string" ||
|
|
2894
|
+
typeof socketKey !== "string" ||
|
|
2895
|
+
typeof expectedSessionId !== "string" ||
|
|
2896
|
+
typeof expectedPaneId !== "string"
|
|
2897
|
+
)
|
|
2898
|
+
return false;
|
|
2899
|
+
const identity = await runner([
|
|
2900
|
+
"tmux",
|
|
2901
|
+
"-L",
|
|
2902
|
+
socketKey,
|
|
2903
|
+
"display-message",
|
|
2904
|
+
"-p",
|
|
2905
|
+
"-t",
|
|
2906
|
+
target,
|
|
2907
|
+
"#{session_id} #{pane_id}",
|
|
2908
|
+
]);
|
|
2909
|
+
const parts = identity.stdout.trim().split(/\s+/);
|
|
2910
|
+
if (identity.exitCode !== 0) return false;
|
|
2911
|
+
return parts.length === 2 && parts[0] === expectedSessionId && parts[1] === expectedPaneId;
|
|
2912
|
+
}
|
|
2913
|
+
async function captureTmuxTail(
|
|
2914
|
+
session: Record<string, unknown>,
|
|
2915
|
+
lines: number,
|
|
2916
|
+
runner: CommandRunner = runCommand,
|
|
2917
|
+
): Promise<string[]> {
|
|
1437
2918
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1438
|
-
|
|
1439
|
-
|
|
2919
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2920
|
+
if (
|
|
2921
|
+
!hasPrivateTmuxOwnerIdentity(session) ||
|
|
2922
|
+
typeof socketKey !== "string" ||
|
|
2923
|
+
socketKey.length === 0 ||
|
|
2924
|
+
typeof target !== "string" ||
|
|
2925
|
+
target.length === 0
|
|
2926
|
+
)
|
|
2927
|
+
return [];
|
|
2928
|
+
const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
|
|
2929
|
+
const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
|
|
2930
|
+
const probe = await coordinatorOwnerIsolationProbe(runner);
|
|
2931
|
+
const proof = await probe.probeServer(socketKey);
|
|
2932
|
+
if (
|
|
2933
|
+
proof.state !== "safe" ||
|
|
2934
|
+
proof.pid !== pid ||
|
|
2935
|
+
proof.startTime !== startTime ||
|
|
2936
|
+
(process.platform === "linux" && proof.cgroup?.classification !== "safe") ||
|
|
2937
|
+
!(await proveImmutableTmuxTarget(session, runner))
|
|
2938
|
+
)
|
|
2939
|
+
return [];
|
|
2940
|
+
const captured = await runner(["tmux", "-L", socketKey, "capture-pane", "-t", target, "-p", "-S", `-${lines}`]);
|
|
1440
2941
|
if (captured.exitCode !== 0) return [];
|
|
1441
2942
|
return captured.stdout.split("\n").slice(-lines);
|
|
1442
2943
|
}
|
|
@@ -1445,10 +2946,38 @@ async function sendTmuxPrompt(
|
|
|
1445
2946
|
session: Record<string, unknown>,
|
|
1446
2947
|
prompt: string,
|
|
1447
2948
|
runner: CommandRunner = runCommand,
|
|
2949
|
+
ownerIsolationProbe?: OwnerIsolationProbe,
|
|
1448
2950
|
): Promise<boolean> {
|
|
1449
2951
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1450
|
-
|
|
1451
|
-
|
|
2952
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2953
|
+
const serverKey =
|
|
2954
|
+
typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
|
|
2955
|
+
const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
|
|
2956
|
+
const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
|
|
2957
|
+
if (
|
|
2958
|
+
typeof target !== "string" ||
|
|
2959
|
+
target.length === 0 ||
|
|
2960
|
+
typeof socketKey !== "string" ||
|
|
2961
|
+
socketKey.length === 0 ||
|
|
2962
|
+
socketKey !== serverKey ||
|
|
2963
|
+
!Number.isSafeInteger(pid) ||
|
|
2964
|
+
typeof startTime !== "string" ||
|
|
2965
|
+
startTime.length === 0
|
|
2966
|
+
)
|
|
2967
|
+
return false;
|
|
2968
|
+
const paneId = session.pane_id ?? session.paneId;
|
|
2969
|
+
if (typeof paneId !== "string" || !(await proveImmutableTmuxTarget(session, runner))) return false;
|
|
2970
|
+
const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
|
|
2971
|
+
const proveCurrentServer = async (): Promise<boolean> => {
|
|
2972
|
+
const proof = await probe.probeServer(socketKey);
|
|
2973
|
+
return (
|
|
2974
|
+
proof.state === "safe" &&
|
|
2975
|
+
proof.pid === pid &&
|
|
2976
|
+
proof.startTime === startTime &&
|
|
2977
|
+
(process.platform !== "linux" || proof.cgroup?.classification === "safe")
|
|
2978
|
+
);
|
|
2979
|
+
};
|
|
2980
|
+
return await sendTmuxPromptKeys(paneId, prompt, runner, socketKey, proveCurrentServer);
|
|
1452
2981
|
}
|
|
1453
2982
|
|
|
1454
2983
|
async function hasTmuxSession(
|
|
@@ -1456,8 +2985,16 @@ async function hasTmuxSession(
|
|
|
1456
2985
|
runner: CommandRunner = runCommand,
|
|
1457
2986
|
): Promise<boolean | null> {
|
|
1458
2987
|
const tmuxSession = typeof session.tmux_session === "string" ? session.tmux_session : session.tmuxSession;
|
|
1459
|
-
|
|
1460
|
-
|
|
2988
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2989
|
+
if (
|
|
2990
|
+
!hasPrivateTmuxOwnerIdentity(session) ||
|
|
2991
|
+
typeof socketKey !== "string" ||
|
|
2992
|
+
socketKey.length === 0 ||
|
|
2993
|
+
typeof tmuxSession !== "string" ||
|
|
2994
|
+
tmuxSession.length === 0
|
|
2995
|
+
)
|
|
2996
|
+
return null;
|
|
2997
|
+
const checked = await runner(["tmux", "-L", socketKey, "has-session", "-t", tmuxSession]);
|
|
1461
2998
|
return checked.exitCode === 0;
|
|
1462
2999
|
}
|
|
1463
3000
|
|
|
@@ -1492,7 +3029,8 @@ async function inspectTmuxSession(
|
|
|
1492
3029
|
runner: CommandRunner = runCommand,
|
|
1493
3030
|
): Promise<Record<string, unknown>> {
|
|
1494
3031
|
const live = await hasTmuxSession(session, runner);
|
|
1495
|
-
const tail = live ? await captureTmuxTail(session, lines) : [];
|
|
3032
|
+
const tail = live ? await captureTmuxTail(session, lines, runner) : [];
|
|
3033
|
+
|
|
1496
3034
|
return {
|
|
1497
3035
|
live,
|
|
1498
3036
|
...summarizePaneTail(tail),
|
|
@@ -1607,7 +3145,7 @@ export async function readCoordinatorArtifact(
|
|
|
1607
3145
|
} catch (error) {
|
|
1608
3146
|
return {
|
|
1609
3147
|
ok: false,
|
|
1610
|
-
reason: (error
|
|
3148
|
+
reason: publicCoordinatorError(error),
|
|
1611
3149
|
};
|
|
1612
3150
|
} finally {
|
|
1613
3151
|
await handle?.close();
|
|
@@ -1621,6 +3159,122 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1621
3159
|
const services = options.services ?? {};
|
|
1622
3160
|
const namespaceDir = coordinatorNamespacePath(config);
|
|
1623
3161
|
const commandRunner = services.commandRunner ?? runCommand;
|
|
3162
|
+
const loadModelProfiles = services.resolveModelProfiles ?? loadCoordinatorModelProfiles;
|
|
3163
|
+
const readinessTimeoutMs = boundedRuntimeReadinessTimeoutMs(env.GJC_COORDINATOR_MCP_RUNTIME_READINESS_TIMEOUT_MS);
|
|
3164
|
+
async function withSessionTransition<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
|
3165
|
+
return await withSessionMutation(`${namespaceDir}::${sessionId}`, operation);
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
async function startCoordinatorSession(
|
|
3169
|
+
cwd: string,
|
|
3170
|
+
prompt?: string,
|
|
3171
|
+
mpreset?: string | null,
|
|
3172
|
+
): Promise<Record<string, unknown>> {
|
|
3173
|
+
const sessionId = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
|
|
3174
|
+
const launchId = randomUUID();
|
|
3175
|
+
const readinessRoot = path.join(namespaceDir, "launch-readiness");
|
|
3176
|
+
const readinessDir = path.join(readinessRoot, sessionId);
|
|
3177
|
+
await ensureDir(readinessDir);
|
|
3178
|
+
const canonicalRoot = await fs.realpath(readinessRoot);
|
|
3179
|
+
const canonicalDir = await fs.realpath(readinessDir);
|
|
3180
|
+
if (!canonicalDir.startsWith(`${canonicalRoot}${path.sep}`))
|
|
3181
|
+
throw new Error("coordinator_readiness_path_invalid");
|
|
3182
|
+
const readinessMarkerFile = path.join(canonicalDir, `${launchId}.json`);
|
|
3183
|
+
const input: SessionStartInput = {
|
|
3184
|
+
cwd,
|
|
3185
|
+
prompt,
|
|
3186
|
+
namespace: config.namespace,
|
|
3187
|
+
mpreset,
|
|
3188
|
+
worktree: true,
|
|
3189
|
+
sessionId,
|
|
3190
|
+
launchId,
|
|
3191
|
+
readinessMarkerFile,
|
|
3192
|
+
readinessFile: readinessMarkerFile,
|
|
3193
|
+
};
|
|
3194
|
+
const started = services.startSession
|
|
3195
|
+
? await services.startSession(input)
|
|
3196
|
+
: await startTmuxSession(config, input, namespaceDir, commandRunner, services.ownerIsolationProbe);
|
|
3197
|
+
const record = asRecord(started);
|
|
3198
|
+
if (!record) throw new Error("coordinator_session_command_required");
|
|
3199
|
+
const attestedSessionId = optionalString(record.sessionId ?? record.session_id);
|
|
3200
|
+
const attestedLaunchId = optionalString(record.launchId ?? record.launch_id);
|
|
3201
|
+
const attestedMarkerFile = optionalString(
|
|
3202
|
+
record.readinessMarkerFile ?? record.readiness_marker_file ?? record.readinessFile ?? record.readiness_file,
|
|
3203
|
+
);
|
|
3204
|
+
if (!attestedSessionId || !attestedLaunchId || !attestedMarkerFile)
|
|
3205
|
+
throw new Error("coordinator_launch_attestation_missing");
|
|
3206
|
+
if (
|
|
3207
|
+
attestedSessionId !== sessionId ||
|
|
3208
|
+
attestedLaunchId !== launchId ||
|
|
3209
|
+
(await canonicalizePath(attestedMarkerFile)) !== readinessMarkerFile
|
|
3210
|
+
)
|
|
3211
|
+
throw new Error("coordinator_launch_attestation_mismatch");
|
|
3212
|
+
return normalizeSession({
|
|
3213
|
+
...record,
|
|
3214
|
+
session_id: sessionId,
|
|
3215
|
+
launch_id: launchId,
|
|
3216
|
+
readiness_marker_file: readinessMarkerFile,
|
|
3217
|
+
readiness_marker_path: readinessMarkerFile,
|
|
3218
|
+
origin: "coordinator_created",
|
|
3219
|
+
runtime_ready_launch_id: null,
|
|
3220
|
+
runtime_ready_at: null,
|
|
3221
|
+
runtime_readiness_latched: false,
|
|
3222
|
+
});
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
function sessionReadinessAuthorityError(
|
|
3226
|
+
reason: "coordinator_launch_metadata_missing" | "session_origin_unverified",
|
|
3227
|
+
session: Record<string, unknown>,
|
|
3228
|
+
): Error {
|
|
3229
|
+
const error = new Error(reason);
|
|
3230
|
+
Object.assign(error, {
|
|
3231
|
+
sessionId: optionalString(session.session_id),
|
|
3232
|
+
action: "Start a new coordinator session or explicitly register the live external tmux session.",
|
|
3233
|
+
});
|
|
3234
|
+
return error;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function assertSessionReadinessAuthority(session: Record<string, unknown>): void {
|
|
3238
|
+
if (session.origin === "registered") return;
|
|
3239
|
+
const tmuxTarget = session.tmux_target ?? session.tmuxTarget;
|
|
3240
|
+
if (
|
|
3241
|
+
session.origin == null &&
|
|
3242
|
+
typeof session.registered_at === "string" &&
|
|
3243
|
+
session.registered_at.length > 0 &&
|
|
3244
|
+
session.authoritative === true &&
|
|
3245
|
+
hasTmuxIdentity(session) &&
|
|
3246
|
+
typeof tmuxTarget === "string" &&
|
|
3247
|
+
tmuxTarget.length > 0
|
|
3248
|
+
)
|
|
3249
|
+
return;
|
|
3250
|
+
if (session.origin === "coordinator_created") {
|
|
3251
|
+
const sessionId = optionalString(session.session_id);
|
|
3252
|
+
const launchId = optionalString(session.launch_id);
|
|
3253
|
+
const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
|
|
3254
|
+
if (!sessionId || !launchId || !markerFile) {
|
|
3255
|
+
throw sessionReadinessAuthorityError("coordinator_launch_metadata_missing", session);
|
|
3256
|
+
}
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
throw sessionReadinessAuthorityError("session_origin_unverified", session);
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
async function awaitRuntimeReadiness(session: Record<string, unknown>): Promise<boolean> {
|
|
3263
|
+
assertSessionReadinessAuthority(session);
|
|
3264
|
+
if (session.origin === "registered" || session.origin == null) return true;
|
|
3265
|
+
const sessionId = optionalString(session.session_id);
|
|
3266
|
+
const launchId = optionalString(session.launch_id);
|
|
3267
|
+
const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
|
|
3268
|
+
if (!sessionId || !launchId || !markerFile) throw new Error("coordinator_launch_metadata_missing");
|
|
3269
|
+
if (session.runtime_ready_launch_id === launchId) return true;
|
|
3270
|
+
return await waitForRuntimeReadinessMarker(
|
|
3271
|
+
markerFile,
|
|
3272
|
+
sessionId,
|
|
3273
|
+
launchId,
|
|
3274
|
+
path.join(namespaceDir, "launch-readiness"),
|
|
3275
|
+
readinessTimeoutMs,
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
1624
3278
|
|
|
1625
3279
|
async function listSessions(): Promise<unknown[]> {
|
|
1626
3280
|
if (!config.namespace.profile || !config.namespace.repo) return [];
|
|
@@ -1630,6 +3284,57 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1630
3284
|
function sessionFile(sessionId: unknown): string {
|
|
1631
3285
|
return path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`);
|
|
1632
3286
|
}
|
|
3287
|
+
async function compensateFailedOwnerStart(
|
|
3288
|
+
session: Record<string, unknown>,
|
|
3289
|
+
ownerTransaction: {
|
|
3290
|
+
rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
|
|
3291
|
+
} | null,
|
|
3292
|
+
): Promise<"cleaned" | "failed" | "unverifiable"> {
|
|
3293
|
+
let cleanupStatus: "cleaned" | "failed" | "unverifiable" = "unverifiable";
|
|
3294
|
+
try {
|
|
3295
|
+
cleanupStatus = (await ownerTransaction?.rollback?.()) ?? "unverifiable";
|
|
3296
|
+
} catch {
|
|
3297
|
+
cleanupStatus = "unverifiable";
|
|
3298
|
+
}
|
|
3299
|
+
try {
|
|
3300
|
+
const sessionId = String(session.session_id);
|
|
3301
|
+
const cwd = typeof session.cwd === "string" ? path.resolve(session.cwd) : null;
|
|
3302
|
+
if (!cwd) throw coordinatorStateError("invalid");
|
|
3303
|
+
const state: CoordinatorSessionState = {
|
|
3304
|
+
schema_version: 1,
|
|
3305
|
+
session_id: sessionId,
|
|
3306
|
+
state: "errored",
|
|
3307
|
+
ready_for_input: false,
|
|
3308
|
+
current_turn_id: null,
|
|
3309
|
+
last_turn_id: null,
|
|
3310
|
+
updated_at: new Date().toISOString(),
|
|
3311
|
+
source: "coordinator",
|
|
3312
|
+
live: false,
|
|
3313
|
+
reason: "coordinator_start_rolled_back",
|
|
3314
|
+
cwd,
|
|
3315
|
+
workdir: cwd,
|
|
3316
|
+
session_file: null,
|
|
3317
|
+
};
|
|
3318
|
+
await writeJsonFile(sessionStateFile(namespaceDir, sessionId), state);
|
|
3319
|
+
await fs.rm(sessionFile(sessionId), { force: true });
|
|
3320
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
3321
|
+
kind: "session.state_changed",
|
|
3322
|
+
sessionId,
|
|
3323
|
+
summary: `Session ${sessionId} start rolled back`,
|
|
3324
|
+
payloadRef: path.relative(namespaceDir, sessionStateFile(namespaceDir, sessionId)),
|
|
3325
|
+
metadata: {
|
|
3326
|
+
state: "errored",
|
|
3327
|
+
ready_for_input: false,
|
|
3328
|
+
live: false,
|
|
3329
|
+
reason: "coordinator_start_rolled_back",
|
|
3330
|
+
},
|
|
3331
|
+
});
|
|
3332
|
+
} catch {
|
|
3333
|
+
return "failed";
|
|
3334
|
+
}
|
|
3335
|
+
return cleanupStatus;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
1633
3338
|
async function listQuestions(args: Record<string, unknown>): Promise<unknown[]> {
|
|
1634
3339
|
const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
|
|
1635
3340
|
const status = typeof args.status === "string" && args.status.length > 0 ? args.status : null;
|
|
@@ -1653,11 +3358,12 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1653
3358
|
return evidence;
|
|
1654
3359
|
}
|
|
1655
3360
|
|
|
1656
|
-
async function
|
|
3361
|
+
async function prepareActiveTurnUnlocked(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
|
|
3362
|
+
const active = await readActiveTurn(namespaceDir, turn.session_id);
|
|
3363
|
+
if (active && active.turn_id !== turn.turn_id) return active;
|
|
1657
3364
|
const timestamp = new Date().toISOString();
|
|
1658
3365
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1659
|
-
const
|
|
1660
|
-
const pendingTurn: TurnRecord = {
|
|
3366
|
+
const pending: TurnRecord = {
|
|
1661
3367
|
...turn,
|
|
1662
3368
|
status: "active",
|
|
1663
3369
|
delivery: {
|
|
@@ -1666,90 +3372,312 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1666
3372
|
target: typeof target === "string" ? target : null,
|
|
1667
3373
|
tmux_keys_sent: false,
|
|
1668
3374
|
prompt_acknowledged: false,
|
|
1669
|
-
state: "
|
|
3375
|
+
state: "awaiting_runtime_readiness",
|
|
1670
3376
|
attempts: [
|
|
1671
3377
|
{
|
|
1672
3378
|
delivered: false,
|
|
1673
3379
|
tmux_keys_sent: false,
|
|
1674
3380
|
channel: "tmux_keys",
|
|
1675
3381
|
created_at: timestamp,
|
|
1676
|
-
reason: "
|
|
3382
|
+
reason: "awaiting_runtime_readiness",
|
|
1677
3383
|
},
|
|
1678
3384
|
],
|
|
1679
3385
|
},
|
|
1680
|
-
liveness: { checked_at:
|
|
3386
|
+
liveness: { checked_at: null, live: null, reason: null },
|
|
1681
3387
|
started_at: turn.started_at ?? timestamp,
|
|
1682
3388
|
updated_at: timestamp,
|
|
1683
3389
|
};
|
|
1684
|
-
await writeTurnRecord(namespaceDir,
|
|
1685
|
-
await writeActiveTurn(namespaceDir,
|
|
1686
|
-
await writeSessionState(namespaceDir,
|
|
1687
|
-
currentTurnId:
|
|
1688
|
-
live,
|
|
1689
|
-
reason:
|
|
3390
|
+
await writeTurnRecord(namespaceDir, pending);
|
|
3391
|
+
await writeActiveTurn(namespaceDir, pending);
|
|
3392
|
+
await writeSessionState(namespaceDir, pending.session_id, "booting", {
|
|
3393
|
+
currentTurnId: pending.turn_id,
|
|
3394
|
+
live: null,
|
|
3395
|
+
reason: "awaiting_runtime_readiness",
|
|
1690
3396
|
});
|
|
3397
|
+
return pending;
|
|
3398
|
+
}
|
|
1691
3399
|
|
|
1692
|
-
|
|
1693
|
-
const
|
|
1694
|
-
const
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
3400
|
+
async function completePreparedTurn(session: Record<string, unknown>, pendingTurn: TurnRecord): Promise<TurnRecord> {
|
|
3401
|
+
const ready = await awaitRuntimeReadiness(session);
|
|
3402
|
+
const observedLive = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
|
|
3403
|
+
return await withSessionTransition(pendingTurn.session_id, async () => {
|
|
3404
|
+
const current = await readTurnRecord(namespaceDir, pendingTurn.turn_id);
|
|
3405
|
+
const active = await readActiveTurn(namespaceDir, pendingTurn.session_id);
|
|
3406
|
+
if (
|
|
3407
|
+
!current ||
|
|
3408
|
+
!active ||
|
|
3409
|
+
active.turn_id !== pendingTurn.turn_id ||
|
|
3410
|
+
TERMINAL_TURN_STATUSES.has(current.status) ||
|
|
3411
|
+
current.delivery.state !== "awaiting_runtime_readiness" ||
|
|
3412
|
+
current.delivery.tmux_keys_sent === true
|
|
3413
|
+
)
|
|
3414
|
+
return current ?? pendingTurn;
|
|
3415
|
+
if (!ready) {
|
|
3416
|
+
const completedAt = new Date().toISOString();
|
|
3417
|
+
const message = `GJC runtime did not attest interactive input readiness within ${readinessTimeoutMs}ms.`;
|
|
3418
|
+
const failed: TurnRecord = {
|
|
3419
|
+
...current,
|
|
3420
|
+
status: "failed",
|
|
3421
|
+
delivery: {
|
|
3422
|
+
...current.delivery,
|
|
1705
3423
|
delivered: false,
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
3424
|
+
queued: false,
|
|
3425
|
+
tmux_keys_sent: false,
|
|
3426
|
+
prompt_acknowledged: false,
|
|
3427
|
+
state: "awaiting_runtime_readiness",
|
|
1710
3428
|
},
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
3429
|
+
final_response: {
|
|
3430
|
+
text: message,
|
|
3431
|
+
format: "markdown",
|
|
3432
|
+
source: "coordinator_runtime_readiness",
|
|
3433
|
+
artifact_path: null,
|
|
3434
|
+
truncated: false,
|
|
3435
|
+
},
|
|
3436
|
+
error: { code: RUNTIME_READINESS_TIMEOUT_REASON, message, recoverable: true },
|
|
3437
|
+
evidence: [
|
|
3438
|
+
...current.evidence,
|
|
3439
|
+
{
|
|
3440
|
+
type: RUNTIME_READINESS_TIMEOUT_REASON,
|
|
3441
|
+
created_at: completedAt,
|
|
3442
|
+
tmux_keys_sent: false,
|
|
3443
|
+
prompt_acknowledged: false,
|
|
3444
|
+
},
|
|
3445
|
+
],
|
|
3446
|
+
updated_at: completedAt,
|
|
3447
|
+
completed_at: completedAt,
|
|
3448
|
+
};
|
|
3449
|
+
await writeTurnRecord(namespaceDir, failed);
|
|
3450
|
+
await clearActiveTurn(namespaceDir, failed);
|
|
3451
|
+
await writeSessionState(namespaceDir, failed.session_id, "booting", {
|
|
3452
|
+
lastTurnId: failed.turn_id,
|
|
3453
|
+
live: null,
|
|
3454
|
+
reason: RUNTIME_READINESS_TIMEOUT_REASON,
|
|
3455
|
+
});
|
|
3456
|
+
return failed;
|
|
3457
|
+
}
|
|
3458
|
+
const currentSession = asRecord(await readJsonFile(sessionFile(pendingTurn.session_id))) ?? session;
|
|
3459
|
+
if (
|
|
3460
|
+
currentSession.origin === "coordinator_created" &&
|
|
3461
|
+
(currentSession.session_id !== session.session_id ||
|
|
3462
|
+
currentSession.launch_id !== session.launch_id ||
|
|
3463
|
+
currentSession.readiness_marker_file !== session.readiness_marker_file)
|
|
3464
|
+
)
|
|
3465
|
+
return current;
|
|
3466
|
+
const latched =
|
|
3467
|
+
currentSession.origin === "coordinator_created"
|
|
3468
|
+
? {
|
|
3469
|
+
...currentSession,
|
|
3470
|
+
runtime_ready_launch_id: currentSession.launch_id,
|
|
3471
|
+
runtime_ready_at: new Date().toISOString(),
|
|
3472
|
+
runtime_readiness_latched: true,
|
|
3473
|
+
}
|
|
3474
|
+
: currentSession;
|
|
3475
|
+
if (latched !== currentSession) await writeJsonFile(sessionFile(pendingTurn.session_id), latched);
|
|
3476
|
+
const target = typeof latched.tmux_target === "string" ? latched.tmux_target : latched.tmuxTarget;
|
|
3477
|
+
const attemptId = `delivery-${randomUUID()}`;
|
|
3478
|
+
const attemptedAt = new Date().toISOString();
|
|
3479
|
+
const delivering: TurnRecord = {
|
|
3480
|
+
...current,
|
|
3481
|
+
status: "delivering",
|
|
3482
|
+
updated_at: attemptedAt,
|
|
3483
|
+
delivery: {
|
|
3484
|
+
...current.delivery,
|
|
3485
|
+
queued: false,
|
|
3486
|
+
state: "delivering",
|
|
3487
|
+
attempts: [
|
|
3488
|
+
...current.delivery.attempts,
|
|
3489
|
+
{
|
|
3490
|
+
attempt_id: attemptId,
|
|
3491
|
+
delivered: false,
|
|
3492
|
+
tmux_keys_sent: false,
|
|
3493
|
+
channel: "tmux_keys",
|
|
3494
|
+
created_at: attemptedAt,
|
|
3495
|
+
reason: "delivery_attempt",
|
|
3496
|
+
},
|
|
3497
|
+
],
|
|
3498
|
+
},
|
|
3499
|
+
};
|
|
3500
|
+
await writeTurnRecord(namespaceDir, delivering);
|
|
3501
|
+
await writeActiveTurn(namespaceDir, delivering);
|
|
3502
|
+
await writeSessionState(namespaceDir, delivering.session_id, "running", {
|
|
3503
|
+
currentTurnId: delivering.turn_id,
|
|
3504
|
+
live: observedLive,
|
|
3505
|
+
reason: null,
|
|
1729
3506
|
});
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
3507
|
+
const tmuxKeysSent = await sendTmuxPrompt(
|
|
3508
|
+
latched,
|
|
3509
|
+
delivering.prompt.text,
|
|
3510
|
+
commandRunner,
|
|
3511
|
+
services.ownerIsolationProbe,
|
|
3512
|
+
);
|
|
3513
|
+
const deliveredAt = new Date().toISOString();
|
|
3514
|
+
const delivered: TurnRecord = {
|
|
3515
|
+
...delivering,
|
|
3516
|
+
status: "active",
|
|
3517
|
+
updated_at: deliveredAt,
|
|
3518
|
+
delivery: {
|
|
3519
|
+
...delivering.delivery,
|
|
3520
|
+
delivered: false,
|
|
3521
|
+
queued: !tmuxKeysSent,
|
|
3522
|
+
tmux_keys_sent: tmuxKeysSent,
|
|
3523
|
+
prompt_acknowledged: false,
|
|
3524
|
+
state: tmuxKeysSent ? "tmux_keys_sent" : "unavailable",
|
|
3525
|
+
attempts: [
|
|
3526
|
+
...delivering.delivery.attempts.slice(0, -1),
|
|
3527
|
+
{
|
|
3528
|
+
attempt_id: attemptId,
|
|
3529
|
+
delivered: false,
|
|
3530
|
+
tmux_keys_sent: tmuxKeysSent,
|
|
3531
|
+
channel: "tmux_keys",
|
|
3532
|
+
created_at: deliveredAt,
|
|
3533
|
+
reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
|
|
3534
|
+
},
|
|
3535
|
+
],
|
|
3536
|
+
},
|
|
3537
|
+
};
|
|
3538
|
+
await writeTurnRecord(namespaceDir, delivered);
|
|
3539
|
+
await writeActiveTurn(namespaceDir, delivered);
|
|
3540
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
3541
|
+
kind: tmuxKeysSent ? "tmux.delivery_succeeded" : "tmux.delivery_failed",
|
|
3542
|
+
sessionId: delivered.session_id,
|
|
3543
|
+
turnId: delivered.turn_id,
|
|
3544
|
+
summary: tmuxKeysSent
|
|
3545
|
+
? `Tmux delivery succeeded for turn ${delivered.turn_id}`
|
|
3546
|
+
: `Tmux delivery failed for turn ${delivered.turn_id}`,
|
|
3547
|
+
payloadRef: path.relative(namespaceDir, turnFile(namespaceDir, delivered.turn_id)),
|
|
3548
|
+
metadata: {
|
|
3549
|
+
target: typeof target === "string" ? target : null,
|
|
3550
|
+
live: observedLive,
|
|
3551
|
+
attempt_id: attemptId,
|
|
3552
|
+
},
|
|
3553
|
+
});
|
|
3554
|
+
return delivered;
|
|
1740
3555
|
});
|
|
1741
|
-
return resolvedTurn;
|
|
1742
3556
|
}
|
|
1743
3557
|
|
|
1744
|
-
async function
|
|
3558
|
+
async function activateTurn(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
|
|
3559
|
+
const pendingTurn = await withSessionTransition(
|
|
3560
|
+
turn.session_id,
|
|
3561
|
+
async () => await prepareActiveTurnUnlocked(session, turn),
|
|
3562
|
+
);
|
|
3563
|
+
if (pendingTurn.turn_id !== turn.turn_id) return pendingTurn;
|
|
3564
|
+
return await completePreparedTurn(session, pendingTurn);
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
async function prepareNextQueuedTurnUnlocked(sessionId: string): Promise<{
|
|
3568
|
+
session: Record<string, unknown>;
|
|
3569
|
+
turn: TurnRecord;
|
|
3570
|
+
} | null> {
|
|
1745
3571
|
const session = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
1746
|
-
if (!session) return null;
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
3572
|
+
if (!session || (await readActiveTurn(namespaceDir, sessionId))) return null;
|
|
3573
|
+
try {
|
|
3574
|
+
assertSessionReadinessAuthority(session);
|
|
3575
|
+
} catch {
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
const queuedTurns = (await listTurnRecords(namespaceDir))
|
|
3579
|
+
.filter(turn => turn.session_id === sessionId && turn.status === "queued")
|
|
1750
3580
|
.sort((left, right) => left.created_at.localeCompare(right.created_at));
|
|
1751
3581
|
const nextTurn = queuedTurns[0];
|
|
1752
|
-
|
|
3582
|
+
if (!nextTurn) return null;
|
|
3583
|
+
return { session, turn: await prepareActiveTurnUnlocked(session, nextTurn) };
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
async function reconcileRuntimeAcknowledgementFenced(
|
|
3587
|
+
turnId: string,
|
|
3588
|
+
sessionState: CoordinatorSessionState | null,
|
|
3589
|
+
failOnTimeout: boolean,
|
|
3590
|
+
): Promise<TurnRecord | null> {
|
|
3591
|
+
const observed = await readTurnRecord(namespaceDir, turnId);
|
|
3592
|
+
if (!observed) return null;
|
|
3593
|
+
const observedTurnId = sessionState?.current_turn_id;
|
|
3594
|
+
return await withSessionTransition(observed.session_id, async () => {
|
|
3595
|
+
const current = await readTurnRecord(namespaceDir, turnId);
|
|
3596
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
3597
|
+
const currentState = await readSessionState(namespaceDir, current.session_id);
|
|
3598
|
+
const state =
|
|
3599
|
+
sessionState &&
|
|
3600
|
+
sessionState.updated_at === currentState?.updated_at &&
|
|
3601
|
+
observedTurnId === currentState.current_turn_id
|
|
3602
|
+
? sessionState
|
|
3603
|
+
: currentState;
|
|
3604
|
+
return await reconcileRuntimeAcknowledgement(namespaceDir, current, state, promptAckTimeoutMs, {
|
|
3605
|
+
failOnTimeout,
|
|
3606
|
+
});
|
|
3607
|
+
});
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
async function markTurnTerminalFromSessionStateFenced(
|
|
3611
|
+
turnId: string,
|
|
3612
|
+
observedState: CoordinatorSessionState,
|
|
3613
|
+
): Promise<TurnRecord | null> {
|
|
3614
|
+
const observed = await readTurnRecord(namespaceDir, turnId);
|
|
3615
|
+
if (!observed) return null;
|
|
3616
|
+
return await withSessionTransition(observed.session_id, async () => {
|
|
3617
|
+
const current = await readTurnRecord(namespaceDir, turnId);
|
|
3618
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
3619
|
+
const currentState = await readSessionState(namespaceDir, current.session_id);
|
|
3620
|
+
if (!currentState || currentState.updated_at !== observedState.updated_at) return current;
|
|
3621
|
+
const matchesTurn =
|
|
3622
|
+
currentState.current_turn_id === current.turn_id ||
|
|
3623
|
+
(currentState.state === "errored" &&
|
|
3624
|
+
currentState.source === "agent_session_event" &&
|
|
3625
|
+
currentState.current_turn_id == null);
|
|
3626
|
+
if (!matchesTurn || (currentState.state !== "completed" && currentState.state !== "errored")) return current;
|
|
3627
|
+
return await markTurnTerminalFromSessionState(namespaceDir, current, currentState);
|
|
3628
|
+
});
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
async function markTurnFailedForUnavailableSessionFenced(
|
|
3632
|
+
turnId: string,
|
|
3633
|
+
reason: string,
|
|
3634
|
+
): Promise<TurnRecord | null> {
|
|
3635
|
+
const observed = await readTurnRecord(namespaceDir, turnId);
|
|
3636
|
+
if (!observed) return null;
|
|
3637
|
+
return await withSessionTransition(observed.session_id, async () => {
|
|
3638
|
+
const current = await readTurnRecord(namespaceDir, turnId);
|
|
3639
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
3640
|
+
return await markTurnFailedForUnavailableSession(namespaceDir, current, reason);
|
|
3641
|
+
});
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
async function preparePromptTurn(
|
|
3645
|
+
session: Record<string, unknown>,
|
|
3646
|
+
prompt: string,
|
|
3647
|
+
options: { force: boolean; queue: boolean; queueWithoutActive?: boolean },
|
|
3648
|
+
): Promise<
|
|
3649
|
+
| { ok: false; activeTurn: TurnRecord }
|
|
3650
|
+
| { ok: true; turn: TurnRecord; priorActiveTurn: TurnRecord | null; queued: boolean }
|
|
3651
|
+
> {
|
|
3652
|
+
const sessionId = safeExternalId("session", session.session_id);
|
|
3653
|
+
const prepared = await withSessionTransition(sessionId, async () => {
|
|
3654
|
+
const activeTurn = await readActiveTurn(namespaceDir, sessionId);
|
|
3655
|
+
if (activeTurn && !options.force && !options.queue) {
|
|
3656
|
+
return { ok: false as const, activeTurn };
|
|
3657
|
+
}
|
|
3658
|
+
if (activeTurn && options.force) {
|
|
3659
|
+
const timestamp = new Date().toISOString();
|
|
3660
|
+
const superseded: TurnRecord = {
|
|
3661
|
+
...activeTurn,
|
|
3662
|
+
status: "superseded",
|
|
3663
|
+
updated_at: timestamp,
|
|
3664
|
+
completed_at: timestamp,
|
|
3665
|
+
};
|
|
3666
|
+
await writeTurnRecord(namespaceDir, superseded);
|
|
3667
|
+
await clearActiveTurn(namespaceDir, superseded);
|
|
3668
|
+
}
|
|
3669
|
+
const shouldQueue =
|
|
3670
|
+
options.queue && !options.force && (activeTurn !== null || options.queueWithoutActive === true);
|
|
3671
|
+
const turn = makeTurnRecord(config, sessionId, prompt, shouldQueue ? "queued" : "active");
|
|
3672
|
+
if (shouldQueue) {
|
|
3673
|
+
await writeTurnRecord(namespaceDir, turn);
|
|
3674
|
+
return { ok: true as const, turn, priorActiveTurn: activeTurn, queued: true };
|
|
3675
|
+
}
|
|
3676
|
+
const pending = await prepareActiveTurnUnlocked(session, turn);
|
|
3677
|
+
return { ok: true as const, turn: pending, priorActiveTurn: activeTurn, queued: false };
|
|
3678
|
+
});
|
|
3679
|
+
if (!prepared.ok || prepared.queued) return prepared;
|
|
3680
|
+
return { ...prepared, turn: await completePreparedTurn(session, prepared.turn) };
|
|
1753
3681
|
}
|
|
1754
3682
|
|
|
1755
3683
|
async function readTurnPayload(
|
|
@@ -1766,24 +3694,21 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1766
3694
|
let resolvedTurn = turn;
|
|
1767
3695
|
let advisoryStatus: Record<string, unknown> = { live: false };
|
|
1768
3696
|
let sessionState = await readSessionState(namespaceDir, turn.session_id);
|
|
1769
|
-
resolvedTurn =
|
|
1770
|
-
|
|
1771
|
-
resolvedTurn,
|
|
1772
|
-
sessionState,
|
|
1773
|
-
promptAckTimeoutMs,
|
|
1774
|
-
{ failOnTimeout: false },
|
|
1775
|
-
);
|
|
3697
|
+
resolvedTurn =
|
|
3698
|
+
(await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, false)) ?? resolvedTurn;
|
|
1776
3699
|
if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1777
3700
|
if (
|
|
1778
3701
|
sessionState &&
|
|
1779
3702
|
ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
|
|
3703
|
+
runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
|
|
1780
3704
|
(sessionState.current_turn_id === resolvedTurn.turn_id ||
|
|
1781
3705
|
(sessionState.state === "errored" &&
|
|
1782
3706
|
sessionState.source === "agent_session_event" &&
|
|
1783
3707
|
sessionState.current_turn_id == null)) &&
|
|
1784
3708
|
(sessionState.state === "completed" || sessionState.state === "errored")
|
|
1785
3709
|
) {
|
|
1786
|
-
resolvedTurn =
|
|
3710
|
+
resolvedTurn =
|
|
3711
|
+
(await markTurnTerminalFromSessionStateFenced(resolvedTurn.turn_id, sessionState)) ?? resolvedTurn;
|
|
1787
3712
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1788
3713
|
} else if (
|
|
1789
3714
|
sessionState &&
|
|
@@ -1795,14 +3720,14 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1795
3720
|
session &&
|
|
1796
3721
|
hasTmuxIdentity(session)
|
|
1797
3722
|
) {
|
|
1798
|
-
resolvedTurn =
|
|
1799
|
-
|
|
1800
|
-
resolvedTurn
|
|
1801
|
-
"tmux_delivery_unavailable",
|
|
1802
|
-
);
|
|
3723
|
+
resolvedTurn =
|
|
3724
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_delivery_unavailable")) ??
|
|
3725
|
+
resolvedTurn;
|
|
1803
3726
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1804
3727
|
} else if (!session && ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1805
|
-
resolvedTurn =
|
|
3728
|
+
resolvedTurn =
|
|
3729
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "session_record_missing")) ??
|
|
3730
|
+
resolvedTurn;
|
|
1806
3731
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1807
3732
|
} else if (session) {
|
|
1808
3733
|
advisoryStatus = await inspectTmuxSession(session, boundedLineCount(lines), commandRunner);
|
|
@@ -1811,21 +3736,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1811
3736
|
hasTmuxIdentity(session) &&
|
|
1812
3737
|
advisoryStatus.live === false
|
|
1813
3738
|
) {
|
|
1814
|
-
resolvedTurn =
|
|
1815
|
-
|
|
1816
|
-
resolvedTurn
|
|
1817
|
-
"tmux_session_missing",
|
|
1818
|
-
);
|
|
3739
|
+
resolvedTurn =
|
|
3740
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_session_missing")) ??
|
|
3741
|
+
resolvedTurn;
|
|
1819
3742
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1820
3743
|
}
|
|
1821
3744
|
}
|
|
1822
3745
|
if (ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1823
|
-
resolvedTurn =
|
|
1824
|
-
|
|
1825
|
-
resolvedTurn,
|
|
1826
|
-
sessionState,
|
|
1827
|
-
promptAckTimeoutMs,
|
|
1828
|
-
);
|
|
3746
|
+
resolvedTurn =
|
|
3747
|
+
(await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, true)) ?? resolvedTurn;
|
|
1829
3748
|
if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1830
3749
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1831
3750
|
}
|
|
@@ -1847,51 +3766,59 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1847
3766
|
}
|
|
1848
3767
|
|
|
1849
3768
|
async function reconcileActiveTurnAcknowledgements(): Promise<void> {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
3769
|
+
await preflightCoordinatorMutation(namespaceDir, undefined, {
|
|
3770
|
+
rejectTerminalRuntimeEvidence: false,
|
|
3771
|
+
});
|
|
3772
|
+
|
|
3773
|
+
const turns = (await listTurnRecords(namespaceDir)).filter(turn => ACTIVE_TURN_STATUSES.has(turn.status));
|
|
1853
3774
|
for (const turn of turns) {
|
|
1854
3775
|
let sessionState = await readSessionState(namespaceDir, turn.session_id);
|
|
1855
|
-
const resolvedTurn = await reconcileRuntimeAcknowledgement(
|
|
1856
|
-
namespaceDir,
|
|
1857
|
-
turn,
|
|
1858
|
-
sessionState,
|
|
1859
|
-
promptAckTimeoutMs,
|
|
1860
|
-
{ failOnTimeout: false },
|
|
1861
|
-
);
|
|
1862
|
-
if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) continue;
|
|
1863
|
-
if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1864
|
-
const session = asRecord(await readJsonFile(sessionFile(resolvedTurn.session_id)));
|
|
1865
3776
|
if (
|
|
1866
3777
|
sessionState &&
|
|
1867
|
-
sessionState.
|
|
3778
|
+
(sessionState.state === "completed" || sessionState.state === "errored") &&
|
|
3779
|
+
(sessionState.current_turn_id === turn.turn_id ||
|
|
3780
|
+
(sessionState.state === "errored" &&
|
|
3781
|
+
sessionState.source === "agent_session_event" &&
|
|
3782
|
+
sessionState.current_turn_id == null))
|
|
3783
|
+
) {
|
|
3784
|
+
const terminal = await markTurnTerminalFromSessionStateFenced(turn.turn_id, sessionState);
|
|
3785
|
+
if (!terminal || !ACTIVE_TURN_STATUSES.has(terminal.status)) continue;
|
|
3786
|
+
}
|
|
3787
|
+
const acknowledged = (await reconcileRuntimeAcknowledgementFenced(turn.turn_id, sessionState, false)) ?? turn;
|
|
3788
|
+
if (!ACTIVE_TURN_STATUSES.has(acknowledged.status)) continue;
|
|
3789
|
+
if (acknowledged !== turn) sessionState = await readSessionState(namespaceDir, acknowledged.session_id);
|
|
3790
|
+
const session = asRecord(await readJsonFile(sessionFile(acknowledged.session_id)));
|
|
3791
|
+
if (
|
|
3792
|
+
sessionState &&
|
|
3793
|
+
sessionState.current_turn_id === acknowledged.turn_id &&
|
|
1868
3794
|
sessionState.state === "stale" &&
|
|
1869
3795
|
sessionState.reason === "tmux_delivery_unavailable" &&
|
|
1870
|
-
|
|
3796
|
+
acknowledged.delivery.state === "unavailable" &&
|
|
1871
3797
|
session &&
|
|
1872
3798
|
hasTmuxIdentity(session)
|
|
1873
3799
|
) {
|
|
1874
|
-
await
|
|
3800
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_delivery_unavailable");
|
|
1875
3801
|
continue;
|
|
1876
3802
|
}
|
|
1877
3803
|
if (!session) {
|
|
1878
|
-
await
|
|
3804
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "session_record_missing");
|
|
1879
3805
|
continue;
|
|
1880
3806
|
}
|
|
1881
3807
|
if (hasTmuxIdentity(session) && (await hasTmuxSession(session, commandRunner)) === false) {
|
|
1882
|
-
await
|
|
3808
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_session_missing");
|
|
1883
3809
|
continue;
|
|
1884
3810
|
}
|
|
1885
|
-
await
|
|
3811
|
+
await reconcileRuntimeAcknowledgementFenced(acknowledged.turn_id, sessionState, true);
|
|
1886
3812
|
}
|
|
1887
3813
|
}
|
|
1888
3814
|
|
|
1889
|
-
async function
|
|
3815
|
+
async function callToolUnlocked(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
1890
3816
|
try {
|
|
1891
3817
|
if (name === "gjc_coordinator_list_sessions") return { ok: true, sessions: await listSessions() };
|
|
1892
3818
|
if (name === "gjc_coordinator_register_session") {
|
|
1893
3819
|
requireCoordinatorMutation(config, "sessions", args);
|
|
1894
3820
|
const sessionId = safeExternalId("session", args.session_id);
|
|
3821
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
1895
3822
|
const cwd = await assertCoordinatorWorkdir(config, args.cwd);
|
|
1896
3823
|
const tmuxSession = safeTmuxSessionName(args.tmux_session);
|
|
1897
3824
|
const tmuxTarget = safeTmuxTarget(args.tmux_target);
|
|
@@ -1915,7 +3842,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1915
3842
|
sessionId,
|
|
1916
3843
|
summary: `Session ${sessionId} registered for coordinator control`,
|
|
1917
3844
|
payloadRef: path.relative(namespaceDir, sessionFile(sessionId)),
|
|
1918
|
-
metadata: {
|
|
3845
|
+
metadata: {
|
|
3846
|
+
source: optionalString(args.source) ?? "register_session",
|
|
3847
|
+
visible: args.visible !== false,
|
|
3848
|
+
},
|
|
1919
3849
|
});
|
|
1920
3850
|
|
|
1921
3851
|
return {
|
|
@@ -1952,7 +3882,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1952
3882
|
}
|
|
1953
3883
|
if (name === "gjc_coordinator_read_tail") {
|
|
1954
3884
|
const session = asRecord(await readJsonFile(sessionFile(args.session_id)));
|
|
1955
|
-
return {
|
|
3885
|
+
return {
|
|
3886
|
+
ok: true,
|
|
3887
|
+
lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines), commandRunner) : [],
|
|
3888
|
+
};
|
|
1956
3889
|
}
|
|
1957
3890
|
if (name === "gjc_coordinator_list_questions") return { ok: true, questions: await listQuestions(args) };
|
|
1958
3891
|
if (name === "gjc_coordinator_list_artifacts") return { ok: true, roots: config.allowedRoots };
|
|
@@ -1987,13 +3920,18 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1987
3920
|
turns,
|
|
1988
3921
|
questions,
|
|
1989
3922
|
reports,
|
|
1990
|
-
events: buildCanonicalCoordinatorEvents({
|
|
3923
|
+
events: buildCanonicalCoordinatorEvents({
|
|
3924
|
+
sessionStates,
|
|
3925
|
+
turns,
|
|
3926
|
+
questions,
|
|
3927
|
+
reports,
|
|
3928
|
+
}),
|
|
1991
3929
|
latest_event_seq: await readLatestEventSeq(namespaceDir),
|
|
1992
3930
|
recent_events: eventSummaries(events.slice(-10)),
|
|
1993
3931
|
};
|
|
1994
3932
|
}
|
|
1995
3933
|
if (name === "gjc_coordinator_watch_events") {
|
|
1996
|
-
await reconcileActiveTurnAcknowledgements
|
|
3934
|
+
await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
|
|
1997
3935
|
const limit = boundedEventLimit(args.limit);
|
|
1998
3936
|
const timeoutMs = boundedEventWatchTimeoutMs(args.timeout_ms);
|
|
1999
3937
|
let events = await readCoordinatorEvents(namespaceDir);
|
|
@@ -2003,7 +3941,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2003
3941
|
const deadline = Date.now() + timeoutMs;
|
|
2004
3942
|
while (matched.length === 0 && Date.now() < deadline) {
|
|
2005
3943
|
await waitForCoordinatorEvents(namespaceDir, Math.min(50, Math.max(1, deadline - Date.now())));
|
|
2006
|
-
await reconcileActiveTurnAcknowledgements
|
|
3944
|
+
await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
|
|
2007
3945
|
events = await readCoordinatorEvents(namespaceDir);
|
|
2008
3946
|
matched = filterCoordinatorEvents(events, args, limit);
|
|
2009
3947
|
}
|
|
@@ -2025,6 +3963,17 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2025
3963
|
const hasPrompt = typeof args.prompt === "string" && args.prompt.trim().length > 0;
|
|
2026
3964
|
const task = hasTask ? String(args.task) : hasPrompt ? String(args.prompt) : null;
|
|
2027
3965
|
if (!task) return { ok: false, reason: "task_required" };
|
|
3966
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
3967
|
+
const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
|
|
3968
|
+
if (!mpresetResolution.ok) {
|
|
3969
|
+
return {
|
|
3970
|
+
ok: false,
|
|
3971
|
+
reason: mpresetResolution.reason,
|
|
3972
|
+
mpreset: mpresetResolution.mpreset,
|
|
3973
|
+
available_profiles: mpresetResolution.available_profiles,
|
|
3974
|
+
};
|
|
3975
|
+
}
|
|
3976
|
+
|
|
2028
3977
|
const promptAliasIgnored = hasTask && hasPrompt;
|
|
2029
3978
|
const mutationRequested = args.allow_mutation === true;
|
|
2030
3979
|
const taggedPrompt = workflowPrompt(delegateWorkflow, name, canonicalCwd, task, {
|
|
@@ -2036,70 +3985,85 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2036
3985
|
let reusedSession = false;
|
|
2037
3986
|
if (args.session_id != null) {
|
|
2038
3987
|
const sessionId = safeExternalId("session", args.session_id);
|
|
3988
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
2039
3989
|
const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
2040
|
-
if (!existing)
|
|
3990
|
+
if (!existing)
|
|
3991
|
+
return {
|
|
3992
|
+
ok: false,
|
|
3993
|
+
reason: "unknown_session",
|
|
3994
|
+
session_id: sessionId,
|
|
3995
|
+
};
|
|
2041
3996
|
const storedCwd = typeof existing.cwd === "string" ? existing.cwd : null;
|
|
2042
3997
|
const canonicalStored = storedCwd ? await canonicalizePath(storedCwd) : null;
|
|
2043
3998
|
const canonicalRequested = await canonicalizePath(canonicalCwd);
|
|
2044
3999
|
if (!canonicalStored || canonicalStored !== canonicalRequested) {
|
|
2045
|
-
return {
|
|
4000
|
+
return {
|
|
4001
|
+
ok: false,
|
|
4002
|
+
reason: "session_cwd_mismatch",
|
|
4003
|
+
session_id: sessionId,
|
|
4004
|
+
};
|
|
4005
|
+
}
|
|
4006
|
+
if (mpresetResolution.mpreset !== null) {
|
|
4007
|
+
const sessionMpreset = typeof existing.mpreset === "string" ? existing.mpreset : null;
|
|
4008
|
+
if (sessionMpreset !== mpresetResolution.mpreset) {
|
|
4009
|
+
return {
|
|
4010
|
+
ok: false,
|
|
4011
|
+
reason: "mpreset_conflict",
|
|
4012
|
+
session_id: sessionId,
|
|
4013
|
+
session_mpreset: sessionMpreset,
|
|
4014
|
+
requested_mpreset: mpresetResolution.mpreset,
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
2046
4017
|
}
|
|
4018
|
+
|
|
2047
4019
|
session = existing;
|
|
2048
4020
|
reusedSession = true;
|
|
2049
4021
|
} else {
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
4022
|
+
session = await startCoordinatorSession(canonicalCwd, undefined, mpresetResolution.mpreset);
|
|
4023
|
+
const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
|
|
4024
|
+
commit?: () => void;
|
|
4025
|
+
rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
|
|
4026
|
+
} | null;
|
|
4027
|
+
try {
|
|
4028
|
+
if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
|
|
4029
|
+
await writeJsonFile(sessionFile(session.session_id), session);
|
|
4030
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
4031
|
+
kind: "session.started",
|
|
4032
|
+
sessionId: String(session.session_id),
|
|
4033
|
+
summary: `Session ${String(session.session_id)} started by coordinator delegate`,
|
|
4034
|
+
payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
|
|
4035
|
+
metadata: { delegate: true, workflow: delegateWorkflow },
|
|
4036
|
+
});
|
|
4037
|
+
await writeSessionState(namespaceDir, String(session.session_id), "booting", {
|
|
4038
|
+
live: null,
|
|
4039
|
+
reason: "awaiting_runtime_readiness",
|
|
4040
|
+
});
|
|
4041
|
+
ownerTransaction?.commit?.();
|
|
4042
|
+
} catch (error) {
|
|
4043
|
+
if (ownerTransaction)
|
|
4044
|
+
throw new CoordinatorStartError(
|
|
4045
|
+
"coordinator_tmux_start_failed",
|
|
4046
|
+
await compensateFailedOwnerStart(session, ownerTransaction),
|
|
4047
|
+
);
|
|
4048
|
+
throw error;
|
|
4049
|
+
}
|
|
2075
4050
|
}
|
|
2076
4051
|
|
|
2077
4052
|
const sessionId = String(session.session_id);
|
|
2078
|
-
|
|
2079
|
-
|
|
4053
|
+
assertSessionReadinessAuthority(session);
|
|
4054
|
+
const prepared = await preparePromptTurn(session, taggedPrompt, {
|
|
4055
|
+
force: args.force === true,
|
|
4056
|
+
queue: args.queue === true,
|
|
4057
|
+
});
|
|
4058
|
+
if (!prepared.ok) {
|
|
2080
4059
|
return {
|
|
2081
4060
|
ok: false,
|
|
2082
4061
|
reason: "active_turn_exists",
|
|
2083
4062
|
session_id: sessionId,
|
|
2084
|
-
active_turn_id: activeTurn.turn_id,
|
|
2085
|
-
};
|
|
2086
|
-
}
|
|
2087
|
-
if (activeTurn && args.force === true) {
|
|
2088
|
-
const timestamp = new Date().toISOString();
|
|
2089
|
-
const superseded = {
|
|
2090
|
-
...activeTurn,
|
|
2091
|
-
status: "superseded" as const,
|
|
2092
|
-
updated_at: timestamp,
|
|
2093
|
-
completed_at: timestamp,
|
|
4063
|
+
active_turn_id: prepared.activeTurn.turn_id,
|
|
2094
4064
|
};
|
|
2095
|
-
await writeTurnRecord(namespaceDir, superseded);
|
|
2096
|
-
await clearActiveTurn(namespaceDir, superseded);
|
|
2097
4065
|
}
|
|
2098
|
-
const
|
|
2099
|
-
const turn = shouldQueue
|
|
2100
|
-
? makeTurnRecord(config, sessionId, taggedPrompt, "queued")
|
|
2101
|
-
: await activateTurn(session, makeTurnRecord(config, sessionId, taggedPrompt, "active"));
|
|
2102
|
-
if (shouldQueue) await writeTurnRecord(namespaceDir, turn);
|
|
4066
|
+
const { turn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
|
|
2103
4067
|
await appendCoordinatorEvent(namespaceDir, {
|
|
2104
4068
|
kind: "delegation.started",
|
|
2105
4069
|
sessionId,
|
|
@@ -2173,31 +4137,50 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2173
4137
|
if (name === "gjc_coordinator_start_session") {
|
|
2174
4138
|
requireCoordinatorMutation(config, "sessions", args);
|
|
2175
4139
|
const cwd = await assertCoordinatorWorkdir(config, args.cwd);
|
|
2176
|
-
|
|
4140
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4141
|
+
const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
|
|
4142
|
+
if (!mpresetResolution.ok) {
|
|
4143
|
+
return {
|
|
4144
|
+
ok: false,
|
|
4145
|
+
reason: mpresetResolution.reason,
|
|
4146
|
+
mpreset: mpresetResolution.mpreset,
|
|
4147
|
+
available_profiles: mpresetResolution.available_profiles,
|
|
4148
|
+
};
|
|
4149
|
+
}
|
|
4150
|
+
|
|
4151
|
+
const session = await startCoordinatorSession(
|
|
2177
4152
|
cwd,
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
4153
|
+
typeof args.prompt === "string" ? args.prompt : undefined,
|
|
4154
|
+
mpresetResolution.mpreset,
|
|
4155
|
+
);
|
|
4156
|
+
const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
|
|
4157
|
+
commit?: () => void;
|
|
4158
|
+
rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
|
|
4159
|
+
} | null;
|
|
4160
|
+
let sessionState: CoordinatorSessionState;
|
|
4161
|
+
try {
|
|
4162
|
+
if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
|
|
4163
|
+
await writeJsonFile(sessionFile(session.session_id), session);
|
|
4164
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
4165
|
+
kind: "session.started",
|
|
4166
|
+
sessionId: String(session.session_id),
|
|
4167
|
+
summary: `Session ${String(session.session_id)} started by coordinator`,
|
|
4168
|
+
payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
|
|
4169
|
+
metadata: { prompted: typeof args.prompt === "string" && args.prompt.length > 0 },
|
|
4170
|
+
});
|
|
4171
|
+
sessionState = await writeSessionState(namespaceDir, String(session.session_id), "booting", {
|
|
4172
|
+
live: null,
|
|
4173
|
+
reason: "awaiting_runtime_readiness",
|
|
4174
|
+
});
|
|
4175
|
+
ownerTransaction?.commit?.();
|
|
4176
|
+
} catch (error) {
|
|
4177
|
+
if (ownerTransaction)
|
|
4178
|
+
throw new CoordinatorStartError(
|
|
4179
|
+
"coordinator_tmux_start_failed",
|
|
4180
|
+
await compensateFailedOwnerStart(session, ownerTransaction),
|
|
4181
|
+
);
|
|
4182
|
+
throw error;
|
|
4183
|
+
}
|
|
2201
4184
|
if (typeof args.prompt === "string" && args.prompt.length > 0) {
|
|
2202
4185
|
const turn = await activateTurn(
|
|
2203
4186
|
session,
|
|
@@ -2233,36 +4216,28 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2233
4216
|
if (name === "gjc_coordinator_send_prompt") {
|
|
2234
4217
|
requireCoordinatorMutation(config, "sessions", args);
|
|
2235
4218
|
const sessionId = safeExternalId("session", args.session_id);
|
|
4219
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
2236
4220
|
const session = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
2237
4221
|
if (!session) return { ok: false, reason: "unknown_session", session_id: sessionId };
|
|
2238
4222
|
if (typeof args.prompt !== "string" || args.prompt.length === 0)
|
|
2239
4223
|
return { ok: false, reason: "prompt_required" };
|
|
2240
|
-
|
|
2241
|
-
|
|
4224
|
+
await readSessionState(namespaceDir, sessionId);
|
|
4225
|
+
await readLatestEventSeq(namespaceDir);
|
|
4226
|
+
assertSessionReadinessAuthority(session);
|
|
4227
|
+
const prepared = await preparePromptTurn(session, args.prompt, {
|
|
4228
|
+
force: args.force === true,
|
|
4229
|
+
queue: args.queue === true,
|
|
4230
|
+
queueWithoutActive: true,
|
|
4231
|
+
});
|
|
4232
|
+
if (!prepared.ok) {
|
|
2242
4233
|
return {
|
|
2243
4234
|
ok: false,
|
|
2244
4235
|
reason: "active_turn_exists",
|
|
2245
4236
|
session_id: sessionId,
|
|
2246
|
-
active_turn_id: activeTurn.turn_id,
|
|
2247
|
-
};
|
|
2248
|
-
}
|
|
2249
|
-
if (activeTurn && args.force === true) {
|
|
2250
|
-
const timestamp = new Date().toISOString();
|
|
2251
|
-
const superseded = {
|
|
2252
|
-
...activeTurn,
|
|
2253
|
-
status: "superseded" as const,
|
|
2254
|
-
updated_at: timestamp,
|
|
2255
|
-
completed_at: timestamp,
|
|
4237
|
+
active_turn_id: prepared.activeTurn.turn_id,
|
|
2256
4238
|
};
|
|
2257
|
-
await writeTurnRecord(namespaceDir, superseded);
|
|
2258
|
-
await clearActiveTurn(namespaceDir, superseded);
|
|
2259
4239
|
}
|
|
2260
|
-
const
|
|
2261
|
-
const turn = shouldQueue
|
|
2262
|
-
? makeTurnRecord(config, sessionId, args.prompt, "queued")
|
|
2263
|
-
: await activateTurn(session, makeTurnRecord(config, sessionId, args.prompt, "active"));
|
|
2264
|
-
if (shouldQueue) await writeTurnRecord(namespaceDir, turn);
|
|
2265
|
-
const recordedTurn = turn;
|
|
4240
|
+
const { turn: recordedTurn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
|
|
2266
4241
|
const prompt = {
|
|
2267
4242
|
session_id: sessionId,
|
|
2268
4243
|
turn_id: recordedTurn.turn_id,
|
|
@@ -2296,7 +4271,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2296
4271
|
const timeoutMs = boundedAwaitTurnTimeoutMs(args.timeout_ms);
|
|
2297
4272
|
const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
|
|
2298
4273
|
const deadline = Date.now() + timeoutMs;
|
|
2299
|
-
let payload = await
|
|
4274
|
+
let payload = await withCoordinatorTransaction(
|
|
4275
|
+
namespaceDir,
|
|
4276
|
+
async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
|
|
4277
|
+
);
|
|
2300
4278
|
while (
|
|
2301
4279
|
payload.ok === true &&
|
|
2302
4280
|
!TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status) &&
|
|
@@ -2308,7 +4286,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2308
4286
|
payload.turn as TurnRecord,
|
|
2309
4287
|
Math.min(pollIntervalMs, remainingMs),
|
|
2310
4288
|
);
|
|
2311
|
-
payload = await
|
|
4289
|
+
payload = await withCoordinatorTransaction(
|
|
4290
|
+
namespaceDir,
|
|
4291
|
+
async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
|
|
4292
|
+
);
|
|
2312
4293
|
}
|
|
2313
4294
|
if (payload.ok === true && !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status)) {
|
|
2314
4295
|
return {
|
|
@@ -2333,6 +4314,16 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2333
4314
|
if (args.turn_id != null && question.turn_id !== safeTurnId(args.turn_id)) {
|
|
2334
4315
|
return { ok: false, reason: "question_turn_mismatch" };
|
|
2335
4316
|
}
|
|
4317
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4318
|
+
const questionSessionId =
|
|
4319
|
+
typeof question.session_id === "string" ? safeExternalId("session", question.session_id) : null;
|
|
4320
|
+
if (!questionSessionId) throw coordinatorStateError("invalid");
|
|
4321
|
+
await preflightCoordinatorMutation(namespaceDir, questionSessionId);
|
|
4322
|
+
if (typeof question.turn_id === "string") {
|
|
4323
|
+
const referencedTurn = await readTurnRecord(namespaceDir, question.turn_id);
|
|
4324
|
+
if (!referencedTurn || referencedTurn.session_id !== questionSessionId)
|
|
4325
|
+
throw coordinatorStateError("invalid");
|
|
4326
|
+
}
|
|
2336
4327
|
const answeredTurnId = typeof question.turn_id === "string" ? question.turn_id : null;
|
|
2337
4328
|
const answered = {
|
|
2338
4329
|
...question,
|
|
@@ -2362,33 +4353,43 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2362
4353
|
|
|
2363
4354
|
let turn: TurnRecord | null = null;
|
|
2364
4355
|
if (answeredTurnId) {
|
|
2365
|
-
|
|
2366
|
-
if (
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
4356
|
+
const observedTurn = await readTurnRecord(namespaceDir, answeredTurnId);
|
|
4357
|
+
if (observedTurn) {
|
|
4358
|
+
turn = await withSessionTransition(observedTurn.session_id, async () => {
|
|
4359
|
+
const current = await readTurnRecord(namespaceDir, answeredTurnId);
|
|
4360
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
4361
|
+
const timestamp = new Date().toISOString();
|
|
4362
|
+
const answeredTurn: TurnRecord = {
|
|
4363
|
+
...current,
|
|
4364
|
+
status: "active",
|
|
4365
|
+
question_ids: [...new Set([...current.question_ids, questionId])],
|
|
4366
|
+
updated_at: timestamp,
|
|
4367
|
+
};
|
|
4368
|
+
await writeTurnRecord(namespaceDir, answeredTurn);
|
|
4369
|
+
await writeActiveTurn(namespaceDir, answeredTurn);
|
|
4370
|
+
await writeSessionState(namespaceDir, answeredTurn.session_id, "running", {
|
|
4371
|
+
currentTurnId: answeredTurn.turn_id,
|
|
4372
|
+
live: null,
|
|
4373
|
+
reason: null,
|
|
4374
|
+
});
|
|
4375
|
+
const session = asRecord(await readJsonFile(sessionFile(answeredTurn.session_id)));
|
|
4376
|
+
if (session && typeof args.answer === "string") {
|
|
4377
|
+
await sendTmuxPrompt(session, args.answer, commandRunner, services.ownerIsolationProbe);
|
|
4378
|
+
}
|
|
4379
|
+
return answeredTurn;
|
|
2380
4380
|
});
|
|
2381
|
-
const session = asRecord(await readJsonFile(sessionFile(turn.session_id)));
|
|
2382
|
-
if (session && typeof args.answer === "string")
|
|
2383
|
-
await sendTmuxPrompt(session, args.answer, commandRunner);
|
|
2384
4381
|
}
|
|
2385
4382
|
}
|
|
2386
4383
|
return { ok: true, question: answered, ...(turn ? { turn } : {}) };
|
|
2387
4384
|
}
|
|
2388
4385
|
if (name === "gjc_coordinator_report_status") {
|
|
2389
4386
|
requireCoordinatorMutation(config, "reports", args);
|
|
4387
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4388
|
+
|
|
2390
4389
|
const evidence = await validateEvidencePaths(args.evidence_paths);
|
|
2391
4390
|
const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
|
|
4391
|
+
if (sessionId) await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
4392
|
+
|
|
2392
4393
|
const report = {
|
|
2393
4394
|
session_id: sessionId,
|
|
2394
4395
|
turn_id: args.turn_id,
|
|
@@ -2401,61 +4402,79 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2401
4402
|
};
|
|
2402
4403
|
let turn: TurnRecord | null = null;
|
|
2403
4404
|
let promotedTurn: TurnRecord | null = null;
|
|
4405
|
+
let promotedPreparation: { session: Record<string, unknown>; turn: TurnRecord } | null = null;
|
|
2404
4406
|
if (args.turn_id != null) {
|
|
2405
4407
|
turn = await readTurnRecord(namespaceDir, args.turn_id);
|
|
2406
4408
|
if (!turn) return { ok: false, reason: "unknown_turn" };
|
|
2407
4409
|
if (sessionId != null && turn.session_id !== sessionId) {
|
|
2408
4410
|
return { ok: false, reason: "turn_session_mismatch" };
|
|
2409
4411
|
}
|
|
4412
|
+
await preflightCoordinatorMutation(namespaceDir, turn.session_id);
|
|
2410
4413
|
const terminalStatus = asTerminalTurnStatus(args.status);
|
|
2411
4414
|
if (terminalStatus) {
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
4415
|
+
const transition = await withSessionTransition(turn.session_id, async () => {
|
|
4416
|
+
const current = await readTurnRecord(namespaceDir, turn?.turn_id);
|
|
4417
|
+
if (!current || TERMINAL_TURN_STATUSES.has(current.status)) {
|
|
4418
|
+
return { turn: current, promotion: null };
|
|
4419
|
+
}
|
|
4420
|
+
const timestamp = new Date().toISOString();
|
|
4421
|
+
const terminalTurn: TurnRecord = {
|
|
4422
|
+
...current,
|
|
4423
|
+
status: terminalStatus,
|
|
4424
|
+
delivery: {
|
|
4425
|
+
...current.delivery,
|
|
4426
|
+
prompt_acknowledged: true,
|
|
4427
|
+
state: "acknowledged",
|
|
4428
|
+
},
|
|
4429
|
+
final_response: {
|
|
4430
|
+
text:
|
|
4431
|
+
typeof args.summary === "string"
|
|
4432
|
+
? args.summary
|
|
4433
|
+
: typeof args.blocker === "string"
|
|
4434
|
+
? args.blocker
|
|
4435
|
+
: null,
|
|
4436
|
+
format: "markdown",
|
|
4437
|
+
source: "report_status",
|
|
4438
|
+
artifact_path: null,
|
|
4439
|
+
truncated: false,
|
|
4440
|
+
},
|
|
4441
|
+
evidence,
|
|
4442
|
+
error:
|
|
4443
|
+
terminalStatus === "failed"
|
|
4444
|
+
? {
|
|
4445
|
+
code: "reported_failure",
|
|
4446
|
+
message:
|
|
4447
|
+
typeof args.blocker === "string"
|
|
4448
|
+
? args.blocker
|
|
4449
|
+
: String(args.summary ?? "failed"),
|
|
4450
|
+
recoverable: true,
|
|
4451
|
+
}
|
|
4452
|
+
: null,
|
|
4453
|
+
updated_at: timestamp,
|
|
4454
|
+
completed_at: timestamp,
|
|
4455
|
+
};
|
|
4456
|
+
await writeTurnRecord(namespaceDir, terminalTurn);
|
|
4457
|
+
await clearActiveTurn(namespaceDir, terminalTurn);
|
|
4458
|
+
await writeSessionState(
|
|
4459
|
+
namespaceDir,
|
|
4460
|
+
terminalTurn.session_id,
|
|
4461
|
+
terminalStatus === "failed" ? "errored" : "completed",
|
|
4462
|
+
{
|
|
4463
|
+
lastTurnId: terminalTurn.turn_id,
|
|
4464
|
+
live: null,
|
|
4465
|
+
reason: terminalStatus === "failed" ? "reported_failure" : null,
|
|
4466
|
+
},
|
|
4467
|
+
);
|
|
4468
|
+
return {
|
|
4469
|
+
turn: terminalTurn,
|
|
4470
|
+
promotion: await prepareNextQueuedTurnUnlocked(terminalTurn.session_id),
|
|
4471
|
+
};
|
|
4472
|
+
});
|
|
4473
|
+
turn = transition.turn;
|
|
4474
|
+
promotedPreparation = transition.promotion;
|
|
4475
|
+
if (promotedPreparation) {
|
|
4476
|
+
promotedTurn = await completePreparedTurn(promotedPreparation.session, promotedPreparation.turn);
|
|
4477
|
+
}
|
|
2459
4478
|
}
|
|
2460
4479
|
}
|
|
2461
4480
|
const reportId = `report-${Date.now()}`;
|
|
@@ -2471,21 +4490,60 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2471
4490
|
? args.summary
|
|
2472
4491
|
: `Report ${String(args.status ?? "unknown")} written`,
|
|
2473
4492
|
payloadRef: path.relative(namespaceDir, reportPath),
|
|
2474
|
-
metadata: {
|
|
4493
|
+
metadata: {
|
|
4494
|
+
status: typeof args.status === "string" ? args.status : null,
|
|
4495
|
+
},
|
|
2475
4496
|
});
|
|
2476
4497
|
return {
|
|
2477
4498
|
ok: true,
|
|
2478
4499
|
report,
|
|
2479
|
-
...(turn
|
|
4500
|
+
...(turn
|
|
4501
|
+
? {
|
|
4502
|
+
turn,
|
|
4503
|
+
session_state: await readSessionState(namespaceDir, turn.session_id),
|
|
4504
|
+
}
|
|
4505
|
+
: {}),
|
|
2480
4506
|
...(promotedTurn ? { promoted_turn: promotedTurn } : {}),
|
|
2481
4507
|
};
|
|
2482
4508
|
}
|
|
2483
4509
|
return { ok: false, reason: "unknown_tool", tool: name };
|
|
2484
4510
|
} catch (error) {
|
|
2485
|
-
|
|
4511
|
+
const failure = error as { sessionId?: unknown; action?: unknown };
|
|
4512
|
+
return {
|
|
4513
|
+
ok: false,
|
|
4514
|
+
reason: publicCoordinatorError(error),
|
|
4515
|
+
...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
|
|
4516
|
+
...(typeof failure.sessionId === "string" ? { session_id: failure.sessionId } : {}),
|
|
4517
|
+
...(typeof failure.action === "string" ? { action: failure.action } : {}),
|
|
4518
|
+
};
|
|
2486
4519
|
}
|
|
2487
4520
|
}
|
|
2488
4521
|
|
|
4522
|
+
async function callToolInternal(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
4523
|
+
try {
|
|
4524
|
+
if (
|
|
4525
|
+
name === "gjc_coordinator_await_turn" ||
|
|
4526
|
+
name === "gjc_coordinator_watch_events" ||
|
|
4527
|
+
(name === "gjc_coordinator_start_session" && typeof args.prompt === "string" && args.prompt.length > 0) ||
|
|
4528
|
+
name === "gjc_coordinator_send_prompt" ||
|
|
4529
|
+
workflowForDelegateTool(name) !== null
|
|
4530
|
+
) {
|
|
4531
|
+
return await callToolUnlocked(name, args);
|
|
4532
|
+
}
|
|
4533
|
+
return await withCoordinatorTransaction(namespaceDir, async () => await callToolUnlocked(name, args));
|
|
4534
|
+
} catch (error) {
|
|
4535
|
+
return {
|
|
4536
|
+
ok: false,
|
|
4537
|
+
reason: publicCoordinatorError(error),
|
|
4538
|
+
...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
|
|
4539
|
+
};
|
|
4540
|
+
}
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
4544
|
+
return publicCoordinatorResponse(await callToolInternal(name, args)) as Record<string, unknown>;
|
|
4545
|
+
}
|
|
4546
|
+
|
|
2489
4547
|
async function handleJsonRpc(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
|
2490
4548
|
const id = request.id ?? null;
|
|
2491
4549
|
if (request.method === "initialize") {
|
|
@@ -2499,8 +4557,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2499
4557
|
},
|
|
2500
4558
|
};
|
|
2501
4559
|
}
|
|
4560
|
+
if (request.method === "ping") {
|
|
4561
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
4562
|
+
}
|
|
2502
4563
|
if (request.method === "tools/list") {
|
|
2503
|
-
return {
|
|
4564
|
+
return {
|
|
4565
|
+
jsonrpc: "2.0",
|
|
4566
|
+
id,
|
|
4567
|
+
result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
|
|
4568
|
+
};
|
|
2504
4569
|
}
|
|
2505
4570
|
if (request.method === "prompts/list") {
|
|
2506
4571
|
return { jsonrpc: "2.0", id, result: { prompts: [] } };
|
|
@@ -2509,17 +4574,31 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2509
4574
|
return { jsonrpc: "2.0", id, result: { resources: [] } };
|
|
2510
4575
|
}
|
|
2511
4576
|
if (request.method === "tools/call") {
|
|
2512
|
-
const params = (request.params ?? {}) as {
|
|
4577
|
+
const params = (request.params ?? {}) as {
|
|
4578
|
+
name?: string;
|
|
4579
|
+
arguments?: Record<string, unknown>;
|
|
4580
|
+
};
|
|
2513
4581
|
const payload = await callTool(params.name ?? "", params.arguments ?? {});
|
|
2514
|
-
return {
|
|
4582
|
+
return {
|
|
4583
|
+
jsonrpc: "2.0",
|
|
4584
|
+
id,
|
|
4585
|
+
result: textResult(payload, payload.ok === false),
|
|
4586
|
+
};
|
|
2515
4587
|
}
|
|
2516
|
-
return {
|
|
4588
|
+
return {
|
|
4589
|
+
jsonrpc: "2.0",
|
|
4590
|
+
id,
|
|
4591
|
+
error: { code: -32601, message: `unknown_method:${request.method}` },
|
|
4592
|
+
};
|
|
2517
4593
|
}
|
|
2518
4594
|
|
|
2519
4595
|
return { config, callTool, handleJsonRpc, handle: handleJsonRpc };
|
|
2520
4596
|
}
|
|
2521
4597
|
|
|
2522
|
-
function legacyToolResult(payload: unknown): {
|
|
4598
|
+
function legacyToolResult(payload: unknown): {
|
|
4599
|
+
content: Array<{ type: "text"; text: string }>;
|
|
4600
|
+
isError: boolean;
|
|
4601
|
+
} {
|
|
2523
4602
|
const failed = typeof payload === "object" && payload !== null && (payload as { ok?: unknown }).ok === false;
|
|
2524
4603
|
return textResult(payload, failed);
|
|
2525
4604
|
}
|
|
@@ -2540,13 +4619,21 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2540
4619
|
};
|
|
2541
4620
|
}
|
|
2542
4621
|
if (request.method === "tools/list") {
|
|
2543
|
-
return {
|
|
4622
|
+
return {
|
|
4623
|
+
jsonrpc: "2.0",
|
|
4624
|
+
id: request.id ?? null,
|
|
4625
|
+
result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
|
|
4626
|
+
};
|
|
2544
4627
|
}
|
|
2545
4628
|
if (request.method === "prompts/list") {
|
|
2546
4629
|
return { jsonrpc: "2.0", id: request.id ?? null, result: { prompts: [] } };
|
|
2547
4630
|
}
|
|
2548
4631
|
if (request.method === "resources/list") {
|
|
2549
|
-
return {
|
|
4632
|
+
return {
|
|
4633
|
+
jsonrpc: "2.0",
|
|
4634
|
+
id: request.id ?? null,
|
|
4635
|
+
result: { resources: [] },
|
|
4636
|
+
};
|
|
2550
4637
|
}
|
|
2551
4638
|
if (request.method !== "tools/call")
|
|
2552
4639
|
return {
|
|
@@ -2554,11 +4641,14 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2554
4641
|
id: request.id ?? null,
|
|
2555
4642
|
error: { code: -32601, message: `unknown_method:${request.method}` },
|
|
2556
4643
|
};
|
|
2557
|
-
const params = (request.params ?? {}) as {
|
|
4644
|
+
const params = (request.params ?? {}) as {
|
|
4645
|
+
name?: string;
|
|
4646
|
+
arguments?: Record<string, unknown>;
|
|
4647
|
+
};
|
|
2558
4648
|
const args = params.arguments ?? {};
|
|
2559
4649
|
const server = createCoordinatorMcpServer({
|
|
2560
4650
|
env: options.env ?? process.env,
|
|
2561
|
-
services: options.createSession ? { startSession:
|
|
4651
|
+
services: options.createSession ? { startSession: input => options.createSession?.(input) } : undefined,
|
|
2562
4652
|
});
|
|
2563
4653
|
return {
|
|
2564
4654
|
jsonrpc: "2.0",
|
|
@@ -2567,23 +4657,161 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2567
4657
|
};
|
|
2568
4658
|
}
|
|
2569
4659
|
|
|
2570
|
-
export
|
|
2571
|
-
|
|
4660
|
+
export interface PumpCoordinatorOptions {
|
|
4661
|
+
/** Max concurrent in-flight *data* (non-control) handlers. Control frames (ping) bypass this. */
|
|
4662
|
+
maxDataConcurrency?: number;
|
|
4663
|
+
/** Max data requests queued waiting for a slot before overflow is rejected as server_busy. */
|
|
4664
|
+
maxQueueDepth?: number;
|
|
4665
|
+
/** Bounded wait for in-flight handlers/writes to settle after input ends. */
|
|
4666
|
+
drainTimeoutMs?: number;
|
|
4667
|
+
}
|
|
4668
|
+
|
|
4669
|
+
/**
|
|
4670
|
+
* Pump a newline-delimited JSON-RPC stream with BOUNDED concurrent dispatch.
|
|
4671
|
+
*
|
|
4672
|
+
* A long-running tool call (e.g. gjc_coordinator_await_turn, which polls for
|
|
4673
|
+
* minutes) must not block the read loop from answering keepalive pings on the
|
|
4674
|
+
* same stdio channel. But naive unbounded concurrency reintroduces its own
|
|
4675
|
+
* hazards, so this pump enforces the safety envelope the coordinator needs:
|
|
4676
|
+
*
|
|
4677
|
+
* - Control frames (ping) bypass the data-concurrency cap → keepalive is always
|
|
4678
|
+
* answerable even while data handlers saturate.
|
|
4679
|
+
* - Data handlers are capped at `maxDataConcurrency`; excess is queued up to
|
|
4680
|
+
* `maxQueueDepth`, then rejected as `server_busy` (bounded memory / fanout).
|
|
4681
|
+
* - `writeLine` failures move the writer to a terminal closed state instead of
|
|
4682
|
+
* poisoning the serialized write chain or escaping as an unhandled rejection;
|
|
4683
|
+
* no writes happen after close.
|
|
4684
|
+
* - On EOF the pump drains in-flight handlers (bounded by `drainTimeoutMs`) and
|
|
4685
|
+
* flushes queued writes before returning, so shutdown never races live work.
|
|
4686
|
+
* - Byte chunks are decoded with a streaming decoder so multibyte characters
|
|
4687
|
+
* split across chunks are not corrupted.
|
|
4688
|
+
*/
|
|
4689
|
+
export async function pumpCoordinatorMcpStream(
|
|
4690
|
+
handleJsonRpc: (request: JsonRpcRequest) => Promise<JsonRpcResponse>,
|
|
4691
|
+
input: AsyncIterable<string | Uint8Array>,
|
|
4692
|
+
writeLine: (line: string) => void | Promise<void>,
|
|
4693
|
+
options: PumpCoordinatorOptions = {},
|
|
4694
|
+
): Promise<void> {
|
|
4695
|
+
const maxDataConcurrency = Math.max(1, options.maxDataConcurrency ?? 32);
|
|
4696
|
+
const maxQueueDepth = Math.max(0, options.maxQueueDepth ?? 256);
|
|
4697
|
+
const drainTimeoutMs = Math.max(0, options.drainTimeoutMs ?? 30_000);
|
|
4698
|
+
|
|
4699
|
+
let writeClosed = false;
|
|
4700
|
+
let draining = false;
|
|
4701
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
4702
|
+
const inFlight = new Set<Promise<void>>();
|
|
4703
|
+
let activeData = 0;
|
|
4704
|
+
const dataQueue: JsonRpcRequest[] = [];
|
|
4705
|
+
|
|
4706
|
+
const emit = (response: JsonRpcResponse): void => {
|
|
4707
|
+
writeChain = writeChain.then(async () => {
|
|
4708
|
+
if (writeClosed) return;
|
|
4709
|
+
try {
|
|
4710
|
+
await writeLine(`${JSON.stringify(response)}\n`);
|
|
4711
|
+
} catch {
|
|
4712
|
+
writeClosed = true; // terminal writer error: stop, but never poison the chain
|
|
4713
|
+
}
|
|
4714
|
+
});
|
|
4715
|
+
};
|
|
4716
|
+
|
|
4717
|
+
const launch = (request: JsonRpcRequest, control: boolean): void => {
|
|
4718
|
+
const task = (async () => {
|
|
4719
|
+
try {
|
|
4720
|
+
emit(await handleJsonRpc(request));
|
|
4721
|
+
} catch (err) {
|
|
4722
|
+
emit({
|
|
4723
|
+
jsonrpc: "2.0",
|
|
4724
|
+
id: request.id ?? null,
|
|
4725
|
+
error: { code: -32603, message: publicCoordinatorError(err) },
|
|
4726
|
+
});
|
|
4727
|
+
} finally {
|
|
4728
|
+
if (!control) {
|
|
4729
|
+
activeData -= 1;
|
|
4730
|
+
if (!draining) {
|
|
4731
|
+
const next = dataQueue.shift();
|
|
4732
|
+
if (next) {
|
|
4733
|
+
activeData += 1;
|
|
4734
|
+
launch(next, false);
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
}
|
|
4739
|
+
})();
|
|
4740
|
+
inFlight.add(task);
|
|
4741
|
+
void task.finally(() => inFlight.delete(task));
|
|
4742
|
+
};
|
|
4743
|
+
|
|
4744
|
+
const dispatch = (request: JsonRpcRequest): void => {
|
|
4745
|
+
// Notifications (no id) get no response; the coordinator has no side-effecting ones.
|
|
4746
|
+
if (request.id === undefined || request.id === null) return;
|
|
4747
|
+
if (request.method === "ping") {
|
|
4748
|
+
launch(request, true); // control frame: bypass the data cap
|
|
4749
|
+
return;
|
|
4750
|
+
}
|
|
4751
|
+
if (activeData < maxDataConcurrency) {
|
|
4752
|
+
activeData += 1;
|
|
4753
|
+
launch(request, false);
|
|
4754
|
+
return;
|
|
4755
|
+
}
|
|
4756
|
+
if (dataQueue.length < maxQueueDepth) {
|
|
4757
|
+
dataQueue.push(request);
|
|
4758
|
+
return;
|
|
4759
|
+
}
|
|
4760
|
+
emit({
|
|
4761
|
+
jsonrpc: "2.0",
|
|
4762
|
+
id: request.id,
|
|
4763
|
+
error: { code: -32000, message: "server_busy: coordinator request queue is full" },
|
|
4764
|
+
});
|
|
4765
|
+
};
|
|
4766
|
+
|
|
4767
|
+
const decoder = new TextDecoder();
|
|
2572
4768
|
let buffer = "";
|
|
2573
|
-
for await (const chunk of
|
|
2574
|
-
buffer += chunk.
|
|
4769
|
+
for await (const chunk of input) {
|
|
4770
|
+
buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
2575
4771
|
let newline = buffer.indexOf("\n");
|
|
2576
4772
|
while (newline >= 0) {
|
|
2577
4773
|
const line = buffer.slice(0, newline).trim();
|
|
2578
4774
|
buffer = buffer.slice(newline + 1);
|
|
2579
4775
|
if (line.length > 0) {
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
4776
|
+
let request: JsonRpcRequest | null = null;
|
|
4777
|
+
try {
|
|
4778
|
+
request = JSON.parse(line) as JsonRpcRequest;
|
|
4779
|
+
} catch {
|
|
4780
|
+
request = null; // ignore malformed frames rather than crashing the loop
|
|
2584
4781
|
}
|
|
4782
|
+
if (request) dispatch(request);
|
|
2585
4783
|
}
|
|
2586
4784
|
newline = buffer.indexOf("\n");
|
|
2587
4785
|
}
|
|
2588
4786
|
}
|
|
4787
|
+
|
|
4788
|
+
// EOF: stop promoting queued work, then drain in-flight handlers under a bound.
|
|
4789
|
+
draining = true;
|
|
4790
|
+
if (inFlight.size > 0) {
|
|
4791
|
+
const drain = Promise.allSettled(Array.from(inFlight)).then(() => undefined);
|
|
4792
|
+
if (drainTimeoutMs > 0) {
|
|
4793
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
4794
|
+
const timeout = new Promise<void>(resolve => {
|
|
4795
|
+
timer = setTimeout(resolve, drainTimeoutMs);
|
|
4796
|
+
(timer as { unref?: () => void }).unref?.();
|
|
4797
|
+
});
|
|
4798
|
+
await Promise.race([drain, timeout]);
|
|
4799
|
+
if (timer) clearTimeout(timer);
|
|
4800
|
+
} else {
|
|
4801
|
+
await drain;
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
await writeChain;
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
|
|
4808
|
+
const server = createCoordinatorMcpServer(options);
|
|
4809
|
+
await pumpCoordinatorMcpStream(
|
|
4810
|
+
request => server.handleJsonRpc(request),
|
|
4811
|
+
process.stdin,
|
|
4812
|
+
line =>
|
|
4813
|
+
new Promise<void>((resolve, reject) => {
|
|
4814
|
+
process.stdout.write(line, err => (err ? reject(err) : resolve()));
|
|
4815
|
+
}),
|
|
4816
|
+
);
|
|
2589
4817
|
}
|