@gajae-code/coding-agent 0.9.6 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -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 +5 -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/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +79 -6
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- 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/extensibility/extensions/types.d.ts +3 -1
- 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/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- 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/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- 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/context-usage.d.ts +4 -19
- 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 +40 -3
- package/dist/types/session/cache-economics.d.ts +79 -0
- package/dist/types/session/context-estimation.d.ts +31 -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/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 +51 -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 +14 -14
- package/src/config/settings-schema.ts +41 -1
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/model-preset.ts +100 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +2881 -425
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- 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/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -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 +12 -11
- package/src/internal-urls/local-protocol.ts +29 -10
- package/src/main.ts +301 -80
- package/src/modes/acp/acp-agent.ts +730 -100
- 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/footer.ts +3 -3
- 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/status-line/segments.ts +13 -6
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- 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 +37 -18
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +62 -15
- package/src/modes/interactive-mode.ts +199 -7
- package/src/modes/irc-observation-ledger.ts +127 -0
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- 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/context-usage.ts +56 -92
- 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 +670 -217
- package/src/session/cache-economics.ts +275 -0
- package/src/session/context-estimation.ts +91 -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 +153 -9
- package/src/slash-commands/helpers/context-report.ts +26 -4
- 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
|
@@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as nodeFs from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { isKnownSinkPeerClosedError } from "@gajae-code/utils";
|
|
5
6
|
import { VERSION } from "@gajae-code/utils/dirs";
|
|
7
|
+
|
|
6
8
|
import {
|
|
7
9
|
COORDINATOR_MCP_PROTOCOL_VERSION,
|
|
8
10
|
COORDINATOR_MCP_SERVER_NAME,
|
|
@@ -11,8 +13,31 @@ import {
|
|
|
11
13
|
} from "../coordinator/contract";
|
|
12
14
|
import {
|
|
13
15
|
GJC_COORDINATOR_SESSION_ID_ENV,
|
|
16
|
+
GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV,
|
|
17
|
+
GJC_COORDINATOR_SESSION_READINESS_FILE_ENV,
|
|
14
18
|
GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
|
|
19
|
+
GJC_TMUX_OWNER_GENERATION_ENV,
|
|
20
|
+
GJC_TMUX_OWNER_SERVER_KEY_ENV,
|
|
21
|
+
GJC_TMUX_OWNER_STATE_DIR_ENV,
|
|
15
22
|
} from "../gjc-runtime/session-state-sidecar";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
captureOwnerGenerationBaselineSync,
|
|
26
|
+
classifyCgroup,
|
|
27
|
+
isExactScopedBootstrapSuccessReceipt,
|
|
28
|
+
type OwnerIsolationProbe,
|
|
29
|
+
ownerProcessStartTime,
|
|
30
|
+
planTmuxOwnerIsolation,
|
|
31
|
+
replaceOwnerGenerationSync,
|
|
32
|
+
type TmuxServerProof,
|
|
33
|
+
} from "../gjc-runtime/tmux-owner-isolation";
|
|
34
|
+
import { forceCloseGjcTmuxSession } from "../gjc-runtime/tmux-sessions";
|
|
35
|
+
import {
|
|
36
|
+
type CoordinatorModelProfileLoader,
|
|
37
|
+
loadCoordinatorModelProfiles,
|
|
38
|
+
resolveCoordinatorMpreset,
|
|
39
|
+
} from "./model-preset";
|
|
40
|
+
|
|
16
41
|
import {
|
|
17
42
|
assertCoordinatorArtifactPath,
|
|
18
43
|
assertCoordinatorWorkdir,
|
|
@@ -21,6 +46,7 @@ import {
|
|
|
21
46
|
coordinatorNamespacePath,
|
|
22
47
|
requireCoordinatorMutation,
|
|
23
48
|
} from "./policy";
|
|
49
|
+
import { createSessionReaper, type ReapableSession, type SessionReaper } from "./session-reaper";
|
|
24
50
|
|
|
25
51
|
export type { CoordinatorToolName };
|
|
26
52
|
export { COORDINATOR_MCP_PROTOCOL_VERSION, COORDINATOR_MCP_SERVER_NAME, COORDINATOR_MCP_TOOL_NAMES };
|
|
@@ -41,11 +67,28 @@ interface JsonRpcResponse {
|
|
|
41
67
|
error?: { code: number; message: string; data?: unknown };
|
|
42
68
|
}
|
|
43
69
|
|
|
70
|
+
function sinkErrorCode(error: unknown): string | undefined {
|
|
71
|
+
if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const code = Reflect.get(error, "code");
|
|
74
|
+
return typeof code === "string" ? code : undefined;
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
44
80
|
interface SessionStartInput {
|
|
45
81
|
cwd: string;
|
|
46
82
|
prompt?: string;
|
|
83
|
+
mpreset?: string | null;
|
|
47
84
|
namespace: { profile: string | null; repo: string | null };
|
|
48
85
|
worktree: true;
|
|
86
|
+
sessionId: string;
|
|
87
|
+
launchId: string;
|
|
88
|
+
/** Canonical, coordinator-owned immutable readiness marker path. */
|
|
89
|
+
readinessMarkerFile: string;
|
|
90
|
+
/** @deprecated Compatibility alias for injected launch providers. */
|
|
91
|
+
readinessFile: string;
|
|
49
92
|
}
|
|
50
93
|
|
|
51
94
|
interface SessionRegisterInput {
|
|
@@ -79,10 +122,26 @@ interface RuntimeSessionStatePayload extends CoordinatorSessionState {
|
|
|
79
122
|
error?: { code: string; message: string; recoverable: boolean } | null;
|
|
80
123
|
}
|
|
81
124
|
|
|
125
|
+
interface CommandResult {
|
|
126
|
+
exitCode: number;
|
|
127
|
+
stdout: string;
|
|
128
|
+
stderr: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type CommandRunner = (command: string[], stdinLine?: string) => Promise<CommandResult>;
|
|
132
|
+
|
|
82
133
|
interface CoordinatorServices {
|
|
83
134
|
listSessions?: () => unknown[] | Promise<unknown[]>;
|
|
84
135
|
startSession?: (input: SessionStartInput) => unknown | Promise<unknown>;
|
|
85
|
-
commandRunner?:
|
|
136
|
+
commandRunner?: CommandRunner;
|
|
137
|
+
forceCloseSession?: (
|
|
138
|
+
sessionName: string,
|
|
139
|
+
env: NodeJS.ProcessEnv,
|
|
140
|
+
expectedSessionId?: string,
|
|
141
|
+
expectedStateFile?: string,
|
|
142
|
+
) => Promise<unknown>;
|
|
143
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
144
|
+
resolveModelProfiles?: CoordinatorModelProfileLoader;
|
|
86
145
|
}
|
|
87
146
|
|
|
88
147
|
interface CoordinatorMcpServerOptions {
|
|
@@ -92,7 +151,7 @@ interface CoordinatorMcpServerOptions {
|
|
|
92
151
|
|
|
93
152
|
interface LegacyHandlerOptions {
|
|
94
153
|
env?: NodeJS.ProcessEnv;
|
|
95
|
-
createSession?: () => unknown;
|
|
154
|
+
createSession?: (input: SessionStartInput) => unknown;
|
|
96
155
|
}
|
|
97
156
|
|
|
98
157
|
type TurnStatus =
|
|
@@ -112,16 +171,28 @@ interface TurnRecord {
|
|
|
112
171
|
session_id: string;
|
|
113
172
|
namespace: { profile: string | null; repo: string | null };
|
|
114
173
|
status: TurnStatus;
|
|
115
|
-
prompt: {
|
|
174
|
+
prompt: {
|
|
175
|
+
text: string;
|
|
176
|
+
created_at: string;
|
|
177
|
+
source: "mcp" | "question_answer";
|
|
178
|
+
};
|
|
116
179
|
delivery: {
|
|
117
180
|
delivered: boolean;
|
|
118
181
|
queued: boolean;
|
|
119
182
|
target: string | null;
|
|
120
183
|
tmux_keys_sent?: boolean;
|
|
121
184
|
prompt_acknowledged?: boolean;
|
|
122
|
-
state?:
|
|
185
|
+
state?:
|
|
186
|
+
| "queued"
|
|
187
|
+
| "awaiting_runtime_readiness"
|
|
188
|
+
| "delivering"
|
|
189
|
+
| "tmux_keys_sent"
|
|
190
|
+
| "acknowledged"
|
|
191
|
+
| "unavailable"
|
|
192
|
+
| "unacknowledged";
|
|
123
193
|
attempts: Array<{
|
|
124
194
|
delivered: boolean;
|
|
195
|
+
attempt_id?: string;
|
|
125
196
|
created_at: string;
|
|
126
197
|
reason: string | null;
|
|
127
198
|
channel?: "tmux_keys" | "runtime_ack";
|
|
@@ -138,7 +209,11 @@ interface TurnRecord {
|
|
|
138
209
|
};
|
|
139
210
|
evidence: Array<Record<string, unknown>>;
|
|
140
211
|
error: { code: string; message: string; recoverable: boolean } | null;
|
|
141
|
-
liveness: {
|
|
212
|
+
liveness: {
|
|
213
|
+
checked_at: string | null;
|
|
214
|
+
live: boolean | null;
|
|
215
|
+
reason: string | null;
|
|
216
|
+
};
|
|
142
217
|
created_at: string;
|
|
143
218
|
updated_at: string;
|
|
144
219
|
started_at: string | null;
|
|
@@ -163,14 +238,28 @@ interface CoordinatorSessionState {
|
|
|
163
238
|
current_turn_id: string | null;
|
|
164
239
|
last_turn_id: string | null;
|
|
165
240
|
updated_at: string;
|
|
166
|
-
source: "coordinator" | "agent_session_event";
|
|
241
|
+
source: "coordinator" | "agent_session_event" | "process_postmortem";
|
|
242
|
+
|
|
167
243
|
live: boolean | null;
|
|
168
244
|
reason: string | null;
|
|
245
|
+
cwd?: string;
|
|
246
|
+
workdir?: string;
|
|
247
|
+
session_file?: string | null;
|
|
248
|
+
owner_generation?: string;
|
|
249
|
+
event?: string;
|
|
250
|
+
owner_terminal?: {
|
|
251
|
+
generation: string;
|
|
252
|
+
socket_key: string;
|
|
253
|
+
classification: string;
|
|
254
|
+
observer: string;
|
|
255
|
+
observed_at: string;
|
|
256
|
+
};
|
|
169
257
|
}
|
|
170
258
|
|
|
171
259
|
type CoordinatorEventKind =
|
|
172
260
|
| "session.registered"
|
|
173
261
|
| "session.started"
|
|
262
|
+
| "session.reaped"
|
|
174
263
|
| "session.state_changed"
|
|
175
264
|
| "turn.queued"
|
|
176
265
|
| "turn.delivering"
|
|
@@ -218,6 +307,10 @@ const MISSING_FINAL_RESPONSE_ADVISORY = "completion_missing_final_response";
|
|
|
218
307
|
const PROMPT_ACK_TIMEOUT_REASON = "runtime_prompt_ack_timeout";
|
|
219
308
|
const DEFAULT_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 10_000;
|
|
220
309
|
const MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
310
|
+
const RUNTIME_READINESS_TIMEOUT_REASON = "runtime_readiness_timeout";
|
|
311
|
+
const DEFAULT_RUNTIME_READINESS_TIMEOUT_MS = 10_000;
|
|
312
|
+
const MAX_RUNTIME_READINESS_TIMEOUT_MS = 5 * 60 * 1000;
|
|
313
|
+
|
|
221
314
|
const ACTIVE_TURN_STATUSES = new Set<TurnStatus>(["delivering", "active", "waiting_for_answer", "completing"]);
|
|
222
315
|
const TERMINAL_TURN_STATUSES = new Set<TurnStatus>(["completed", "failed", "cancelled", "superseded"]);
|
|
223
316
|
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 +326,12 @@ function textResult(
|
|
|
233
326
|
isError = false,
|
|
234
327
|
): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
|
|
235
328
|
return {
|
|
236
|
-
content: [
|
|
329
|
+
content: [
|
|
330
|
+
{
|
|
331
|
+
type: "text",
|
|
332
|
+
text: typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
333
|
+
},
|
|
334
|
+
],
|
|
237
335
|
isError,
|
|
238
336
|
};
|
|
239
337
|
}
|
|
@@ -243,13 +341,27 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
243
341
|
description: string;
|
|
244
342
|
inputSchema: Record<string, unknown>;
|
|
245
343
|
} {
|
|
246
|
-
const allowMutation = {
|
|
344
|
+
const allowMutation = {
|
|
345
|
+
type: "boolean",
|
|
346
|
+
description: "Required and must be true for mutating tools.",
|
|
347
|
+
};
|
|
247
348
|
const cwd = {
|
|
248
349
|
type: "string",
|
|
249
350
|
description: "Canonicalized GJC worktree or project directory inside configured roots.",
|
|
250
351
|
};
|
|
251
|
-
const sessionId = {
|
|
252
|
-
|
|
352
|
+
const sessionId = {
|
|
353
|
+
type: "string",
|
|
354
|
+
description: "GJC coordinator bridge session id.",
|
|
355
|
+
};
|
|
356
|
+
const pathField = {
|
|
357
|
+
type: "string",
|
|
358
|
+
description: "Artifact path inside configured safe roots.",
|
|
359
|
+
};
|
|
360
|
+
const mpreset = {
|
|
361
|
+
type: "string",
|
|
362
|
+
description:
|
|
363
|
+
"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.",
|
|
364
|
+
};
|
|
253
365
|
const common = { type: "object", properties: {} as Record<string, unknown> };
|
|
254
366
|
if (name === "gjc_coordinator_register_session") {
|
|
255
367
|
return {
|
|
@@ -278,11 +390,36 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
278
390
|
description: "Start a GJC worktree/tmux oriented session through the coordinator bridge.",
|
|
279
391
|
inputSchema: {
|
|
280
392
|
type: "object",
|
|
281
|
-
properties: {
|
|
393
|
+
properties: {
|
|
394
|
+
cwd,
|
|
395
|
+
prompt: { type: "string" },
|
|
396
|
+
mpreset,
|
|
397
|
+
allow_mutation: allowMutation,
|
|
398
|
+
},
|
|
282
399
|
required: ["cwd", "allow_mutation"],
|
|
283
400
|
},
|
|
284
401
|
};
|
|
285
402
|
}
|
|
403
|
+
if (name === "gjc_coordinator_stop_session") {
|
|
404
|
+
return {
|
|
405
|
+
name,
|
|
406
|
+
description:
|
|
407
|
+
"Reap a coordinator delegate-created (ephemeral) session: terminate its owner via the owner-proof forceCloseGjcTmuxSession, then purge its state files only on verified termination. Never touches non-ephemeral (user-registered) sessions unless force is set AND the force-stop capability is enabled.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: "object",
|
|
410
|
+
properties: {
|
|
411
|
+
session_id: sessionId,
|
|
412
|
+
force: {
|
|
413
|
+
type: "boolean",
|
|
414
|
+
description: "Reap a non-ephemeral session; requires the GJC_COORDINATOR_MCP_FORCE_STOP capability.",
|
|
415
|
+
},
|
|
416
|
+
reason: { type: "string", description: "Optional audit reason recorded on the session.reaped event." },
|
|
417
|
+
allow_mutation: allowMutation,
|
|
418
|
+
},
|
|
419
|
+
required: ["session_id", "allow_mutation"],
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
286
423
|
if (name === "gjc_coordinator_send_prompt") {
|
|
287
424
|
return {
|
|
288
425
|
name,
|
|
@@ -307,7 +444,11 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
307
444
|
description: "Read authoritative durable turn state plus bounded advisory tmux status.",
|
|
308
445
|
inputSchema: {
|
|
309
446
|
type: "object",
|
|
310
|
-
properties: {
|
|
447
|
+
properties: {
|
|
448
|
+
session_id: sessionId,
|
|
449
|
+
turn_id: { type: "string" },
|
|
450
|
+
lines: { type: "number" },
|
|
451
|
+
},
|
|
311
452
|
required: ["turn_id"],
|
|
312
453
|
},
|
|
313
454
|
};
|
|
@@ -376,7 +517,11 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
376
517
|
return {
|
|
377
518
|
name,
|
|
378
519
|
description: "Read one bounded artifact from configured safe roots.",
|
|
379
|
-
inputSchema: {
|
|
520
|
+
inputSchema: {
|
|
521
|
+
type: "object",
|
|
522
|
+
properties: { path: pathField },
|
|
523
|
+
required: ["path"],
|
|
524
|
+
},
|
|
380
525
|
};
|
|
381
526
|
}
|
|
382
527
|
if (name === "gjc_coordinator_read_status") {
|
|
@@ -390,21 +535,35 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
390
535
|
return {
|
|
391
536
|
name,
|
|
392
537
|
description: "Read a bounded structured session tail, not tmux scrollback.",
|
|
393
|
-
inputSchema: {
|
|
538
|
+
inputSchema: {
|
|
539
|
+
type: "object",
|
|
540
|
+
properties: { session_id: sessionId, lines: { type: "number" } },
|
|
541
|
+
},
|
|
394
542
|
};
|
|
395
543
|
}
|
|
396
544
|
if (name === "gjc_coordinator_list_questions") {
|
|
397
545
|
return {
|
|
398
546
|
name,
|
|
399
547
|
description: "List bounded structured questions for coordinator coordination.",
|
|
400
|
-
inputSchema: {
|
|
548
|
+
inputSchema: {
|
|
549
|
+
type: "object",
|
|
550
|
+
properties: { session_id: sessionId, status: { type: "string" } },
|
|
551
|
+
},
|
|
401
552
|
};
|
|
402
553
|
}
|
|
403
554
|
if (name === "gjc_coordinator_list_artifacts") {
|
|
404
|
-
return {
|
|
555
|
+
return {
|
|
556
|
+
name,
|
|
557
|
+
description: "List known safe artifact roots for coordinator coordination.",
|
|
558
|
+
inputSchema: common,
|
|
559
|
+
};
|
|
405
560
|
}
|
|
406
561
|
if (name === "gjc_coordinator_read_coordination_status") {
|
|
407
|
-
return {
|
|
562
|
+
return {
|
|
563
|
+
name,
|
|
564
|
+
description: "Read coordinator coordination reports.",
|
|
565
|
+
inputSchema: common,
|
|
566
|
+
};
|
|
408
567
|
}
|
|
409
568
|
if (name === "gjc_coordinator_watch_events") {
|
|
410
569
|
return {
|
|
@@ -438,7 +597,10 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
438
597
|
type: "string",
|
|
439
598
|
description: "Delegated task or objective to run through the selected GJC workflow.",
|
|
440
599
|
},
|
|
441
|
-
prompt: {
|
|
600
|
+
prompt: {
|
|
601
|
+
type: "string",
|
|
602
|
+
description: "Alias for task; accepted when task is absent.",
|
|
603
|
+
},
|
|
442
604
|
allow_mutation: allowMutation,
|
|
443
605
|
session_id: {
|
|
444
606
|
type: "string",
|
|
@@ -453,24 +615,38 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
453
615
|
type: "boolean",
|
|
454
616
|
description: "When reusing a session with an active turn, supersede it before sending.",
|
|
455
617
|
},
|
|
618
|
+
mpreset,
|
|
456
619
|
model: {
|
|
457
620
|
type: "string",
|
|
458
621
|
description: "Optional model hint passed in prompt metadata; no provider default is implied.",
|
|
459
622
|
},
|
|
460
|
-
await_completion: {
|
|
623
|
+
await_completion: {
|
|
624
|
+
type: "boolean",
|
|
625
|
+
description: "If true, poll the turn until terminal or timeout.",
|
|
626
|
+
},
|
|
461
627
|
timeout_ms: {
|
|
462
628
|
type: "number",
|
|
463
629
|
description:
|
|
464
630
|
"Bounded await timeout in milliseconds, capped at 30 minutes like gjc_coordinator_await_turn.",
|
|
465
631
|
},
|
|
466
|
-
poll_interval_ms: {
|
|
467
|
-
|
|
632
|
+
poll_interval_ms: {
|
|
633
|
+
type: "number",
|
|
634
|
+
description: "Bounded await polling interval.",
|
|
635
|
+
},
|
|
636
|
+
lines: {
|
|
637
|
+
type: "number",
|
|
638
|
+
description: "Bounded advisory tail lines returned with await/read payloads.",
|
|
639
|
+
},
|
|
468
640
|
},
|
|
469
641
|
required: ["cwd", "allow_mutation"],
|
|
470
642
|
},
|
|
471
643
|
};
|
|
472
644
|
}
|
|
473
|
-
return {
|
|
645
|
+
return {
|
|
646
|
+
name,
|
|
647
|
+
description: "List known scoped GJC coordinator bridge sessions.",
|
|
648
|
+
inputSchema: common,
|
|
649
|
+
};
|
|
474
650
|
}
|
|
475
651
|
|
|
476
652
|
type DelegateWorkflow = "plan" | "execute" | "team";
|
|
@@ -536,13 +712,80 @@ function workflowPrompt(
|
|
|
536
712
|
].join("\n");
|
|
537
713
|
}
|
|
538
714
|
|
|
715
|
+
const PRIVATE_SESSION_CONTROL_FIELDS = new Set([
|
|
716
|
+
"tmux_socket_key",
|
|
717
|
+
"tmuxSocketKey",
|
|
718
|
+
"tmux_owner_generation",
|
|
719
|
+
"tmuxOwnerGeneration",
|
|
720
|
+
"tmux_owner_state_dir",
|
|
721
|
+
"tmuxOwnerStateDir",
|
|
722
|
+
"tmux_owner_server_key",
|
|
723
|
+
"tmuxOwnerServerKey",
|
|
724
|
+
"tmux_owner_server_pid",
|
|
725
|
+
"tmuxOwnerServerPid",
|
|
726
|
+
"tmux_owner_server_start_time",
|
|
727
|
+
"tmuxOwnerServerStartTime",
|
|
728
|
+
"tmux_native_session_id",
|
|
729
|
+
"tmuxNativeSessionId",
|
|
730
|
+
"pane_id",
|
|
731
|
+
"paneId",
|
|
732
|
+
"socket_key",
|
|
733
|
+
"socketKey",
|
|
734
|
+
"owner_generation",
|
|
735
|
+
"ownerGeneration",
|
|
736
|
+
"state_dir",
|
|
737
|
+
"ownerStateDir",
|
|
738
|
+
"owner_server_key",
|
|
739
|
+
"ownerServerKey",
|
|
740
|
+
"owner_server_pid",
|
|
741
|
+
"ownerServerPid",
|
|
742
|
+
"owner_server_start_time",
|
|
743
|
+
"ownerServerStartTime",
|
|
744
|
+
"owner_terminal",
|
|
745
|
+
"ownerTerminal",
|
|
746
|
+
"generation",
|
|
747
|
+
"server_key",
|
|
748
|
+
"intent_id",
|
|
749
|
+
"dedupe_key",
|
|
750
|
+
"__coordinatorOwnerTransaction",
|
|
751
|
+
]);
|
|
752
|
+
|
|
753
|
+
function publicSessionProjection(session: Record<string, unknown>): Record<string, unknown> {
|
|
754
|
+
return Object.fromEntries(Object.entries(session).filter(([key]) => !PRIVATE_SESSION_CONTROL_FIELDS.has(key)));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function publicCoordinatorResponse(value: unknown): unknown {
|
|
758
|
+
if (Array.isArray(value)) return value.map(publicCoordinatorResponse);
|
|
759
|
+
const record = asRecord(value);
|
|
760
|
+
if (!record) return value;
|
|
761
|
+
return Object.fromEntries(
|
|
762
|
+
Object.entries(publicSessionProjection(record)).map(([key, item]) => [key, publicCoordinatorResponse(item)]),
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
539
766
|
function normalizeSession(session: Record<string, unknown>): Record<string, unknown> {
|
|
767
|
+
const suppliedIds = [session.session_id, session.sessionId, session.name].filter(
|
|
768
|
+
(value): value is string => typeof value === "string",
|
|
769
|
+
);
|
|
770
|
+
if (suppliedIds.length === 0) throw new Error("invalid_session_id");
|
|
771
|
+
const sessionId = safeExternalId("session", suppliedIds[0]);
|
|
772
|
+
if (suppliedIds.some(value => value !== sessionId)) throw new Error("session_id_conflict");
|
|
540
773
|
return {
|
|
541
|
-
session_id: session.sessionId ?? session.session_id ?? session.name ?? "unknown",
|
|
542
774
|
...(session.tmuxSession ? { tmux_session: session.tmuxSession } : {}),
|
|
775
|
+
...(session.tmuxTarget ? { tmux_target: session.tmuxTarget } : {}),
|
|
776
|
+
...(session.tmuxSocketKey ? { tmux_socket_key: session.tmuxSocketKey } : {}),
|
|
777
|
+
...(session.tmuxOwnerGeneration ? { tmux_owner_generation: session.tmuxOwnerGeneration } : {}),
|
|
778
|
+
...(session.tmuxOwnerStateDir ? { tmux_owner_state_dir: session.tmuxOwnerStateDir } : {}),
|
|
779
|
+
...(session.tmuxOwnerServerKey ? { tmux_owner_server_key: session.tmuxOwnerServerKey } : {}),
|
|
780
|
+
...(session.tmuxOwnerServerPid ? { tmux_owner_server_pid: session.tmuxOwnerServerPid } : {}),
|
|
781
|
+
...(session.tmuxOwnerServerStartTime ? { tmux_owner_server_start_time: session.tmuxOwnerServerStartTime } : {}),
|
|
782
|
+
...(session.tmuxNativeSessionId ? { tmux_native_session_id: session.tmuxNativeSessionId } : {}),
|
|
783
|
+
...(session.paneId ? { pane_id: session.paneId } : {}),
|
|
543
784
|
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
544
785
|
...(session.createdAt ? { created_at: session.createdAt } : {}),
|
|
545
786
|
...session,
|
|
787
|
+
session_id: sessionId,
|
|
788
|
+
sessionId,
|
|
546
789
|
};
|
|
547
790
|
}
|
|
548
791
|
|
|
@@ -554,15 +797,380 @@ async function canonicalizePath(value: string): Promise<string> {
|
|
|
554
797
|
}
|
|
555
798
|
}
|
|
556
799
|
|
|
800
|
+
interface RuntimeReadinessMarker {
|
|
801
|
+
schema_version: 1;
|
|
802
|
+
session_id: string;
|
|
803
|
+
launch_id: string;
|
|
804
|
+
state: "ready_for_input";
|
|
805
|
+
event: "interactive_input_ready";
|
|
806
|
+
source: "gjc_interactive_runtime";
|
|
807
|
+
ready_for_input: true;
|
|
808
|
+
created_at: string;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function readRuntimeReadinessMarker(
|
|
812
|
+
markerFile: string,
|
|
813
|
+
sessionId: string,
|
|
814
|
+
launchId: string,
|
|
815
|
+
readinessRoot?: string,
|
|
816
|
+
): Promise<RuntimeReadinessMarker | null> {
|
|
817
|
+
try {
|
|
818
|
+
const expectedFile = path.resolve(markerFile);
|
|
819
|
+
if (readinessRoot) {
|
|
820
|
+
const canonicalRoot = await fs.realpath(readinessRoot);
|
|
821
|
+
const canonicalParent = await fs.realpath(path.dirname(expectedFile));
|
|
822
|
+
if (
|
|
823
|
+
!expectedFile.startsWith(`${canonicalRoot}${path.sep}`) ||
|
|
824
|
+
!canonicalParent.startsWith(`${canonicalRoot}${path.sep}`)
|
|
825
|
+
)
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
const stat = await fs.lstat(expectedFile);
|
|
829
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
|
830
|
+
const marker = JSON.parse(await fs.readFile(expectedFile, "utf8")) as RuntimeReadinessMarker;
|
|
831
|
+
return marker.schema_version === 1 &&
|
|
832
|
+
marker.session_id === sessionId &&
|
|
833
|
+
marker.launch_id === launchId &&
|
|
834
|
+
marker.state === "ready_for_input" &&
|
|
835
|
+
marker.event === "interactive_input_ready" &&
|
|
836
|
+
marker.source === "gjc_interactive_runtime" &&
|
|
837
|
+
marker.ready_for_input === true &&
|
|
838
|
+
typeof marker.created_at === "string" &&
|
|
839
|
+
marker.created_at.length > 0 &&
|
|
840
|
+
Number.isFinite(Date.parse(marker.created_at))
|
|
841
|
+
? marker
|
|
842
|
+
: null;
|
|
843
|
+
} catch {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function waitForRuntimeReadinessMarker(
|
|
849
|
+
markerFile: string,
|
|
850
|
+
sessionId: string,
|
|
851
|
+
launchId: string,
|
|
852
|
+
readinessRoot: string,
|
|
853
|
+
timeoutMs: number,
|
|
854
|
+
): Promise<boolean> {
|
|
855
|
+
const ready = async (): Promise<boolean> =>
|
|
856
|
+
(await readRuntimeReadinessMarker(markerFile, sessionId, launchId, readinessRoot)) !== null;
|
|
857
|
+
if (await ready()) return true;
|
|
858
|
+
|
|
859
|
+
const markerDir = path.dirname(markerFile);
|
|
860
|
+
await ensureDir(markerDir);
|
|
861
|
+
const deadline = Date.now() + Math.max(timeoutMs, 0);
|
|
862
|
+
let wake = Promise.withResolvers<void>();
|
|
863
|
+
let watcher: nodeFs.FSWatcher | null = null;
|
|
864
|
+
try {
|
|
865
|
+
watcher = nodeFs.watch(markerDir, (_eventType, filename) => {
|
|
866
|
+
if (filename === path.basename(markerFile)) wake.resolve();
|
|
867
|
+
});
|
|
868
|
+
// A marker may be installed between the first read and watch creation.
|
|
869
|
+
if (await ready()) return true;
|
|
870
|
+
while (Date.now() < deadline) {
|
|
871
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
872
|
+
let timer: NodeJS.Timeout | null = null;
|
|
873
|
+
const fallback = new Promise<void>(resolve => {
|
|
874
|
+
timer = setTimeout(resolve, Math.min(50, remainingMs));
|
|
875
|
+
timer.unref?.();
|
|
876
|
+
});
|
|
877
|
+
await Promise.race([wake.promise, fallback]);
|
|
878
|
+
if (timer) clearTimeout(timer);
|
|
879
|
+
wake = Promise.withResolvers<void>();
|
|
880
|
+
if (await ready()) return true;
|
|
881
|
+
}
|
|
882
|
+
return false;
|
|
883
|
+
} finally {
|
|
884
|
+
watcher?.close();
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
557
888
|
async function ensureDir(dir: string): Promise<void> {
|
|
558
889
|
await fs.mkdir(dir, { recursive: true });
|
|
559
890
|
}
|
|
560
891
|
|
|
892
|
+
class CoordinatorStateError extends Error {
|
|
893
|
+
constructor(readonly reason: "invalid" | "unreadable") {
|
|
894
|
+
super(`coordinator_state_${reason}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const COORDINATOR_SESSION_STATES = new Set<CoordinatorSessionStateValue>([
|
|
899
|
+
"booting",
|
|
900
|
+
"ready_for_input",
|
|
901
|
+
"running",
|
|
902
|
+
"needs_user_input",
|
|
903
|
+
"completed",
|
|
904
|
+
"errored",
|
|
905
|
+
"stale",
|
|
906
|
+
"unknown",
|
|
907
|
+
]);
|
|
908
|
+
const COORDINATOR_EVENT_KINDS = new Set<CoordinatorEventKind>([
|
|
909
|
+
"session.registered",
|
|
910
|
+
"session.started",
|
|
911
|
+
"session.reaped",
|
|
912
|
+
"session.state_changed",
|
|
913
|
+
"turn.queued",
|
|
914
|
+
"turn.delivering",
|
|
915
|
+
"turn.active",
|
|
916
|
+
"turn.acknowledged",
|
|
917
|
+
"turn.waiting_for_answer",
|
|
918
|
+
"turn.completed",
|
|
919
|
+
"turn.failed",
|
|
920
|
+
"turn.cancelled",
|
|
921
|
+
"turn.superseded",
|
|
922
|
+
"question.opened",
|
|
923
|
+
"question.answered",
|
|
924
|
+
"report.written",
|
|
925
|
+
"tmux.delivery_succeeded",
|
|
926
|
+
"tmux.delivery_failed",
|
|
927
|
+
"delegation.started",
|
|
928
|
+
]);
|
|
929
|
+
|
|
930
|
+
function coordinatorStateError(reason: "invalid" | "unreadable"): CoordinatorStateError {
|
|
931
|
+
return new CoordinatorStateError(reason);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function isValidCoordinatorSessionState(value: unknown, sessionId: string): value is CoordinatorSessionState {
|
|
935
|
+
const record = asRecord(value);
|
|
936
|
+
return (
|
|
937
|
+
record?.schema_version === 1 &&
|
|
938
|
+
record.session_id === sessionId &&
|
|
939
|
+
typeof record.state === "string" &&
|
|
940
|
+
COORDINATOR_SESSION_STATES.has(record.state as CoordinatorSessionStateValue) &&
|
|
941
|
+
typeof record.ready_for_input === "boolean" &&
|
|
942
|
+
record.ready_for_input === (record.state === "ready_for_input" || record.state === "completed") &&
|
|
943
|
+
(record.current_turn_id === null ||
|
|
944
|
+
(typeof record.current_turn_id === "string" && TURN_ID_PATTERN.test(record.current_turn_id))) &&
|
|
945
|
+
(record.last_turn_id === null ||
|
|
946
|
+
(typeof record.last_turn_id === "string" && TURN_ID_PATTERN.test(record.last_turn_id))) &&
|
|
947
|
+
typeof record.updated_at === "string" &&
|
|
948
|
+
Number.isFinite(Date.parse(record.updated_at)) &&
|
|
949
|
+
(record.source === "coordinator" ||
|
|
950
|
+
record.source === "agent_session_event" ||
|
|
951
|
+
record.source === "process_postmortem") &&
|
|
952
|
+
(record.live === null || typeof record.live === "boolean") &&
|
|
953
|
+
(record.reason === null || typeof record.reason === "string")
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH = 16_384;
|
|
958
|
+
const MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH = 256;
|
|
959
|
+
const MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH = 1_024;
|
|
960
|
+
const MAX_RUNTIME_ERROR_CODE_LENGTH = 128;
|
|
961
|
+
const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 4_096;
|
|
962
|
+
|
|
963
|
+
function isBoundedNullableString(value: unknown, maxLength: number): value is string | null {
|
|
964
|
+
return value === null || (typeof value === "string" && value.length <= maxLength);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function isValidRuntimeFinalResponse(value: unknown): value is CoordinatorFinalResponse {
|
|
968
|
+
const record = asRecord(value);
|
|
969
|
+
return (
|
|
970
|
+
record !== null &&
|
|
971
|
+
Object.keys(record).every(key => ["text", "format", "source", "artifact_path", "truncated"].includes(key)) &&
|
|
972
|
+
isBoundedNullableString(record.text, MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH) &&
|
|
973
|
+
record.format === "markdown" &&
|
|
974
|
+
(record.source === null ||
|
|
975
|
+
(typeof record.source === "string" &&
|
|
976
|
+
record.source.length > 0 &&
|
|
977
|
+
record.source.length <= MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH)) &&
|
|
978
|
+
(record.artifact_path === null ||
|
|
979
|
+
(typeof record.artifact_path === "string" &&
|
|
980
|
+
record.artifact_path.length > 0 &&
|
|
981
|
+
record.artifact_path.length <= MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH &&
|
|
982
|
+
!path.isAbsolute(record.artifact_path) &&
|
|
983
|
+
!record.artifact_path.split(/[\\/]/).includes(".."))) &&
|
|
984
|
+
typeof record.truncated === "boolean"
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function isValidRuntimeError(value: unknown): value is NonNullable<RuntimeSessionStatePayload["error"]> {
|
|
989
|
+
const record = asRecord(value);
|
|
990
|
+
return (
|
|
991
|
+
record !== null &&
|
|
992
|
+
Object.keys(record).every(key => ["code", "message", "recoverable"].includes(key)) &&
|
|
993
|
+
typeof record.code === "string" &&
|
|
994
|
+
record.code.length > 0 &&
|
|
995
|
+
record.code.length <= MAX_RUNTIME_ERROR_CODE_LENGTH &&
|
|
996
|
+
typeof record.message === "string" &&
|
|
997
|
+
record.message.length > 0 &&
|
|
998
|
+
record.message.length <= MAX_RUNTIME_ERROR_MESSAGE_LENGTH &&
|
|
999
|
+
typeof record.recoverable === "boolean"
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function isValidOwnerTerminalRuntimeReceipt(value: unknown, sessionId: string): boolean {
|
|
1004
|
+
const record = asRecord(value);
|
|
1005
|
+
if (!record) return false;
|
|
1006
|
+
const baseKeys = [
|
|
1007
|
+
"generation",
|
|
1008
|
+
"socket_key",
|
|
1009
|
+
"signal",
|
|
1010
|
+
"result",
|
|
1011
|
+
"classification",
|
|
1012
|
+
"observer",
|
|
1013
|
+
"observed_at",
|
|
1014
|
+
"dedupe_key",
|
|
1015
|
+
];
|
|
1016
|
+
const keys = Object.keys(record);
|
|
1017
|
+
if (!keys.every(key => [...baseKeys, "intent_id"].includes(key)) || baseKeys.some(key => !keys.includes(key)))
|
|
1018
|
+
return false;
|
|
1019
|
+
if (
|
|
1020
|
+
!validRuntimeOwnerGeneration(record.generation) ||
|
|
1021
|
+
!validRuntimeOwnerGeneration(record.socket_key) ||
|
|
1022
|
+
typeof record.signal !== "string" ||
|
|
1023
|
+
!["SIGTERM", "SIGHUP", "SIGINT", "SIGKILL", "EXIT", "MANUAL", "UNKNOWN"].includes(record.signal) ||
|
|
1024
|
+
typeof record.result !== "string" ||
|
|
1025
|
+
!record.result ||
|
|
1026
|
+
record.result.length > 64 ||
|
|
1027
|
+
!["expected_operator_shutdown", "unexpected_owner_loss", "non_operator_cleanup"].includes(
|
|
1028
|
+
String(record.classification),
|
|
1029
|
+
) ||
|
|
1030
|
+
!["sidecar", "raw_monitor"].includes(String(record.observer)) ||
|
|
1031
|
+
typeof record.observed_at !== "string" ||
|
|
1032
|
+
!Number.isFinite(Date.parse(record.observed_at)) ||
|
|
1033
|
+
record.dedupe_key !== `owner-loss:${sessionId}:${record.generation}`
|
|
1034
|
+
)
|
|
1035
|
+
return false;
|
|
1036
|
+
if (record.classification === "expected_operator_shutdown")
|
|
1037
|
+
return (
|
|
1038
|
+
record.signal === "SIGTERM" &&
|
|
1039
|
+
record.result === "owner_term_then_session_cleanup" &&
|
|
1040
|
+
typeof record.intent_id === "string" &&
|
|
1041
|
+
record.intent_id.length > 0
|
|
1042
|
+
);
|
|
1043
|
+
if (record.intent_id !== undefined) return false;
|
|
1044
|
+
if (record.classification === "non_operator_cleanup") return record.result === "cleanup";
|
|
1045
|
+
return ["owner_lost", "cleanup", "process_postmortem", "exit", "unknown_terminal"].includes(record.result);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function isValidRuntimeSessionState(value: unknown, sessionId: string): value is RuntimeSessionStatePayload {
|
|
1049
|
+
if (!isValidCoordinatorSessionState(value, sessionId)) return false;
|
|
1050
|
+
const record = asRecord(value);
|
|
1051
|
+
if (!record) return false;
|
|
1052
|
+
const finalResponse = record.final_response;
|
|
1053
|
+
const error = record.error;
|
|
1054
|
+
if (finalResponse !== undefined && !isValidRuntimeFinalResponse(finalResponse)) return false;
|
|
1055
|
+
if (record.source !== "coordinator" && record.live !== (record.state === "running")) return false;
|
|
1056
|
+
if (error !== undefined && error !== null && !isValidRuntimeError(error)) return false;
|
|
1057
|
+
if (record.owner_generation !== undefined && !validRuntimeOwnerGeneration(record.owner_generation)) return false;
|
|
1058
|
+
if (record.source === "coordinator") return finalResponse === undefined && (error === undefined || error === null);
|
|
1059
|
+
if (record.source === "process_postmortem") {
|
|
1060
|
+
if (record.state !== "completed" && record.state !== "errored") return false;
|
|
1061
|
+
if (record.event === "owner_terminal") {
|
|
1062
|
+
if (!isValidOwnerTerminalRuntimeReceipt(record.owner_terminal, sessionId)) return false;
|
|
1063
|
+
const ownerTerminal = asRecord(record.owner_terminal)!;
|
|
1064
|
+
const expected = ownerTerminal.classification === "expected_operator_shutdown";
|
|
1065
|
+
if (
|
|
1066
|
+
record.reason !== ownerTerminal.classification ||
|
|
1067
|
+
record.state !== (expected ? "completed" : "errored") ||
|
|
1068
|
+
(expected
|
|
1069
|
+
? error !== undefined && error !== null
|
|
1070
|
+
: error === undefined ||
|
|
1071
|
+
error === null ||
|
|
1072
|
+
error.code !== ownerTerminal.classification ||
|
|
1073
|
+
error.recoverable !== true)
|
|
1074
|
+
)
|
|
1075
|
+
return false;
|
|
1076
|
+
} else if (record.event === "process_exit") {
|
|
1077
|
+
if (
|
|
1078
|
+
record.owner_terminal !== undefined ||
|
|
1079
|
+
typeof record.exit_kind !== "string" ||
|
|
1080
|
+
record.exit_kind.length === 0 ||
|
|
1081
|
+
record.exit_kind.length > 128 ||
|
|
1082
|
+
(record.exit_code !== null &&
|
|
1083
|
+
(typeof record.exit_code !== "number" || !Number.isSafeInteger(record.exit_code))) ||
|
|
1084
|
+
(record.signal !== null &&
|
|
1085
|
+
record.signal !== "SIGINT" &&
|
|
1086
|
+
record.signal !== "SIGTERM" &&
|
|
1087
|
+
record.signal !== "SIGHUP") ||
|
|
1088
|
+
typeof record.prompt_accepted !== "boolean" ||
|
|
1089
|
+
typeof record.observed_recoverable_worktree_changes !== "boolean" ||
|
|
1090
|
+
(record.worktree_baseline_dirty !== null && typeof record.worktree_baseline_dirty !== "boolean") ||
|
|
1091
|
+
typeof record.worktree_changed_since_baseline !== "boolean"
|
|
1092
|
+
)
|
|
1093
|
+
return false;
|
|
1094
|
+
} else {
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (record.state === "completed") return error === undefined || error === null;
|
|
1099
|
+
if (record.state === "errored") return error !== undefined && error !== null;
|
|
1100
|
+
return finalResponse === undefined && (error === undefined || error === null);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const PUBLIC_COORDINATOR_ERRORS = new Set([
|
|
1104
|
+
"coordinator_workdir_required",
|
|
1105
|
+
"coordinator_workdir_roots_required",
|
|
1106
|
+
"coordinator_workdir_outside_allowed_roots",
|
|
1107
|
+
"coordinator_artifact_path_required",
|
|
1108
|
+
"coordinator_artifact_roots_required",
|
|
1109
|
+
"coordinator_artifact_outside_allowed_roots",
|
|
1110
|
+
"coordinator_mutation_class_disabled",
|
|
1111
|
+
"coordinator_mutation_call_not_allowed",
|
|
1112
|
+
"invalid_session_id",
|
|
1113
|
+
"invalid_tmux_session",
|
|
1114
|
+
"invalid_tmux_target",
|
|
1115
|
+
"invalid_turn_id",
|
|
1116
|
+
"invalid_question_id",
|
|
1117
|
+
"session_id_conflict",
|
|
1118
|
+
"tmux_session_unavailable",
|
|
1119
|
+
"tmux_target_unavailable",
|
|
1120
|
+
"coordinator_session_command_required",
|
|
1121
|
+
"coordinator_launch_attestation_missing",
|
|
1122
|
+
"coordinator_launch_attestation_mismatch",
|
|
1123
|
+
"coordinator_launch_metadata_missing",
|
|
1124
|
+
"coordinator_readiness_path_invalid",
|
|
1125
|
+
"session_origin_unverified",
|
|
1126
|
+
"coordinator_prompt_buffer_privacy_unverified",
|
|
1127
|
+
"coordinator_tmux_start_failed",
|
|
1128
|
+
"coordinator_tmux_owner_server_unsafe",
|
|
1129
|
+
"coordinator_tmux_owner_server_unverifiable",
|
|
1130
|
+
"coordinator_tmux_owner_server_race",
|
|
1131
|
+
"coordinator_evidence_paths_must_be_array",
|
|
1132
|
+
]);
|
|
1133
|
+
|
|
1134
|
+
class CoordinatorStartError extends Error {
|
|
1135
|
+
constructor(
|
|
1136
|
+
message: string,
|
|
1137
|
+
readonly cleanupStatus: "cleaned" | "failed" | "unverifiable",
|
|
1138
|
+
) {
|
|
1139
|
+
super(message);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function publicCleanupStatus(error: unknown): "cleaned" | "failed" | "unverifiable" | undefined {
|
|
1144
|
+
return error instanceof CoordinatorStartError ? error.cleanupStatus : undefined;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function publicCoordinatorError(error: unknown): string {
|
|
1148
|
+
if (error instanceof CoordinatorStateError) return `coordinator_state_${error.reason}`;
|
|
1149
|
+
const message = error instanceof Error ? error.message : "";
|
|
1150
|
+
const normalized = message
|
|
1151
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
1152
|
+
.replace(/\s+/g, " ")
|
|
1153
|
+
.trim();
|
|
1154
|
+
const code = normalized.split(":", 1)[0];
|
|
1155
|
+
return PUBLIC_COORDINATOR_ERRORS.has(code) ? code : "coordinator_request_failed";
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function isExpectedStateAbsence(error: unknown): boolean {
|
|
1159
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
1160
|
+
}
|
|
1161
|
+
|
|
561
1162
|
async function readJsonFile(file: string): Promise<unknown | null> {
|
|
1163
|
+
let content: string;
|
|
1164
|
+
try {
|
|
1165
|
+
content = await fs.readFile(file, "utf8");
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
if (isExpectedStateAbsence(error)) return null;
|
|
1168
|
+
throw coordinatorStateError("unreadable");
|
|
1169
|
+
}
|
|
562
1170
|
try {
|
|
563
|
-
return JSON.parse(
|
|
1171
|
+
return JSON.parse(content);
|
|
564
1172
|
} catch {
|
|
565
|
-
|
|
1173
|
+
throw coordinatorStateError("invalid");
|
|
566
1174
|
}
|
|
567
1175
|
}
|
|
568
1176
|
|
|
@@ -572,15 +1180,16 @@ async function writeJsonFile(file: string, value: unknown): Promise<void> {
|
|
|
572
1180
|
}
|
|
573
1181
|
|
|
574
1182
|
async function listJsonFiles(dir: string): Promise<unknown[]> {
|
|
1183
|
+
let entries: string[];
|
|
575
1184
|
try {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
);
|
|
580
|
-
return values.filter(value => value !== null);
|
|
581
|
-
} catch {
|
|
582
|
-
return [];
|
|
1185
|
+
entries = await fs.readdir(dir);
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1188
|
+
throw coordinatorStateError("unreadable");
|
|
583
1189
|
}
|
|
1190
|
+
return await Promise.all(
|
|
1191
|
+
entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
|
|
1192
|
+
);
|
|
584
1193
|
}
|
|
585
1194
|
|
|
586
1195
|
const COORDINATOR_STATUS_EVENT_LIMIT = 100;
|
|
@@ -668,22 +1277,39 @@ function boundSummary(value: string): string {
|
|
|
668
1277
|
}
|
|
669
1278
|
|
|
670
1279
|
async function readLatestEventSeq(namespaceDir: string): Promise<number> {
|
|
671
|
-
const
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1280
|
+
const rawSequence = await readJsonFile(eventSequenceFile(namespaceDir));
|
|
1281
|
+
const events = await readCoordinatorEvents(namespaceDir);
|
|
1282
|
+
const journalLatestSeq = events.at(-1)?.seq ?? 0;
|
|
1283
|
+
if (rawSequence === null) return journalLatestSeq;
|
|
1284
|
+
const sequence = asRecord(rawSequence);
|
|
1285
|
+
if (
|
|
1286
|
+
!sequence ||
|
|
1287
|
+
Object.keys(sequence).some(key => key !== "seq" && key !== "updated_at") ||
|
|
1288
|
+
typeof sequence.seq !== "number" ||
|
|
1289
|
+
!Number.isSafeInteger(sequence.seq) ||
|
|
1290
|
+
sequence.seq < 0 ||
|
|
1291
|
+
typeof sequence.updated_at !== "string" ||
|
|
1292
|
+
!Number.isFinite(Date.parse(sequence.updated_at)) ||
|
|
1293
|
+
sequence.seq > journalLatestSeq
|
|
1294
|
+
)
|
|
1295
|
+
throw coordinatorStateError("invalid");
|
|
1296
|
+
return journalLatestSeq;
|
|
677
1297
|
}
|
|
678
1298
|
|
|
679
1299
|
const eventAppendQueues = new Map<string, Promise<unknown>>();
|
|
680
1300
|
|
|
1301
|
+
/** Lock order: namespace transaction lock, then event journal lock. */
|
|
1302
|
+
function coordinatorTransactionLockFile(namespaceDir: string): string {
|
|
1303
|
+
return path.join(namespaceDir, "locks", "mutation.lock");
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
async function withCoordinatorTransaction<T>(namespaceDir: string, operation: () => Promise<T>): Promise<T> {
|
|
1307
|
+
return await withSessionStateLock(coordinatorTransactionLockFile(namespaceDir), operation);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
681
1310
|
async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEventInput): Promise<CoordinatorEvent> {
|
|
682
1311
|
const previous = eventAppendQueues.get(namespaceDir) ?? Promise.resolve();
|
|
683
|
-
|
|
684
|
-
const current = new Promise<void>(resolve => {
|
|
685
|
-
release = resolve;
|
|
686
|
-
});
|
|
1312
|
+
const { promise: current, resolve: release } = Promise.withResolvers<void>();
|
|
687
1313
|
const queued = previous.then(
|
|
688
1314
|
() => current,
|
|
689
1315
|
() => current,
|
|
@@ -692,57 +1318,148 @@ async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEv
|
|
|
692
1318
|
|
|
693
1319
|
await previous.catch(() => undefined);
|
|
694
1320
|
try {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1321
|
+
return await withSessionStateLock(eventJournalFile(namespaceDir), async () => {
|
|
1322
|
+
const latestSeq = await readLatestEventSeq(namespaceDir);
|
|
1323
|
+
const seq = latestSeq + 1;
|
|
1324
|
+
const timestamp = new Date().toISOString();
|
|
1325
|
+
const event: CoordinatorEvent = {
|
|
1326
|
+
schema_version: 1,
|
|
1327
|
+
seq,
|
|
1328
|
+
id: `event-${seq.toString().padStart(12, "0")}`,
|
|
1329
|
+
timestamp,
|
|
1330
|
+
kind: input.kind,
|
|
1331
|
+
summary: boundSummary(input.summary),
|
|
1332
|
+
...(input.sessionId ? { session_id: input.sessionId } : {}),
|
|
1333
|
+
...(input.turnId ? { turn_id: input.turnId } : {}),
|
|
1334
|
+
...(input.questionId ? { question_id: input.questionId } : {}),
|
|
1335
|
+
...(input.reportId ? { report_id: input.reportId } : {}),
|
|
1336
|
+
...(input.payloadRef ? { payload_ref: input.payloadRef } : {}),
|
|
1337
|
+
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
1338
|
+
};
|
|
1339
|
+
await ensureDir(eventsDir(namespaceDir));
|
|
1340
|
+
await fs.appendFile(eventJournalFile(namespaceDir), `${JSON.stringify(event)}\n`);
|
|
1341
|
+
await writeJsonFile(eventSequenceFile(namespaceDir), {
|
|
1342
|
+
seq,
|
|
1343
|
+
updated_at: timestamp,
|
|
1344
|
+
});
|
|
1345
|
+
return event;
|
|
1346
|
+
});
|
|
716
1347
|
} finally {
|
|
717
1348
|
release();
|
|
718
1349
|
if (eventAppendQueues.get(namespaceDir) === queued) eventAppendQueues.delete(namespaceDir);
|
|
719
1350
|
}
|
|
720
1351
|
}
|
|
721
1352
|
|
|
722
|
-
|
|
1353
|
+
// Per-session mutation lock. Concurrent stdio dispatch (see pumpCoordinatorMcpStream) makes
|
|
1354
|
+
// same-session read-modify-write tool calls (notably send_prompt: read active turn → decide →
|
|
1355
|
+
// write new turn) interleave, which could persist two "active" turns while only one active-turn
|
|
1356
|
+
// pointer survives. Serializing per session — same promise-chain shape as eventAppendQueues —
|
|
1357
|
+
// restores the atomicity the former serial read loop provided, without serializing across
|
|
1358
|
+
// sessions or blocking long read-only polls (await_turn/watch_events). This is an in-process
|
|
1359
|
+
// lock distinct from the file-based withSessionStateLock/withCoordinatorTransaction, so it can
|
|
1360
|
+
// never deadlock by nesting inside them.
|
|
1361
|
+
const sessionMutationQueues = new Map<string, Promise<unknown>>();
|
|
1362
|
+
|
|
1363
|
+
async function withSessionMutation<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
1364
|
+
const previous = sessionMutationQueues.get(key) ?? Promise.resolve();
|
|
1365
|
+
const { promise: current, resolve: release } = Promise.withResolvers<void>();
|
|
1366
|
+
const queued = previous.then(
|
|
1367
|
+
() => current,
|
|
1368
|
+
() => current,
|
|
1369
|
+
);
|
|
1370
|
+
sessionMutationQueues.set(key, queued);
|
|
1371
|
+
await previous.catch(() => undefined);
|
|
1372
|
+
try {
|
|
1373
|
+
return await fn();
|
|
1374
|
+
} finally {
|
|
1375
|
+
release();
|
|
1376
|
+
if (sessionMutationQueues.get(key) === queued) sessionMutationQueues.delete(key);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function parseCoordinatorEvent(line: string): CoordinatorEvent {
|
|
1381
|
+
let event: unknown;
|
|
723
1382
|
try {
|
|
724
|
-
|
|
725
|
-
if (typeof event.seq !== "number" || typeof event.kind !== "string") return null;
|
|
726
|
-
return event;
|
|
1383
|
+
event = JSON.parse(line);
|
|
727
1384
|
} catch {
|
|
728
|
-
|
|
1385
|
+
throw coordinatorStateError("invalid");
|
|
1386
|
+
}
|
|
1387
|
+
const record = asRecord(event);
|
|
1388
|
+
const allowed = new Set([
|
|
1389
|
+
"schema_version",
|
|
1390
|
+
"seq",
|
|
1391
|
+
"id",
|
|
1392
|
+
"timestamp",
|
|
1393
|
+
"kind",
|
|
1394
|
+
"session_id",
|
|
1395
|
+
"turn_id",
|
|
1396
|
+
"question_id",
|
|
1397
|
+
"report_id",
|
|
1398
|
+
"summary",
|
|
1399
|
+
"payload_ref",
|
|
1400
|
+
"metadata",
|
|
1401
|
+
]);
|
|
1402
|
+
const metadata = asRecord(record?.metadata);
|
|
1403
|
+
const validMetadata =
|
|
1404
|
+
record?.metadata === undefined && metadata === null
|
|
1405
|
+
? true
|
|
1406
|
+
: metadata !== null &&
|
|
1407
|
+
Object.values(metadata).every(
|
|
1408
|
+
value => value === null || ["string", "number", "boolean"].includes(typeof value),
|
|
1409
|
+
);
|
|
1410
|
+
const validOptionalExternalId = (value: unknown) =>
|
|
1411
|
+
value === undefined || (typeof value === "string" && SAFE_EXTERNAL_ID_PATTERN.test(value));
|
|
1412
|
+
const validPayloadRef =
|
|
1413
|
+
record?.payload_ref === undefined ||
|
|
1414
|
+
(typeof record.payload_ref === "string" &&
|
|
1415
|
+
record.payload_ref.length > 0 &&
|
|
1416
|
+
!path.isAbsolute(record.payload_ref) &&
|
|
1417
|
+
!record.payload_ref.split(/[\\/]/).includes(".."));
|
|
1418
|
+
if (
|
|
1419
|
+
!record ||
|
|
1420
|
+
Object.keys(record).some(key => !allowed.has(key)) ||
|
|
1421
|
+
record.schema_version !== 1 ||
|
|
1422
|
+
typeof record.seq !== "number" ||
|
|
1423
|
+
!Number.isSafeInteger(record.seq) ||
|
|
1424
|
+
record.seq <= 0 ||
|
|
1425
|
+
record.id !== `event-${record.seq.toString().padStart(12, "0")}` ||
|
|
1426
|
+
typeof record.timestamp !== "string" ||
|
|
1427
|
+
!Number.isFinite(Date.parse(record.timestamp)) ||
|
|
1428
|
+
typeof record.kind !== "string" ||
|
|
1429
|
+
!COORDINATOR_EVENT_KINDS.has(record.kind as CoordinatorEventKind) ||
|
|
1430
|
+
typeof record.summary !== "string" ||
|
|
1431
|
+
record.summary.length > 240 ||
|
|
1432
|
+
!validOptionalExternalId(record.session_id) ||
|
|
1433
|
+
(record.turn_id !== undefined && (typeof record.turn_id !== "string" || !TURN_ID_PATTERN.test(record.turn_id))) ||
|
|
1434
|
+
!validOptionalExternalId(record.question_id) ||
|
|
1435
|
+
!validOptionalExternalId(record.report_id) ||
|
|
1436
|
+
!validPayloadRef ||
|
|
1437
|
+
!validMetadata
|
|
1438
|
+
) {
|
|
1439
|
+
throw coordinatorStateError("invalid");
|
|
729
1440
|
}
|
|
1441
|
+
return record as unknown as CoordinatorEvent;
|
|
730
1442
|
}
|
|
731
1443
|
|
|
732
1444
|
async function readCoordinatorEvents(namespaceDir: string): Promise<CoordinatorEvent[]> {
|
|
1445
|
+
const journalFile = eventJournalFile(namespaceDir);
|
|
1446
|
+
let content: string;
|
|
733
1447
|
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);
|
|
1448
|
+
content = await fs.readFile(journalFile, "utf8");
|
|
742
1449
|
} catch (error) {
|
|
743
|
-
if ((error
|
|
744
|
-
throw
|
|
1450
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1451
|
+
throw coordinatorStateError("unreadable");
|
|
745
1452
|
}
|
|
1453
|
+
const events = content
|
|
1454
|
+
.split("\n")
|
|
1455
|
+
.map(line => line.trim())
|
|
1456
|
+
.filter(Boolean)
|
|
1457
|
+
.map(parseCoordinatorEvent);
|
|
1458
|
+
for (let index = 1; index < events.length; index++) {
|
|
1459
|
+
if (events[index].seq !== events[index - 1].seq + 1) throw coordinatorStateError("invalid");
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return events;
|
|
746
1463
|
}
|
|
747
1464
|
|
|
748
1465
|
function boundedEventLimit(value: unknown): number {
|
|
@@ -819,6 +1536,33 @@ function safeTmuxTarget(value: unknown): string {
|
|
|
819
1536
|
return value;
|
|
820
1537
|
}
|
|
821
1538
|
|
|
1539
|
+
function safeTmuxRuntimeIdentity(value: string): boolean {
|
|
1540
|
+
return value.length > 0 && value.length <= 160 && !/[\s\u0000-\u001f\u007f]/.test(value);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function coordinatorCleanupPredicate(expectedPid: number, nativeSessionId: string, attemptSession: string): string {
|
|
1544
|
+
return `#{&&:#{==:#{pid},${expectedPid}},#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_name},${attemptSession}}}}`;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function parseTmuxNewSessionRecord(
|
|
1548
|
+
stdout: string,
|
|
1549
|
+
sessionName: string,
|
|
1550
|
+
): { tmuxTarget: string; paneId: string; nativeSessionId: string } | undefined {
|
|
1551
|
+
const match = /^(\S+) (%\d+) (\$\d+)\n?$/.exec(stdout);
|
|
1552
|
+
if (!match) return undefined;
|
|
1553
|
+
const [tmuxTarget, paneId, nativeSessionId] = match.slice(1);
|
|
1554
|
+
const targetSuffix = tmuxTarget!.slice(`${sessionName}:`.length);
|
|
1555
|
+
if (
|
|
1556
|
+
!tmuxTarget!.startsWith(`${sessionName}:`) ||
|
|
1557
|
+
!/^\d+\.\d+$/.test(targetSuffix) ||
|
|
1558
|
+
!safeTmuxRuntimeIdentity(tmuxTarget!) ||
|
|
1559
|
+
!safeTmuxRuntimeIdentity(paneId!) ||
|
|
1560
|
+
!safeTmuxRuntimeIdentity(nativeSessionId!)
|
|
1561
|
+
)
|
|
1562
|
+
return undefined;
|
|
1563
|
+
return { tmuxTarget: tmuxTarget!, paneId: paneId!, nativeSessionId: nativeSessionId! };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
822
1566
|
function optionalString(value: unknown): string | null {
|
|
823
1567
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
824
1568
|
}
|
|
@@ -847,8 +1591,150 @@ function sessionStateFile(namespaceDir: string, sessionId: string): string {
|
|
|
847
1591
|
return path.join(namespaceDir, "session-states", `${safeExternalId("session", sessionId)}.json`);
|
|
848
1592
|
}
|
|
849
1593
|
|
|
1594
|
+
function isTimestamp(value: unknown): value is string {
|
|
1595
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function isValidTurnRecord(value: unknown): value is TurnRecord {
|
|
1599
|
+
const turn = asRecord(value);
|
|
1600
|
+
const delivery = asRecord(turn?.delivery);
|
|
1601
|
+
const prompt = asRecord(turn?.prompt);
|
|
1602
|
+
const response = asRecord(turn?.final_response);
|
|
1603
|
+
const liveness = asRecord(turn?.liveness);
|
|
1604
|
+
const namespace = asRecord(turn?.namespace);
|
|
1605
|
+
const statuses: readonly TurnStatus[] = [
|
|
1606
|
+
"queued",
|
|
1607
|
+
"delivering",
|
|
1608
|
+
"active",
|
|
1609
|
+
"waiting_for_answer",
|
|
1610
|
+
"completing",
|
|
1611
|
+
"completed",
|
|
1612
|
+
"failed",
|
|
1613
|
+
"cancelled",
|
|
1614
|
+
"superseded",
|
|
1615
|
+
];
|
|
1616
|
+
if (
|
|
1617
|
+
turn?.schema_version !== 1 ||
|
|
1618
|
+
typeof turn.turn_id !== "string" ||
|
|
1619
|
+
!TURN_ID_PATTERN.test(turn.turn_id) ||
|
|
1620
|
+
typeof turn.session_id !== "string" ||
|
|
1621
|
+
!SAFE_EXTERNAL_ID_PATTERN.test(turn.session_id) ||
|
|
1622
|
+
!namespace ||
|
|
1623
|
+
(namespace.profile !== null && typeof namespace.profile !== "string") ||
|
|
1624
|
+
(namespace.repo !== null && typeof namespace.repo !== "string") ||
|
|
1625
|
+
!statuses.includes(turn.status as TurnStatus)
|
|
1626
|
+
)
|
|
1627
|
+
return false;
|
|
1628
|
+
if (
|
|
1629
|
+
!prompt ||
|
|
1630
|
+
typeof prompt.text !== "string" ||
|
|
1631
|
+
!isTimestamp(prompt.created_at) ||
|
|
1632
|
+
(prompt.source !== "mcp" && prompt.source !== "question_answer")
|
|
1633
|
+
)
|
|
1634
|
+
return false;
|
|
1635
|
+
if (
|
|
1636
|
+
!delivery ||
|
|
1637
|
+
typeof delivery.delivered !== "boolean" ||
|
|
1638
|
+
typeof delivery.queued !== "boolean" ||
|
|
1639
|
+
(delivery.target !== null && typeof delivery.target !== "string") ||
|
|
1640
|
+
(delivery.tmux_keys_sent !== undefined && typeof delivery.tmux_keys_sent !== "boolean") ||
|
|
1641
|
+
(delivery.prompt_acknowledged !== undefined && typeof delivery.prompt_acknowledged !== "boolean") ||
|
|
1642
|
+
(delivery.state !== undefined &&
|
|
1643
|
+
![
|
|
1644
|
+
"queued",
|
|
1645
|
+
"awaiting_runtime_readiness",
|
|
1646
|
+
"delivering",
|
|
1647
|
+
"tmux_keys_sent",
|
|
1648
|
+
"acknowledged",
|
|
1649
|
+
"unavailable",
|
|
1650
|
+
"unacknowledged",
|
|
1651
|
+
].includes(delivery.state as string)) ||
|
|
1652
|
+
!Array.isArray(delivery.attempts) ||
|
|
1653
|
+
!delivery.attempts.every(attempt => {
|
|
1654
|
+
const item = asRecord(attempt);
|
|
1655
|
+
return (
|
|
1656
|
+
item !== null &&
|
|
1657
|
+
typeof item.delivered === "boolean" &&
|
|
1658
|
+
isTimestamp(item.created_at) &&
|
|
1659
|
+
(item.reason === null || typeof item.reason === "string") &&
|
|
1660
|
+
(item.channel === undefined || item.channel === "tmux_keys" || item.channel === "runtime_ack") &&
|
|
1661
|
+
(item.tmux_keys_sent === undefined || typeof item.tmux_keys_sent === "boolean")
|
|
1662
|
+
);
|
|
1663
|
+
})
|
|
1664
|
+
)
|
|
1665
|
+
return false;
|
|
1666
|
+
if (
|
|
1667
|
+
!Array.isArray(turn.question_ids) ||
|
|
1668
|
+
!turn.question_ids.every(id => typeof id === "string" && SAFE_EXTERNAL_ID_PATTERN.test(id)) ||
|
|
1669
|
+
!isValidRuntimeFinalResponse(response) ||
|
|
1670
|
+
!Array.isArray(turn.evidence) ||
|
|
1671
|
+
!turn.evidence.every(item => asRecord(item) !== null) ||
|
|
1672
|
+
(turn.error !== null && !isValidRuntimeError(turn.error))
|
|
1673
|
+
)
|
|
1674
|
+
return false;
|
|
1675
|
+
if (
|
|
1676
|
+
!liveness ||
|
|
1677
|
+
(liveness.checked_at !== null && !isTimestamp(liveness.checked_at)) ||
|
|
1678
|
+
(liveness.live !== null && typeof liveness.live !== "boolean") ||
|
|
1679
|
+
(liveness.reason !== null && typeof liveness.reason !== "string") ||
|
|
1680
|
+
!isTimestamp(turn.created_at) ||
|
|
1681
|
+
!isTimestamp(turn.updated_at) ||
|
|
1682
|
+
(turn.started_at !== null && !isTimestamp(turn.started_at)) ||
|
|
1683
|
+
(turn.completed_at !== null && !isTimestamp(turn.completed_at))
|
|
1684
|
+
)
|
|
1685
|
+
return false;
|
|
1686
|
+
return TERMINAL_TURN_STATUSES.has(turn.status as TurnStatus)
|
|
1687
|
+
? turn.completed_at !== null && (turn.status === "failed" ? turn.error !== null : turn.error === null)
|
|
1688
|
+
: turn.completed_at === null && turn.error === null;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function isValidActiveTurnRecord(
|
|
1692
|
+
value: unknown,
|
|
1693
|
+
sessionId: string,
|
|
1694
|
+
): value is {
|
|
1695
|
+
session_id: string;
|
|
1696
|
+
turn_id: string;
|
|
1697
|
+
status: TurnStatus;
|
|
1698
|
+
updated_at: string;
|
|
1699
|
+
} {
|
|
1700
|
+
const active = asRecord(value);
|
|
1701
|
+
return (
|
|
1702
|
+
active !== null &&
|
|
1703
|
+
Object.keys(active).every(key => ["session_id", "turn_id", "status", "updated_at"].includes(key)) &&
|
|
1704
|
+
active.session_id === sessionId &&
|
|
1705
|
+
typeof active.turn_id === "string" &&
|
|
1706
|
+
TURN_ID_PATTERN.test(active.turn_id) &&
|
|
1707
|
+
ACTIVE_TURN_STATUSES.has(active.status as TurnStatus) &&
|
|
1708
|
+
isTimestamp(active.updated_at)
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
850
1712
|
async function readTurnRecord(namespaceDir: string, turnId: unknown): Promise<TurnRecord | null> {
|
|
851
|
-
|
|
1713
|
+
const requestedTurnId = safeTurnId(turnId);
|
|
1714
|
+
const value = await readJsonFile(turnFile(namespaceDir, requestedTurnId));
|
|
1715
|
+
if (value === null) return null;
|
|
1716
|
+
if (!isValidTurnRecord(value) || value.turn_id !== requestedTurnId) throw coordinatorStateError("invalid");
|
|
1717
|
+
return value;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
async function listTurnRecords(namespaceDir: string): Promise<TurnRecord[]> {
|
|
1721
|
+
let entries: string[];
|
|
1722
|
+
try {
|
|
1723
|
+
entries = await fs.readdir(turnsDir(namespaceDir));
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1726
|
+
throw coordinatorStateError("unreadable");
|
|
1727
|
+
}
|
|
1728
|
+
const records = await Promise.all(
|
|
1729
|
+
entries
|
|
1730
|
+
.filter(entry => entry.endsWith(".json"))
|
|
1731
|
+
.map(async entry => {
|
|
1732
|
+
const turnId = entry.slice(0, -".json".length);
|
|
1733
|
+
if (!TURN_ID_PATTERN.test(turnId)) throw coordinatorStateError("invalid");
|
|
1734
|
+
return await readTurnRecord(namespaceDir, turnId);
|
|
1735
|
+
}),
|
|
1736
|
+
);
|
|
1737
|
+
return records.filter((record): record is TurnRecord => record !== null);
|
|
852
1738
|
}
|
|
853
1739
|
|
|
854
1740
|
function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
|
|
@@ -864,7 +1750,8 @@ function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
|
|
|
864
1750
|
}
|
|
865
1751
|
|
|
866
1752
|
async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
867
|
-
|
|
1753
|
+
if (!isValidTurnRecord(turn)) throw coordinatorStateError("invalid");
|
|
1754
|
+
const previous = await readTurnRecord(namespaceDir, turn.turn_id);
|
|
868
1755
|
await writeJsonFile(turnFile(namespaceDir, turn.turn_id), turn);
|
|
869
1756
|
const kind = previous?.status === turn.status ? null : turnEventKind(turn.status);
|
|
870
1757
|
if (kind) {
|
|
@@ -884,14 +1771,17 @@ async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<
|
|
|
884
1771
|
}
|
|
885
1772
|
|
|
886
1773
|
async function readActiveTurn(namespaceDir: string, sessionId: string): Promise<TurnRecord | null> {
|
|
887
|
-
const
|
|
888
|
-
if (
|
|
889
|
-
|
|
890
|
-
|
|
1774
|
+
const value = await readJsonFile(activeTurnFile(namespaceDir, sessionId));
|
|
1775
|
+
if (value === null) return null;
|
|
1776
|
+
if (!isValidActiveTurnRecord(value, sessionId)) throw coordinatorStateError("invalid");
|
|
1777
|
+
const turn = await readTurnRecord(namespaceDir, value.turn_id);
|
|
1778
|
+
if (!turn || turn.session_id !== sessionId || turn.status !== value.status || !ACTIVE_TURN_STATUSES.has(turn.status))
|
|
1779
|
+
throw coordinatorStateError("invalid");
|
|
891
1780
|
return turn;
|
|
892
1781
|
}
|
|
893
1782
|
|
|
894
1783
|
async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
1784
|
+
if (!ACTIVE_TURN_STATUSES.has(turn.status)) throw coordinatorStateError("invalid");
|
|
895
1785
|
await writeJsonFile(activeTurnFile(namespaceDir, turn.session_id), {
|
|
896
1786
|
session_id: turn.session_id,
|
|
897
1787
|
turn_id: turn.turn_id,
|
|
@@ -901,15 +1791,84 @@ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<
|
|
|
901
1791
|
}
|
|
902
1792
|
|
|
903
1793
|
async function clearActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
904
|
-
const
|
|
905
|
-
if (
|
|
1794
|
+
const value = await readJsonFile(activeTurnFile(namespaceDir, turn.session_id));
|
|
1795
|
+
if (value === null) return;
|
|
1796
|
+
if (!isValidActiveTurnRecord(value, turn.session_id)) throw coordinatorStateError("invalid");
|
|
1797
|
+
if (value.turn_id === turn.turn_id) await fs.rm(activeTurnFile(namespaceDir, turn.session_id), { force: true });
|
|
906
1798
|
}
|
|
907
1799
|
|
|
908
1800
|
async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
|
|
909
|
-
|
|
1801
|
+
const value = await readJsonFile(sessionStateFile(namespaceDir, sessionId));
|
|
1802
|
+
if (value === null) return null;
|
|
1803
|
+
if (!isValidRuntimeSessionState(value, sessionId)) throw coordinatorStateError("invalid");
|
|
1804
|
+
return value;
|
|
910
1805
|
}
|
|
911
1806
|
|
|
912
|
-
|
|
1807
|
+
function validRuntimeOwnerGeneration(value: unknown): value is string {
|
|
1808
|
+
return (
|
|
1809
|
+
typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/.test(value)
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function runtimeStateMatchesPrivateOwnerGeneration(
|
|
1814
|
+
sessionState: CoordinatorSessionState,
|
|
1815
|
+
session: Record<string, unknown> | null,
|
|
1816
|
+
): boolean {
|
|
1817
|
+
const expectedGeneration = session?.tmux_owner_generation ?? session?.tmuxOwnerGeneration;
|
|
1818
|
+
if (expectedGeneration === undefined)
|
|
1819
|
+
return !(sessionState.source === "process_postmortem" && sessionState.event === "owner_terminal");
|
|
1820
|
+
if (!validRuntimeOwnerGeneration(expectedGeneration) || sessionState.owner_generation !== expectedGeneration)
|
|
1821
|
+
return false;
|
|
1822
|
+
if (sessionState.source !== "process_postmortem") return true;
|
|
1823
|
+
if (sessionState.event !== "owner_terminal") return false;
|
|
1824
|
+
const expectedSocketKey = session?.tmux_owner_server_key ?? session?.tmuxOwnerServerKey;
|
|
1825
|
+
return (
|
|
1826
|
+
typeof expectedSocketKey === "string" &&
|
|
1827
|
+
sessionState.owner_terminal?.generation === expectedGeneration &&
|
|
1828
|
+
sessionState.owner_terminal.socket_key === expectedSocketKey
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
async function reconcileTerminalRuntimeEvidence(namespaceDir: string, sessionId: string): Promise<boolean> {
|
|
1833
|
+
const sessionState = await readSessionState(namespaceDir, sessionId);
|
|
1834
|
+
if (
|
|
1835
|
+
!sessionState ||
|
|
1836
|
+
sessionState.source === "coordinator" ||
|
|
1837
|
+
(sessionState.state !== "completed" && sessionState.state !== "errored")
|
|
1838
|
+
)
|
|
1839
|
+
return false;
|
|
1840
|
+
const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
|
|
1841
|
+
if (!runtimeStateMatchesPrivateOwnerGeneration(sessionState, session)) return false;
|
|
1842
|
+
|
|
1843
|
+
const activeTurn = await readActiveTurn(namespaceDir, sessionId);
|
|
1844
|
+
if (activeTurn && (sessionState.current_turn_id === null || sessionState.current_turn_id === activeTurn.turn_id)) {
|
|
1845
|
+
await markTurnTerminalFromSessionState(namespaceDir, activeTurn, sessionState);
|
|
1846
|
+
}
|
|
1847
|
+
return true;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
async function preflightCoordinatorMutation(
|
|
1851
|
+
namespaceDir: string,
|
|
1852
|
+
sessionId?: string,
|
|
1853
|
+
options: { rejectTerminalRuntimeEvidence?: boolean } = {},
|
|
1854
|
+
): Promise<void> {
|
|
1855
|
+
await readLatestEventSeq(namespaceDir);
|
|
1856
|
+
if (!sessionId) return;
|
|
1857
|
+
const [sessionState, session] = await Promise.all([
|
|
1858
|
+
readSessionState(namespaceDir, sessionId),
|
|
1859
|
+
readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)),
|
|
1860
|
+
]);
|
|
1861
|
+
const sessionRecord = asRecord(session);
|
|
1862
|
+
if (sessionRecord && normalizeSession(sessionRecord).session_id !== sessionId)
|
|
1863
|
+
throw coordinatorStateError("invalid");
|
|
1864
|
+
if (sessionState && sessionState.session_id !== sessionId) throw coordinatorStateError("invalid");
|
|
1865
|
+
await readActiveTurn(namespaceDir, sessionId);
|
|
1866
|
+
const terminalRuntimeEvidence = await reconcileTerminalRuntimeEvidence(namespaceDir, sessionId);
|
|
1867
|
+
if (terminalRuntimeEvidence && options.rejectTerminalRuntimeEvidence !== false)
|
|
1868
|
+
throw coordinatorStateError("invalid");
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
async function writeSessionStateUnlocked(
|
|
913
1872
|
namespaceDir: string,
|
|
914
1873
|
sessionId: string,
|
|
915
1874
|
state: CoordinatorSessionStateValue,
|
|
@@ -922,6 +1881,20 @@ async function writeSessionState(
|
|
|
922
1881
|
} = {},
|
|
923
1882
|
): Promise<CoordinatorSessionState> {
|
|
924
1883
|
const previous = await readSessionState(namespaceDir, sessionId);
|
|
1884
|
+
if (previous && previous.source !== "coordinator") return previous;
|
|
1885
|
+
const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
|
|
1886
|
+
const previousCwd = typeof previous?.cwd === "string" && previous.cwd.trim() ? previous.cwd : null;
|
|
1887
|
+
const cwd =
|
|
1888
|
+
typeof session?.cwd === "string" && session.cwd.trim()
|
|
1889
|
+
? path.resolve(session.cwd)
|
|
1890
|
+
: previousCwd
|
|
1891
|
+
? path.resolve(previousCwd)
|
|
1892
|
+
: null;
|
|
1893
|
+
if (!cwd) throw coordinatorStateError("invalid");
|
|
1894
|
+
const sessionFile =
|
|
1895
|
+
typeof session?.session_file === "string" && session.session_file.trim()
|
|
1896
|
+
? path.resolve(session.session_file)
|
|
1897
|
+
: null;
|
|
925
1898
|
const payload: CoordinatorSessionState = {
|
|
926
1899
|
schema_version: 1,
|
|
927
1900
|
session_id: sessionId,
|
|
@@ -933,6 +1906,9 @@ async function writeSessionState(
|
|
|
933
1906
|
source: options.source ?? "coordinator",
|
|
934
1907
|
live: options.live ?? previous?.live ?? null,
|
|
935
1908
|
reason: options.reason ?? null,
|
|
1909
|
+
cwd,
|
|
1910
|
+
workdir: cwd,
|
|
1911
|
+
session_file: sessionFile,
|
|
936
1912
|
};
|
|
937
1913
|
await writeJsonFile(sessionStateFile(namespaceDir, sessionId), payload);
|
|
938
1914
|
if (
|
|
@@ -960,6 +1936,138 @@ async function writeSessionState(
|
|
|
960
1936
|
return payload;
|
|
961
1937
|
}
|
|
962
1938
|
|
|
1939
|
+
interface SessionStateLockOwner {
|
|
1940
|
+
pid: number;
|
|
1941
|
+
start_time: string;
|
|
1942
|
+
token: string;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function processStartTime(pid: number): string | null {
|
|
1946
|
+
try {
|
|
1947
|
+
const stat = nodeFs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1948
|
+
const close = stat.lastIndexOf(")");
|
|
1949
|
+
const fields = stat
|
|
1950
|
+
.slice(close + 1)
|
|
1951
|
+
.trim()
|
|
1952
|
+
.split(/\s+/);
|
|
1953
|
+
return fields[19] ?? null;
|
|
1954
|
+
} catch {
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function validLockOwner(value: unknown): value is SessionStateLockOwner {
|
|
1960
|
+
if (!value || typeof value !== "object") return false;
|
|
1961
|
+
const owner = value as Partial<SessionStateLockOwner>;
|
|
1962
|
+
return (
|
|
1963
|
+
typeof owner.pid === "number" &&
|
|
1964
|
+
Number.isSafeInteger(owner.pid) &&
|
|
1965
|
+
owner.pid > 0 &&
|
|
1966
|
+
typeof owner.start_time === "string" &&
|
|
1967
|
+
typeof owner.token === "string" &&
|
|
1968
|
+
owner.token.length > 0
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function lockOwnerIsAlive(value: unknown): boolean {
|
|
1973
|
+
if (!validLockOwner(value)) return false;
|
|
1974
|
+
const owner = value;
|
|
1975
|
+
try {
|
|
1976
|
+
process.kill(owner.pid, 0);
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
|
|
1979
|
+
return true;
|
|
1980
|
+
}
|
|
1981
|
+
const currentStartTime = processStartTime(owner.pid);
|
|
1982
|
+
return currentStartTime === null || currentStartTime === owner.start_time;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
|
|
1986
|
+
let raw: string;
|
|
1987
|
+
try {
|
|
1988
|
+
raw = await fs.readFile(lockFile, "utf8");
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
1991
|
+
throw error;
|
|
1992
|
+
}
|
|
1993
|
+
let owner: unknown;
|
|
1994
|
+
try {
|
|
1995
|
+
owner = JSON.parse(raw);
|
|
1996
|
+
} catch {
|
|
1997
|
+
owner = null;
|
|
1998
|
+
}
|
|
1999
|
+
if (!validLockOwner(owner)) {
|
|
2000
|
+
const stat = await fs.stat(lockFile);
|
|
2001
|
+
if (Date.now() - stat.mtimeMs < 30_000) return;
|
|
2002
|
+
} else if (lockOwnerIsAlive(owner)) return;
|
|
2003
|
+
try {
|
|
2004
|
+
if ((await fs.readFile(lockFile, "utf8")) === raw) await fs.rm(lockFile);
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async function withSessionStateLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
2011
|
+
const lockFile = `${stateFile}.lock`;
|
|
2012
|
+
const owner: SessionStateLockOwner = {
|
|
2013
|
+
pid: process.pid,
|
|
2014
|
+
start_time: processStartTime(process.pid) ?? "unknown",
|
|
2015
|
+
token: randomUUID(),
|
|
2016
|
+
};
|
|
2017
|
+
await ensureDir(path.dirname(stateFile));
|
|
2018
|
+
for (let attempt = 0; attempt < 12_000; attempt++) {
|
|
2019
|
+
let handle: fs.FileHandle | undefined;
|
|
2020
|
+
try {
|
|
2021
|
+
handle = await fs.open(lockFile, "wx");
|
|
2022
|
+
try {
|
|
2023
|
+
await handle.writeFile(JSON.stringify(owner));
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
await handle.close().catch(() => undefined);
|
|
2026
|
+
handle = undefined;
|
|
2027
|
+
await fs.rm(lockFile, { force: true }).catch(() => undefined);
|
|
2028
|
+
throw error;
|
|
2029
|
+
}
|
|
2030
|
+
const outcome = await operation().then(
|
|
2031
|
+
value => ({ ok: true as const, value }),
|
|
2032
|
+
error => ({ ok: false as const, error }),
|
|
2033
|
+
);
|
|
2034
|
+
await handle.close();
|
|
2035
|
+
try {
|
|
2036
|
+
if ((await fs.readFile(lockFile, "utf8")) === JSON.stringify(owner)) await fs.rm(lockFile);
|
|
2037
|
+
} catch (error) {
|
|
2038
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
2039
|
+
}
|
|
2040
|
+
if (!outcome.ok) throw outcome.error;
|
|
2041
|
+
return outcome.value;
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
if (handle) throw error;
|
|
2044
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw coordinatorStateError("unreadable");
|
|
2045
|
+
await reclaimStaleSessionStateLock(lockFile);
|
|
2046
|
+
await Bun.sleep(5);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
throw coordinatorStateError("unreadable");
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
async function writeSessionState(
|
|
2053
|
+
namespaceDir: string,
|
|
2054
|
+
sessionId: string,
|
|
2055
|
+
state: CoordinatorSessionStateValue,
|
|
2056
|
+
options: {
|
|
2057
|
+
currentTurnId?: string | null;
|
|
2058
|
+
lastTurnId?: string | null;
|
|
2059
|
+
live?: boolean | null;
|
|
2060
|
+
reason?: string | null;
|
|
2061
|
+
source?: CoordinatorSessionState["source"];
|
|
2062
|
+
} = {},
|
|
2063
|
+
): Promise<CoordinatorSessionState> {
|
|
2064
|
+
const file = sessionStateFile(namespaceDir, sessionId);
|
|
2065
|
+
return await withSessionStateLock(
|
|
2066
|
+
file,
|
|
2067
|
+
async () => await writeSessionStateUnlocked(namespaceDir, sessionId, state, options),
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
963
2071
|
function hasTmuxIdentity(session: Record<string, unknown>): boolean {
|
|
964
2072
|
return (
|
|
965
2073
|
(typeof session.tmux_session === "string" && session.tmux_session.length > 0) ||
|
|
@@ -1012,7 +2120,11 @@ async function markTurnFailedForUnavailableSession(
|
|
|
1012
2120
|
truncated: false,
|
|
1013
2121
|
},
|
|
1014
2122
|
evidence: unavailableSessionEvidence(turn, durableReason, timestamp),
|
|
1015
|
-
error: {
|
|
2123
|
+
error: {
|
|
2124
|
+
code: "session_unavailable",
|
|
2125
|
+
message: durableReason,
|
|
2126
|
+
recoverable: true,
|
|
2127
|
+
},
|
|
1016
2128
|
liveness: { checked_at: timestamp, live: false, reason: durableReason },
|
|
1017
2129
|
updated_at: timestamp,
|
|
1018
2130
|
completed_at: timestamp,
|
|
@@ -1193,7 +2305,11 @@ async function markTurnFailedForUnacknowledgedDelivery(
|
|
|
1193
2305
|
created_at: timestamp,
|
|
1194
2306
|
},
|
|
1195
2307
|
],
|
|
1196
|
-
liveness: {
|
|
2308
|
+
liveness: {
|
|
2309
|
+
checked_at: timestamp,
|
|
2310
|
+
live: turn.liveness.live,
|
|
2311
|
+
reason: PROMPT_ACK_TIMEOUT_REASON,
|
|
2312
|
+
},
|
|
1197
2313
|
updated_at: timestamp,
|
|
1198
2314
|
completed_at: timestamp,
|
|
1199
2315
|
};
|
|
@@ -1214,6 +2330,16 @@ async function reconcileRuntimeAcknowledgement(
|
|
|
1214
2330
|
ackTimeoutMs: number,
|
|
1215
2331
|
options: { failOnTimeout: boolean } = { failOnTimeout: true },
|
|
1216
2332
|
): Promise<TurnRecord> {
|
|
2333
|
+
const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${turn.session_id}.json`)));
|
|
2334
|
+
if (
|
|
2335
|
+
sessionState &&
|
|
2336
|
+
runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
|
|
2337
|
+
sessionState.source !== "coordinator" &&
|
|
2338
|
+
(sessionState.state === "completed" || sessionState.state === "errored") &&
|
|
2339
|
+
(sessionState.current_turn_id === null || sessionState.current_turn_id === turn.turn_id)
|
|
2340
|
+
) {
|
|
2341
|
+
return await markTurnTerminalFromSessionState(namespaceDir, turn, sessionState);
|
|
2342
|
+
}
|
|
1217
2343
|
if (sessionState && runtimeStateAcknowledgesTurn(turn, sessionState)) {
|
|
1218
2344
|
return await markTurnAcknowledgedFromRuntimeState(namespaceDir, turn, sessionState);
|
|
1219
2345
|
}
|
|
@@ -1226,6 +2352,27 @@ async function reconcileRuntimeAcknowledgement(
|
|
|
1226
2352
|
function shellQuote(value: string): string {
|
|
1227
2353
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1228
2354
|
}
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Append an authoritative `--mpreset <profile>` to the coordinator child launch
|
|
2358
|
+
* command so the spawned GJC session activates the selected profile from its
|
|
2359
|
+
* first turn. The profile name is validated against the merged registry before
|
|
2360
|
+
* reaching here; it is still shell-quoted as defense in depth.
|
|
2361
|
+
*
|
|
2362
|
+
* Precedence note: this matches `gjc --mpreset <profile>` semantics exactly, so
|
|
2363
|
+
* an explicit model-affecting flag already present in the operator-configured
|
|
2364
|
+
* `sessionCommand` (e.g. a `--model`/`--thinking` baked into the launch command)
|
|
2365
|
+
* follows the same CLI precedence as a direct `gjc --mpreset ... --model ...`
|
|
2366
|
+
* invocation. The effective profile recorded on the session is the
|
|
2367
|
+
* coordinator-resolved request, not a child-attested activation receipt; a
|
|
2368
|
+
* cross-process activation receipt is a possible future enhancement and is out
|
|
2369
|
+
* of scope for the per-session selection contract here.
|
|
2370
|
+
*/
|
|
2371
|
+
export function buildCoordinatorSessionCommand(sessionCommand: string, mpreset: string | null | undefined): string {
|
|
2372
|
+
if (!mpreset) return sessionCommand;
|
|
2373
|
+
return `${sessionCommand} --mpreset ${shellQuote(mpreset)}`;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
1229
2376
|
function makeTurnRecord(
|
|
1230
2377
|
config: CoordinatorMcpConfig,
|
|
1231
2378
|
sessionId: string,
|
|
@@ -1250,7 +2397,13 @@ function makeTurnRecord(
|
|
|
1250
2397
|
attempts: [],
|
|
1251
2398
|
},
|
|
1252
2399
|
question_ids: [],
|
|
1253
|
-
final_response: {
|
|
2400
|
+
final_response: {
|
|
2401
|
+
text: null,
|
|
2402
|
+
format: "markdown",
|
|
2403
|
+
source: null,
|
|
2404
|
+
artifact_path: null,
|
|
2405
|
+
truncated: false,
|
|
2406
|
+
},
|
|
1254
2407
|
evidence: [],
|
|
1255
2408
|
error: null,
|
|
1256
2409
|
liveness: { checked_at: null, live: null, reason: null },
|
|
@@ -1272,6 +2425,8 @@ function asTerminalTurnStatus(status: unknown): TurnStatus | null {
|
|
|
1272
2425
|
|
|
1273
2426
|
export const COORDINATOR_AWAIT_TURN_TIMEOUT_MAX_MS = 30 * 60 * 1000;
|
|
1274
2427
|
export const COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS = MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS;
|
|
2428
|
+
export const COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS = MAX_RUNTIME_READINESS_TIMEOUT_MS;
|
|
2429
|
+
|
|
1275
2430
|
export const COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS = 30_000;
|
|
1276
2431
|
export const COORDINATOR_POLL_INTERVAL_MAX_MS = 10_000;
|
|
1277
2432
|
|
|
@@ -1290,6 +2445,12 @@ export function boundedRuntimePromptAckTimeoutMs(value: unknown): number {
|
|
|
1290
2445
|
COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS,
|
|
1291
2446
|
);
|
|
1292
2447
|
}
|
|
2448
|
+
export function boundedRuntimeReadinessTimeoutMs(value: unknown): number {
|
|
2449
|
+
return Math.min(
|
|
2450
|
+
parsePositiveIntegerMs(value, DEFAULT_RUNTIME_READINESS_TIMEOUT_MS),
|
|
2451
|
+
COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS,
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
1293
2454
|
|
|
1294
2455
|
export function boundedEventWatchTimeoutMs(value: unknown): number {
|
|
1295
2456
|
return Math.min(parsePositiveIntegerMs(value, 1000), COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS);
|
|
@@ -1298,8 +2459,21 @@ export function boundedEventWatchTimeoutMs(value: unknown): number {
|
|
|
1298
2459
|
export function boundedPollIntervalMs(value: unknown): number {
|
|
1299
2460
|
return Math.min(Math.max(parsePositiveIntegerMs(value, 100), 10), COORDINATOR_POLL_INTERVAL_MAX_MS);
|
|
1300
2461
|
}
|
|
1301
|
-
async function runCommand(
|
|
1302
|
-
|
|
2462
|
+
async function runCommand(
|
|
2463
|
+
command: string[],
|
|
2464
|
+
stdinLine?: string,
|
|
2465
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
2466
|
+
const proc = Bun.spawn(command, {
|
|
2467
|
+
stdin: stdinLine === undefined ? "ignore" : "pipe",
|
|
2468
|
+
stdout: "pipe",
|
|
2469
|
+
stderr: "pipe",
|
|
2470
|
+
});
|
|
2471
|
+
if (stdinLine !== undefined) {
|
|
2472
|
+
const stdin = proc.stdin;
|
|
2473
|
+
if (!stdin) throw new Error("coordinator_command_stdin_unavailable");
|
|
2474
|
+
stdin.write(`${stdinLine}\n`);
|
|
2475
|
+
stdin.end();
|
|
2476
|
+
}
|
|
1303
2477
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1304
2478
|
new Response(proc.stdout).text(),
|
|
1305
2479
|
new Response(proc.stderr).text(),
|
|
@@ -1308,29 +2482,39 @@ async function runCommand(command: string[]): Promise<{ exitCode: number; stdout
|
|
|
1308
2482
|
return { exitCode, stdout, stderr };
|
|
1309
2483
|
}
|
|
1310
2484
|
|
|
1311
|
-
type CommandRunner = (command: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
1312
|
-
|
|
1313
2485
|
async function sendTmuxPromptKeys(
|
|
1314
2486
|
target: string,
|
|
1315
2487
|
prompt: string,
|
|
1316
|
-
runner: CommandRunner
|
|
2488
|
+
runner: CommandRunner,
|
|
2489
|
+
socketKey: string,
|
|
2490
|
+
proveCurrentServer: () => Promise<boolean>,
|
|
1317
2491
|
): Promise<boolean> {
|
|
2492
|
+
const runMutation = async (argv: string[]): Promise<CommandResult | null> => {
|
|
2493
|
+
if (!(await proveCurrentServer())) return null;
|
|
2494
|
+
return await runner(argv);
|
|
2495
|
+
};
|
|
1318
2496
|
const bufferName = `gjc-coordinator-prompt-${randomUUID()}`;
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
2497
|
+
const tmux = ["tmux", "-L", socketKey];
|
|
2498
|
+
const zeroize = async (): Promise<boolean> => {
|
|
2499
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2500
|
+
const cleared = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", ""]);
|
|
2501
|
+
const deleted =
|
|
2502
|
+
cleared?.exitCode === 0 ? await runMutation([...tmux, "delete-buffer", "-b", bufferName]) : null;
|
|
2503
|
+
if (cleared?.exitCode === 0 && deleted?.exitCode === 0) return true;
|
|
2504
|
+
}
|
|
2505
|
+
return false;
|
|
2506
|
+
};
|
|
2507
|
+
const buffered = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", prompt]);
|
|
2508
|
+
if (buffered?.exitCode !== 0) return false;
|
|
2509
|
+
const pasted = await runMutation([...tmux, "paste-buffer", "-d", "-b", bufferName, "-t", target]);
|
|
2510
|
+
if (pasted?.exitCode !== 0) {
|
|
2511
|
+
if (!(await zeroize())) throw new Error("coordinator_prompt_buffer_privacy_unverified");
|
|
1324
2512
|
return false;
|
|
1325
2513
|
}
|
|
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;
|
|
2514
|
+
const dismissedAutocomplete = await runMutation([...tmux, "send-keys", "-t", target, "Escape"]);
|
|
2515
|
+
if (dismissedAutocomplete?.exitCode !== 0) return false;
|
|
2516
|
+
const submitted = await runMutation([...tmux, "send-keys", "-t", target, "Enter"]);
|
|
2517
|
+
return submitted?.exitCode === 0;
|
|
1334
2518
|
}
|
|
1335
2519
|
|
|
1336
2520
|
function boundedLineCount(value: unknown): number {
|
|
@@ -1355,7 +2539,10 @@ async function registerExistingTmuxSession(
|
|
|
1355
2539
|
namespaceDir: string,
|
|
1356
2540
|
sessionFilePath: string,
|
|
1357
2541
|
runner: CommandRunner = runCommand,
|
|
1358
|
-
): Promise<{
|
|
2542
|
+
): Promise<{
|
|
2543
|
+
session: Record<string, unknown>;
|
|
2544
|
+
sessionState: CoordinatorSessionState;
|
|
2545
|
+
}> {
|
|
1359
2546
|
await assertTmuxTargetAvailable(input.tmuxSession, input.tmuxTarget, runner);
|
|
1360
2547
|
const existing = asRecord(await readJsonFile(sessionFilePath));
|
|
1361
2548
|
if (existing) {
|
|
@@ -1381,6 +2568,7 @@ async function registerExistingTmuxSession(
|
|
|
1381
2568
|
authoritative: true,
|
|
1382
2569
|
warp_attached: input.warpAttached,
|
|
1383
2570
|
source: input.source,
|
|
2571
|
+
origin: "registered",
|
|
1384
2572
|
model: input.model,
|
|
1385
2573
|
};
|
|
1386
2574
|
await writeJsonFile(sessionFilePath, session);
|
|
@@ -1391,52 +2579,406 @@ async function registerExistingTmuxSession(
|
|
|
1391
2579
|
return { session, sessionState: state };
|
|
1392
2580
|
}
|
|
1393
2581
|
|
|
2582
|
+
async function portableProcessStartTime(pid: number, runner: CommandRunner): Promise<string | null> {
|
|
2583
|
+
const result = await runner(["ps", "-o", "lstart=", "-p", String(pid)]);
|
|
2584
|
+
const startTime = result.stdout.trim();
|
|
2585
|
+
return result.exitCode === 0 &&
|
|
2586
|
+
startTime.length > 0 &&
|
|
2587
|
+
startTime.length <= 160 &&
|
|
2588
|
+
!/[\r\n\u0000-\u001f\u007f]/.test(startTime)
|
|
2589
|
+
? startTime
|
|
2590
|
+
: null;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
export async function coordinatorOwnerIsolationProbe(runner: CommandRunner): Promise<OwnerIsolationProbe> {
|
|
2594
|
+
return {
|
|
2595
|
+
readCallerCgroup: async () => await fs.readFile("/proc/self/cgroup", "utf8").catch(() => null),
|
|
2596
|
+
probeServer: async (socketKey): Promise<TmuxServerProof> => {
|
|
2597
|
+
try {
|
|
2598
|
+
const result = await runner(["tmux", "-L", socketKey, "list-sessions", "-F", "#{pid} #{session_name}"]);
|
|
2599
|
+
if (result.exitCode !== 0) {
|
|
2600
|
+
const diagnostic = `${result.stdout}\n${result.stderr}`.slice(0, 512);
|
|
2601
|
+
return /(?:no server running|failed to connect to server|no sessions|error connecting to)/i.test(
|
|
2602
|
+
diagnostic,
|
|
2603
|
+
)
|
|
2604
|
+
? { state: "absent" }
|
|
2605
|
+
: { state: "unverifiable" };
|
|
2606
|
+
}
|
|
2607
|
+
const rows = result.stdout
|
|
2608
|
+
.split("\n")
|
|
2609
|
+
.map(line => line.trim().split(/\s+/, 2))
|
|
2610
|
+
.filter(([pid, name]) => Boolean(pid && name));
|
|
2611
|
+
const pid = Number(rows[0]?.[0]);
|
|
2612
|
+
const sessionNames = rows.map(([, name]) => name as string);
|
|
2613
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || rows.some(([rowPid]) => Number(rowPid) !== pid))
|
|
2614
|
+
return { state: "unverifiable" };
|
|
2615
|
+
const stat =
|
|
2616
|
+
process.platform === "linux" ? await fs.readFile(`/proc/${pid}/stat`, "utf8").catch(() => null) : null;
|
|
2617
|
+
const startTime =
|
|
2618
|
+
process.platform === "linux"
|
|
2619
|
+
? ownerProcessStartTime(process.platform, stat)
|
|
2620
|
+
: await portableProcessStartTime(pid, runner);
|
|
2621
|
+
if (!startTime) return { state: "unverifiable", pid, sessionNames };
|
|
2622
|
+
if (process.platform !== "linux")
|
|
2623
|
+
return {
|
|
2624
|
+
state: "safe",
|
|
2625
|
+
pid,
|
|
2626
|
+
startTime,
|
|
2627
|
+
sessionNames,
|
|
2628
|
+
cgroup: { classification: "not_applicable" },
|
|
2629
|
+
};
|
|
2630
|
+
const cgroupText = await fs.readFile(`/proc/${pid}/cgroup`, "utf8").catch(() => null);
|
|
2631
|
+
const cgroup = classifyCgroup({ platform: process.platform, cgroupText });
|
|
2632
|
+
return {
|
|
2633
|
+
state:
|
|
2634
|
+
cgroup.classification === "safe"
|
|
2635
|
+
? "safe"
|
|
2636
|
+
: cgroup.classification === "unsafe_service"
|
|
2637
|
+
? "unsafe"
|
|
2638
|
+
: "unverifiable",
|
|
2639
|
+
pid,
|
|
2640
|
+
startTime,
|
|
2641
|
+
sessionNames,
|
|
2642
|
+
cgroup,
|
|
2643
|
+
};
|
|
2644
|
+
} catch {
|
|
2645
|
+
// A transient spawn failure (e.g. EAGAIN under load, or a momentary tmux/ps hiccup)
|
|
2646
|
+
// must not fatally abort the owner-server proof. Surfacing it as an unverifiable state
|
|
2647
|
+
// lets proveCoordinatorOwnerServer's bounded retry loop re-probe once the transient
|
|
2648
|
+
// clears, instead of propagating the throw and failing the entire delegate outright.
|
|
2649
|
+
return { state: "unverifiable" };
|
|
2650
|
+
}
|
|
2651
|
+
},
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
async function proveCoordinatorOwnerServer(probe: OwnerIsolationProbe, socketKey: string): Promise<TmuxServerProof> {
|
|
2656
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
2657
|
+
const proof = await probe.probeServer(socketKey);
|
|
2658
|
+
if (proof.state === "unsafe" || (proof.state === "safe" && proof.pid && proof.startTime)) return proof;
|
|
2659
|
+
if (attempt < 49) await Bun.sleep(10);
|
|
2660
|
+
}
|
|
2661
|
+
return { state: "unverifiable" };
|
|
2662
|
+
}
|
|
2663
|
+
|
|
1394
2664
|
async function startTmuxSession(
|
|
1395
2665
|
config: CoordinatorMcpConfig,
|
|
1396
2666
|
input: SessionStartInput,
|
|
1397
2667
|
namespaceDir: string,
|
|
1398
2668
|
runner: CommandRunner = runCommand,
|
|
2669
|
+
ownerIsolationProbe?: OwnerIsolationProbe,
|
|
1399
2670
|
): Promise<Record<string, unknown>> {
|
|
1400
2671
|
if (!config.sessionCommand) throw new Error("coordinator_session_command_required");
|
|
1401
|
-
const sessionName =
|
|
2672
|
+
const sessionName = input.sessionId;
|
|
2673
|
+
const socketKey = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
|
|
2674
|
+
const ownerStateDir = namespaceDir;
|
|
2675
|
+
const ownerGeneration = randomUUID();
|
|
2676
|
+
const generationBaseline = captureOwnerGenerationBaselineSync(ownerStateDir, sessionName);
|
|
1402
2677
|
const runtimeStateFile = sessionStateFile(namespaceDir, sessionName);
|
|
1403
2678
|
const sessionCommand = [
|
|
1404
2679
|
"exec env",
|
|
1405
2680
|
`${GJC_COORDINATOR_SESSION_STATE_FILE_ENV}=${shellQuote(runtimeStateFile)}`,
|
|
1406
2681
|
`${GJC_COORDINATOR_SESSION_ID_ENV}=${shellQuote(sessionName)}`,
|
|
1407
|
-
|
|
2682
|
+
`${GJC_TMUX_OWNER_GENERATION_ENV}=${shellQuote(ownerGeneration)}`,
|
|
2683
|
+
`${GJC_TMUX_OWNER_STATE_DIR_ENV}=${shellQuote(ownerStateDir)}`,
|
|
2684
|
+
`${GJC_TMUX_OWNER_SERVER_KEY_ENV}=${shellQuote(socketKey)}`,
|
|
2685
|
+
`${GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV}=${shellQuote(input.launchId)}`,
|
|
2686
|
+
`${GJC_COORDINATOR_SESSION_READINESS_FILE_ENV}=${shellQuote(input.readinessMarkerFile)}`,
|
|
2687
|
+
buildCoordinatorSessionCommand(config.sessionCommand, input.mpreset),
|
|
1408
2688
|
].join(" ");
|
|
1409
|
-
const
|
|
2689
|
+
const tmuxArgv = [
|
|
1410
2690
|
"tmux",
|
|
2691
|
+
"-L",
|
|
2692
|
+
socketKey,
|
|
1411
2693
|
"new-session",
|
|
1412
2694
|
"-d",
|
|
1413
2695
|
"-P",
|
|
1414
2696
|
"-F",
|
|
1415
|
-
"#{session_name}:#{window_index}.#{pane_index} #{pane_id}",
|
|
2697
|
+
"#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
|
|
1416
2698
|
"-s",
|
|
1417
2699
|
sessionName,
|
|
1418
2700
|
"-c",
|
|
1419
2701
|
input.cwd,
|
|
1420
2702
|
sessionCommand,
|
|
1421
|
-
]
|
|
1422
|
-
|
|
1423
|
-
|
|
2703
|
+
];
|
|
2704
|
+
const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
|
|
2705
|
+
let plan = await planTmuxOwnerIsolation(
|
|
2706
|
+
{
|
|
2707
|
+
schema_version: 1,
|
|
2708
|
+
op: "plan",
|
|
2709
|
+
platform: process.platform,
|
|
2710
|
+
session_id: sessionName,
|
|
2711
|
+
owner_generation: ownerGeneration,
|
|
2712
|
+
baseline: generationBaseline,
|
|
2713
|
+
cwd: input.cwd,
|
|
2714
|
+
state_dir: ownerStateDir,
|
|
2715
|
+
socket_key: socketKey,
|
|
2716
|
+
tmux_argv: tmuxArgv,
|
|
2717
|
+
},
|
|
2718
|
+
probe,
|
|
2719
|
+
);
|
|
2720
|
+
if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
|
|
2721
|
+
if (plan.execution.mode === "direct" && plan.execution.server_absent_before) {
|
|
2722
|
+
plan = await planTmuxOwnerIsolation(
|
|
2723
|
+
{
|
|
2724
|
+
schema_version: 1,
|
|
2725
|
+
op: "plan",
|
|
2726
|
+
platform: process.platform,
|
|
2727
|
+
session_id: sessionName,
|
|
2728
|
+
owner_generation: ownerGeneration,
|
|
2729
|
+
baseline: generationBaseline,
|
|
2730
|
+
cwd: input.cwd,
|
|
2731
|
+
state_dir: ownerStateDir,
|
|
2732
|
+
socket_key: socketKey,
|
|
2733
|
+
tmux_argv: tmuxArgv,
|
|
2734
|
+
},
|
|
2735
|
+
{
|
|
2736
|
+
...probe,
|
|
2737
|
+
readCallerCgroup: async () => "0::/gjc-coordinator-bootstrap.service",
|
|
2738
|
+
},
|
|
2739
|
+
);
|
|
2740
|
+
if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
|
|
2741
|
+
}
|
|
2742
|
+
const preSpawnProof =
|
|
2743
|
+
plan.execution.mode === "direct" && plan.server_state === "safe"
|
|
2744
|
+
? await proveCoordinatorOwnerServer(probe, socketKey)
|
|
2745
|
+
: null;
|
|
2746
|
+
if (
|
|
2747
|
+
preSpawnProof &&
|
|
2748
|
+
(preSpawnProof.state !== "safe" ||
|
|
2749
|
+
!preSpawnProof.pid ||
|
|
2750
|
+
!preSpawnProof.startTime ||
|
|
2751
|
+
(process.platform === "linux" && preSpawnProof.cgroup?.classification !== "safe"))
|
|
2752
|
+
)
|
|
2753
|
+
throw new Error("coordinator_tmux_owner_server_unverifiable");
|
|
2754
|
+
const started = await runner(
|
|
2755
|
+
plan.execution.argv,
|
|
2756
|
+
plan.execution.mode === "scoped" ? plan.execution.stdin_line : undefined,
|
|
2757
|
+
);
|
|
2758
|
+
|
|
2759
|
+
const rawScopedReceipt =
|
|
2760
|
+
plan.execution.mode === "scoped" && isExactScopedBootstrapSuccessReceipt(started.stdout)
|
|
2761
|
+
? (JSON.parse(started.stdout.trim()) as {
|
|
2762
|
+
native_session_id: string;
|
|
2763
|
+
server_pid: number;
|
|
2764
|
+
server_start_time: string;
|
|
2765
|
+
session_name: string;
|
|
2766
|
+
})
|
|
2767
|
+
: undefined;
|
|
2768
|
+
const scopedReceipt =
|
|
2769
|
+
rawScopedReceipt?.session_name === plan.execution.attempt_session ? rawScopedReceipt : undefined;
|
|
2770
|
+
const directReceipt =
|
|
2771
|
+
plan.execution.mode === "direct"
|
|
2772
|
+
? parseTmuxNewSessionRecord(started.stdout, plan.execution.attempt_session)
|
|
2773
|
+
: undefined;
|
|
2774
|
+
const directNativeSessionId = directReceipt?.nativeSessionId;
|
|
2775
|
+
const spawnedNativeSessionId = scopedReceipt?.native_session_id ?? directNativeSessionId;
|
|
2776
|
+
let expectedServer = scopedReceipt
|
|
2777
|
+
? {
|
|
2778
|
+
pid: scopedReceipt.server_pid,
|
|
2779
|
+
startTime: scopedReceipt.server_start_time,
|
|
2780
|
+
}
|
|
2781
|
+
: preSpawnProof && preSpawnProof.state === "safe" && preSpawnProof.pid && preSpawnProof.startTime
|
|
2782
|
+
? { pid: preSpawnProof.pid, startTime: preSpawnProof.startTime }
|
|
2783
|
+
: undefined;
|
|
2784
|
+
const cleanupFailedAttempt = async (): Promise<"cleaned" | "failed" | "unverifiable"> => {
|
|
2785
|
+
if (!spawnedNativeSessionId || !expectedServer) return "unverifiable";
|
|
2786
|
+
try {
|
|
2787
|
+
const result = await runner([
|
|
2788
|
+
"tmux",
|
|
2789
|
+
"-L",
|
|
2790
|
+
socketKey,
|
|
2791
|
+
"if-shell",
|
|
2792
|
+
"-t",
|
|
2793
|
+
spawnedNativeSessionId,
|
|
2794
|
+
"-F",
|
|
2795
|
+
coordinatorCleanupPredicate(expectedServer.pid, spawnedNativeSessionId, plan.execution.attempt_session),
|
|
2796
|
+
`kill-session -t '${spawnedNativeSessionId}' ; display-message -p __gjc_coordinator_cleanup_ok__`,
|
|
2797
|
+
"display-message -p __gjc_coordinator_cleanup_refused__",
|
|
2798
|
+
]);
|
|
2799
|
+
if (result.exitCode !== 0) return "failed";
|
|
2800
|
+
return result.stdout.trim() === "__gjc_coordinator_cleanup_ok__" ? "cleaned" : "unverifiable";
|
|
2801
|
+
} catch {
|
|
2802
|
+
return "unverifiable";
|
|
2803
|
+
}
|
|
2804
|
+
};
|
|
2805
|
+
const failAfterCleanup = async (reason: string): Promise<never> => {
|
|
2806
|
+
throw new CoordinatorStartError(reason, await cleanupFailedAttempt());
|
|
2807
|
+
};
|
|
2808
|
+
if (started.exitCode !== 0) return await failAfterCleanup("coordinator_tmux_start_failed");
|
|
2809
|
+
const bootstrapReceiptValid = plan.execution.mode !== "scoped" || scopedReceipt !== undefined;
|
|
2810
|
+
const initialProof = await proveCoordinatorOwnerServer(probe, socketKey).catch(error =>
|
|
2811
|
+
failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_owner_server_unverifiable"),
|
|
2812
|
+
);
|
|
2813
|
+
if (
|
|
2814
|
+
initialProof.state !== "safe" ||
|
|
2815
|
+
!initialProof.pid ||
|
|
2816
|
+
!initialProof.startTime ||
|
|
2817
|
+
(process.platform === "linux" && initialProof.cgroup?.classification !== "safe")
|
|
2818
|
+
) {
|
|
2819
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_unverifiable");
|
|
2820
|
+
}
|
|
2821
|
+
if (
|
|
2822
|
+
scopedReceipt &&
|
|
2823
|
+
(initialProof.pid !== scopedReceipt.server_pid || initialProof.startTime !== scopedReceipt.server_start_time)
|
|
2824
|
+
) {
|
|
2825
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2826
|
+
}
|
|
2827
|
+
if (
|
|
2828
|
+
preSpawnProof &&
|
|
2829
|
+
(initialProof.pid !== preSpawnProof.pid || initialProof.startTime !== preSpawnProof.startTime)
|
|
2830
|
+
) {
|
|
2831
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2832
|
+
}
|
|
2833
|
+
if (!expectedServer)
|
|
2834
|
+
expectedServer = {
|
|
2835
|
+
pid: initialProof.pid,
|
|
2836
|
+
startTime: initialProof.startTime,
|
|
2837
|
+
};
|
|
2838
|
+
if (!bootstrapReceiptValid) {
|
|
2839
|
+
return await failAfterCleanup("coordinator_tmux_start_failed");
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
const startRecord = await (async () => {
|
|
2843
|
+
const queried = await runner([
|
|
2844
|
+
"tmux",
|
|
2845
|
+
"-L",
|
|
2846
|
+
socketKey,
|
|
2847
|
+
"display-message",
|
|
2848
|
+
"-p",
|
|
2849
|
+
"-t",
|
|
2850
|
+
sessionName,
|
|
2851
|
+
"#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
|
|
2852
|
+
]);
|
|
2853
|
+
if (queried.exitCode !== 0) throw new Error("coordinator_tmux_start_failed");
|
|
2854
|
+
return queried.stdout.trim();
|
|
2855
|
+
})().catch(error => failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_start_failed"));
|
|
2856
|
+
const postLaunchProof = await probe.probeServer(socketKey);
|
|
2857
|
+
if (
|
|
2858
|
+
postLaunchProof.state !== "safe" ||
|
|
2859
|
+
postLaunchProof.pid !== initialProof.pid ||
|
|
2860
|
+
postLaunchProof.startTime !== initialProof.startTime
|
|
2861
|
+
) {
|
|
2862
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2863
|
+
}
|
|
2864
|
+
const parsedStartRecord = parseTmuxNewSessionRecord(startRecord, plan.execution.attempt_session);
|
|
2865
|
+
if (!parsedStartRecord) {
|
|
2866
|
+
return await failAfterCleanup("coordinator_tmux_start_failed");
|
|
2867
|
+
}
|
|
2868
|
+
const { tmuxTarget, paneId, nativeSessionId } = parsedStartRecord;
|
|
2869
|
+
if (
|
|
2870
|
+
(scopedReceipt && nativeSessionId !== scopedReceipt.native_session_id) ||
|
|
2871
|
+
(plan.execution.mode === "direct" && nativeSessionId !== directNativeSessionId)
|
|
2872
|
+
) {
|
|
2873
|
+
return await failAfterCleanup("coordinator_tmux_owner_server_race");
|
|
2874
|
+
}
|
|
2875
|
+
const ownerTransaction = {
|
|
2876
|
+
commit: () => replaceOwnerGenerationSync(ownerStateDir, sessionName, ownerGeneration, generationBaseline),
|
|
2877
|
+
rollback: cleanupFailedAttempt,
|
|
2878
|
+
};
|
|
1424
2879
|
return {
|
|
1425
2880
|
sessionId: sessionName,
|
|
1426
2881
|
tmuxSession: sessionName,
|
|
1427
|
-
tmuxTarget
|
|
2882
|
+
tmuxTarget,
|
|
1428
2883
|
paneId,
|
|
2884
|
+
tmuxNativeSessionId: nativeSessionId,
|
|
2885
|
+
tmuxSocketKey: socketKey,
|
|
2886
|
+
tmuxOwnerGeneration: ownerGeneration,
|
|
2887
|
+
tmuxOwnerStateDir: ownerStateDir,
|
|
2888
|
+
tmuxOwnerServerKey: socketKey,
|
|
2889
|
+
tmuxOwnerServerPid: initialProof.pid,
|
|
2890
|
+
tmuxOwnerServerStartTime: initialProof.startTime,
|
|
2891
|
+
|
|
1429
2892
|
cwd: input.cwd,
|
|
1430
2893
|
createdAt: new Date().toISOString(),
|
|
1431
2894
|
sessionCommand: config.sessionCommand,
|
|
2895
|
+
...(input.mpreset ? { mpreset: input.mpreset } : {}),
|
|
1432
2896
|
runtimeStateFile,
|
|
2897
|
+
__coordinatorOwnerTransaction: ownerTransaction,
|
|
2898
|
+
launchId: input.launchId,
|
|
2899
|
+
readinessMarkerFile: input.readinessMarkerFile,
|
|
2900
|
+
readinessFile: input.readinessMarkerFile,
|
|
2901
|
+
origin: "coordinator_created",
|
|
2902
|
+
runtime_readiness_latched: false,
|
|
1433
2903
|
};
|
|
1434
2904
|
}
|
|
1435
2905
|
|
|
1436
|
-
|
|
2906
|
+
function hasPrivateTmuxOwnerIdentity(session: Record<string, unknown>): boolean {
|
|
2907
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2908
|
+
const serverKey =
|
|
2909
|
+
typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
|
|
2910
|
+
const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
|
|
2911
|
+
const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
|
|
2912
|
+
const nativeSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
|
|
2913
|
+
const paneId = session.pane_id ?? session.paneId;
|
|
2914
|
+
return (
|
|
2915
|
+
typeof socketKey === "string" &&
|
|
2916
|
+
socketKey.length > 0 &&
|
|
2917
|
+
socketKey === serverKey &&
|
|
2918
|
+
Number.isSafeInteger(pid) &&
|
|
2919
|
+
typeof startTime === "string" &&
|
|
2920
|
+
startTime.length > 0 &&
|
|
2921
|
+
typeof nativeSessionId === "string" &&
|
|
2922
|
+
nativeSessionId.length > 0 &&
|
|
2923
|
+
typeof paneId === "string" &&
|
|
2924
|
+
paneId.length > 0
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
async function proveImmutableTmuxTarget(session: Record<string, unknown>, runner: CommandRunner): Promise<boolean> {
|
|
2929
|
+
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
2930
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2931
|
+
const expectedSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
|
|
2932
|
+
const expectedPaneId = session.pane_id ?? session.paneId;
|
|
2933
|
+
if (
|
|
2934
|
+
typeof target !== "string" ||
|
|
2935
|
+
typeof socketKey !== "string" ||
|
|
2936
|
+
typeof expectedSessionId !== "string" ||
|
|
2937
|
+
typeof expectedPaneId !== "string"
|
|
2938
|
+
)
|
|
2939
|
+
return false;
|
|
2940
|
+
const identity = await runner([
|
|
2941
|
+
"tmux",
|
|
2942
|
+
"-L",
|
|
2943
|
+
socketKey,
|
|
2944
|
+
"display-message",
|
|
2945
|
+
"-p",
|
|
2946
|
+
"-t",
|
|
2947
|
+
target,
|
|
2948
|
+
"#{session_id} #{pane_id}",
|
|
2949
|
+
]);
|
|
2950
|
+
const parts = identity.stdout.trim().split(/\s+/);
|
|
2951
|
+
if (identity.exitCode !== 0) return false;
|
|
2952
|
+
return parts.length === 2 && parts[0] === expectedSessionId && parts[1] === expectedPaneId;
|
|
2953
|
+
}
|
|
2954
|
+
async function captureTmuxTail(
|
|
2955
|
+
session: Record<string, unknown>,
|
|
2956
|
+
lines: number,
|
|
2957
|
+
runner: CommandRunner = runCommand,
|
|
2958
|
+
): Promise<string[]> {
|
|
1437
2959
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1438
|
-
|
|
1439
|
-
|
|
2960
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2961
|
+
if (
|
|
2962
|
+
!hasPrivateTmuxOwnerIdentity(session) ||
|
|
2963
|
+
typeof socketKey !== "string" ||
|
|
2964
|
+
socketKey.length === 0 ||
|
|
2965
|
+
typeof target !== "string" ||
|
|
2966
|
+
target.length === 0
|
|
2967
|
+
)
|
|
2968
|
+
return [];
|
|
2969
|
+
const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
|
|
2970
|
+
const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
|
|
2971
|
+
const probe = await coordinatorOwnerIsolationProbe(runner);
|
|
2972
|
+
const proof = await probe.probeServer(socketKey);
|
|
2973
|
+
if (
|
|
2974
|
+
proof.state !== "safe" ||
|
|
2975
|
+
proof.pid !== pid ||
|
|
2976
|
+
proof.startTime !== startTime ||
|
|
2977
|
+
(process.platform === "linux" && proof.cgroup?.classification !== "safe") ||
|
|
2978
|
+
!(await proveImmutableTmuxTarget(session, runner))
|
|
2979
|
+
)
|
|
2980
|
+
return [];
|
|
2981
|
+
const captured = await runner(["tmux", "-L", socketKey, "capture-pane", "-t", target, "-p", "-S", `-${lines}`]);
|
|
1440
2982
|
if (captured.exitCode !== 0) return [];
|
|
1441
2983
|
return captured.stdout.split("\n").slice(-lines);
|
|
1442
2984
|
}
|
|
@@ -1445,10 +2987,38 @@ async function sendTmuxPrompt(
|
|
|
1445
2987
|
session: Record<string, unknown>,
|
|
1446
2988
|
prompt: string,
|
|
1447
2989
|
runner: CommandRunner = runCommand,
|
|
2990
|
+
ownerIsolationProbe?: OwnerIsolationProbe,
|
|
1448
2991
|
): Promise<boolean> {
|
|
1449
2992
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1450
|
-
|
|
1451
|
-
|
|
2993
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
2994
|
+
const serverKey =
|
|
2995
|
+
typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
|
|
2996
|
+
const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
|
|
2997
|
+
const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
|
|
2998
|
+
if (
|
|
2999
|
+
typeof target !== "string" ||
|
|
3000
|
+
target.length === 0 ||
|
|
3001
|
+
typeof socketKey !== "string" ||
|
|
3002
|
+
socketKey.length === 0 ||
|
|
3003
|
+
socketKey !== serverKey ||
|
|
3004
|
+
!Number.isSafeInteger(pid) ||
|
|
3005
|
+
typeof startTime !== "string" ||
|
|
3006
|
+
startTime.length === 0
|
|
3007
|
+
)
|
|
3008
|
+
return false;
|
|
3009
|
+
const paneId = session.pane_id ?? session.paneId;
|
|
3010
|
+
if (typeof paneId !== "string" || !(await proveImmutableTmuxTarget(session, runner))) return false;
|
|
3011
|
+
const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
|
|
3012
|
+
const proveCurrentServer = async (): Promise<boolean> => {
|
|
3013
|
+
const proof = await probe.probeServer(socketKey);
|
|
3014
|
+
return (
|
|
3015
|
+
proof.state === "safe" &&
|
|
3016
|
+
proof.pid === pid &&
|
|
3017
|
+
proof.startTime === startTime &&
|
|
3018
|
+
(process.platform !== "linux" || proof.cgroup?.classification === "safe")
|
|
3019
|
+
);
|
|
3020
|
+
};
|
|
3021
|
+
return await sendTmuxPromptKeys(paneId, prompt, runner, socketKey, proveCurrentServer);
|
|
1452
3022
|
}
|
|
1453
3023
|
|
|
1454
3024
|
async function hasTmuxSession(
|
|
@@ -1456,8 +3026,16 @@ async function hasTmuxSession(
|
|
|
1456
3026
|
runner: CommandRunner = runCommand,
|
|
1457
3027
|
): Promise<boolean | null> {
|
|
1458
3028
|
const tmuxSession = typeof session.tmux_session === "string" ? session.tmux_session : session.tmuxSession;
|
|
1459
|
-
|
|
1460
|
-
|
|
3029
|
+
const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
|
|
3030
|
+
if (
|
|
3031
|
+
!hasPrivateTmuxOwnerIdentity(session) ||
|
|
3032
|
+
typeof socketKey !== "string" ||
|
|
3033
|
+
socketKey.length === 0 ||
|
|
3034
|
+
typeof tmuxSession !== "string" ||
|
|
3035
|
+
tmuxSession.length === 0
|
|
3036
|
+
)
|
|
3037
|
+
return null;
|
|
3038
|
+
const checked = await runner(["tmux", "-L", socketKey, "has-session", "-t", tmuxSession]);
|
|
1461
3039
|
return checked.exitCode === 0;
|
|
1462
3040
|
}
|
|
1463
3041
|
|
|
@@ -1492,7 +3070,8 @@ async function inspectTmuxSession(
|
|
|
1492
3070
|
runner: CommandRunner = runCommand,
|
|
1493
3071
|
): Promise<Record<string, unknown>> {
|
|
1494
3072
|
const live = await hasTmuxSession(session, runner);
|
|
1495
|
-
const tail = live ? await captureTmuxTail(session, lines) : [];
|
|
3073
|
+
const tail = live ? await captureTmuxTail(session, lines, runner) : [];
|
|
3074
|
+
|
|
1496
3075
|
return {
|
|
1497
3076
|
live,
|
|
1498
3077
|
...summarizePaneTail(tail),
|
|
@@ -1607,7 +3186,7 @@ export async function readCoordinatorArtifact(
|
|
|
1607
3186
|
} catch (error) {
|
|
1608
3187
|
return {
|
|
1609
3188
|
ok: false,
|
|
1610
|
-
reason: (error
|
|
3189
|
+
reason: publicCoordinatorError(error),
|
|
1611
3190
|
};
|
|
1612
3191
|
} finally {
|
|
1613
3192
|
await handle?.close();
|
|
@@ -1621,6 +3200,122 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1621
3200
|
const services = options.services ?? {};
|
|
1622
3201
|
const namespaceDir = coordinatorNamespacePath(config);
|
|
1623
3202
|
const commandRunner = services.commandRunner ?? runCommand;
|
|
3203
|
+
const loadModelProfiles = services.resolveModelProfiles ?? loadCoordinatorModelProfiles;
|
|
3204
|
+
const readinessTimeoutMs = boundedRuntimeReadinessTimeoutMs(env.GJC_COORDINATOR_MCP_RUNTIME_READINESS_TIMEOUT_MS);
|
|
3205
|
+
async function withSessionTransition<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
|
3206
|
+
return await withSessionMutation(`${namespaceDir}::${sessionId}`, operation);
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
async function startCoordinatorSession(
|
|
3210
|
+
cwd: string,
|
|
3211
|
+
prompt?: string,
|
|
3212
|
+
mpreset?: string | null,
|
|
3213
|
+
): Promise<Record<string, unknown>> {
|
|
3214
|
+
const sessionId = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
|
|
3215
|
+
const launchId = randomUUID();
|
|
3216
|
+
const readinessRoot = path.join(namespaceDir, "launch-readiness");
|
|
3217
|
+
const readinessDir = path.join(readinessRoot, sessionId);
|
|
3218
|
+
await ensureDir(readinessDir);
|
|
3219
|
+
const canonicalRoot = await fs.realpath(readinessRoot);
|
|
3220
|
+
const canonicalDir = await fs.realpath(readinessDir);
|
|
3221
|
+
if (!canonicalDir.startsWith(`${canonicalRoot}${path.sep}`))
|
|
3222
|
+
throw new Error("coordinator_readiness_path_invalid");
|
|
3223
|
+
const readinessMarkerFile = path.join(canonicalDir, `${launchId}.json`);
|
|
3224
|
+
const input: SessionStartInput = {
|
|
3225
|
+
cwd,
|
|
3226
|
+
prompt,
|
|
3227
|
+
namespace: config.namespace,
|
|
3228
|
+
mpreset,
|
|
3229
|
+
worktree: true,
|
|
3230
|
+
sessionId,
|
|
3231
|
+
launchId,
|
|
3232
|
+
readinessMarkerFile,
|
|
3233
|
+
readinessFile: readinessMarkerFile,
|
|
3234
|
+
};
|
|
3235
|
+
const started = services.startSession
|
|
3236
|
+
? await services.startSession(input)
|
|
3237
|
+
: await startTmuxSession(config, input, namespaceDir, commandRunner, services.ownerIsolationProbe);
|
|
3238
|
+
const record = asRecord(started);
|
|
3239
|
+
if (!record) throw new Error("coordinator_session_command_required");
|
|
3240
|
+
const attestedSessionId = optionalString(record.sessionId ?? record.session_id);
|
|
3241
|
+
const attestedLaunchId = optionalString(record.launchId ?? record.launch_id);
|
|
3242
|
+
const attestedMarkerFile = optionalString(
|
|
3243
|
+
record.readinessMarkerFile ?? record.readiness_marker_file ?? record.readinessFile ?? record.readiness_file,
|
|
3244
|
+
);
|
|
3245
|
+
if (!attestedSessionId || !attestedLaunchId || !attestedMarkerFile)
|
|
3246
|
+
throw new Error("coordinator_launch_attestation_missing");
|
|
3247
|
+
if (
|
|
3248
|
+
attestedSessionId !== sessionId ||
|
|
3249
|
+
attestedLaunchId !== launchId ||
|
|
3250
|
+
(await canonicalizePath(attestedMarkerFile)) !== readinessMarkerFile
|
|
3251
|
+
)
|
|
3252
|
+
throw new Error("coordinator_launch_attestation_mismatch");
|
|
3253
|
+
return normalizeSession({
|
|
3254
|
+
...record,
|
|
3255
|
+
session_id: sessionId,
|
|
3256
|
+
launch_id: launchId,
|
|
3257
|
+
readiness_marker_file: readinessMarkerFile,
|
|
3258
|
+
readiness_marker_path: readinessMarkerFile,
|
|
3259
|
+
origin: "coordinator_created",
|
|
3260
|
+
runtime_ready_launch_id: null,
|
|
3261
|
+
runtime_ready_at: null,
|
|
3262
|
+
runtime_readiness_latched: false,
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
function sessionReadinessAuthorityError(
|
|
3267
|
+
reason: "coordinator_launch_metadata_missing" | "session_origin_unverified",
|
|
3268
|
+
session: Record<string, unknown>,
|
|
3269
|
+
): Error {
|
|
3270
|
+
const error = new Error(reason);
|
|
3271
|
+
Object.assign(error, {
|
|
3272
|
+
sessionId: optionalString(session.session_id),
|
|
3273
|
+
action: "Start a new coordinator session or explicitly register the live external tmux session.",
|
|
3274
|
+
});
|
|
3275
|
+
return error;
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
function assertSessionReadinessAuthority(session: Record<string, unknown>): void {
|
|
3279
|
+
if (session.origin === "registered") return;
|
|
3280
|
+
const tmuxTarget = session.tmux_target ?? session.tmuxTarget;
|
|
3281
|
+
if (
|
|
3282
|
+
session.origin == null &&
|
|
3283
|
+
typeof session.registered_at === "string" &&
|
|
3284
|
+
session.registered_at.length > 0 &&
|
|
3285
|
+
session.authoritative === true &&
|
|
3286
|
+
hasTmuxIdentity(session) &&
|
|
3287
|
+
typeof tmuxTarget === "string" &&
|
|
3288
|
+
tmuxTarget.length > 0
|
|
3289
|
+
)
|
|
3290
|
+
return;
|
|
3291
|
+
if (session.origin === "coordinator_created") {
|
|
3292
|
+
const sessionId = optionalString(session.session_id);
|
|
3293
|
+
const launchId = optionalString(session.launch_id);
|
|
3294
|
+
const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
|
|
3295
|
+
if (!sessionId || !launchId || !markerFile) {
|
|
3296
|
+
throw sessionReadinessAuthorityError("coordinator_launch_metadata_missing", session);
|
|
3297
|
+
}
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
throw sessionReadinessAuthorityError("session_origin_unverified", session);
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
async function awaitRuntimeReadiness(session: Record<string, unknown>): Promise<boolean> {
|
|
3304
|
+
assertSessionReadinessAuthority(session);
|
|
3305
|
+
if (session.origin === "registered" || session.origin == null) return true;
|
|
3306
|
+
const sessionId = optionalString(session.session_id);
|
|
3307
|
+
const launchId = optionalString(session.launch_id);
|
|
3308
|
+
const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
|
|
3309
|
+
if (!sessionId || !launchId || !markerFile) throw new Error("coordinator_launch_metadata_missing");
|
|
3310
|
+
if (session.runtime_ready_launch_id === launchId) return true;
|
|
3311
|
+
return await waitForRuntimeReadinessMarker(
|
|
3312
|
+
markerFile,
|
|
3313
|
+
sessionId,
|
|
3314
|
+
launchId,
|
|
3315
|
+
path.join(namespaceDir, "launch-readiness"),
|
|
3316
|
+
readinessTimeoutMs,
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
1624
3319
|
|
|
1625
3320
|
async function listSessions(): Promise<unknown[]> {
|
|
1626
3321
|
if (!config.namespace.profile || !config.namespace.repo) return [];
|
|
@@ -1630,6 +3325,154 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1630
3325
|
function sessionFile(sessionId: unknown): string {
|
|
1631
3326
|
return path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`);
|
|
1632
3327
|
}
|
|
3328
|
+
const forceCloseSession =
|
|
3329
|
+
services.forceCloseSession ??
|
|
3330
|
+
((name: string, targetEnv: NodeJS.ProcessEnv, expectedSessionId?: string, expectedStateFile?: string) =>
|
|
3331
|
+
forceCloseGjcTmuxSession(name, targetEnv, expectedSessionId, expectedStateFile));
|
|
3332
|
+
|
|
3333
|
+
// Reap a coordinator delegate-created (ephemeral) session. Addresses the #2034 review:
|
|
3334
|
+
// - terminates via the owner-proof forceCloseGjcTmuxSession (pid + native session_id +
|
|
3335
|
+
// generation + server_key + start_time verified atomically) — never a raw process.kill,
|
|
3336
|
+
// so a recycled PID can never signal a foreign process.
|
|
3337
|
+
// - re-validates ephemeral + no-active-turn AT kill time under a per-session lock, closing
|
|
3338
|
+
// the select→kill TOCTOU and races with concurrent send_prompt / delegate / stop.
|
|
3339
|
+
// - purges state files ONLY on verified termination; on failure the record is kept for retry
|
|
3340
|
+
// and no session.reaped event is emitted (honest audit, no worse leak).
|
|
3341
|
+
async function reapSession(
|
|
3342
|
+
rawId: unknown,
|
|
3343
|
+
opts: { force?: boolean; reason?: string } = {},
|
|
3344
|
+
): Promise<{ ok: boolean; reason?: string; killed: boolean; active_turn_id?: string; detail?: string }> {
|
|
3345
|
+
const id = safeExternalId("session", rawId);
|
|
3346
|
+
return await withSessionMutation(`${namespaceDir}::${id}`, async () => {
|
|
3347
|
+
const record = asRecord(await readJsonFile(sessionFile(id)));
|
|
3348
|
+
if (!record) return { ok: false, reason: "unknown_session", killed: false };
|
|
3349
|
+
if (record.ephemeral !== true && opts.force !== true) {
|
|
3350
|
+
return { ok: false, reason: "not_ephemeral", killed: false };
|
|
3351
|
+
}
|
|
3352
|
+
const activeTurn = await readActiveTurn(namespaceDir, id);
|
|
3353
|
+
if (activeTurn) {
|
|
3354
|
+
return { ok: false, reason: "active_turn", killed: false, active_turn_id: activeTurn.turn_id };
|
|
3355
|
+
}
|
|
3356
|
+
const tmuxSession = optionalString(record.tmux_session) ?? optionalString(record.tmuxSession);
|
|
3357
|
+
let killed = false;
|
|
3358
|
+
if (tmuxSession) {
|
|
3359
|
+
// Production delegate records persist the owner state-file path as `runtimeStateFile`
|
|
3360
|
+
// (see startTmuxSession). Read that so the owner-proof close is actually bound to the
|
|
3361
|
+
// recorded state-file identity; `sessionStateFile` is only a compatibility fallback for
|
|
3362
|
+
// any externally-registered record that used the older key.
|
|
3363
|
+
const expectedStateFile =
|
|
3364
|
+
optionalString(record.runtimeStateFile) ?? optionalString(record.sessionStateFile) ?? undefined;
|
|
3365
|
+
try {
|
|
3366
|
+
await forceCloseSession(tmuxSession, env, undefined, expectedStateFile);
|
|
3367
|
+
killed = true;
|
|
3368
|
+
} catch (err) {
|
|
3369
|
+
return {
|
|
3370
|
+
ok: false,
|
|
3371
|
+
reason: "terminate_failed",
|
|
3372
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
3373
|
+
killed: false,
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
// Purge the session record FIRST so a partial rm failure can never re-list → re-reap →
|
|
3378
|
+
// emit a duplicate session.reaped. `killed` is honest: false when nothing was signalled.
|
|
3379
|
+
await fs.rm(sessionFile(id), { force: true });
|
|
3380
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
3381
|
+
kind: "session.reaped",
|
|
3382
|
+
sessionId: id,
|
|
3383
|
+
summary: `Session ${id} reaped${opts.reason ? ` (${opts.reason})` : ""}`,
|
|
3384
|
+
metadata: { reason: opts.reason ?? null, force: opts.force === true, killed },
|
|
3385
|
+
});
|
|
3386
|
+
await fs.rm(sessionStateFile(namespaceDir, id), { force: true });
|
|
3387
|
+
await fs.rm(activeTurnFile(namespaceDir, id), { force: true });
|
|
3388
|
+
return { ok: true, killed };
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
const sessionReaper: SessionReaper = createSessionReaper(
|
|
3393
|
+
{
|
|
3394
|
+
listSessions: async (): Promise<ReapableSession[]> => {
|
|
3395
|
+
const out: ReapableSession[] = [];
|
|
3396
|
+
for (const raw of await listSessions()) {
|
|
3397
|
+
try {
|
|
3398
|
+
const rec = asRecord(raw);
|
|
3399
|
+
if (rec?.ephemeral !== true) continue;
|
|
3400
|
+
const sid = optionalString(rec.session_id);
|
|
3401
|
+
if (!sid) continue;
|
|
3402
|
+
const activeTurn = await readActiveTurn(namespaceDir, sid);
|
|
3403
|
+
const state = await readSessionState(namespaceDir, sid);
|
|
3404
|
+
const stamp = optionalString(state?.updated_at) ?? optionalString(rec.created_at);
|
|
3405
|
+
const lastActivityMs = stamp ? Date.parse(stamp) : 0;
|
|
3406
|
+
out.push({
|
|
3407
|
+
sessionId: sid,
|
|
3408
|
+
ephemeral: true,
|
|
3409
|
+
hasActiveTurn: activeTurn !== null,
|
|
3410
|
+
lastActivityMs: Number.isFinite(lastActivityMs) ? lastActivityMs : 0,
|
|
3411
|
+
});
|
|
3412
|
+
} catch {
|
|
3413
|
+
// A single corrupt session/state file must not abort the whole sweep — skip it.
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
return out;
|
|
3417
|
+
},
|
|
3418
|
+
reapSession: async (sid: string): Promise<void> => {
|
|
3419
|
+
await reapSession(sid, { reason: "idle_reaper" });
|
|
3420
|
+
},
|
|
3421
|
+
now: () => Date.now(),
|
|
3422
|
+
},
|
|
3423
|
+
{ idleTtlMs: config.sessionIdleTtlMs, sweepIntervalMs: config.sessionSweepIntervalMs },
|
|
3424
|
+
);
|
|
3425
|
+
async function compensateFailedOwnerStart(
|
|
3426
|
+
session: Record<string, unknown>,
|
|
3427
|
+
ownerTransaction: {
|
|
3428
|
+
rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
|
|
3429
|
+
} | null,
|
|
3430
|
+
): Promise<"cleaned" | "failed" | "unverifiable"> {
|
|
3431
|
+
let cleanupStatus: "cleaned" | "failed" | "unverifiable" = "unverifiable";
|
|
3432
|
+
try {
|
|
3433
|
+
cleanupStatus = (await ownerTransaction?.rollback?.()) ?? "unverifiable";
|
|
3434
|
+
} catch {
|
|
3435
|
+
cleanupStatus = "unverifiable";
|
|
3436
|
+
}
|
|
3437
|
+
try {
|
|
3438
|
+
const sessionId = String(session.session_id);
|
|
3439
|
+
const cwd = typeof session.cwd === "string" ? path.resolve(session.cwd) : null;
|
|
3440
|
+
if (!cwd) throw coordinatorStateError("invalid");
|
|
3441
|
+
const state: CoordinatorSessionState = {
|
|
3442
|
+
schema_version: 1,
|
|
3443
|
+
session_id: sessionId,
|
|
3444
|
+
state: "errored",
|
|
3445
|
+
ready_for_input: false,
|
|
3446
|
+
current_turn_id: null,
|
|
3447
|
+
last_turn_id: null,
|
|
3448
|
+
updated_at: new Date().toISOString(),
|
|
3449
|
+
source: "coordinator",
|
|
3450
|
+
live: false,
|
|
3451
|
+
reason: "coordinator_start_rolled_back",
|
|
3452
|
+
cwd,
|
|
3453
|
+
workdir: cwd,
|
|
3454
|
+
session_file: null,
|
|
3455
|
+
};
|
|
3456
|
+
await writeJsonFile(sessionStateFile(namespaceDir, sessionId), state);
|
|
3457
|
+
await fs.rm(sessionFile(sessionId), { force: true });
|
|
3458
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
3459
|
+
kind: "session.state_changed",
|
|
3460
|
+
sessionId,
|
|
3461
|
+
summary: `Session ${sessionId} start rolled back`,
|
|
3462
|
+
payloadRef: path.relative(namespaceDir, sessionStateFile(namespaceDir, sessionId)),
|
|
3463
|
+
metadata: {
|
|
3464
|
+
state: "errored",
|
|
3465
|
+
ready_for_input: false,
|
|
3466
|
+
live: false,
|
|
3467
|
+
reason: "coordinator_start_rolled_back",
|
|
3468
|
+
},
|
|
3469
|
+
});
|
|
3470
|
+
} catch {
|
|
3471
|
+
return "failed";
|
|
3472
|
+
}
|
|
3473
|
+
return cleanupStatus;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
1633
3476
|
async function listQuestions(args: Record<string, unknown>): Promise<unknown[]> {
|
|
1634
3477
|
const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
|
|
1635
3478
|
const status = typeof args.status === "string" && args.status.length > 0 ? args.status : null;
|
|
@@ -1653,11 +3496,12 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1653
3496
|
return evidence;
|
|
1654
3497
|
}
|
|
1655
3498
|
|
|
1656
|
-
async function
|
|
3499
|
+
async function prepareActiveTurnUnlocked(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
|
|
3500
|
+
const active = await readActiveTurn(namespaceDir, turn.session_id);
|
|
3501
|
+
if (active && active.turn_id !== turn.turn_id) return active;
|
|
1657
3502
|
const timestamp = new Date().toISOString();
|
|
1658
3503
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1659
|
-
const
|
|
1660
|
-
const pendingTurn: TurnRecord = {
|
|
3504
|
+
const pending: TurnRecord = {
|
|
1661
3505
|
...turn,
|
|
1662
3506
|
status: "active",
|
|
1663
3507
|
delivery: {
|
|
@@ -1666,90 +3510,312 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1666
3510
|
target: typeof target === "string" ? target : null,
|
|
1667
3511
|
tmux_keys_sent: false,
|
|
1668
3512
|
prompt_acknowledged: false,
|
|
1669
|
-
state: "
|
|
3513
|
+
state: "awaiting_runtime_readiness",
|
|
1670
3514
|
attempts: [
|
|
1671
3515
|
{
|
|
1672
3516
|
delivered: false,
|
|
1673
3517
|
tmux_keys_sent: false,
|
|
1674
3518
|
channel: "tmux_keys",
|
|
1675
3519
|
created_at: timestamp,
|
|
1676
|
-
reason: "
|
|
3520
|
+
reason: "awaiting_runtime_readiness",
|
|
1677
3521
|
},
|
|
1678
3522
|
],
|
|
1679
3523
|
},
|
|
1680
|
-
liveness: { checked_at:
|
|
3524
|
+
liveness: { checked_at: null, live: null, reason: null },
|
|
1681
3525
|
started_at: turn.started_at ?? timestamp,
|
|
1682
3526
|
updated_at: timestamp,
|
|
1683
3527
|
};
|
|
1684
|
-
await writeTurnRecord(namespaceDir,
|
|
1685
|
-
await writeActiveTurn(namespaceDir,
|
|
1686
|
-
await writeSessionState(namespaceDir,
|
|
1687
|
-
currentTurnId:
|
|
1688
|
-
live,
|
|
1689
|
-
reason:
|
|
3528
|
+
await writeTurnRecord(namespaceDir, pending);
|
|
3529
|
+
await writeActiveTurn(namespaceDir, pending);
|
|
3530
|
+
await writeSessionState(namespaceDir, pending.session_id, "booting", {
|
|
3531
|
+
currentTurnId: pending.turn_id,
|
|
3532
|
+
live: null,
|
|
3533
|
+
reason: "awaiting_runtime_readiness",
|
|
1690
3534
|
});
|
|
3535
|
+
return pending;
|
|
3536
|
+
}
|
|
1691
3537
|
|
|
1692
|
-
|
|
1693
|
-
const
|
|
1694
|
-
const
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
3538
|
+
async function completePreparedTurn(session: Record<string, unknown>, pendingTurn: TurnRecord): Promise<TurnRecord> {
|
|
3539
|
+
const ready = await awaitRuntimeReadiness(session);
|
|
3540
|
+
const observedLive = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
|
|
3541
|
+
return await withSessionTransition(pendingTurn.session_id, async () => {
|
|
3542
|
+
const current = await readTurnRecord(namespaceDir, pendingTurn.turn_id);
|
|
3543
|
+
const active = await readActiveTurn(namespaceDir, pendingTurn.session_id);
|
|
3544
|
+
if (
|
|
3545
|
+
!current ||
|
|
3546
|
+
!active ||
|
|
3547
|
+
active.turn_id !== pendingTurn.turn_id ||
|
|
3548
|
+
TERMINAL_TURN_STATUSES.has(current.status) ||
|
|
3549
|
+
current.delivery.state !== "awaiting_runtime_readiness" ||
|
|
3550
|
+
current.delivery.tmux_keys_sent === true
|
|
3551
|
+
)
|
|
3552
|
+
return current ?? pendingTurn;
|
|
3553
|
+
if (!ready) {
|
|
3554
|
+
const completedAt = new Date().toISOString();
|
|
3555
|
+
const message = `GJC runtime did not attest interactive input readiness within ${readinessTimeoutMs}ms.`;
|
|
3556
|
+
const failed: TurnRecord = {
|
|
3557
|
+
...current,
|
|
3558
|
+
status: "failed",
|
|
3559
|
+
delivery: {
|
|
3560
|
+
...current.delivery,
|
|
1705
3561
|
delivered: false,
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
3562
|
+
queued: false,
|
|
3563
|
+
tmux_keys_sent: false,
|
|
3564
|
+
prompt_acknowledged: false,
|
|
3565
|
+
state: "awaiting_runtime_readiness",
|
|
1710
3566
|
},
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
3567
|
+
final_response: {
|
|
3568
|
+
text: message,
|
|
3569
|
+
format: "markdown",
|
|
3570
|
+
source: "coordinator_runtime_readiness",
|
|
3571
|
+
artifact_path: null,
|
|
3572
|
+
truncated: false,
|
|
3573
|
+
},
|
|
3574
|
+
error: { code: RUNTIME_READINESS_TIMEOUT_REASON, message, recoverable: true },
|
|
3575
|
+
evidence: [
|
|
3576
|
+
...current.evidence,
|
|
3577
|
+
{
|
|
3578
|
+
type: RUNTIME_READINESS_TIMEOUT_REASON,
|
|
3579
|
+
created_at: completedAt,
|
|
3580
|
+
tmux_keys_sent: false,
|
|
3581
|
+
prompt_acknowledged: false,
|
|
3582
|
+
},
|
|
3583
|
+
],
|
|
3584
|
+
updated_at: completedAt,
|
|
3585
|
+
completed_at: completedAt,
|
|
3586
|
+
};
|
|
3587
|
+
await writeTurnRecord(namespaceDir, failed);
|
|
3588
|
+
await clearActiveTurn(namespaceDir, failed);
|
|
3589
|
+
await writeSessionState(namespaceDir, failed.session_id, "booting", {
|
|
3590
|
+
lastTurnId: failed.turn_id,
|
|
3591
|
+
live: null,
|
|
3592
|
+
reason: RUNTIME_READINESS_TIMEOUT_REASON,
|
|
3593
|
+
});
|
|
3594
|
+
return failed;
|
|
3595
|
+
}
|
|
3596
|
+
const currentSession = asRecord(await readJsonFile(sessionFile(pendingTurn.session_id))) ?? session;
|
|
3597
|
+
if (
|
|
3598
|
+
currentSession.origin === "coordinator_created" &&
|
|
3599
|
+
(currentSession.session_id !== session.session_id ||
|
|
3600
|
+
currentSession.launch_id !== session.launch_id ||
|
|
3601
|
+
currentSession.readiness_marker_file !== session.readiness_marker_file)
|
|
3602
|
+
)
|
|
3603
|
+
return current;
|
|
3604
|
+
const latched =
|
|
3605
|
+
currentSession.origin === "coordinator_created"
|
|
3606
|
+
? {
|
|
3607
|
+
...currentSession,
|
|
3608
|
+
runtime_ready_launch_id: currentSession.launch_id,
|
|
3609
|
+
runtime_ready_at: new Date().toISOString(),
|
|
3610
|
+
runtime_readiness_latched: true,
|
|
3611
|
+
}
|
|
3612
|
+
: currentSession;
|
|
3613
|
+
if (latched !== currentSession) await writeJsonFile(sessionFile(pendingTurn.session_id), latched);
|
|
3614
|
+
const target = typeof latched.tmux_target === "string" ? latched.tmux_target : latched.tmuxTarget;
|
|
3615
|
+
const attemptId = `delivery-${randomUUID()}`;
|
|
3616
|
+
const attemptedAt = new Date().toISOString();
|
|
3617
|
+
const delivering: TurnRecord = {
|
|
3618
|
+
...current,
|
|
3619
|
+
status: "delivering",
|
|
3620
|
+
updated_at: attemptedAt,
|
|
3621
|
+
delivery: {
|
|
3622
|
+
...current.delivery,
|
|
3623
|
+
queued: false,
|
|
3624
|
+
state: "delivering",
|
|
3625
|
+
attempts: [
|
|
3626
|
+
...current.delivery.attempts,
|
|
3627
|
+
{
|
|
3628
|
+
attempt_id: attemptId,
|
|
3629
|
+
delivered: false,
|
|
3630
|
+
tmux_keys_sent: false,
|
|
3631
|
+
channel: "tmux_keys",
|
|
3632
|
+
created_at: attemptedAt,
|
|
3633
|
+
reason: "delivery_attempt",
|
|
3634
|
+
},
|
|
3635
|
+
],
|
|
3636
|
+
},
|
|
3637
|
+
};
|
|
3638
|
+
await writeTurnRecord(namespaceDir, delivering);
|
|
3639
|
+
await writeActiveTurn(namespaceDir, delivering);
|
|
3640
|
+
await writeSessionState(namespaceDir, delivering.session_id, "running", {
|
|
3641
|
+
currentTurnId: delivering.turn_id,
|
|
3642
|
+
live: observedLive,
|
|
3643
|
+
reason: null,
|
|
1729
3644
|
});
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
3645
|
+
const tmuxKeysSent = await sendTmuxPrompt(
|
|
3646
|
+
latched,
|
|
3647
|
+
delivering.prompt.text,
|
|
3648
|
+
commandRunner,
|
|
3649
|
+
services.ownerIsolationProbe,
|
|
3650
|
+
);
|
|
3651
|
+
const deliveredAt = new Date().toISOString();
|
|
3652
|
+
const delivered: TurnRecord = {
|
|
3653
|
+
...delivering,
|
|
3654
|
+
status: "active",
|
|
3655
|
+
updated_at: deliveredAt,
|
|
3656
|
+
delivery: {
|
|
3657
|
+
...delivering.delivery,
|
|
3658
|
+
delivered: false,
|
|
3659
|
+
queued: !tmuxKeysSent,
|
|
3660
|
+
tmux_keys_sent: tmuxKeysSent,
|
|
3661
|
+
prompt_acknowledged: false,
|
|
3662
|
+
state: tmuxKeysSent ? "tmux_keys_sent" : "unavailable",
|
|
3663
|
+
attempts: [
|
|
3664
|
+
...delivering.delivery.attempts.slice(0, -1),
|
|
3665
|
+
{
|
|
3666
|
+
attempt_id: attemptId,
|
|
3667
|
+
delivered: false,
|
|
3668
|
+
tmux_keys_sent: tmuxKeysSent,
|
|
3669
|
+
channel: "tmux_keys",
|
|
3670
|
+
created_at: deliveredAt,
|
|
3671
|
+
reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
|
|
3672
|
+
},
|
|
3673
|
+
],
|
|
3674
|
+
},
|
|
3675
|
+
};
|
|
3676
|
+
await writeTurnRecord(namespaceDir, delivered);
|
|
3677
|
+
await writeActiveTurn(namespaceDir, delivered);
|
|
3678
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
3679
|
+
kind: tmuxKeysSent ? "tmux.delivery_succeeded" : "tmux.delivery_failed",
|
|
3680
|
+
sessionId: delivered.session_id,
|
|
3681
|
+
turnId: delivered.turn_id,
|
|
3682
|
+
summary: tmuxKeysSent
|
|
3683
|
+
? `Tmux delivery succeeded for turn ${delivered.turn_id}`
|
|
3684
|
+
: `Tmux delivery failed for turn ${delivered.turn_id}`,
|
|
3685
|
+
payloadRef: path.relative(namespaceDir, turnFile(namespaceDir, delivered.turn_id)),
|
|
3686
|
+
metadata: {
|
|
3687
|
+
target: typeof target === "string" ? target : null,
|
|
3688
|
+
live: observedLive,
|
|
3689
|
+
attempt_id: attemptId,
|
|
3690
|
+
},
|
|
3691
|
+
});
|
|
3692
|
+
return delivered;
|
|
1740
3693
|
});
|
|
1741
|
-
return resolvedTurn;
|
|
1742
3694
|
}
|
|
1743
3695
|
|
|
1744
|
-
async function
|
|
3696
|
+
async function activateTurn(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
|
|
3697
|
+
const pendingTurn = await withSessionTransition(
|
|
3698
|
+
turn.session_id,
|
|
3699
|
+
async () => await prepareActiveTurnUnlocked(session, turn),
|
|
3700
|
+
);
|
|
3701
|
+
if (pendingTurn.turn_id !== turn.turn_id) return pendingTurn;
|
|
3702
|
+
return await completePreparedTurn(session, pendingTurn);
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
async function prepareNextQueuedTurnUnlocked(sessionId: string): Promise<{
|
|
3706
|
+
session: Record<string, unknown>;
|
|
3707
|
+
turn: TurnRecord;
|
|
3708
|
+
} | null> {
|
|
1745
3709
|
const session = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
1746
|
-
if (!session) return null;
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
3710
|
+
if (!session || (await readActiveTurn(namespaceDir, sessionId))) return null;
|
|
3711
|
+
try {
|
|
3712
|
+
assertSessionReadinessAuthority(session);
|
|
3713
|
+
} catch {
|
|
3714
|
+
return null;
|
|
3715
|
+
}
|
|
3716
|
+
const queuedTurns = (await listTurnRecords(namespaceDir))
|
|
3717
|
+
.filter(turn => turn.session_id === sessionId && turn.status === "queued")
|
|
1750
3718
|
.sort((left, right) => left.created_at.localeCompare(right.created_at));
|
|
1751
3719
|
const nextTurn = queuedTurns[0];
|
|
1752
|
-
|
|
3720
|
+
if (!nextTurn) return null;
|
|
3721
|
+
return { session, turn: await prepareActiveTurnUnlocked(session, nextTurn) };
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
async function reconcileRuntimeAcknowledgementFenced(
|
|
3725
|
+
turnId: string,
|
|
3726
|
+
sessionState: CoordinatorSessionState | null,
|
|
3727
|
+
failOnTimeout: boolean,
|
|
3728
|
+
): Promise<TurnRecord | null> {
|
|
3729
|
+
const observed = await readTurnRecord(namespaceDir, turnId);
|
|
3730
|
+
if (!observed) return null;
|
|
3731
|
+
const observedTurnId = sessionState?.current_turn_id;
|
|
3732
|
+
return await withSessionTransition(observed.session_id, async () => {
|
|
3733
|
+
const current = await readTurnRecord(namespaceDir, turnId);
|
|
3734
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
3735
|
+
const currentState = await readSessionState(namespaceDir, current.session_id);
|
|
3736
|
+
const state =
|
|
3737
|
+
sessionState &&
|
|
3738
|
+
sessionState.updated_at === currentState?.updated_at &&
|
|
3739
|
+
observedTurnId === currentState.current_turn_id
|
|
3740
|
+
? sessionState
|
|
3741
|
+
: currentState;
|
|
3742
|
+
return await reconcileRuntimeAcknowledgement(namespaceDir, current, state, promptAckTimeoutMs, {
|
|
3743
|
+
failOnTimeout,
|
|
3744
|
+
});
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
async function markTurnTerminalFromSessionStateFenced(
|
|
3749
|
+
turnId: string,
|
|
3750
|
+
observedState: CoordinatorSessionState,
|
|
3751
|
+
): Promise<TurnRecord | null> {
|
|
3752
|
+
const observed = await readTurnRecord(namespaceDir, turnId);
|
|
3753
|
+
if (!observed) return null;
|
|
3754
|
+
return await withSessionTransition(observed.session_id, async () => {
|
|
3755
|
+
const current = await readTurnRecord(namespaceDir, turnId);
|
|
3756
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
3757
|
+
const currentState = await readSessionState(namespaceDir, current.session_id);
|
|
3758
|
+
if (!currentState || currentState.updated_at !== observedState.updated_at) return current;
|
|
3759
|
+
const matchesTurn =
|
|
3760
|
+
currentState.current_turn_id === current.turn_id ||
|
|
3761
|
+
(currentState.state === "errored" &&
|
|
3762
|
+
currentState.source === "agent_session_event" &&
|
|
3763
|
+
currentState.current_turn_id == null);
|
|
3764
|
+
if (!matchesTurn || (currentState.state !== "completed" && currentState.state !== "errored")) return current;
|
|
3765
|
+
return await markTurnTerminalFromSessionState(namespaceDir, current, currentState);
|
|
3766
|
+
});
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
async function markTurnFailedForUnavailableSessionFenced(
|
|
3770
|
+
turnId: string,
|
|
3771
|
+
reason: string,
|
|
3772
|
+
): Promise<TurnRecord | null> {
|
|
3773
|
+
const observed = await readTurnRecord(namespaceDir, turnId);
|
|
3774
|
+
if (!observed) return null;
|
|
3775
|
+
return await withSessionTransition(observed.session_id, async () => {
|
|
3776
|
+
const current = await readTurnRecord(namespaceDir, turnId);
|
|
3777
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
3778
|
+
return await markTurnFailedForUnavailableSession(namespaceDir, current, reason);
|
|
3779
|
+
});
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
async function preparePromptTurn(
|
|
3783
|
+
session: Record<string, unknown>,
|
|
3784
|
+
prompt: string,
|
|
3785
|
+
options: { force: boolean; queue: boolean; queueWithoutActive?: boolean },
|
|
3786
|
+
): Promise<
|
|
3787
|
+
| { ok: false; activeTurn: TurnRecord }
|
|
3788
|
+
| { ok: true; turn: TurnRecord; priorActiveTurn: TurnRecord | null; queued: boolean }
|
|
3789
|
+
> {
|
|
3790
|
+
const sessionId = safeExternalId("session", session.session_id);
|
|
3791
|
+
const prepared = await withSessionTransition(sessionId, async () => {
|
|
3792
|
+
const activeTurn = await readActiveTurn(namespaceDir, sessionId);
|
|
3793
|
+
if (activeTurn && !options.force && !options.queue) {
|
|
3794
|
+
return { ok: false as const, activeTurn };
|
|
3795
|
+
}
|
|
3796
|
+
if (activeTurn && options.force) {
|
|
3797
|
+
const timestamp = new Date().toISOString();
|
|
3798
|
+
const superseded: TurnRecord = {
|
|
3799
|
+
...activeTurn,
|
|
3800
|
+
status: "superseded",
|
|
3801
|
+
updated_at: timestamp,
|
|
3802
|
+
completed_at: timestamp,
|
|
3803
|
+
};
|
|
3804
|
+
await writeTurnRecord(namespaceDir, superseded);
|
|
3805
|
+
await clearActiveTurn(namespaceDir, superseded);
|
|
3806
|
+
}
|
|
3807
|
+
const shouldQueue =
|
|
3808
|
+
options.queue && !options.force && (activeTurn !== null || options.queueWithoutActive === true);
|
|
3809
|
+
const turn = makeTurnRecord(config, sessionId, prompt, shouldQueue ? "queued" : "active");
|
|
3810
|
+
if (shouldQueue) {
|
|
3811
|
+
await writeTurnRecord(namespaceDir, turn);
|
|
3812
|
+
return { ok: true as const, turn, priorActiveTurn: activeTurn, queued: true };
|
|
3813
|
+
}
|
|
3814
|
+
const pending = await prepareActiveTurnUnlocked(session, turn);
|
|
3815
|
+
return { ok: true as const, turn: pending, priorActiveTurn: activeTurn, queued: false };
|
|
3816
|
+
});
|
|
3817
|
+
if (!prepared.ok || prepared.queued) return prepared;
|
|
3818
|
+
return { ...prepared, turn: await completePreparedTurn(session, prepared.turn) };
|
|
1753
3819
|
}
|
|
1754
3820
|
|
|
1755
3821
|
async function readTurnPayload(
|
|
@@ -1766,24 +3832,21 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1766
3832
|
let resolvedTurn = turn;
|
|
1767
3833
|
let advisoryStatus: Record<string, unknown> = { live: false };
|
|
1768
3834
|
let sessionState = await readSessionState(namespaceDir, turn.session_id);
|
|
1769
|
-
resolvedTurn =
|
|
1770
|
-
|
|
1771
|
-
resolvedTurn,
|
|
1772
|
-
sessionState,
|
|
1773
|
-
promptAckTimeoutMs,
|
|
1774
|
-
{ failOnTimeout: false },
|
|
1775
|
-
);
|
|
3835
|
+
resolvedTurn =
|
|
3836
|
+
(await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, false)) ?? resolvedTurn;
|
|
1776
3837
|
if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1777
3838
|
if (
|
|
1778
3839
|
sessionState &&
|
|
1779
3840
|
ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
|
|
3841
|
+
runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
|
|
1780
3842
|
(sessionState.current_turn_id === resolvedTurn.turn_id ||
|
|
1781
3843
|
(sessionState.state === "errored" &&
|
|
1782
3844
|
sessionState.source === "agent_session_event" &&
|
|
1783
3845
|
sessionState.current_turn_id == null)) &&
|
|
1784
3846
|
(sessionState.state === "completed" || sessionState.state === "errored")
|
|
1785
3847
|
) {
|
|
1786
|
-
resolvedTurn =
|
|
3848
|
+
resolvedTurn =
|
|
3849
|
+
(await markTurnTerminalFromSessionStateFenced(resolvedTurn.turn_id, sessionState)) ?? resolvedTurn;
|
|
1787
3850
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1788
3851
|
} else if (
|
|
1789
3852
|
sessionState &&
|
|
@@ -1795,14 +3858,14 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1795
3858
|
session &&
|
|
1796
3859
|
hasTmuxIdentity(session)
|
|
1797
3860
|
) {
|
|
1798
|
-
resolvedTurn =
|
|
1799
|
-
|
|
1800
|
-
resolvedTurn
|
|
1801
|
-
"tmux_delivery_unavailable",
|
|
1802
|
-
);
|
|
3861
|
+
resolvedTurn =
|
|
3862
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_delivery_unavailable")) ??
|
|
3863
|
+
resolvedTurn;
|
|
1803
3864
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1804
3865
|
} else if (!session && ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1805
|
-
resolvedTurn =
|
|
3866
|
+
resolvedTurn =
|
|
3867
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "session_record_missing")) ??
|
|
3868
|
+
resolvedTurn;
|
|
1806
3869
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1807
3870
|
} else if (session) {
|
|
1808
3871
|
advisoryStatus = await inspectTmuxSession(session, boundedLineCount(lines), commandRunner);
|
|
@@ -1811,21 +3874,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1811
3874
|
hasTmuxIdentity(session) &&
|
|
1812
3875
|
advisoryStatus.live === false
|
|
1813
3876
|
) {
|
|
1814
|
-
resolvedTurn =
|
|
1815
|
-
|
|
1816
|
-
resolvedTurn
|
|
1817
|
-
"tmux_session_missing",
|
|
1818
|
-
);
|
|
3877
|
+
resolvedTurn =
|
|
3878
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_session_missing")) ??
|
|
3879
|
+
resolvedTurn;
|
|
1819
3880
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1820
3881
|
}
|
|
1821
3882
|
}
|
|
1822
3883
|
if (ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1823
|
-
resolvedTurn =
|
|
1824
|
-
|
|
1825
|
-
resolvedTurn,
|
|
1826
|
-
sessionState,
|
|
1827
|
-
promptAckTimeoutMs,
|
|
1828
|
-
);
|
|
3884
|
+
resolvedTurn =
|
|
3885
|
+
(await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, true)) ?? resolvedTurn;
|
|
1829
3886
|
if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1830
3887
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1831
3888
|
}
|
|
@@ -1847,51 +3904,59 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1847
3904
|
}
|
|
1848
3905
|
|
|
1849
3906
|
async function reconcileActiveTurnAcknowledgements(): Promise<void> {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
3907
|
+
await preflightCoordinatorMutation(namespaceDir, undefined, {
|
|
3908
|
+
rejectTerminalRuntimeEvidence: false,
|
|
3909
|
+
});
|
|
3910
|
+
|
|
3911
|
+
const turns = (await listTurnRecords(namespaceDir)).filter(turn => ACTIVE_TURN_STATUSES.has(turn.status));
|
|
1853
3912
|
for (const turn of turns) {
|
|
1854
3913
|
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
3914
|
if (
|
|
1866
3915
|
sessionState &&
|
|
1867
|
-
sessionState.
|
|
3916
|
+
(sessionState.state === "completed" || sessionState.state === "errored") &&
|
|
3917
|
+
(sessionState.current_turn_id === turn.turn_id ||
|
|
3918
|
+
(sessionState.state === "errored" &&
|
|
3919
|
+
sessionState.source === "agent_session_event" &&
|
|
3920
|
+
sessionState.current_turn_id == null))
|
|
3921
|
+
) {
|
|
3922
|
+
const terminal = await markTurnTerminalFromSessionStateFenced(turn.turn_id, sessionState);
|
|
3923
|
+
if (!terminal || !ACTIVE_TURN_STATUSES.has(terminal.status)) continue;
|
|
3924
|
+
}
|
|
3925
|
+
const acknowledged = (await reconcileRuntimeAcknowledgementFenced(turn.turn_id, sessionState, false)) ?? turn;
|
|
3926
|
+
if (!ACTIVE_TURN_STATUSES.has(acknowledged.status)) continue;
|
|
3927
|
+
if (acknowledged !== turn) sessionState = await readSessionState(namespaceDir, acknowledged.session_id);
|
|
3928
|
+
const session = asRecord(await readJsonFile(sessionFile(acknowledged.session_id)));
|
|
3929
|
+
if (
|
|
3930
|
+
sessionState &&
|
|
3931
|
+
sessionState.current_turn_id === acknowledged.turn_id &&
|
|
1868
3932
|
sessionState.state === "stale" &&
|
|
1869
3933
|
sessionState.reason === "tmux_delivery_unavailable" &&
|
|
1870
|
-
|
|
3934
|
+
acknowledged.delivery.state === "unavailable" &&
|
|
1871
3935
|
session &&
|
|
1872
3936
|
hasTmuxIdentity(session)
|
|
1873
3937
|
) {
|
|
1874
|
-
await
|
|
3938
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_delivery_unavailable");
|
|
1875
3939
|
continue;
|
|
1876
3940
|
}
|
|
1877
3941
|
if (!session) {
|
|
1878
|
-
await
|
|
3942
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "session_record_missing");
|
|
1879
3943
|
continue;
|
|
1880
3944
|
}
|
|
1881
3945
|
if (hasTmuxIdentity(session) && (await hasTmuxSession(session, commandRunner)) === false) {
|
|
1882
|
-
await
|
|
3946
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_session_missing");
|
|
1883
3947
|
continue;
|
|
1884
3948
|
}
|
|
1885
|
-
await
|
|
3949
|
+
await reconcileRuntimeAcknowledgementFenced(acknowledged.turn_id, sessionState, true);
|
|
1886
3950
|
}
|
|
1887
3951
|
}
|
|
1888
3952
|
|
|
1889
|
-
async function
|
|
3953
|
+
async function callToolUnlocked(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
1890
3954
|
try {
|
|
1891
3955
|
if (name === "gjc_coordinator_list_sessions") return { ok: true, sessions: await listSessions() };
|
|
1892
3956
|
if (name === "gjc_coordinator_register_session") {
|
|
1893
3957
|
requireCoordinatorMutation(config, "sessions", args);
|
|
1894
3958
|
const sessionId = safeExternalId("session", args.session_id);
|
|
3959
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
1895
3960
|
const cwd = await assertCoordinatorWorkdir(config, args.cwd);
|
|
1896
3961
|
const tmuxSession = safeTmuxSessionName(args.tmux_session);
|
|
1897
3962
|
const tmuxTarget = safeTmuxTarget(args.tmux_target);
|
|
@@ -1915,7 +3980,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1915
3980
|
sessionId,
|
|
1916
3981
|
summary: `Session ${sessionId} registered for coordinator control`,
|
|
1917
3982
|
payloadRef: path.relative(namespaceDir, sessionFile(sessionId)),
|
|
1918
|
-
metadata: {
|
|
3983
|
+
metadata: {
|
|
3984
|
+
source: optionalString(args.source) ?? "register_session",
|
|
3985
|
+
visible: args.visible !== false,
|
|
3986
|
+
},
|
|
1919
3987
|
});
|
|
1920
3988
|
|
|
1921
3989
|
return {
|
|
@@ -1952,7 +4020,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1952
4020
|
}
|
|
1953
4021
|
if (name === "gjc_coordinator_read_tail") {
|
|
1954
4022
|
const session = asRecord(await readJsonFile(sessionFile(args.session_id)));
|
|
1955
|
-
return {
|
|
4023
|
+
return {
|
|
4024
|
+
ok: true,
|
|
4025
|
+
lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines), commandRunner) : [],
|
|
4026
|
+
};
|
|
1956
4027
|
}
|
|
1957
4028
|
if (name === "gjc_coordinator_list_questions") return { ok: true, questions: await listQuestions(args) };
|
|
1958
4029
|
if (name === "gjc_coordinator_list_artifacts") return { ok: true, roots: config.allowedRoots };
|
|
@@ -1987,13 +4058,18 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1987
4058
|
turns,
|
|
1988
4059
|
questions,
|
|
1989
4060
|
reports,
|
|
1990
|
-
events: buildCanonicalCoordinatorEvents({
|
|
4061
|
+
events: buildCanonicalCoordinatorEvents({
|
|
4062
|
+
sessionStates,
|
|
4063
|
+
turns,
|
|
4064
|
+
questions,
|
|
4065
|
+
reports,
|
|
4066
|
+
}),
|
|
1991
4067
|
latest_event_seq: await readLatestEventSeq(namespaceDir),
|
|
1992
4068
|
recent_events: eventSummaries(events.slice(-10)),
|
|
1993
4069
|
};
|
|
1994
4070
|
}
|
|
1995
4071
|
if (name === "gjc_coordinator_watch_events") {
|
|
1996
|
-
await reconcileActiveTurnAcknowledgements
|
|
4072
|
+
await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
|
|
1997
4073
|
const limit = boundedEventLimit(args.limit);
|
|
1998
4074
|
const timeoutMs = boundedEventWatchTimeoutMs(args.timeout_ms);
|
|
1999
4075
|
let events = await readCoordinatorEvents(namespaceDir);
|
|
@@ -2003,7 +4079,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2003
4079
|
const deadline = Date.now() + timeoutMs;
|
|
2004
4080
|
while (matched.length === 0 && Date.now() < deadline) {
|
|
2005
4081
|
await waitForCoordinatorEvents(namespaceDir, Math.min(50, Math.max(1, deadline - Date.now())));
|
|
2006
|
-
await reconcileActiveTurnAcknowledgements
|
|
4082
|
+
await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
|
|
2007
4083
|
events = await readCoordinatorEvents(namespaceDir);
|
|
2008
4084
|
matched = filterCoordinatorEvents(events, args, limit);
|
|
2009
4085
|
}
|
|
@@ -2025,6 +4101,17 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2025
4101
|
const hasPrompt = typeof args.prompt === "string" && args.prompt.trim().length > 0;
|
|
2026
4102
|
const task = hasTask ? String(args.task) : hasPrompt ? String(args.prompt) : null;
|
|
2027
4103
|
if (!task) return { ok: false, reason: "task_required" };
|
|
4104
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4105
|
+
const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
|
|
4106
|
+
if (!mpresetResolution.ok) {
|
|
4107
|
+
return {
|
|
4108
|
+
ok: false,
|
|
4109
|
+
reason: mpresetResolution.reason,
|
|
4110
|
+
mpreset: mpresetResolution.mpreset,
|
|
4111
|
+
available_profiles: mpresetResolution.available_profiles,
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
|
|
2028
4115
|
const promptAliasIgnored = hasTask && hasPrompt;
|
|
2029
4116
|
const mutationRequested = args.allow_mutation === true;
|
|
2030
4117
|
const taggedPrompt = workflowPrompt(delegateWorkflow, name, canonicalCwd, task, {
|
|
@@ -2036,70 +4123,88 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2036
4123
|
let reusedSession = false;
|
|
2037
4124
|
if (args.session_id != null) {
|
|
2038
4125
|
const sessionId = safeExternalId("session", args.session_id);
|
|
4126
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
2039
4127
|
const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
2040
|
-
if (!existing)
|
|
4128
|
+
if (!existing)
|
|
4129
|
+
return {
|
|
4130
|
+
ok: false,
|
|
4131
|
+
reason: "unknown_session",
|
|
4132
|
+
session_id: sessionId,
|
|
4133
|
+
};
|
|
2041
4134
|
const storedCwd = typeof existing.cwd === "string" ? existing.cwd : null;
|
|
2042
4135
|
const canonicalStored = storedCwd ? await canonicalizePath(storedCwd) : null;
|
|
2043
4136
|
const canonicalRequested = await canonicalizePath(canonicalCwd);
|
|
2044
4137
|
if (!canonicalStored || canonicalStored !== canonicalRequested) {
|
|
2045
|
-
return {
|
|
4138
|
+
return {
|
|
4139
|
+
ok: false,
|
|
4140
|
+
reason: "session_cwd_mismatch",
|
|
4141
|
+
session_id: sessionId,
|
|
4142
|
+
};
|
|
4143
|
+
}
|
|
4144
|
+
if (mpresetResolution.mpreset !== null) {
|
|
4145
|
+
const sessionMpreset = typeof existing.mpreset === "string" ? existing.mpreset : null;
|
|
4146
|
+
if (sessionMpreset !== mpresetResolution.mpreset) {
|
|
4147
|
+
return {
|
|
4148
|
+
ok: false,
|
|
4149
|
+
reason: "mpreset_conflict",
|
|
4150
|
+
session_id: sessionId,
|
|
4151
|
+
session_mpreset: sessionMpreset,
|
|
4152
|
+
requested_mpreset: mpresetResolution.mpreset,
|
|
4153
|
+
};
|
|
4154
|
+
}
|
|
2046
4155
|
}
|
|
4156
|
+
|
|
2047
4157
|
session = existing;
|
|
2048
4158
|
reusedSession = true;
|
|
2049
4159
|
} 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
|
-
|
|
4160
|
+
session = await startCoordinatorSession(canonicalCwd, undefined, mpresetResolution.mpreset);
|
|
4161
|
+
const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
|
|
4162
|
+
commit?: () => void;
|
|
4163
|
+
rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
|
|
4164
|
+
} | null;
|
|
4165
|
+
// Delegate-created sessions are ephemeral: the idle reaper may reclaim them once
|
|
4166
|
+
// idle past the TTL with no active turn. User-registered sessions are never flagged.
|
|
4167
|
+
session.ephemeral = true;
|
|
4168
|
+
try {
|
|
4169
|
+
if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
|
|
4170
|
+
await writeJsonFile(sessionFile(session.session_id), session);
|
|
4171
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
4172
|
+
kind: "session.started",
|
|
4173
|
+
sessionId: String(session.session_id),
|
|
4174
|
+
summary: `Session ${String(session.session_id)} started by coordinator delegate`,
|
|
4175
|
+
payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
|
|
4176
|
+
metadata: { delegate: true, workflow: delegateWorkflow },
|
|
4177
|
+
});
|
|
4178
|
+
await writeSessionState(namespaceDir, String(session.session_id), "booting", {
|
|
4179
|
+
live: null,
|
|
4180
|
+
reason: "awaiting_runtime_readiness",
|
|
4181
|
+
});
|
|
4182
|
+
ownerTransaction?.commit?.();
|
|
4183
|
+
} catch (error) {
|
|
4184
|
+
if (ownerTransaction)
|
|
4185
|
+
throw new CoordinatorStartError(
|
|
4186
|
+
"coordinator_tmux_start_failed",
|
|
4187
|
+
await compensateFailedOwnerStart(session, ownerTransaction),
|
|
4188
|
+
);
|
|
4189
|
+
throw error;
|
|
4190
|
+
}
|
|
2075
4191
|
}
|
|
2076
4192
|
|
|
2077
4193
|
const sessionId = String(session.session_id);
|
|
2078
|
-
|
|
2079
|
-
|
|
4194
|
+
assertSessionReadinessAuthority(session);
|
|
4195
|
+
const prepared = await preparePromptTurn(session, taggedPrompt, {
|
|
4196
|
+
force: args.force === true,
|
|
4197
|
+
queue: args.queue === true,
|
|
4198
|
+
});
|
|
4199
|
+
if (!prepared.ok) {
|
|
2080
4200
|
return {
|
|
2081
4201
|
ok: false,
|
|
2082
4202
|
reason: "active_turn_exists",
|
|
2083
4203
|
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,
|
|
4204
|
+
active_turn_id: prepared.activeTurn.turn_id,
|
|
2094
4205
|
};
|
|
2095
|
-
await writeTurnRecord(namespaceDir, superseded);
|
|
2096
|
-
await clearActiveTurn(namespaceDir, superseded);
|
|
2097
4206
|
}
|
|
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);
|
|
4207
|
+
const { turn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
|
|
2103
4208
|
await appendCoordinatorEvent(namespaceDir, {
|
|
2104
4209
|
kind: "delegation.started",
|
|
2105
4210
|
sessionId,
|
|
@@ -2170,34 +4275,75 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2170
4275
|
}
|
|
2171
4276
|
return base;
|
|
2172
4277
|
}
|
|
4278
|
+
if (name === "gjc_coordinator_stop_session") {
|
|
4279
|
+
requireCoordinatorMutation(config, "sessions", args);
|
|
4280
|
+
const sessionId = safeExternalId("session", args.session_id);
|
|
4281
|
+
const forceRequested = args.force === true;
|
|
4282
|
+
// force is a capability distinct from allow_mutation: reaping a non-ephemeral
|
|
4283
|
+
// (user-registered) session requires GJC_COORDINATOR_MCP_FORCE_STOP to be enabled.
|
|
4284
|
+
if (forceRequested && !config.forceStopEnabled) {
|
|
4285
|
+
return { ok: false, reason: "force_not_authorized", session_id: sessionId, killed: false };
|
|
4286
|
+
}
|
|
4287
|
+
const result = await reapSession(sessionId, {
|
|
4288
|
+
force: forceRequested,
|
|
4289
|
+
reason: optionalString(args.reason) ?? "stop_session",
|
|
4290
|
+
});
|
|
4291
|
+
return {
|
|
4292
|
+
ok: result.ok,
|
|
4293
|
+
session_id: sessionId,
|
|
4294
|
+
killed: result.killed,
|
|
4295
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
4296
|
+
...(result.active_turn_id ? { active_turn_id: result.active_turn_id } : {}),
|
|
4297
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
4298
|
+
};
|
|
4299
|
+
}
|
|
2173
4300
|
if (name === "gjc_coordinator_start_session") {
|
|
2174
4301
|
requireCoordinatorMutation(config, "sessions", args);
|
|
2175
4302
|
const cwd = await assertCoordinatorWorkdir(config, args.cwd);
|
|
2176
|
-
|
|
4303
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4304
|
+
const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
|
|
4305
|
+
if (!mpresetResolution.ok) {
|
|
4306
|
+
return {
|
|
4307
|
+
ok: false,
|
|
4308
|
+
reason: mpresetResolution.reason,
|
|
4309
|
+
mpreset: mpresetResolution.mpreset,
|
|
4310
|
+
available_profiles: mpresetResolution.available_profiles,
|
|
4311
|
+
};
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
const session = await startCoordinatorSession(
|
|
2177
4315
|
cwd,
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
4316
|
+
typeof args.prompt === "string" ? args.prompt : undefined,
|
|
4317
|
+
mpresetResolution.mpreset,
|
|
4318
|
+
);
|
|
4319
|
+
const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
|
|
4320
|
+
commit?: () => void;
|
|
4321
|
+
rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
|
|
4322
|
+
} | null;
|
|
4323
|
+
let sessionState: CoordinatorSessionState;
|
|
4324
|
+
try {
|
|
4325
|
+
if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
|
|
4326
|
+
await writeJsonFile(sessionFile(session.session_id), session);
|
|
4327
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
4328
|
+
kind: "session.started",
|
|
4329
|
+
sessionId: String(session.session_id),
|
|
4330
|
+
summary: `Session ${String(session.session_id)} started by coordinator`,
|
|
4331
|
+
payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
|
|
4332
|
+
metadata: { prompted: typeof args.prompt === "string" && args.prompt.length > 0 },
|
|
4333
|
+
});
|
|
4334
|
+
sessionState = await writeSessionState(namespaceDir, String(session.session_id), "booting", {
|
|
4335
|
+
live: null,
|
|
4336
|
+
reason: "awaiting_runtime_readiness",
|
|
4337
|
+
});
|
|
4338
|
+
ownerTransaction?.commit?.();
|
|
4339
|
+
} catch (error) {
|
|
4340
|
+
if (ownerTransaction)
|
|
4341
|
+
throw new CoordinatorStartError(
|
|
4342
|
+
"coordinator_tmux_start_failed",
|
|
4343
|
+
await compensateFailedOwnerStart(session, ownerTransaction),
|
|
4344
|
+
);
|
|
4345
|
+
throw error;
|
|
4346
|
+
}
|
|
2201
4347
|
if (typeof args.prompt === "string" && args.prompt.length > 0) {
|
|
2202
4348
|
const turn = await activateTurn(
|
|
2203
4349
|
session,
|
|
@@ -2233,36 +4379,28 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2233
4379
|
if (name === "gjc_coordinator_send_prompt") {
|
|
2234
4380
|
requireCoordinatorMutation(config, "sessions", args);
|
|
2235
4381
|
const sessionId = safeExternalId("session", args.session_id);
|
|
4382
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
2236
4383
|
const session = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
2237
4384
|
if (!session) return { ok: false, reason: "unknown_session", session_id: sessionId };
|
|
2238
4385
|
if (typeof args.prompt !== "string" || args.prompt.length === 0)
|
|
2239
4386
|
return { ok: false, reason: "prompt_required" };
|
|
2240
|
-
|
|
2241
|
-
|
|
4387
|
+
await readSessionState(namespaceDir, sessionId);
|
|
4388
|
+
await readLatestEventSeq(namespaceDir);
|
|
4389
|
+
assertSessionReadinessAuthority(session);
|
|
4390
|
+
const prepared = await preparePromptTurn(session, args.prompt, {
|
|
4391
|
+
force: args.force === true,
|
|
4392
|
+
queue: args.queue === true,
|
|
4393
|
+
queueWithoutActive: true,
|
|
4394
|
+
});
|
|
4395
|
+
if (!prepared.ok) {
|
|
2242
4396
|
return {
|
|
2243
4397
|
ok: false,
|
|
2244
4398
|
reason: "active_turn_exists",
|
|
2245
4399
|
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,
|
|
4400
|
+
active_turn_id: prepared.activeTurn.turn_id,
|
|
2256
4401
|
};
|
|
2257
|
-
await writeTurnRecord(namespaceDir, superseded);
|
|
2258
|
-
await clearActiveTurn(namespaceDir, superseded);
|
|
2259
4402
|
}
|
|
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;
|
|
4403
|
+
const { turn: recordedTurn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
|
|
2266
4404
|
const prompt = {
|
|
2267
4405
|
session_id: sessionId,
|
|
2268
4406
|
turn_id: recordedTurn.turn_id,
|
|
@@ -2296,7 +4434,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2296
4434
|
const timeoutMs = boundedAwaitTurnTimeoutMs(args.timeout_ms);
|
|
2297
4435
|
const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
|
|
2298
4436
|
const deadline = Date.now() + timeoutMs;
|
|
2299
|
-
let payload = await
|
|
4437
|
+
let payload = await withCoordinatorTransaction(
|
|
4438
|
+
namespaceDir,
|
|
4439
|
+
async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
|
|
4440
|
+
);
|
|
2300
4441
|
while (
|
|
2301
4442
|
payload.ok === true &&
|
|
2302
4443
|
!TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status) &&
|
|
@@ -2308,7 +4449,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2308
4449
|
payload.turn as TurnRecord,
|
|
2309
4450
|
Math.min(pollIntervalMs, remainingMs),
|
|
2310
4451
|
);
|
|
2311
|
-
payload = await
|
|
4452
|
+
payload = await withCoordinatorTransaction(
|
|
4453
|
+
namespaceDir,
|
|
4454
|
+
async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
|
|
4455
|
+
);
|
|
2312
4456
|
}
|
|
2313
4457
|
if (payload.ok === true && !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status)) {
|
|
2314
4458
|
return {
|
|
@@ -2333,6 +4477,16 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2333
4477
|
if (args.turn_id != null && question.turn_id !== safeTurnId(args.turn_id)) {
|
|
2334
4478
|
return { ok: false, reason: "question_turn_mismatch" };
|
|
2335
4479
|
}
|
|
4480
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4481
|
+
const questionSessionId =
|
|
4482
|
+
typeof question.session_id === "string" ? safeExternalId("session", question.session_id) : null;
|
|
4483
|
+
if (!questionSessionId) throw coordinatorStateError("invalid");
|
|
4484
|
+
await preflightCoordinatorMutation(namespaceDir, questionSessionId);
|
|
4485
|
+
if (typeof question.turn_id === "string") {
|
|
4486
|
+
const referencedTurn = await readTurnRecord(namespaceDir, question.turn_id);
|
|
4487
|
+
if (!referencedTurn || referencedTurn.session_id !== questionSessionId)
|
|
4488
|
+
throw coordinatorStateError("invalid");
|
|
4489
|
+
}
|
|
2336
4490
|
const answeredTurnId = typeof question.turn_id === "string" ? question.turn_id : null;
|
|
2337
4491
|
const answered = {
|
|
2338
4492
|
...question,
|
|
@@ -2362,33 +4516,43 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2362
4516
|
|
|
2363
4517
|
let turn: TurnRecord | null = null;
|
|
2364
4518
|
if (answeredTurnId) {
|
|
2365
|
-
|
|
2366
|
-
if (
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
4519
|
+
const observedTurn = await readTurnRecord(namespaceDir, answeredTurnId);
|
|
4520
|
+
if (observedTurn) {
|
|
4521
|
+
turn = await withSessionTransition(observedTurn.session_id, async () => {
|
|
4522
|
+
const current = await readTurnRecord(namespaceDir, answeredTurnId);
|
|
4523
|
+
if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
|
|
4524
|
+
const timestamp = new Date().toISOString();
|
|
4525
|
+
const answeredTurn: TurnRecord = {
|
|
4526
|
+
...current,
|
|
4527
|
+
status: "active",
|
|
4528
|
+
question_ids: [...new Set([...current.question_ids, questionId])],
|
|
4529
|
+
updated_at: timestamp,
|
|
4530
|
+
};
|
|
4531
|
+
await writeTurnRecord(namespaceDir, answeredTurn);
|
|
4532
|
+
await writeActiveTurn(namespaceDir, answeredTurn);
|
|
4533
|
+
await writeSessionState(namespaceDir, answeredTurn.session_id, "running", {
|
|
4534
|
+
currentTurnId: answeredTurn.turn_id,
|
|
4535
|
+
live: null,
|
|
4536
|
+
reason: null,
|
|
4537
|
+
});
|
|
4538
|
+
const session = asRecord(await readJsonFile(sessionFile(answeredTurn.session_id)));
|
|
4539
|
+
if (session && typeof args.answer === "string") {
|
|
4540
|
+
await sendTmuxPrompt(session, args.answer, commandRunner, services.ownerIsolationProbe);
|
|
4541
|
+
}
|
|
4542
|
+
return answeredTurn;
|
|
2380
4543
|
});
|
|
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
4544
|
}
|
|
2385
4545
|
}
|
|
2386
4546
|
return { ok: true, question: answered, ...(turn ? { turn } : {}) };
|
|
2387
4547
|
}
|
|
2388
4548
|
if (name === "gjc_coordinator_report_status") {
|
|
2389
4549
|
requireCoordinatorMutation(config, "reports", args);
|
|
4550
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4551
|
+
|
|
2390
4552
|
const evidence = await validateEvidencePaths(args.evidence_paths);
|
|
2391
4553
|
const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
|
|
4554
|
+
if (sessionId) await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
4555
|
+
|
|
2392
4556
|
const report = {
|
|
2393
4557
|
session_id: sessionId,
|
|
2394
4558
|
turn_id: args.turn_id,
|
|
@@ -2401,61 +4565,79 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2401
4565
|
};
|
|
2402
4566
|
let turn: TurnRecord | null = null;
|
|
2403
4567
|
let promotedTurn: TurnRecord | null = null;
|
|
4568
|
+
let promotedPreparation: { session: Record<string, unknown>; turn: TurnRecord } | null = null;
|
|
2404
4569
|
if (args.turn_id != null) {
|
|
2405
4570
|
turn = await readTurnRecord(namespaceDir, args.turn_id);
|
|
2406
4571
|
if (!turn) return { ok: false, reason: "unknown_turn" };
|
|
2407
4572
|
if (sessionId != null && turn.session_id !== sessionId) {
|
|
2408
4573
|
return { ok: false, reason: "turn_session_mismatch" };
|
|
2409
4574
|
}
|
|
4575
|
+
await preflightCoordinatorMutation(namespaceDir, turn.session_id);
|
|
2410
4576
|
const terminalStatus = asTerminalTurnStatus(args.status);
|
|
2411
4577
|
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
|
-
|
|
4578
|
+
const transition = await withSessionTransition(turn.session_id, async () => {
|
|
4579
|
+
const current = await readTurnRecord(namespaceDir, turn?.turn_id);
|
|
4580
|
+
if (!current || TERMINAL_TURN_STATUSES.has(current.status)) {
|
|
4581
|
+
return { turn: current, promotion: null };
|
|
4582
|
+
}
|
|
4583
|
+
const timestamp = new Date().toISOString();
|
|
4584
|
+
const terminalTurn: TurnRecord = {
|
|
4585
|
+
...current,
|
|
4586
|
+
status: terminalStatus,
|
|
4587
|
+
delivery: {
|
|
4588
|
+
...current.delivery,
|
|
4589
|
+
prompt_acknowledged: true,
|
|
4590
|
+
state: "acknowledged",
|
|
4591
|
+
},
|
|
4592
|
+
final_response: {
|
|
4593
|
+
text:
|
|
4594
|
+
typeof args.summary === "string"
|
|
4595
|
+
? args.summary
|
|
4596
|
+
: typeof args.blocker === "string"
|
|
4597
|
+
? args.blocker
|
|
4598
|
+
: null,
|
|
4599
|
+
format: "markdown",
|
|
4600
|
+
source: "report_status",
|
|
4601
|
+
artifact_path: null,
|
|
4602
|
+
truncated: false,
|
|
4603
|
+
},
|
|
4604
|
+
evidence,
|
|
4605
|
+
error:
|
|
4606
|
+
terminalStatus === "failed"
|
|
4607
|
+
? {
|
|
4608
|
+
code: "reported_failure",
|
|
4609
|
+
message:
|
|
4610
|
+
typeof args.blocker === "string"
|
|
4611
|
+
? args.blocker
|
|
4612
|
+
: String(args.summary ?? "failed"),
|
|
4613
|
+
recoverable: true,
|
|
4614
|
+
}
|
|
4615
|
+
: null,
|
|
4616
|
+
updated_at: timestamp,
|
|
4617
|
+
completed_at: timestamp,
|
|
4618
|
+
};
|
|
4619
|
+
await writeTurnRecord(namespaceDir, terminalTurn);
|
|
4620
|
+
await clearActiveTurn(namespaceDir, terminalTurn);
|
|
4621
|
+
await writeSessionState(
|
|
4622
|
+
namespaceDir,
|
|
4623
|
+
terminalTurn.session_id,
|
|
4624
|
+
terminalStatus === "failed" ? "errored" : "completed",
|
|
4625
|
+
{
|
|
4626
|
+
lastTurnId: terminalTurn.turn_id,
|
|
4627
|
+
live: null,
|
|
4628
|
+
reason: terminalStatus === "failed" ? "reported_failure" : null,
|
|
4629
|
+
},
|
|
4630
|
+
);
|
|
4631
|
+
return {
|
|
4632
|
+
turn: terminalTurn,
|
|
4633
|
+
promotion: await prepareNextQueuedTurnUnlocked(terminalTurn.session_id),
|
|
4634
|
+
};
|
|
4635
|
+
});
|
|
4636
|
+
turn = transition.turn;
|
|
4637
|
+
promotedPreparation = transition.promotion;
|
|
4638
|
+
if (promotedPreparation) {
|
|
4639
|
+
promotedTurn = await completePreparedTurn(promotedPreparation.session, promotedPreparation.turn);
|
|
4640
|
+
}
|
|
2459
4641
|
}
|
|
2460
4642
|
}
|
|
2461
4643
|
const reportId = `report-${Date.now()}`;
|
|
@@ -2471,21 +4653,60 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2471
4653
|
? args.summary
|
|
2472
4654
|
: `Report ${String(args.status ?? "unknown")} written`,
|
|
2473
4655
|
payloadRef: path.relative(namespaceDir, reportPath),
|
|
2474
|
-
metadata: {
|
|
4656
|
+
metadata: {
|
|
4657
|
+
status: typeof args.status === "string" ? args.status : null,
|
|
4658
|
+
},
|
|
2475
4659
|
});
|
|
2476
4660
|
return {
|
|
2477
4661
|
ok: true,
|
|
2478
4662
|
report,
|
|
2479
|
-
...(turn
|
|
4663
|
+
...(turn
|
|
4664
|
+
? {
|
|
4665
|
+
turn,
|
|
4666
|
+
session_state: await readSessionState(namespaceDir, turn.session_id),
|
|
4667
|
+
}
|
|
4668
|
+
: {}),
|
|
2480
4669
|
...(promotedTurn ? { promoted_turn: promotedTurn } : {}),
|
|
2481
4670
|
};
|
|
2482
4671
|
}
|
|
2483
4672
|
return { ok: false, reason: "unknown_tool", tool: name };
|
|
2484
4673
|
} catch (error) {
|
|
2485
|
-
|
|
4674
|
+
const failure = error as { sessionId?: unknown; action?: unknown };
|
|
4675
|
+
return {
|
|
4676
|
+
ok: false,
|
|
4677
|
+
reason: publicCoordinatorError(error),
|
|
4678
|
+
...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
|
|
4679
|
+
...(typeof failure.sessionId === "string" ? { session_id: failure.sessionId } : {}),
|
|
4680
|
+
...(typeof failure.action === "string" ? { action: failure.action } : {}),
|
|
4681
|
+
};
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4684
|
+
|
|
4685
|
+
async function callToolInternal(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
4686
|
+
try {
|
|
4687
|
+
if (
|
|
4688
|
+
name === "gjc_coordinator_await_turn" ||
|
|
4689
|
+
name === "gjc_coordinator_watch_events" ||
|
|
4690
|
+
(name === "gjc_coordinator_start_session" && typeof args.prompt === "string" && args.prompt.length > 0) ||
|
|
4691
|
+
name === "gjc_coordinator_send_prompt" ||
|
|
4692
|
+
workflowForDelegateTool(name) !== null
|
|
4693
|
+
) {
|
|
4694
|
+
return await callToolUnlocked(name, args);
|
|
4695
|
+
}
|
|
4696
|
+
return await withCoordinatorTransaction(namespaceDir, async () => await callToolUnlocked(name, args));
|
|
4697
|
+
} catch (error) {
|
|
4698
|
+
return {
|
|
4699
|
+
ok: false,
|
|
4700
|
+
reason: publicCoordinatorError(error),
|
|
4701
|
+
...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
|
|
4702
|
+
};
|
|
2486
4703
|
}
|
|
2487
4704
|
}
|
|
2488
4705
|
|
|
4706
|
+
async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
4707
|
+
return publicCoordinatorResponse(await callToolInternal(name, args)) as Record<string, unknown>;
|
|
4708
|
+
}
|
|
4709
|
+
|
|
2489
4710
|
async function handleJsonRpc(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
|
2490
4711
|
const id = request.id ?? null;
|
|
2491
4712
|
if (request.method === "initialize") {
|
|
@@ -2499,8 +4720,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2499
4720
|
},
|
|
2500
4721
|
};
|
|
2501
4722
|
}
|
|
4723
|
+
if (request.method === "ping") {
|
|
4724
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
4725
|
+
}
|
|
2502
4726
|
if (request.method === "tools/list") {
|
|
2503
|
-
return {
|
|
4727
|
+
return {
|
|
4728
|
+
jsonrpc: "2.0",
|
|
4729
|
+
id,
|
|
4730
|
+
result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
|
|
4731
|
+
};
|
|
2504
4732
|
}
|
|
2505
4733
|
if (request.method === "prompts/list") {
|
|
2506
4734
|
return { jsonrpc: "2.0", id, result: { prompts: [] } };
|
|
@@ -2509,17 +4737,31 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2509
4737
|
return { jsonrpc: "2.0", id, result: { resources: [] } };
|
|
2510
4738
|
}
|
|
2511
4739
|
if (request.method === "tools/call") {
|
|
2512
|
-
const params = (request.params ?? {}) as {
|
|
4740
|
+
const params = (request.params ?? {}) as {
|
|
4741
|
+
name?: string;
|
|
4742
|
+
arguments?: Record<string, unknown>;
|
|
4743
|
+
};
|
|
2513
4744
|
const payload = await callTool(params.name ?? "", params.arguments ?? {});
|
|
2514
|
-
return {
|
|
4745
|
+
return {
|
|
4746
|
+
jsonrpc: "2.0",
|
|
4747
|
+
id,
|
|
4748
|
+
result: textResult(payload, payload.ok === false),
|
|
4749
|
+
};
|
|
2515
4750
|
}
|
|
2516
|
-
return {
|
|
4751
|
+
return {
|
|
4752
|
+
jsonrpc: "2.0",
|
|
4753
|
+
id,
|
|
4754
|
+
error: { code: -32601, message: `unknown_method:${request.method}` },
|
|
4755
|
+
};
|
|
2517
4756
|
}
|
|
2518
4757
|
|
|
2519
|
-
return { config, callTool, handleJsonRpc, handle: handleJsonRpc };
|
|
4758
|
+
return { config, callTool, handleJsonRpc, handle: handleJsonRpc, reapSession, sessionReaper };
|
|
2520
4759
|
}
|
|
2521
4760
|
|
|
2522
|
-
function legacyToolResult(payload: unknown): {
|
|
4761
|
+
function legacyToolResult(payload: unknown): {
|
|
4762
|
+
content: Array<{ type: "text"; text: string }>;
|
|
4763
|
+
isError: boolean;
|
|
4764
|
+
} {
|
|
2523
4765
|
const failed = typeof payload === "object" && payload !== null && (payload as { ok?: unknown }).ok === false;
|
|
2524
4766
|
return textResult(payload, failed);
|
|
2525
4767
|
}
|
|
@@ -2540,13 +4782,21 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2540
4782
|
};
|
|
2541
4783
|
}
|
|
2542
4784
|
if (request.method === "tools/list") {
|
|
2543
|
-
return {
|
|
4785
|
+
return {
|
|
4786
|
+
jsonrpc: "2.0",
|
|
4787
|
+
id: request.id ?? null,
|
|
4788
|
+
result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
|
|
4789
|
+
};
|
|
2544
4790
|
}
|
|
2545
4791
|
if (request.method === "prompts/list") {
|
|
2546
4792
|
return { jsonrpc: "2.0", id: request.id ?? null, result: { prompts: [] } };
|
|
2547
4793
|
}
|
|
2548
4794
|
if (request.method === "resources/list") {
|
|
2549
|
-
return {
|
|
4795
|
+
return {
|
|
4796
|
+
jsonrpc: "2.0",
|
|
4797
|
+
id: request.id ?? null,
|
|
4798
|
+
result: { resources: [] },
|
|
4799
|
+
};
|
|
2550
4800
|
}
|
|
2551
4801
|
if (request.method !== "tools/call")
|
|
2552
4802
|
return {
|
|
@@ -2554,11 +4804,14 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2554
4804
|
id: request.id ?? null,
|
|
2555
4805
|
error: { code: -32601, message: `unknown_method:${request.method}` },
|
|
2556
4806
|
};
|
|
2557
|
-
const params = (request.params ?? {}) as {
|
|
4807
|
+
const params = (request.params ?? {}) as {
|
|
4808
|
+
name?: string;
|
|
4809
|
+
arguments?: Record<string, unknown>;
|
|
4810
|
+
};
|
|
2558
4811
|
const args = params.arguments ?? {};
|
|
2559
4812
|
const server = createCoordinatorMcpServer({
|
|
2560
4813
|
env: options.env ?? process.env,
|
|
2561
|
-
services: options.createSession ? { startSession:
|
|
4814
|
+
services: options.createSession ? { startSession: input => options.createSession?.(input) } : undefined,
|
|
2562
4815
|
});
|
|
2563
4816
|
return {
|
|
2564
4817
|
jsonrpc: "2.0",
|
|
@@ -2567,23 +4820,226 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2567
4820
|
};
|
|
2568
4821
|
}
|
|
2569
4822
|
|
|
2570
|
-
export
|
|
2571
|
-
|
|
4823
|
+
export interface PumpCoordinatorOptions {
|
|
4824
|
+
/** Max concurrent in-flight *data* (non-control) handlers. Control frames (ping) bypass this. */
|
|
4825
|
+
maxDataConcurrency?: number;
|
|
4826
|
+
/** Max data requests queued waiting for a slot before overflow is rejected as server_busy. */
|
|
4827
|
+
maxQueueDepth?: number;
|
|
4828
|
+
/** Bounded wait for in-flight handlers/writes to settle after input ends. */
|
|
4829
|
+
drainTimeoutMs?: number;
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4832
|
+
/**
|
|
4833
|
+
* Pump a newline-delimited JSON-RPC stream with BOUNDED concurrent dispatch.
|
|
4834
|
+
*
|
|
4835
|
+
* A long-running tool call (e.g. gjc_coordinator_await_turn, which polls for
|
|
4836
|
+
* minutes) must not block the read loop from answering keepalive pings on the
|
|
4837
|
+
* same stdio channel. But naive unbounded concurrency reintroduces its own
|
|
4838
|
+
* hazards, so this pump enforces the safety envelope the coordinator needs:
|
|
4839
|
+
*
|
|
4840
|
+
* - Control frames (ping) bypass the data-concurrency cap → keepalive is always
|
|
4841
|
+
* answerable even while data handlers saturate.
|
|
4842
|
+
* - Data handlers are capped at `maxDataConcurrency`; excess is queued up to
|
|
4843
|
+
* `maxQueueDepth`, then rejected as `server_busy` (bounded memory / fanout).
|
|
4844
|
+
* - A coded local `EPIPE` terminalizes writer and dispatch together; other
|
|
4845
|
+
* writer faults reject the pump without poisoning the serialized write chain.
|
|
4846
|
+
* - On EOF or a closed peer the pump drains already-running handlers (bounded
|
|
4847
|
+
* by `drainTimeoutMs`) and never promotes queued work.
|
|
4848
|
+
* - Byte chunks are decoded with a streaming decoder so multibyte characters
|
|
4849
|
+
* split across chunks are not corrupted.
|
|
4850
|
+
*/
|
|
4851
|
+
export async function pumpCoordinatorMcpStream(
|
|
4852
|
+
handleJsonRpc: (request: JsonRpcRequest) => Promise<JsonRpcResponse>,
|
|
4853
|
+
input: AsyncIterable<string | Uint8Array>,
|
|
4854
|
+
writeLine: (line: string) => void | Promise<void>,
|
|
4855
|
+
options: PumpCoordinatorOptions = {},
|
|
4856
|
+
): Promise<void> {
|
|
4857
|
+
const maxDataConcurrency = Math.max(1, options.maxDataConcurrency ?? 32);
|
|
4858
|
+
const maxQueueDepth = Math.max(0, options.maxQueueDepth ?? 256);
|
|
4859
|
+
const drainTimeoutMs = Math.max(1, options.drainTimeoutMs ?? 30_000);
|
|
4860
|
+
|
|
4861
|
+
let writerState: "open" | "terminalizing" | "closed" = "open";
|
|
4862
|
+
let draining = false;
|
|
4863
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
4864
|
+
const inFlight = new Set<Promise<void>>();
|
|
4865
|
+
let activeData = 0;
|
|
4866
|
+
const dataQueue: JsonRpcRequest[] = [];
|
|
4867
|
+
const peerClosed = Promise.withResolvers<void>();
|
|
4868
|
+
const writerFailure = Promise.withResolvers<never>();
|
|
4869
|
+
void writerFailure.promise.catch(() => {});
|
|
4870
|
+
const inputIterator = input[Symbol.asyncIterator]();
|
|
4871
|
+
let inputDetached = false;
|
|
4872
|
+
let fatalWriterFailure: { value: unknown } | undefined;
|
|
4873
|
+
|
|
4874
|
+
const detachInput = (): void => {
|
|
4875
|
+
if (inputDetached) return;
|
|
4876
|
+
inputDetached = true;
|
|
4877
|
+
const detached = inputIterator.return?.();
|
|
4878
|
+
if (detached) void detached.catch(() => {});
|
|
4879
|
+
};
|
|
4880
|
+
const terminalizePeer = (): void => {
|
|
4881
|
+
if (writerState !== "open") return;
|
|
4882
|
+
writerState = "terminalizing";
|
|
4883
|
+
draining = true;
|
|
4884
|
+
dataQueue.length = 0;
|
|
4885
|
+
detachInput();
|
|
4886
|
+
peerClosed.resolve();
|
|
4887
|
+
};
|
|
4888
|
+
const failWriter = (failure: unknown): void => {
|
|
4889
|
+
if (writerState === "closed") return;
|
|
4890
|
+
writerState = "closed";
|
|
4891
|
+
draining = true;
|
|
4892
|
+
dataQueue.length = 0;
|
|
4893
|
+
detachInput();
|
|
4894
|
+
fatalWriterFailure = { value: failure };
|
|
4895
|
+
|
|
4896
|
+
writerFailure.reject(failure);
|
|
4897
|
+
};
|
|
4898
|
+
const isExpectedPeerClosure = (failure: unknown): boolean =>
|
|
4899
|
+
isKnownSinkPeerClosedError(failure) && sinkErrorCode(failure) === "EPIPE";
|
|
4900
|
+
|
|
4901
|
+
const emit = (response: JsonRpcResponse): Promise<void> => {
|
|
4902
|
+
writeChain = writeChain.then(async () => {
|
|
4903
|
+
if (writerState !== "open") return;
|
|
4904
|
+
try {
|
|
4905
|
+
await writeLine(`${JSON.stringify(response)}\n`);
|
|
4906
|
+
} catch (failure) {
|
|
4907
|
+
if (isExpectedPeerClosure(failure)) terminalizePeer();
|
|
4908
|
+
else failWriter(failure);
|
|
4909
|
+
}
|
|
4910
|
+
});
|
|
4911
|
+
return writeChain;
|
|
4912
|
+
};
|
|
4913
|
+
|
|
4914
|
+
const launch = (request: JsonRpcRequest, control: boolean): void => {
|
|
4915
|
+
const task = (async () => {
|
|
4916
|
+
let response: JsonRpcResponse;
|
|
4917
|
+
try {
|
|
4918
|
+
response = await handleJsonRpc(request);
|
|
4919
|
+
} catch (failure) {
|
|
4920
|
+
response = {
|
|
4921
|
+
jsonrpc: "2.0",
|
|
4922
|
+
id: request.id ?? null,
|
|
4923
|
+
error: { code: -32603, message: publicCoordinatorError(failure) },
|
|
4924
|
+
};
|
|
4925
|
+
}
|
|
4926
|
+
await emit(response);
|
|
4927
|
+
if (!control) {
|
|
4928
|
+
activeData -= 1;
|
|
4929
|
+
if (!draining && writerState === "open") {
|
|
4930
|
+
const next = dataQueue.shift();
|
|
4931
|
+
if (next) {
|
|
4932
|
+
activeData += 1;
|
|
4933
|
+
launch(next, false);
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
})();
|
|
4938
|
+
inFlight.add(task);
|
|
4939
|
+
void task.finally(() => inFlight.delete(task));
|
|
4940
|
+
};
|
|
4941
|
+
|
|
4942
|
+
const dispatch = (request: JsonRpcRequest): void => {
|
|
4943
|
+
if (writerState !== "open") return;
|
|
4944
|
+
// Notifications (no id) get no response; the coordinator has no side-effecting ones.
|
|
4945
|
+
if (request.id === undefined || request.id === null) return;
|
|
4946
|
+
if (request.method === "ping") {
|
|
4947
|
+
launch(request, true); // control frame: bypass the data cap
|
|
4948
|
+
return;
|
|
4949
|
+
}
|
|
4950
|
+
if (activeData < maxDataConcurrency) {
|
|
4951
|
+
activeData += 1;
|
|
4952
|
+
launch(request, false);
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
if (dataQueue.length < maxQueueDepth) {
|
|
4956
|
+
dataQueue.push(request);
|
|
4957
|
+
return;
|
|
4958
|
+
}
|
|
4959
|
+
void emit({
|
|
4960
|
+
jsonrpc: "2.0",
|
|
4961
|
+
id: request.id,
|
|
4962
|
+
error: { code: -32000, message: "server_busy: coordinator request queue is full" },
|
|
4963
|
+
});
|
|
4964
|
+
};
|
|
4965
|
+
|
|
4966
|
+
const drainInFlightAndWrites = async (): Promise<void> => {
|
|
4967
|
+
let timer: NodeJS.Timeout | undefined;
|
|
4968
|
+
const timeout = new Promise<"timed_out">(resolve => {
|
|
4969
|
+
timer = setTimeout(() => resolve("timed_out"), drainTimeoutMs);
|
|
4970
|
+
(timer as { unref?: () => void }).unref?.();
|
|
4971
|
+
});
|
|
4972
|
+
const drain = async (): Promise<"drained"> => {
|
|
4973
|
+
while (true) {
|
|
4974
|
+
if (inFlight.size > 0) await Promise.allSettled([...inFlight]);
|
|
4975
|
+
const writes = writeChain;
|
|
4976
|
+
await writes;
|
|
4977
|
+
if (inFlight.size === 0 && writes === writeChain) return "drained";
|
|
4978
|
+
}
|
|
4979
|
+
};
|
|
4980
|
+
try {
|
|
4981
|
+
if ((await Promise.race([drain(), timeout])) === "timed_out") {
|
|
4982
|
+
writerState = "closed";
|
|
4983
|
+
draining = true;
|
|
4984
|
+
dataQueue.length = 0;
|
|
4985
|
+
detachInput();
|
|
4986
|
+
}
|
|
4987
|
+
} finally {
|
|
4988
|
+
if (timer) clearTimeout(timer);
|
|
4989
|
+
}
|
|
4990
|
+
};
|
|
4991
|
+
|
|
4992
|
+
const decoder = new TextDecoder();
|
|
2572
4993
|
let buffer = "";
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
buffer
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
4994
|
+
let inputFailure: unknown;
|
|
4995
|
+
try {
|
|
4996
|
+
while (writerState === "open") {
|
|
4997
|
+
const next = await Promise.race([inputIterator.next(), peerClosed.promise, writerFailure.promise]);
|
|
4998
|
+
if (!next || next.done) break;
|
|
4999
|
+
buffer += typeof next.value === "string" ? next.value : decoder.decode(next.value, { stream: true });
|
|
5000
|
+
let newline = buffer.indexOf("\n");
|
|
5001
|
+
while (newline >= 0 && writerState === "open") {
|
|
5002
|
+
const line = buffer.slice(0, newline).trim();
|
|
5003
|
+
buffer = buffer.slice(newline + 1);
|
|
5004
|
+
if (line.length > 0) {
|
|
5005
|
+
let request: JsonRpcRequest | null = null;
|
|
5006
|
+
try {
|
|
5007
|
+
request = JSON.parse(line) as JsonRpcRequest;
|
|
5008
|
+
} catch {
|
|
5009
|
+
request = null; // ignore malformed frames rather than crashing the loop
|
|
5010
|
+
}
|
|
5011
|
+
if (request) dispatch(request);
|
|
2584
5012
|
}
|
|
5013
|
+
newline = buffer.indexOf("\n");
|
|
2585
5014
|
}
|
|
2586
|
-
newline = buffer.indexOf("\n");
|
|
2587
5015
|
}
|
|
5016
|
+
} catch (failure) {
|
|
5017
|
+
inputFailure = failure;
|
|
5018
|
+
}
|
|
5019
|
+
|
|
5020
|
+
// Input EOF or a closed peer stops promotion. One deadline bounds both the
|
|
5021
|
+
// active handlers and the serialized writes they have already queued.
|
|
5022
|
+
draining = true;
|
|
5023
|
+
dataQueue.length = 0;
|
|
5024
|
+
await drainInFlightAndWrites();
|
|
5025
|
+
if (fatalWriterFailure) throw fatalWriterFailure.value;
|
|
5026
|
+
if (inputFailure !== undefined) throw inputFailure;
|
|
5027
|
+
writerState = "closed";
|
|
5028
|
+
}
|
|
5029
|
+
|
|
5030
|
+
export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
|
|
5031
|
+
const server = createCoordinatorMcpServer(options);
|
|
5032
|
+
server.sessionReaper.start(); // background idle-reap of ephemeral delegate sessions
|
|
5033
|
+
try {
|
|
5034
|
+
await pumpCoordinatorMcpStream(
|
|
5035
|
+
request => server.handleJsonRpc(request),
|
|
5036
|
+
process.stdin,
|
|
5037
|
+
line =>
|
|
5038
|
+
new Promise<void>((resolve, reject) => {
|
|
5039
|
+
process.stdout.write(line, err => (err ? reject(err) : resolve()));
|
|
5040
|
+
}),
|
|
5041
|
+
);
|
|
5042
|
+
} finally {
|
|
5043
|
+
server.sessionReaper.stop();
|
|
2588
5044
|
}
|
|
2589
5045
|
}
|