@sayknow-cli/coding-agent 0.3.12 → 0.3.15
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/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
- 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/commands/web-search.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
- package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
- package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
- 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 +3 -3
- 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/commit/shared-llm.d.ts +9 -9
- package/dist/types/config/keybindings.d.ts +134 -129
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +35 -35
- package/dist/types/config/models-config-schema.d.ts +111 -111
- package/dist/types/config/settings-schema.d.ts +56 -16
- 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/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
- package/dist/types/edit/modes/patch.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/goals/tools/goal-tool.d.ts +6 -6
- package/dist/types/i18n/messages/en.d.ts +8 -8
- package/dist/types/internal-urls/local-protocol.d.ts +13 -9
- package/dist/types/lsp/index.d.ts +9 -9
- package/dist/types/lsp/types.d.ts +9 -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/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/sayknow-pet-widget.d.ts +59 -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/skc-runtime/session-state-sidecar.d.ts +34 -3
- package/dist/types/skc-runtime/state-schema.d.ts +19 -19
- package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
- package/dist/types/task/agents.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 +35 -27
- package/dist/types/tools/ask-answer-registry.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
- package/dist/types/tools/browser.d.ts +28 -28
- package/dist/types/tools/computer.d.ts +20 -20
- package/dist/types/tools/cron.d.ts +2 -2
- package/dist/types/tools/debug.d.ts +40 -40
- package/dist/types/tools/gh.d.ts +10 -10
- package/dist/types/tools/image-gen.d.ts +2 -2
- package/dist/types/tools/index.d.ts +58 -6
- package/dist/types/tools/monitor.d.ts +6 -6
- package/dist/types/tools/skill-discovery.d.ts +4 -4
- package/dist/types/tools/subagent.d.ts +12 -12
- package/dist/types/tools/telegram-send.d.ts +7 -0
- package/dist/types/tools/todo-write.d.ts +8 -8
- package/dist/types/utils/external-editor.d.ts +2 -0
- package/dist/types/web/insane/bridge.d.ts +9 -9
- package/dist/types/web/search/index.d.ts +4 -4
- package/package.json +8 -8
- 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 +2958 -430
- 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/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
- package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
- package/src/defaults/skc/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/internal-urls/docs-index.generated.ts +10 -9
- 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/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/sayknow-pet-widget.ts +360 -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 +14 -7
- 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-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/skc-runtime/session-state-sidecar.ts +756 -135
- package/src/skc-runtime/tmux-common.ts +26 -7
- package/src/skc-runtime/tmux-gc.ts +52 -9
- package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
- package/src/skc-runtime/tmux-sessions.ts +871 -68
- 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/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 "@sayknow-cli/utils";
|
|
5
6
|
import { VERSION } from "@sayknow-cli/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
|
SKC_COORDINATOR_SESSION_ID_ENV,
|
|
16
|
+
SKC_COORDINATOR_SESSION_LAUNCH_ID_ENV,
|
|
17
|
+
SKC_COORDINATOR_SESSION_READINESS_FILE_ENV,
|
|
14
18
|
SKC_COORDINATOR_SESSION_STATE_FILE_ENV,
|
|
19
|
+
SKC_TMUX_OWNER_GENERATION_ENV,
|
|
20
|
+
SKC_TMUX_OWNER_SERVER_KEY_ENV,
|
|
21
|
+
SKC_TMUX_OWNER_STATE_DIR_ENV,
|
|
15
22
|
} from "../skc-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 "../skc-runtime/tmux-owner-isolation";
|
|
34
|
+
import { forceCloseSkcTmuxSession } from "../skc-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,18 +238,33 @@ 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"
|
|
177
266
|
| "turn.active"
|
|
267
|
+
| "turn.acknowledged"
|
|
178
268
|
| "turn.waiting_for_answer"
|
|
179
269
|
| "turn.completed"
|
|
180
270
|
| "turn.failed"
|
|
@@ -217,6 +307,10 @@ const MISSING_FINAL_RESPONSE_ADVISORY = "completion_missing_final_response";
|
|
|
217
307
|
const PROMPT_ACK_TIMEOUT_REASON = "runtime_prompt_ack_timeout";
|
|
218
308
|
const DEFAULT_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 10_000;
|
|
219
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
|
+
|
|
220
314
|
const ACTIVE_TURN_STATUSES = new Set<TurnStatus>(["delivering", "active", "waiting_for_answer", "completing"]);
|
|
221
315
|
const TERMINAL_TURN_STATUSES = new Set<TurnStatus>(["completed", "failed", "cancelled", "superseded"]);
|
|
222
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;
|
|
@@ -232,7 +326,12 @@ function textResult(
|
|
|
232
326
|
isError = false,
|
|
233
327
|
): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
|
|
234
328
|
return {
|
|
235
|
-
content: [
|
|
329
|
+
content: [
|
|
330
|
+
{
|
|
331
|
+
type: "text",
|
|
332
|
+
text: typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
333
|
+
},
|
|
334
|
+
],
|
|
236
335
|
isError,
|
|
237
336
|
};
|
|
238
337
|
}
|
|
@@ -242,13 +341,27 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
242
341
|
description: string;
|
|
243
342
|
inputSchema: Record<string, unknown>;
|
|
244
343
|
} {
|
|
245
|
-
const allowMutation = {
|
|
344
|
+
const allowMutation = {
|
|
345
|
+
type: "boolean",
|
|
346
|
+
description: "Required and must be true for mutating tools.",
|
|
347
|
+
};
|
|
246
348
|
const cwd = {
|
|
247
349
|
type: "string",
|
|
248
350
|
description: "Canonicalized SKC worktree or project directory inside configured roots.",
|
|
249
351
|
};
|
|
250
|
-
const sessionId = {
|
|
251
|
-
|
|
352
|
+
const sessionId = {
|
|
353
|
+
type: "string",
|
|
354
|
+
description: "SKC 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 SKC model profile (`skc --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
|
+
};
|
|
252
365
|
const common = { type: "object", properties: {} as Record<string, unknown> };
|
|
253
366
|
if (name === "skc_coordinator_register_session") {
|
|
254
367
|
return {
|
|
@@ -277,11 +390,36 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
277
390
|
description: "Start a SKC worktree/tmux oriented session through the coordinator bridge.",
|
|
278
391
|
inputSchema: {
|
|
279
392
|
type: "object",
|
|
280
|
-
properties: {
|
|
393
|
+
properties: {
|
|
394
|
+
cwd,
|
|
395
|
+
prompt: { type: "string" },
|
|
396
|
+
mpreset,
|
|
397
|
+
allow_mutation: allowMutation,
|
|
398
|
+
},
|
|
281
399
|
required: ["cwd", "allow_mutation"],
|
|
282
400
|
},
|
|
283
401
|
};
|
|
284
402
|
}
|
|
403
|
+
if (name === "skc_coordinator_stop_session") {
|
|
404
|
+
return {
|
|
405
|
+
name,
|
|
406
|
+
description:
|
|
407
|
+
"Reap a coordinator delegate-created (ephemeral) session: terminate its owner via the owner-proof forceCloseSkcTmuxSession, 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 SKC_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
|
+
}
|
|
285
423
|
if (name === "skc_coordinator_send_prompt") {
|
|
286
424
|
return {
|
|
287
425
|
name,
|
|
@@ -306,7 +444,11 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
306
444
|
description: "Read authoritative durable turn state plus bounded advisory tmux status.",
|
|
307
445
|
inputSchema: {
|
|
308
446
|
type: "object",
|
|
309
|
-
properties: {
|
|
447
|
+
properties: {
|
|
448
|
+
session_id: sessionId,
|
|
449
|
+
turn_id: { type: "string" },
|
|
450
|
+
lines: { type: "number" },
|
|
451
|
+
},
|
|
310
452
|
required: ["turn_id"],
|
|
311
453
|
},
|
|
312
454
|
};
|
|
@@ -375,7 +517,11 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
375
517
|
return {
|
|
376
518
|
name,
|
|
377
519
|
description: "Read one bounded artifact from configured safe roots.",
|
|
378
|
-
inputSchema: {
|
|
520
|
+
inputSchema: {
|
|
521
|
+
type: "object",
|
|
522
|
+
properties: { path: pathField },
|
|
523
|
+
required: ["path"],
|
|
524
|
+
},
|
|
379
525
|
};
|
|
380
526
|
}
|
|
381
527
|
if (name === "skc_coordinator_read_status") {
|
|
@@ -389,21 +535,35 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
389
535
|
return {
|
|
390
536
|
name,
|
|
391
537
|
description: "Read a bounded structured session tail, not tmux scrollback.",
|
|
392
|
-
inputSchema: {
|
|
538
|
+
inputSchema: {
|
|
539
|
+
type: "object",
|
|
540
|
+
properties: { session_id: sessionId, lines: { type: "number" } },
|
|
541
|
+
},
|
|
393
542
|
};
|
|
394
543
|
}
|
|
395
544
|
if (name === "skc_coordinator_list_questions") {
|
|
396
545
|
return {
|
|
397
546
|
name,
|
|
398
547
|
description: "List bounded structured questions for coordinator coordination.",
|
|
399
|
-
inputSchema: {
|
|
548
|
+
inputSchema: {
|
|
549
|
+
type: "object",
|
|
550
|
+
properties: { session_id: sessionId, status: { type: "string" } },
|
|
551
|
+
},
|
|
400
552
|
};
|
|
401
553
|
}
|
|
402
554
|
if (name === "skc_coordinator_list_artifacts") {
|
|
403
|
-
return {
|
|
555
|
+
return {
|
|
556
|
+
name,
|
|
557
|
+
description: "List known safe artifact roots for coordinator coordination.",
|
|
558
|
+
inputSchema: common,
|
|
559
|
+
};
|
|
404
560
|
}
|
|
405
561
|
if (name === "skc_coordinator_read_coordination_status") {
|
|
406
|
-
return {
|
|
562
|
+
return {
|
|
563
|
+
name,
|
|
564
|
+
description: "Read coordinator coordination reports.",
|
|
565
|
+
inputSchema: common,
|
|
566
|
+
};
|
|
407
567
|
}
|
|
408
568
|
if (name === "skc_coordinator_watch_events") {
|
|
409
569
|
return {
|
|
@@ -437,7 +597,10 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
437
597
|
type: "string",
|
|
438
598
|
description: "Delegated task or objective to run through the selected SKC workflow.",
|
|
439
599
|
},
|
|
440
|
-
prompt: {
|
|
600
|
+
prompt: {
|
|
601
|
+
type: "string",
|
|
602
|
+
description: "Alias for task; accepted when task is absent.",
|
|
603
|
+
},
|
|
441
604
|
allow_mutation: allowMutation,
|
|
442
605
|
session_id: {
|
|
443
606
|
type: "string",
|
|
@@ -452,24 +615,38 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
452
615
|
type: "boolean",
|
|
453
616
|
description: "When reusing a session with an active turn, supersede it before sending.",
|
|
454
617
|
},
|
|
618
|
+
mpreset,
|
|
455
619
|
model: {
|
|
456
620
|
type: "string",
|
|
457
621
|
description: "Optional model hint passed in prompt metadata; no provider default is implied.",
|
|
458
622
|
},
|
|
459
|
-
await_completion: {
|
|
623
|
+
await_completion: {
|
|
624
|
+
type: "boolean",
|
|
625
|
+
description: "If true, poll the turn until terminal or timeout.",
|
|
626
|
+
},
|
|
460
627
|
timeout_ms: {
|
|
461
628
|
type: "number",
|
|
462
629
|
description:
|
|
463
630
|
"Bounded await timeout in milliseconds, capped at 30 minutes like skc_coordinator_await_turn.",
|
|
464
631
|
},
|
|
465
|
-
poll_interval_ms: {
|
|
466
|
-
|
|
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
|
+
},
|
|
467
640
|
},
|
|
468
641
|
required: ["cwd", "allow_mutation"],
|
|
469
642
|
},
|
|
470
643
|
};
|
|
471
644
|
}
|
|
472
|
-
return {
|
|
645
|
+
return {
|
|
646
|
+
name,
|
|
647
|
+
description: "List known scoped SKC coordinator bridge sessions.",
|
|
648
|
+
inputSchema: common,
|
|
649
|
+
};
|
|
473
650
|
}
|
|
474
651
|
|
|
475
652
|
type DelegateWorkflow = "plan" | "execute" | "team";
|
|
@@ -535,13 +712,80 @@ function workflowPrompt(
|
|
|
535
712
|
].join("\n");
|
|
536
713
|
}
|
|
537
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
|
+
|
|
538
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");
|
|
539
773
|
return {
|
|
540
|
-
session_id: session.sessionId ?? session.session_id ?? session.name ?? "unknown",
|
|
541
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 } : {}),
|
|
542
784
|
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
543
785
|
...(session.createdAt ? { created_at: session.createdAt } : {}),
|
|
544
786
|
...session,
|
|
787
|
+
session_id: sessionId,
|
|
788
|
+
sessionId,
|
|
545
789
|
};
|
|
546
790
|
}
|
|
547
791
|
|
|
@@ -553,15 +797,380 @@ async function canonicalizePath(value: string): Promise<string> {
|
|
|
553
797
|
}
|
|
554
798
|
}
|
|
555
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: "skc_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 === "skc_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
|
+
|
|
556
888
|
async function ensureDir(dir: string): Promise<void> {
|
|
557
889
|
await fs.mkdir(dir, { recursive: true });
|
|
558
890
|
}
|
|
559
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
|
+
|
|
560
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
|
+
}
|
|
561
1170
|
try {
|
|
562
|
-
return JSON.parse(
|
|
1171
|
+
return JSON.parse(content);
|
|
563
1172
|
} catch {
|
|
564
|
-
|
|
1173
|
+
throw coordinatorStateError("invalid");
|
|
565
1174
|
}
|
|
566
1175
|
}
|
|
567
1176
|
|
|
@@ -571,15 +1180,16 @@ async function writeJsonFile(file: string, value: unknown): Promise<void> {
|
|
|
571
1180
|
}
|
|
572
1181
|
|
|
573
1182
|
async function listJsonFiles(dir: string): Promise<unknown[]> {
|
|
1183
|
+
let entries: string[];
|
|
574
1184
|
try {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
);
|
|
579
|
-
return values.filter(value => value !== null);
|
|
580
|
-
} catch {
|
|
581
|
-
return [];
|
|
1185
|
+
entries = await fs.readdir(dir);
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1188
|
+
throw coordinatorStateError("unreadable");
|
|
582
1189
|
}
|
|
1190
|
+
return await Promise.all(
|
|
1191
|
+
entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
|
|
1192
|
+
);
|
|
583
1193
|
}
|
|
584
1194
|
|
|
585
1195
|
const COORDINATOR_STATUS_EVENT_LIMIT = 100;
|
|
@@ -667,22 +1277,39 @@ function boundSummary(value: string): string {
|
|
|
667
1277
|
}
|
|
668
1278
|
|
|
669
1279
|
async function readLatestEventSeq(namespaceDir: string): Promise<number> {
|
|
670
|
-
const
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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;
|
|
676
1297
|
}
|
|
677
1298
|
|
|
678
1299
|
const eventAppendQueues = new Map<string, Promise<unknown>>();
|
|
679
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
|
+
|
|
680
1310
|
async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEventInput): Promise<CoordinatorEvent> {
|
|
681
1311
|
const previous = eventAppendQueues.get(namespaceDir) ?? Promise.resolve();
|
|
682
|
-
|
|
683
|
-
const current = new Promise<void>(resolve => {
|
|
684
|
-
release = resolve;
|
|
685
|
-
});
|
|
1312
|
+
const { promise: current, resolve: release } = Promise.withResolvers<void>();
|
|
686
1313
|
const queued = previous.then(
|
|
687
1314
|
() => current,
|
|
688
1315
|
() => current,
|
|
@@ -691,57 +1318,148 @@ async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEv
|
|
|
691
1318
|
|
|
692
1319
|
await previous.catch(() => undefined);
|
|
693
1320
|
try {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
+
});
|
|
715
1347
|
} finally {
|
|
716
1348
|
release();
|
|
717
1349
|
if (eventAppendQueues.get(namespaceDir) === queued) eventAppendQueues.delete(namespaceDir);
|
|
718
1350
|
}
|
|
719
1351
|
}
|
|
720
1352
|
|
|
721
|
-
|
|
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;
|
|
722
1382
|
try {
|
|
723
|
-
|
|
724
|
-
if (typeof event.seq !== "number" || typeof event.kind !== "string") return null;
|
|
725
|
-
return event;
|
|
1383
|
+
event = JSON.parse(line);
|
|
726
1384
|
} catch {
|
|
727
|
-
|
|
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");
|
|
728
1440
|
}
|
|
1441
|
+
return record as unknown as CoordinatorEvent;
|
|
729
1442
|
}
|
|
730
1443
|
|
|
731
1444
|
async function readCoordinatorEvents(namespaceDir: string): Promise<CoordinatorEvent[]> {
|
|
1445
|
+
const journalFile = eventJournalFile(namespaceDir);
|
|
1446
|
+
let content: string;
|
|
732
1447
|
try {
|
|
733
|
-
|
|
734
|
-
return content
|
|
735
|
-
.split("\n")
|
|
736
|
-
.map(line => line.trim())
|
|
737
|
-
.filter(Boolean)
|
|
738
|
-
.map(parseCoordinatorEvent)
|
|
739
|
-
.filter((event): event is CoordinatorEvent => event !== null)
|
|
740
|
-
.sort((left, right) => left.seq - right.seq);
|
|
1448
|
+
content = await fs.readFile(journalFile, "utf8");
|
|
741
1449
|
} catch (error) {
|
|
742
|
-
if ((error
|
|
743
|
-
throw
|
|
1450
|
+
if (isExpectedStateAbsence(error)) return [];
|
|
1451
|
+
throw coordinatorStateError("unreadable");
|
|
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");
|
|
744
1460
|
}
|
|
1461
|
+
|
|
1462
|
+
return events;
|
|
745
1463
|
}
|
|
746
1464
|
|
|
747
1465
|
function boundedEventLimit(value: unknown): number {
|
|
@@ -818,6 +1536,33 @@ function safeTmuxTarget(value: unknown): string {
|
|
|
818
1536
|
return value;
|
|
819
1537
|
}
|
|
820
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
|
+
|
|
821
1566
|
function optionalString(value: unknown): string | null {
|
|
822
1567
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
823
1568
|
}
|
|
@@ -846,8 +1591,150 @@ function sessionStateFile(namespaceDir: string, sessionId: string): string {
|
|
|
846
1591
|
return path.join(namespaceDir, "session-states", `${safeExternalId("session", sessionId)}.json`);
|
|
847
1592
|
}
|
|
848
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
|
+
|
|
849
1712
|
async function readTurnRecord(namespaceDir: string, turnId: unknown): Promise<TurnRecord | null> {
|
|
850
|
-
|
|
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);
|
|
851
1738
|
}
|
|
852
1739
|
|
|
853
1740
|
function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
|
|
@@ -863,7 +1750,8 @@ function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
|
|
|
863
1750
|
}
|
|
864
1751
|
|
|
865
1752
|
async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
866
|
-
|
|
1753
|
+
if (!isValidTurnRecord(turn)) throw coordinatorStateError("invalid");
|
|
1754
|
+
const previous = await readTurnRecord(namespaceDir, turn.turn_id);
|
|
867
1755
|
await writeJsonFile(turnFile(namespaceDir, turn.turn_id), turn);
|
|
868
1756
|
const kind = previous?.status === turn.status ? null : turnEventKind(turn.status);
|
|
869
1757
|
if (kind) {
|
|
@@ -883,14 +1771,17 @@ async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<
|
|
|
883
1771
|
}
|
|
884
1772
|
|
|
885
1773
|
async function readActiveTurn(namespaceDir: string, sessionId: string): Promise<TurnRecord | null> {
|
|
886
|
-
const
|
|
887
|
-
if (
|
|
888
|
-
|
|
889
|
-
|
|
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");
|
|
890
1780
|
return turn;
|
|
891
1781
|
}
|
|
892
1782
|
|
|
893
1783
|
async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
1784
|
+
if (!ACTIVE_TURN_STATUSES.has(turn.status)) throw coordinatorStateError("invalid");
|
|
894
1785
|
await writeJsonFile(activeTurnFile(namespaceDir, turn.session_id), {
|
|
895
1786
|
session_id: turn.session_id,
|
|
896
1787
|
turn_id: turn.turn_id,
|
|
@@ -900,15 +1791,84 @@ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<
|
|
|
900
1791
|
}
|
|
901
1792
|
|
|
902
1793
|
async function clearActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
|
|
903
|
-
const
|
|
904
|
-
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 });
|
|
905
1798
|
}
|
|
906
1799
|
|
|
907
1800
|
async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
|
|
908
|
-
|
|
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;
|
|
909
1805
|
}
|
|
910
1806
|
|
|
911
|
-
|
|
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(
|
|
912
1872
|
namespaceDir: string,
|
|
913
1873
|
sessionId: string,
|
|
914
1874
|
state: CoordinatorSessionStateValue,
|
|
@@ -921,6 +1881,20 @@ async function writeSessionState(
|
|
|
921
1881
|
} = {},
|
|
922
1882
|
): Promise<CoordinatorSessionState> {
|
|
923
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;
|
|
924
1898
|
const payload: CoordinatorSessionState = {
|
|
925
1899
|
schema_version: 1,
|
|
926
1900
|
session_id: sessionId,
|
|
@@ -932,6 +1906,9 @@ async function writeSessionState(
|
|
|
932
1906
|
source: options.source ?? "coordinator",
|
|
933
1907
|
live: options.live ?? previous?.live ?? null,
|
|
934
1908
|
reason: options.reason ?? null,
|
|
1909
|
+
cwd,
|
|
1910
|
+
workdir: cwd,
|
|
1911
|
+
session_file: sessionFile,
|
|
935
1912
|
};
|
|
936
1913
|
await writeJsonFile(sessionStateFile(namespaceDir, sessionId), payload);
|
|
937
1914
|
if (
|
|
@@ -959,31 +1936,196 @@ async function writeSessionState(
|
|
|
959
1936
|
return payload;
|
|
960
1937
|
}
|
|
961
1938
|
|
|
962
|
-
|
|
963
|
-
|
|
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
|
+
|
|
2071
|
+
function hasTmuxIdentity(session: Record<string, unknown>): boolean {
|
|
2072
|
+
return (
|
|
964
2073
|
(typeof session.tmux_session === "string" && session.tmux_session.length > 0) ||
|
|
965
2074
|
(typeof session.tmuxSession === "string" && session.tmuxSession.length > 0)
|
|
966
2075
|
);
|
|
967
2076
|
}
|
|
968
2077
|
|
|
2078
|
+
function unavailableSessionReason(turn: TurnRecord, reason: string): string {
|
|
2079
|
+
if (
|
|
2080
|
+
reason === "tmux_session_missing" &&
|
|
2081
|
+
turn.delivery.tmux_keys_sent === true &&
|
|
2082
|
+
turn.delivery.prompt_acknowledged === true
|
|
2083
|
+
) {
|
|
2084
|
+
return "tmux_session_missing_after_prompt_acknowledgement";
|
|
2085
|
+
}
|
|
2086
|
+
return reason;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function unavailableSessionEvidence(turn: TurnRecord, reason: string, timestamp: string): Record<string, unknown>[] {
|
|
2090
|
+
if (reason !== "tmux_session_missing_after_prompt_acknowledgement") return turn.evidence;
|
|
2091
|
+
return [
|
|
2092
|
+
...turn.evidence,
|
|
2093
|
+
{
|
|
2094
|
+
type: reason,
|
|
2095
|
+
message:
|
|
2096
|
+
"The tmux session disappeared after SKC runtime acknowledged the prompt, before any terminal final_response or error was recorded. Treat this as an in-flight vanished session and inspect/restart with recovery evidence rather than resubmitting blindly.",
|
|
2097
|
+
tmux_keys_sent: true,
|
|
2098
|
+
prompt_acknowledged: true,
|
|
2099
|
+
prior_status: turn.status,
|
|
2100
|
+
created_at: timestamp,
|
|
2101
|
+
},
|
|
2102
|
+
];
|
|
2103
|
+
}
|
|
2104
|
+
|
|
969
2105
|
async function markTurnFailedForUnavailableSession(
|
|
970
2106
|
namespaceDir: string,
|
|
971
2107
|
turn: TurnRecord,
|
|
972
2108
|
reason: string,
|
|
973
2109
|
): Promise<TurnRecord> {
|
|
974
2110
|
const timestamp = new Date().toISOString();
|
|
2111
|
+
const durableReason = unavailableSessionReason(turn, reason);
|
|
975
2112
|
const failed: TurnRecord = {
|
|
976
2113
|
...turn,
|
|
977
2114
|
status: "failed",
|
|
978
2115
|
final_response: {
|
|
979
|
-
text: `Coordinator session unavailable: ${
|
|
2116
|
+
text: `Coordinator session unavailable: ${durableReason}`,
|
|
980
2117
|
format: "markdown",
|
|
981
2118
|
source: "coordinator_liveness",
|
|
982
2119
|
artifact_path: null,
|
|
983
2120
|
truncated: false,
|
|
984
2121
|
},
|
|
985
|
-
|
|
986
|
-
|
|
2122
|
+
evidence: unavailableSessionEvidence(turn, durableReason, timestamp),
|
|
2123
|
+
error: {
|
|
2124
|
+
code: "session_unavailable",
|
|
2125
|
+
message: durableReason,
|
|
2126
|
+
recoverable: true,
|
|
2127
|
+
},
|
|
2128
|
+
liveness: { checked_at: timestamp, live: false, reason: durableReason },
|
|
987
2129
|
updated_at: timestamp,
|
|
988
2130
|
completed_at: timestamp,
|
|
989
2131
|
};
|
|
@@ -992,7 +2134,7 @@ async function markTurnFailedForUnavailableSession(
|
|
|
992
2134
|
await writeSessionState(namespaceDir, failed.session_id, "stale", {
|
|
993
2135
|
lastTurnId: failed.turn_id,
|
|
994
2136
|
live: false,
|
|
995
|
-
reason,
|
|
2137
|
+
reason: durableReason,
|
|
996
2138
|
});
|
|
997
2139
|
return failed;
|
|
998
2140
|
}
|
|
@@ -1092,6 +2234,18 @@ async function markTurnAcknowledgedFromRuntimeState(
|
|
|
1092
2234
|
};
|
|
1093
2235
|
await writeTurnRecord(namespaceDir, acknowledged);
|
|
1094
2236
|
await writeActiveTurn(namespaceDir, acknowledged);
|
|
2237
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
2238
|
+
kind: "turn.acknowledged",
|
|
2239
|
+
sessionId: acknowledged.session_id,
|
|
2240
|
+
turnId: acknowledged.turn_id,
|
|
2241
|
+
summary: `Turn ${acknowledged.turn_id} was acknowledged by the SKC runtime`,
|
|
2242
|
+
payloadRef: path.relative(namespaceDir, turnFile(namespaceDir, acknowledged.turn_id)),
|
|
2243
|
+
metadata: {
|
|
2244
|
+
status: acknowledged.status,
|
|
2245
|
+
tmux_keys_sent: acknowledged.delivery.tmux_keys_sent ?? null,
|
|
2246
|
+
prompt_acknowledged: true,
|
|
2247
|
+
},
|
|
2248
|
+
});
|
|
1095
2249
|
return acknowledged;
|
|
1096
2250
|
}
|
|
1097
2251
|
|
|
@@ -1151,7 +2305,11 @@ async function markTurnFailedForUnacknowledgedDelivery(
|
|
|
1151
2305
|
created_at: timestamp,
|
|
1152
2306
|
},
|
|
1153
2307
|
],
|
|
1154
|
-
liveness: {
|
|
2308
|
+
liveness: {
|
|
2309
|
+
checked_at: timestamp,
|
|
2310
|
+
live: turn.liveness.live,
|
|
2311
|
+
reason: PROMPT_ACK_TIMEOUT_REASON,
|
|
2312
|
+
},
|
|
1155
2313
|
updated_at: timestamp,
|
|
1156
2314
|
completed_at: timestamp,
|
|
1157
2315
|
};
|
|
@@ -1172,6 +2330,16 @@ async function reconcileRuntimeAcknowledgement(
|
|
|
1172
2330
|
ackTimeoutMs: number,
|
|
1173
2331
|
options: { failOnTimeout: boolean } = { failOnTimeout: true },
|
|
1174
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
|
+
}
|
|
1175
2343
|
if (sessionState && runtimeStateAcknowledgesTurn(turn, sessionState)) {
|
|
1176
2344
|
return await markTurnAcknowledgedFromRuntimeState(namespaceDir, turn, sessionState);
|
|
1177
2345
|
}
|
|
@@ -1184,6 +2352,27 @@ async function reconcileRuntimeAcknowledgement(
|
|
|
1184
2352
|
function shellQuote(value: string): string {
|
|
1185
2353
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1186
2354
|
}
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Append an authoritative `--mpreset <profile>` to the coordinator child launch
|
|
2358
|
+
* command so the spawned SKC 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 `skc --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 `skc --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
|
+
|
|
1187
2376
|
function makeTurnRecord(
|
|
1188
2377
|
config: CoordinatorMcpConfig,
|
|
1189
2378
|
sessionId: string,
|
|
@@ -1208,7 +2397,13 @@ function makeTurnRecord(
|
|
|
1208
2397
|
attempts: [],
|
|
1209
2398
|
},
|
|
1210
2399
|
question_ids: [],
|
|
1211
|
-
final_response: {
|
|
2400
|
+
final_response: {
|
|
2401
|
+
text: null,
|
|
2402
|
+
format: "markdown",
|
|
2403
|
+
source: null,
|
|
2404
|
+
artifact_path: null,
|
|
2405
|
+
truncated: false,
|
|
2406
|
+
},
|
|
1212
2407
|
evidence: [],
|
|
1213
2408
|
error: null,
|
|
1214
2409
|
liveness: { checked_at: null, live: null, reason: null },
|
|
@@ -1230,6 +2425,8 @@ function asTerminalTurnStatus(status: unknown): TurnStatus | null {
|
|
|
1230
2425
|
|
|
1231
2426
|
export const COORDINATOR_AWAIT_TURN_TIMEOUT_MAX_MS = 30 * 60 * 1000;
|
|
1232
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
|
+
|
|
1233
2430
|
export const COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS = 30_000;
|
|
1234
2431
|
export const COORDINATOR_POLL_INTERVAL_MAX_MS = 10_000;
|
|
1235
2432
|
|
|
@@ -1248,6 +2445,12 @@ export function boundedRuntimePromptAckTimeoutMs(value: unknown): number {
|
|
|
1248
2445
|
COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS,
|
|
1249
2446
|
);
|
|
1250
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
|
+
}
|
|
1251
2454
|
|
|
1252
2455
|
export function boundedEventWatchTimeoutMs(value: unknown): number {
|
|
1253
2456
|
return Math.min(parsePositiveIntegerMs(value, 1000), COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS);
|
|
@@ -1256,8 +2459,21 @@ export function boundedEventWatchTimeoutMs(value: unknown): number {
|
|
|
1256
2459
|
export function boundedPollIntervalMs(value: unknown): number {
|
|
1257
2460
|
return Math.min(Math.max(parsePositiveIntegerMs(value, 100), 10), COORDINATOR_POLL_INTERVAL_MAX_MS);
|
|
1258
2461
|
}
|
|
1259
|
-
async function runCommand(
|
|
1260
|
-
|
|
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
|
+
}
|
|
1261
2477
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1262
2478
|
new Response(proc.stdout).text(),
|
|
1263
2479
|
new Response(proc.stderr).text(),
|
|
@@ -1266,15 +2482,39 @@ async function runCommand(command: string[]): Promise<{ exitCode: number; stdout
|
|
|
1266
2482
|
return { exitCode, stdout, stderr };
|
|
1267
2483
|
}
|
|
1268
2484
|
|
|
1269
|
-
type CommandRunner = (command: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
1270
|
-
|
|
1271
2485
|
async function sendTmuxPromptKeys(
|
|
1272
2486
|
target: string,
|
|
1273
2487
|
prompt: string,
|
|
1274
|
-
runner: CommandRunner
|
|
2488
|
+
runner: CommandRunner,
|
|
2489
|
+
socketKey: string,
|
|
2490
|
+
proveCurrentServer: () => Promise<boolean>,
|
|
1275
2491
|
): Promise<boolean> {
|
|
1276
|
-
const
|
|
1277
|
-
|
|
2492
|
+
const runMutation = async (argv: string[]): Promise<CommandResult | null> => {
|
|
2493
|
+
if (!(await proveCurrentServer())) return null;
|
|
2494
|
+
return await runner(argv);
|
|
2495
|
+
};
|
|
2496
|
+
const bufferName = `skc-coordinator-prompt-${randomUUID()}`;
|
|
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");
|
|
2512
|
+
return false;
|
|
2513
|
+
}
|
|
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;
|
|
1278
2518
|
}
|
|
1279
2519
|
|
|
1280
2520
|
function boundedLineCount(value: unknown): number {
|
|
@@ -1299,7 +2539,10 @@ async function registerExistingTmuxSession(
|
|
|
1299
2539
|
namespaceDir: string,
|
|
1300
2540
|
sessionFilePath: string,
|
|
1301
2541
|
runner: CommandRunner = runCommand,
|
|
1302
|
-
): Promise<{
|
|
2542
|
+
): Promise<{
|
|
2543
|
+
session: Record<string, unknown>;
|
|
2544
|
+
sessionState: CoordinatorSessionState;
|
|
2545
|
+
}> {
|
|
1303
2546
|
await assertTmuxTargetAvailable(input.tmuxSession, input.tmuxTarget, runner);
|
|
1304
2547
|
const existing = asRecord(await readJsonFile(sessionFilePath));
|
|
1305
2548
|
if (existing) {
|
|
@@ -1325,6 +2568,7 @@ async function registerExistingTmuxSession(
|
|
|
1325
2568
|
authoritative: true,
|
|
1326
2569
|
warp_attached: input.warpAttached,
|
|
1327
2570
|
source: input.source,
|
|
2571
|
+
origin: "registered",
|
|
1328
2572
|
model: input.model,
|
|
1329
2573
|
};
|
|
1330
2574
|
await writeJsonFile(sessionFilePath, session);
|
|
@@ -1335,52 +2579,406 @@ async function registerExistingTmuxSession(
|
|
|
1335
2579
|
return { session, sessionState: state };
|
|
1336
2580
|
}
|
|
1337
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
|
+
|
|
1338
2664
|
async function startTmuxSession(
|
|
1339
2665
|
config: CoordinatorMcpConfig,
|
|
1340
2666
|
input: SessionStartInput,
|
|
1341
2667
|
namespaceDir: string,
|
|
1342
2668
|
runner: CommandRunner = runCommand,
|
|
2669
|
+
ownerIsolationProbe?: OwnerIsolationProbe,
|
|
1343
2670
|
): Promise<Record<string, unknown>> {
|
|
1344
2671
|
if (!config.sessionCommand) throw new Error("coordinator_session_command_required");
|
|
1345
|
-
const sessionName =
|
|
2672
|
+
const sessionName = input.sessionId;
|
|
2673
|
+
const socketKey = `skc-coordinator-${randomUUID().slice(0, 8)}`;
|
|
2674
|
+
const ownerStateDir = namespaceDir;
|
|
2675
|
+
const ownerGeneration = randomUUID();
|
|
2676
|
+
const generationBaseline = captureOwnerGenerationBaselineSync(ownerStateDir, sessionName);
|
|
1346
2677
|
const runtimeStateFile = sessionStateFile(namespaceDir, sessionName);
|
|
1347
2678
|
const sessionCommand = [
|
|
1348
2679
|
"exec env",
|
|
1349
2680
|
`${SKC_COORDINATOR_SESSION_STATE_FILE_ENV}=${shellQuote(runtimeStateFile)}`,
|
|
1350
2681
|
`${SKC_COORDINATOR_SESSION_ID_ENV}=${shellQuote(sessionName)}`,
|
|
1351
|
-
|
|
2682
|
+
`${SKC_TMUX_OWNER_GENERATION_ENV}=${shellQuote(ownerGeneration)}`,
|
|
2683
|
+
`${SKC_TMUX_OWNER_STATE_DIR_ENV}=${shellQuote(ownerStateDir)}`,
|
|
2684
|
+
`${SKC_TMUX_OWNER_SERVER_KEY_ENV}=${shellQuote(socketKey)}`,
|
|
2685
|
+
`${SKC_COORDINATOR_SESSION_LAUNCH_ID_ENV}=${shellQuote(input.launchId)}`,
|
|
2686
|
+
`${SKC_COORDINATOR_SESSION_READINESS_FILE_ENV}=${shellQuote(input.readinessMarkerFile)}`,
|
|
2687
|
+
buildCoordinatorSessionCommand(config.sessionCommand, input.mpreset),
|
|
1352
2688
|
].join(" ");
|
|
1353
|
-
const
|
|
2689
|
+
const tmuxArgv = [
|
|
1354
2690
|
"tmux",
|
|
2691
|
+
"-L",
|
|
2692
|
+
socketKey,
|
|
1355
2693
|
"new-session",
|
|
1356
2694
|
"-d",
|
|
1357
2695
|
"-P",
|
|
1358
2696
|
"-F",
|
|
1359
|
-
"#{session_name}:#{window_index}.#{pane_index} #{pane_id}",
|
|
2697
|
+
"#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
|
|
1360
2698
|
"-s",
|
|
1361
2699
|
sessionName,
|
|
1362
2700
|
"-c",
|
|
1363
2701
|
input.cwd,
|
|
1364
2702
|
sessionCommand,
|
|
1365
|
-
]
|
|
1366
|
-
|
|
1367
|
-
|
|
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::/skc-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 __skc_coordinator_cleanup_ok__`,
|
|
2797
|
+
"display-message -p __skc_coordinator_cleanup_refused__",
|
|
2798
|
+
]);
|
|
2799
|
+
if (result.exitCode !== 0) return "failed";
|
|
2800
|
+
return result.stdout.trim() === "__skc_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
|
+
};
|
|
1368
2879
|
return {
|
|
1369
2880
|
sessionId: sessionName,
|
|
1370
2881
|
tmuxSession: sessionName,
|
|
1371
|
-
tmuxTarget
|
|
2882
|
+
tmuxTarget,
|
|
1372
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
|
+
|
|
1373
2892
|
cwd: input.cwd,
|
|
1374
2893
|
createdAt: new Date().toISOString(),
|
|
1375
2894
|
sessionCommand: config.sessionCommand,
|
|
2895
|
+
...(input.mpreset ? { mpreset: input.mpreset } : {}),
|
|
1376
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,
|
|
1377
2903
|
};
|
|
1378
2904
|
}
|
|
1379
2905
|
|
|
1380
|
-
|
|
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[]> {
|
|
1381
2959
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1382
|
-
|
|
1383
|
-
|
|
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}`]);
|
|
1384
2982
|
if (captured.exitCode !== 0) return [];
|
|
1385
2983
|
return captured.stdout.split("\n").slice(-lines);
|
|
1386
2984
|
}
|
|
@@ -1389,10 +2987,38 @@ async function sendTmuxPrompt(
|
|
|
1389
2987
|
session: Record<string, unknown>,
|
|
1390
2988
|
prompt: string,
|
|
1391
2989
|
runner: CommandRunner = runCommand,
|
|
2990
|
+
ownerIsolationProbe?: OwnerIsolationProbe,
|
|
1392
2991
|
): Promise<boolean> {
|
|
1393
2992
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1394
|
-
|
|
1395
|
-
|
|
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);
|
|
1396
3022
|
}
|
|
1397
3023
|
|
|
1398
3024
|
async function hasTmuxSession(
|
|
@@ -1400,8 +3026,16 @@ async function hasTmuxSession(
|
|
|
1400
3026
|
runner: CommandRunner = runCommand,
|
|
1401
3027
|
): Promise<boolean | null> {
|
|
1402
3028
|
const tmuxSession = typeof session.tmux_session === "string" ? session.tmux_session : session.tmuxSession;
|
|
1403
|
-
|
|
1404
|
-
|
|
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]);
|
|
1405
3039
|
return checked.exitCode === 0;
|
|
1406
3040
|
}
|
|
1407
3041
|
|
|
@@ -1436,7 +3070,8 @@ async function inspectTmuxSession(
|
|
|
1436
3070
|
runner: CommandRunner = runCommand,
|
|
1437
3071
|
): Promise<Record<string, unknown>> {
|
|
1438
3072
|
const live = await hasTmuxSession(session, runner);
|
|
1439
|
-
const tail = live ? await captureTmuxTail(session, lines) : [];
|
|
3073
|
+
const tail = live ? await captureTmuxTail(session, lines, runner) : [];
|
|
3074
|
+
|
|
1440
3075
|
return {
|
|
1441
3076
|
live,
|
|
1442
3077
|
...summarizePaneTail(tail),
|
|
@@ -1490,14 +3125,27 @@ async function waitForCoordinatorEvents(namespaceDir: string, timeoutMs: number)
|
|
|
1490
3125
|
};
|
|
1491
3126
|
const timer = setTimeout(finish, Math.max(timeoutMs, 0));
|
|
1492
3127
|
timer.unref?.();
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
3128
|
+
const eventDir = eventsDir(namespaceDir);
|
|
3129
|
+
const watchedDirs = [
|
|
3130
|
+
eventDir,
|
|
3131
|
+
turnsDir(namespaceDir),
|
|
3132
|
+
path.join(namespaceDir, "active-turns"),
|
|
3133
|
+
path.join(namespaceDir, "session-states"),
|
|
3134
|
+
];
|
|
3135
|
+
for (const dir of watchedDirs) {
|
|
3136
|
+
await ensureDir(dir);
|
|
3137
|
+
try {
|
|
3138
|
+
const watcher = nodeFs.watch(dir, (_eventType, filename) => {
|
|
3139
|
+
if (dir === eventDir) {
|
|
3140
|
+
if (filename === "event-journal.jsonl" || filename === "latest-seq.json") finish();
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
if (typeof filename === "string" && filename.endsWith(".json")) finish();
|
|
3144
|
+
});
|
|
3145
|
+
watchers.push(watcher);
|
|
3146
|
+
} catch {
|
|
3147
|
+
// Directory may not be watchable on this platform; the timeout remains a bounded fallback.
|
|
3148
|
+
}
|
|
1501
3149
|
}
|
|
1502
3150
|
return deferred.promise;
|
|
1503
3151
|
}
|
|
@@ -1538,7 +3186,7 @@ export async function readCoordinatorArtifact(
|
|
|
1538
3186
|
} catch (error) {
|
|
1539
3187
|
return {
|
|
1540
3188
|
ok: false,
|
|
1541
|
-
reason: (error
|
|
3189
|
+
reason: publicCoordinatorError(error),
|
|
1542
3190
|
};
|
|
1543
3191
|
} finally {
|
|
1544
3192
|
await handle?.close();
|
|
@@ -1552,6 +3200,122 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1552
3200
|
const services = options.services ?? {};
|
|
1553
3201
|
const namespaceDir = coordinatorNamespacePath(config);
|
|
1554
3202
|
const commandRunner = services.commandRunner ?? runCommand;
|
|
3203
|
+
const loadModelProfiles = services.resolveModelProfiles ?? loadCoordinatorModelProfiles;
|
|
3204
|
+
const readinessTimeoutMs = boundedRuntimeReadinessTimeoutMs(env.SKC_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 = `skc-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
|
+
}
|
|
1555
3319
|
|
|
1556
3320
|
async function listSessions(): Promise<unknown[]> {
|
|
1557
3321
|
if (!config.namespace.profile || !config.namespace.repo) return [];
|
|
@@ -1561,6 +3325,154 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1561
3325
|
function sessionFile(sessionId: unknown): string {
|
|
1562
3326
|
return path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`);
|
|
1563
3327
|
}
|
|
3328
|
+
const forceCloseSession =
|
|
3329
|
+
services.forceCloseSession ??
|
|
3330
|
+
((name: string, targetEnv: NodeJS.ProcessEnv, expectedSessionId?: string, expectedStateFile?: string) =>
|
|
3331
|
+
forceCloseSkcTmuxSession(name, targetEnv, expectedSessionId, expectedStateFile));
|
|
3332
|
+
|
|
3333
|
+
// Reap a coordinator delegate-created (ephemeral) session. Addresses the #2034 review:
|
|
3334
|
+
// - terminates via the owner-proof forceCloseSkcTmuxSession (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
|
+
|
|
1564
3476
|
async function listQuestions(args: Record<string, unknown>): Promise<unknown[]> {
|
|
1565
3477
|
const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
|
|
1566
3478
|
const status = typeof args.status === "string" && args.status.length > 0 ? args.status : null;
|
|
@@ -1584,11 +3496,12 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1584
3496
|
return evidence;
|
|
1585
3497
|
}
|
|
1586
3498
|
|
|
1587
|
-
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;
|
|
1588
3502
|
const timestamp = new Date().toISOString();
|
|
1589
3503
|
const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
|
|
1590
|
-
const
|
|
1591
|
-
const pendingTurn: TurnRecord = {
|
|
3504
|
+
const pending: TurnRecord = {
|
|
1592
3505
|
...turn,
|
|
1593
3506
|
status: "active",
|
|
1594
3507
|
delivery: {
|
|
@@ -1597,90 +3510,312 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1597
3510
|
target: typeof target === "string" ? target : null,
|
|
1598
3511
|
tmux_keys_sent: false,
|
|
1599
3512
|
prompt_acknowledged: false,
|
|
1600
|
-
state: "
|
|
3513
|
+
state: "awaiting_runtime_readiness",
|
|
1601
3514
|
attempts: [
|
|
1602
3515
|
{
|
|
1603
3516
|
delivered: false,
|
|
1604
3517
|
tmux_keys_sent: false,
|
|
1605
3518
|
channel: "tmux_keys",
|
|
1606
3519
|
created_at: timestamp,
|
|
1607
|
-
reason: "
|
|
3520
|
+
reason: "awaiting_runtime_readiness",
|
|
1608
3521
|
},
|
|
1609
3522
|
],
|
|
1610
3523
|
},
|
|
1611
|
-
liveness: { checked_at:
|
|
3524
|
+
liveness: { checked_at: null, live: null, reason: null },
|
|
1612
3525
|
started_at: turn.started_at ?? timestamp,
|
|
1613
3526
|
updated_at: timestamp,
|
|
1614
3527
|
};
|
|
1615
|
-
await writeTurnRecord(namespaceDir,
|
|
1616
|
-
await writeActiveTurn(namespaceDir,
|
|
1617
|
-
await writeSessionState(namespaceDir,
|
|
1618
|
-
currentTurnId:
|
|
1619
|
-
live,
|
|
1620
|
-
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",
|
|
1621
3534
|
});
|
|
3535
|
+
return pending;
|
|
3536
|
+
}
|
|
1622
3537
|
|
|
1623
|
-
|
|
1624
|
-
const
|
|
1625
|
-
const
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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 = `SKC runtime did not attest interactive input readiness within ${readinessTimeoutMs}ms.`;
|
|
3556
|
+
const failed: TurnRecord = {
|
|
3557
|
+
...current,
|
|
3558
|
+
status: "failed",
|
|
3559
|
+
delivery: {
|
|
3560
|
+
...current.delivery,
|
|
1636
3561
|
delivered: false,
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
3562
|
+
queued: false,
|
|
3563
|
+
tmux_keys_sent: false,
|
|
3564
|
+
prompt_acknowledged: false,
|
|
3565
|
+
state: "awaiting_runtime_readiness",
|
|
1641
3566
|
},
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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,
|
|
1660
3644
|
});
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
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;
|
|
1671
3693
|
});
|
|
1672
|
-
return resolvedTurn;
|
|
1673
3694
|
}
|
|
1674
3695
|
|
|
1675
|
-
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> {
|
|
1676
3709
|
const session = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
1677
|
-
if (!session) return null;
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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")
|
|
1681
3718
|
.sort((left, right) => left.created_at.localeCompare(right.created_at));
|
|
1682
3719
|
const nextTurn = queuedTurns[0];
|
|
1683
|
-
|
|
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) };
|
|
1684
3819
|
}
|
|
1685
3820
|
|
|
1686
3821
|
async function readTurnPayload(
|
|
@@ -1697,24 +3832,21 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1697
3832
|
let resolvedTurn = turn;
|
|
1698
3833
|
let advisoryStatus: Record<string, unknown> = { live: false };
|
|
1699
3834
|
let sessionState = await readSessionState(namespaceDir, turn.session_id);
|
|
1700
|
-
resolvedTurn =
|
|
1701
|
-
|
|
1702
|
-
resolvedTurn,
|
|
1703
|
-
sessionState,
|
|
1704
|
-
promptAckTimeoutMs,
|
|
1705
|
-
{ failOnTimeout: false },
|
|
1706
|
-
);
|
|
3835
|
+
resolvedTurn =
|
|
3836
|
+
(await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, false)) ?? resolvedTurn;
|
|
1707
3837
|
if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1708
3838
|
if (
|
|
1709
3839
|
sessionState &&
|
|
1710
3840
|
ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
|
|
3841
|
+
runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
|
|
1711
3842
|
(sessionState.current_turn_id === resolvedTurn.turn_id ||
|
|
1712
3843
|
(sessionState.state === "errored" &&
|
|
1713
3844
|
sessionState.source === "agent_session_event" &&
|
|
1714
3845
|
sessionState.current_turn_id == null)) &&
|
|
1715
3846
|
(sessionState.state === "completed" || sessionState.state === "errored")
|
|
1716
3847
|
) {
|
|
1717
|
-
resolvedTurn =
|
|
3848
|
+
resolvedTurn =
|
|
3849
|
+
(await markTurnTerminalFromSessionStateFenced(resolvedTurn.turn_id, sessionState)) ?? resolvedTurn;
|
|
1718
3850
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1719
3851
|
} else if (
|
|
1720
3852
|
sessionState &&
|
|
@@ -1726,14 +3858,14 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1726
3858
|
session &&
|
|
1727
3859
|
hasTmuxIdentity(session)
|
|
1728
3860
|
) {
|
|
1729
|
-
resolvedTurn =
|
|
1730
|
-
|
|
1731
|
-
resolvedTurn
|
|
1732
|
-
"tmux_delivery_unavailable",
|
|
1733
|
-
);
|
|
3861
|
+
resolvedTurn =
|
|
3862
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_delivery_unavailable")) ??
|
|
3863
|
+
resolvedTurn;
|
|
1734
3864
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1735
3865
|
} else if (!session && ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1736
|
-
resolvedTurn =
|
|
3866
|
+
resolvedTurn =
|
|
3867
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "session_record_missing")) ??
|
|
3868
|
+
resolvedTurn;
|
|
1737
3869
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1738
3870
|
} else if (session) {
|
|
1739
3871
|
advisoryStatus = await inspectTmuxSession(session, boundedLineCount(lines), commandRunner);
|
|
@@ -1742,21 +3874,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1742
3874
|
hasTmuxIdentity(session) &&
|
|
1743
3875
|
advisoryStatus.live === false
|
|
1744
3876
|
) {
|
|
1745
|
-
resolvedTurn =
|
|
1746
|
-
|
|
1747
|
-
resolvedTurn
|
|
1748
|
-
"tmux_session_missing",
|
|
1749
|
-
);
|
|
3877
|
+
resolvedTurn =
|
|
3878
|
+
(await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_session_missing")) ??
|
|
3879
|
+
resolvedTurn;
|
|
1750
3880
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1751
3881
|
}
|
|
1752
3882
|
}
|
|
1753
3883
|
if (ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1754
|
-
resolvedTurn =
|
|
1755
|
-
|
|
1756
|
-
resolvedTurn,
|
|
1757
|
-
sessionState,
|
|
1758
|
-
promptAckTimeoutMs,
|
|
1759
|
-
);
|
|
3884
|
+
resolvedTurn =
|
|
3885
|
+
(await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, true)) ?? resolvedTurn;
|
|
1760
3886
|
if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
|
|
1761
3887
|
sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1762
3888
|
}
|
|
@@ -1778,51 +3904,59 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1778
3904
|
}
|
|
1779
3905
|
|
|
1780
3906
|
async function reconcileActiveTurnAcknowledgements(): Promise<void> {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
3907
|
+
await preflightCoordinatorMutation(namespaceDir, undefined, {
|
|
3908
|
+
rejectTerminalRuntimeEvidence: false,
|
|
3909
|
+
});
|
|
3910
|
+
|
|
3911
|
+
const turns = (await listTurnRecords(namespaceDir)).filter(turn => ACTIVE_TURN_STATUSES.has(turn.status));
|
|
1784
3912
|
for (const turn of turns) {
|
|
1785
3913
|
let sessionState = await readSessionState(namespaceDir, turn.session_id);
|
|
1786
|
-
const resolvedTurn = await reconcileRuntimeAcknowledgement(
|
|
1787
|
-
namespaceDir,
|
|
1788
|
-
turn,
|
|
1789
|
-
sessionState,
|
|
1790
|
-
promptAckTimeoutMs,
|
|
1791
|
-
{ failOnTimeout: false },
|
|
1792
|
-
);
|
|
1793
|
-
if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) continue;
|
|
1794
|
-
if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
|
|
1795
|
-
const session = asRecord(await readJsonFile(sessionFile(resolvedTurn.session_id)));
|
|
1796
3914
|
if (
|
|
1797
3915
|
sessionState &&
|
|
1798
|
-
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 &&
|
|
1799
3932
|
sessionState.state === "stale" &&
|
|
1800
3933
|
sessionState.reason === "tmux_delivery_unavailable" &&
|
|
1801
|
-
|
|
3934
|
+
acknowledged.delivery.state === "unavailable" &&
|
|
1802
3935
|
session &&
|
|
1803
3936
|
hasTmuxIdentity(session)
|
|
1804
3937
|
) {
|
|
1805
|
-
await
|
|
3938
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_delivery_unavailable");
|
|
1806
3939
|
continue;
|
|
1807
3940
|
}
|
|
1808
3941
|
if (!session) {
|
|
1809
|
-
await
|
|
3942
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "session_record_missing");
|
|
1810
3943
|
continue;
|
|
1811
3944
|
}
|
|
1812
3945
|
if (hasTmuxIdentity(session) && (await hasTmuxSession(session, commandRunner)) === false) {
|
|
1813
|
-
await
|
|
3946
|
+
await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_session_missing");
|
|
1814
3947
|
continue;
|
|
1815
3948
|
}
|
|
1816
|
-
await
|
|
3949
|
+
await reconcileRuntimeAcknowledgementFenced(acknowledged.turn_id, sessionState, true);
|
|
1817
3950
|
}
|
|
1818
3951
|
}
|
|
1819
3952
|
|
|
1820
|
-
async function
|
|
3953
|
+
async function callToolUnlocked(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
1821
3954
|
try {
|
|
1822
3955
|
if (name === "skc_coordinator_list_sessions") return { ok: true, sessions: await listSessions() };
|
|
1823
3956
|
if (name === "skc_coordinator_register_session") {
|
|
1824
3957
|
requireCoordinatorMutation(config, "sessions", args);
|
|
1825
3958
|
const sessionId = safeExternalId("session", args.session_id);
|
|
3959
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
1826
3960
|
const cwd = await assertCoordinatorWorkdir(config, args.cwd);
|
|
1827
3961
|
const tmuxSession = safeTmuxSessionName(args.tmux_session);
|
|
1828
3962
|
const tmuxTarget = safeTmuxTarget(args.tmux_target);
|
|
@@ -1846,7 +3980,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1846
3980
|
sessionId,
|
|
1847
3981
|
summary: `Session ${sessionId} registered for coordinator control`,
|
|
1848
3982
|
payloadRef: path.relative(namespaceDir, sessionFile(sessionId)),
|
|
1849
|
-
metadata: {
|
|
3983
|
+
metadata: {
|
|
3984
|
+
source: optionalString(args.source) ?? "register_session",
|
|
3985
|
+
visible: args.visible !== false,
|
|
3986
|
+
},
|
|
1850
3987
|
});
|
|
1851
3988
|
|
|
1852
3989
|
return {
|
|
@@ -1883,7 +4020,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1883
4020
|
}
|
|
1884
4021
|
if (name === "skc_coordinator_read_tail") {
|
|
1885
4022
|
const session = asRecord(await readJsonFile(sessionFile(args.session_id)));
|
|
1886
|
-
return {
|
|
4023
|
+
return {
|
|
4024
|
+
ok: true,
|
|
4025
|
+
lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines), commandRunner) : [],
|
|
4026
|
+
};
|
|
1887
4027
|
}
|
|
1888
4028
|
if (name === "skc_coordinator_list_questions") return { ok: true, questions: await listQuestions(args) };
|
|
1889
4029
|
if (name === "skc_coordinator_list_artifacts") return { ok: true, roots: config.allowedRoots };
|
|
@@ -1918,23 +4058,31 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1918
4058
|
turns,
|
|
1919
4059
|
questions,
|
|
1920
4060
|
reports,
|
|
1921
|
-
events: buildCanonicalCoordinatorEvents({
|
|
4061
|
+
events: buildCanonicalCoordinatorEvents({
|
|
4062
|
+
sessionStates,
|
|
4063
|
+
turns,
|
|
4064
|
+
questions,
|
|
4065
|
+
reports,
|
|
4066
|
+
}),
|
|
1922
4067
|
latest_event_seq: await readLatestEventSeq(namespaceDir),
|
|
1923
4068
|
recent_events: eventSummaries(events.slice(-10)),
|
|
1924
4069
|
};
|
|
1925
4070
|
}
|
|
1926
4071
|
if (name === "skc_coordinator_watch_events") {
|
|
1927
|
-
await reconcileActiveTurnAcknowledgements
|
|
4072
|
+
await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
|
|
1928
4073
|
const limit = boundedEventLimit(args.limit);
|
|
1929
4074
|
const timeoutMs = boundedEventWatchTimeoutMs(args.timeout_ms);
|
|
1930
4075
|
let events = await readCoordinatorEvents(namespaceDir);
|
|
1931
4076
|
let matched = filterCoordinatorEvents(events, args, limit);
|
|
1932
4077
|
let timedOut = false;
|
|
1933
4078
|
if (matched.length === 0 && timeoutMs > 0) {
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
4079
|
+
const deadline = Date.now() + timeoutMs;
|
|
4080
|
+
while (matched.length === 0 && Date.now() < deadline) {
|
|
4081
|
+
await waitForCoordinatorEvents(namespaceDir, Math.min(50, Math.max(1, deadline - Date.now())));
|
|
4082
|
+
await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
|
|
4083
|
+
events = await readCoordinatorEvents(namespaceDir);
|
|
4084
|
+
matched = filterCoordinatorEvents(events, args, limit);
|
|
4085
|
+
}
|
|
1938
4086
|
timedOut = matched.length === 0;
|
|
1939
4087
|
}
|
|
1940
4088
|
return {
|
|
@@ -1953,6 +4101,17 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1953
4101
|
const hasPrompt = typeof args.prompt === "string" && args.prompt.trim().length > 0;
|
|
1954
4102
|
const task = hasTask ? String(args.task) : hasPrompt ? String(args.prompt) : null;
|
|
1955
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
|
+
|
|
1956
4115
|
const promptAliasIgnored = hasTask && hasPrompt;
|
|
1957
4116
|
const mutationRequested = args.allow_mutation === true;
|
|
1958
4117
|
const taggedPrompt = workflowPrompt(delegateWorkflow, name, canonicalCwd, task, {
|
|
@@ -1964,70 +4123,88 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
1964
4123
|
let reusedSession = false;
|
|
1965
4124
|
if (args.session_id != null) {
|
|
1966
4125
|
const sessionId = safeExternalId("session", args.session_id);
|
|
4126
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
1967
4127
|
const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
1968
|
-
if (!existing)
|
|
4128
|
+
if (!existing)
|
|
4129
|
+
return {
|
|
4130
|
+
ok: false,
|
|
4131
|
+
reason: "unknown_session",
|
|
4132
|
+
session_id: sessionId,
|
|
4133
|
+
};
|
|
1969
4134
|
const storedCwd = typeof existing.cwd === "string" ? existing.cwd : null;
|
|
1970
4135
|
const canonicalStored = storedCwd ? await canonicalizePath(storedCwd) : null;
|
|
1971
4136
|
const canonicalRequested = await canonicalizePath(canonicalCwd);
|
|
1972
4137
|
if (!canonicalStored || canonicalStored !== canonicalRequested) {
|
|
1973
|
-
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
|
+
}
|
|
1974
4155
|
}
|
|
4156
|
+
|
|
1975
4157
|
session = existing;
|
|
1976
4158
|
reusedSession = true;
|
|
1977
4159
|
} else {
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
+
}
|
|
2003
4191
|
}
|
|
2004
4192
|
|
|
2005
4193
|
const sessionId = String(session.session_id);
|
|
2006
|
-
|
|
2007
|
-
|
|
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) {
|
|
2008
4200
|
return {
|
|
2009
4201
|
ok: false,
|
|
2010
4202
|
reason: "active_turn_exists",
|
|
2011
4203
|
session_id: sessionId,
|
|
2012
|
-
active_turn_id: activeTurn.turn_id,
|
|
2013
|
-
};
|
|
2014
|
-
}
|
|
2015
|
-
if (activeTurn && args.force === true) {
|
|
2016
|
-
const timestamp = new Date().toISOString();
|
|
2017
|
-
const superseded = {
|
|
2018
|
-
...activeTurn,
|
|
2019
|
-
status: "superseded" as const,
|
|
2020
|
-
updated_at: timestamp,
|
|
2021
|
-
completed_at: timestamp,
|
|
4204
|
+
active_turn_id: prepared.activeTurn.turn_id,
|
|
2022
4205
|
};
|
|
2023
|
-
await writeTurnRecord(namespaceDir, superseded);
|
|
2024
|
-
await clearActiveTurn(namespaceDir, superseded);
|
|
2025
4206
|
}
|
|
2026
|
-
const
|
|
2027
|
-
const turn = shouldQueue
|
|
2028
|
-
? makeTurnRecord(config, sessionId, taggedPrompt, "queued")
|
|
2029
|
-
: await activateTurn(session, makeTurnRecord(config, sessionId, taggedPrompt, "active"));
|
|
2030
|
-
if (shouldQueue) await writeTurnRecord(namespaceDir, turn);
|
|
4207
|
+
const { turn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
|
|
2031
4208
|
await appendCoordinatorEvent(namespaceDir, {
|
|
2032
4209
|
kind: "delegation.started",
|
|
2033
4210
|
sessionId,
|
|
@@ -2098,34 +4275,75 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2098
4275
|
}
|
|
2099
4276
|
return base;
|
|
2100
4277
|
}
|
|
4278
|
+
if (name === "skc_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 SKC_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
|
+
}
|
|
2101
4300
|
if (name === "skc_coordinator_start_session") {
|
|
2102
4301
|
requireCoordinatorMutation(config, "sessions", args);
|
|
2103
4302
|
const cwd = await assertCoordinatorWorkdir(config, args.cwd);
|
|
2104
|
-
|
|
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(
|
|
2105
4315
|
cwd,
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
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
|
+
}
|
|
2129
4347
|
if (typeof args.prompt === "string" && args.prompt.length > 0) {
|
|
2130
4348
|
const turn = await activateTurn(
|
|
2131
4349
|
session,
|
|
@@ -2161,36 +4379,28 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2161
4379
|
if (name === "skc_coordinator_send_prompt") {
|
|
2162
4380
|
requireCoordinatorMutation(config, "sessions", args);
|
|
2163
4381
|
const sessionId = safeExternalId("session", args.session_id);
|
|
4382
|
+
await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
2164
4383
|
const session = asRecord(await readJsonFile(sessionFile(sessionId)));
|
|
2165
4384
|
if (!session) return { ok: false, reason: "unknown_session", session_id: sessionId };
|
|
2166
4385
|
if (typeof args.prompt !== "string" || args.prompt.length === 0)
|
|
2167
4386
|
return { ok: false, reason: "prompt_required" };
|
|
2168
|
-
|
|
2169
|
-
|
|
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) {
|
|
2170
4396
|
return {
|
|
2171
4397
|
ok: false,
|
|
2172
4398
|
reason: "active_turn_exists",
|
|
2173
4399
|
session_id: sessionId,
|
|
2174
|
-
active_turn_id: activeTurn.turn_id,
|
|
4400
|
+
active_turn_id: prepared.activeTurn.turn_id,
|
|
2175
4401
|
};
|
|
2176
4402
|
}
|
|
2177
|
-
|
|
2178
|
-
const timestamp = new Date().toISOString();
|
|
2179
|
-
const superseded = {
|
|
2180
|
-
...activeTurn,
|
|
2181
|
-
status: "superseded" as const,
|
|
2182
|
-
updated_at: timestamp,
|
|
2183
|
-
completed_at: timestamp,
|
|
2184
|
-
};
|
|
2185
|
-
await writeTurnRecord(namespaceDir, superseded);
|
|
2186
|
-
await clearActiveTurn(namespaceDir, superseded);
|
|
2187
|
-
}
|
|
2188
|
-
const shouldQueue = args.queue === true && args.force !== true;
|
|
2189
|
-
const turn = shouldQueue
|
|
2190
|
-
? makeTurnRecord(config, sessionId, args.prompt, "queued")
|
|
2191
|
-
: await activateTurn(session, makeTurnRecord(config, sessionId, args.prompt, "active"));
|
|
2192
|
-
if (shouldQueue) await writeTurnRecord(namespaceDir, turn);
|
|
2193
|
-
const recordedTurn = turn;
|
|
4403
|
+
const { turn: recordedTurn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
|
|
2194
4404
|
const prompt = {
|
|
2195
4405
|
session_id: sessionId,
|
|
2196
4406
|
turn_id: recordedTurn.turn_id,
|
|
@@ -2224,7 +4434,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2224
4434
|
const timeoutMs = boundedAwaitTurnTimeoutMs(args.timeout_ms);
|
|
2225
4435
|
const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
|
|
2226
4436
|
const deadline = Date.now() + timeoutMs;
|
|
2227
|
-
let payload = await
|
|
4437
|
+
let payload = await withCoordinatorTransaction(
|
|
4438
|
+
namespaceDir,
|
|
4439
|
+
async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
|
|
4440
|
+
);
|
|
2228
4441
|
while (
|
|
2229
4442
|
payload.ok === true &&
|
|
2230
4443
|
!TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status) &&
|
|
@@ -2236,7 +4449,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2236
4449
|
payload.turn as TurnRecord,
|
|
2237
4450
|
Math.min(pollIntervalMs, remainingMs),
|
|
2238
4451
|
);
|
|
2239
|
-
payload = await
|
|
4452
|
+
payload = await withCoordinatorTransaction(
|
|
4453
|
+
namespaceDir,
|
|
4454
|
+
async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
|
|
4455
|
+
);
|
|
2240
4456
|
}
|
|
2241
4457
|
if (payload.ok === true && !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status)) {
|
|
2242
4458
|
return {
|
|
@@ -2261,6 +4477,16 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2261
4477
|
if (args.turn_id != null && question.turn_id !== safeTurnId(args.turn_id)) {
|
|
2262
4478
|
return { ok: false, reason: "question_turn_mismatch" };
|
|
2263
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
|
+
}
|
|
2264
4490
|
const answeredTurnId = typeof question.turn_id === "string" ? question.turn_id : null;
|
|
2265
4491
|
const answered = {
|
|
2266
4492
|
...question,
|
|
@@ -2290,33 +4516,43 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2290
4516
|
|
|
2291
4517
|
let turn: TurnRecord | null = null;
|
|
2292
4518
|
if (answeredTurnId) {
|
|
2293
|
-
|
|
2294
|
-
if (
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
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;
|
|
2308
4543
|
});
|
|
2309
|
-
const session = asRecord(await readJsonFile(sessionFile(turn.session_id)));
|
|
2310
|
-
if (session && typeof args.answer === "string")
|
|
2311
|
-
await sendTmuxPrompt(session, args.answer, commandRunner);
|
|
2312
4544
|
}
|
|
2313
4545
|
}
|
|
2314
4546
|
return { ok: true, question: answered, ...(turn ? { turn } : {}) };
|
|
2315
4547
|
}
|
|
2316
4548
|
if (name === "skc_coordinator_report_status") {
|
|
2317
4549
|
requireCoordinatorMutation(config, "reports", args);
|
|
4550
|
+
await preflightCoordinatorMutation(namespaceDir);
|
|
4551
|
+
|
|
2318
4552
|
const evidence = await validateEvidencePaths(args.evidence_paths);
|
|
2319
4553
|
const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
|
|
4554
|
+
if (sessionId) await preflightCoordinatorMutation(namespaceDir, sessionId);
|
|
4555
|
+
|
|
2320
4556
|
const report = {
|
|
2321
4557
|
session_id: sessionId,
|
|
2322
4558
|
turn_id: args.turn_id,
|
|
@@ -2329,61 +4565,79 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2329
4565
|
};
|
|
2330
4566
|
let turn: TurnRecord | null = null;
|
|
2331
4567
|
let promotedTurn: TurnRecord | null = null;
|
|
4568
|
+
let promotedPreparation: { session: Record<string, unknown>; turn: TurnRecord } | null = null;
|
|
2332
4569
|
if (args.turn_id != null) {
|
|
2333
4570
|
turn = await readTurnRecord(namespaceDir, args.turn_id);
|
|
2334
4571
|
if (!turn) return { ok: false, reason: "unknown_turn" };
|
|
2335
4572
|
if (sessionId != null && turn.session_id !== sessionId) {
|
|
2336
4573
|
return { ok: false, reason: "turn_session_mismatch" };
|
|
2337
4574
|
}
|
|
4575
|
+
await preflightCoordinatorMutation(namespaceDir, turn.session_id);
|
|
2338
4576
|
const terminalStatus = asTerminalTurnStatus(args.status);
|
|
2339
4577
|
if (terminalStatus) {
|
|
2340
|
-
const
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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
|
+
}
|
|
2387
4641
|
}
|
|
2388
4642
|
}
|
|
2389
4643
|
const reportId = `report-${Date.now()}`;
|
|
@@ -2399,21 +4653,60 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2399
4653
|
? args.summary
|
|
2400
4654
|
: `Report ${String(args.status ?? "unknown")} written`,
|
|
2401
4655
|
payloadRef: path.relative(namespaceDir, reportPath),
|
|
2402
|
-
metadata: {
|
|
4656
|
+
metadata: {
|
|
4657
|
+
status: typeof args.status === "string" ? args.status : null,
|
|
4658
|
+
},
|
|
2403
4659
|
});
|
|
2404
4660
|
return {
|
|
2405
4661
|
ok: true,
|
|
2406
4662
|
report,
|
|
2407
|
-
...(turn
|
|
4663
|
+
...(turn
|
|
4664
|
+
? {
|
|
4665
|
+
turn,
|
|
4666
|
+
session_state: await readSessionState(namespaceDir, turn.session_id),
|
|
4667
|
+
}
|
|
4668
|
+
: {}),
|
|
2408
4669
|
...(promotedTurn ? { promoted_turn: promotedTurn } : {}),
|
|
2409
4670
|
};
|
|
2410
4671
|
}
|
|
2411
4672
|
return { ok: false, reason: "unknown_tool", tool: name };
|
|
2412
4673
|
} catch (error) {
|
|
2413
|
-
|
|
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
|
+
};
|
|
2414
4682
|
}
|
|
2415
4683
|
}
|
|
2416
4684
|
|
|
4685
|
+
async function callToolInternal(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
4686
|
+
try {
|
|
4687
|
+
if (
|
|
4688
|
+
name === "skc_coordinator_await_turn" ||
|
|
4689
|
+
name === "skc_coordinator_watch_events" ||
|
|
4690
|
+
(name === "skc_coordinator_start_session" && typeof args.prompt === "string" && args.prompt.length > 0) ||
|
|
4691
|
+
name === "skc_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
|
+
};
|
|
4703
|
+
}
|
|
4704
|
+
}
|
|
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
|
+
|
|
2417
4710
|
async function handleJsonRpc(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
|
2418
4711
|
const id = request.id ?? null;
|
|
2419
4712
|
if (request.method === "initialize") {
|
|
@@ -2427,8 +4720,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2427
4720
|
},
|
|
2428
4721
|
};
|
|
2429
4722
|
}
|
|
4723
|
+
if (request.method === "ping") {
|
|
4724
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
4725
|
+
}
|
|
2430
4726
|
if (request.method === "tools/list") {
|
|
2431
|
-
return {
|
|
4727
|
+
return {
|
|
4728
|
+
jsonrpc: "2.0",
|
|
4729
|
+
id,
|
|
4730
|
+
result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
|
|
4731
|
+
};
|
|
2432
4732
|
}
|
|
2433
4733
|
if (request.method === "prompts/list") {
|
|
2434
4734
|
return { jsonrpc: "2.0", id, result: { prompts: [] } };
|
|
@@ -2437,17 +4737,31 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
2437
4737
|
return { jsonrpc: "2.0", id, result: { resources: [] } };
|
|
2438
4738
|
}
|
|
2439
4739
|
if (request.method === "tools/call") {
|
|
2440
|
-
const params = (request.params ?? {}) as {
|
|
4740
|
+
const params = (request.params ?? {}) as {
|
|
4741
|
+
name?: string;
|
|
4742
|
+
arguments?: Record<string, unknown>;
|
|
4743
|
+
};
|
|
2441
4744
|
const payload = await callTool(params.name ?? "", params.arguments ?? {});
|
|
2442
|
-
return {
|
|
4745
|
+
return {
|
|
4746
|
+
jsonrpc: "2.0",
|
|
4747
|
+
id,
|
|
4748
|
+
result: textResult(payload, payload.ok === false),
|
|
4749
|
+
};
|
|
2443
4750
|
}
|
|
2444
|
-
return {
|
|
4751
|
+
return {
|
|
4752
|
+
jsonrpc: "2.0",
|
|
4753
|
+
id,
|
|
4754
|
+
error: { code: -32601, message: `unknown_method:${request.method}` },
|
|
4755
|
+
};
|
|
2445
4756
|
}
|
|
2446
4757
|
|
|
2447
|
-
return { config, callTool, handleJsonRpc, handle: handleJsonRpc };
|
|
4758
|
+
return { config, callTool, handleJsonRpc, handle: handleJsonRpc, reapSession, sessionReaper };
|
|
2448
4759
|
}
|
|
2449
4760
|
|
|
2450
|
-
function legacyToolResult(payload: unknown): {
|
|
4761
|
+
function legacyToolResult(payload: unknown): {
|
|
4762
|
+
content: Array<{ type: "text"; text: string }>;
|
|
4763
|
+
isError: boolean;
|
|
4764
|
+
} {
|
|
2451
4765
|
const failed = typeof payload === "object" && payload !== null && (payload as { ok?: unknown }).ok === false;
|
|
2452
4766
|
return textResult(payload, failed);
|
|
2453
4767
|
}
|
|
@@ -2468,13 +4782,21 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2468
4782
|
};
|
|
2469
4783
|
}
|
|
2470
4784
|
if (request.method === "tools/list") {
|
|
2471
|
-
return {
|
|
4785
|
+
return {
|
|
4786
|
+
jsonrpc: "2.0",
|
|
4787
|
+
id: request.id ?? null,
|
|
4788
|
+
result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
|
|
4789
|
+
};
|
|
2472
4790
|
}
|
|
2473
4791
|
if (request.method === "prompts/list") {
|
|
2474
4792
|
return { jsonrpc: "2.0", id: request.id ?? null, result: { prompts: [] } };
|
|
2475
4793
|
}
|
|
2476
4794
|
if (request.method === "resources/list") {
|
|
2477
|
-
return {
|
|
4795
|
+
return {
|
|
4796
|
+
jsonrpc: "2.0",
|
|
4797
|
+
id: request.id ?? null,
|
|
4798
|
+
result: { resources: [] },
|
|
4799
|
+
};
|
|
2478
4800
|
}
|
|
2479
4801
|
if (request.method !== "tools/call")
|
|
2480
4802
|
return {
|
|
@@ -2482,11 +4804,14 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2482
4804
|
id: request.id ?? null,
|
|
2483
4805
|
error: { code: -32601, message: `unknown_method:${request.method}` },
|
|
2484
4806
|
};
|
|
2485
|
-
const params = (request.params ?? {}) as {
|
|
4807
|
+
const params = (request.params ?? {}) as {
|
|
4808
|
+
name?: string;
|
|
4809
|
+
arguments?: Record<string, unknown>;
|
|
4810
|
+
};
|
|
2486
4811
|
const args = params.arguments ?? {};
|
|
2487
4812
|
const server = createCoordinatorMcpServer({
|
|
2488
4813
|
env: options.env ?? process.env,
|
|
2489
|
-
services: options.createSession ? { startSession:
|
|
4814
|
+
services: options.createSession ? { startSession: input => options.createSession?.(input) } : undefined,
|
|
2490
4815
|
});
|
|
2491
4816
|
return {
|
|
2492
4817
|
jsonrpc: "2.0",
|
|
@@ -2495,23 +4820,226 @@ export async function handleCoordinatorMcpRequest(
|
|
|
2495
4820
|
};
|
|
2496
4821
|
}
|
|
2497
4822
|
|
|
2498
|
-
export
|
|
2499
|
-
|
|
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. skc_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();
|
|
2500
4993
|
let buffer = "";
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
buffer
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
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);
|
|
2512
5012
|
}
|
|
5013
|
+
newline = buffer.indexOf("\n");
|
|
2513
5014
|
}
|
|
2514
|
-
newline = buffer.indexOf("\n");
|
|
2515
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();
|
|
2516
5044
|
}
|
|
2517
5045
|
}
|