@gajae-code/coding-agent 0.11.6 → 0.11.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +34 -4
- package/dist/types/config/model-profile-activation.d.ts +4 -3
- package/dist/types/config/model-profile-contract.d.ts +38 -0
- package/dist/types/config/settings-schema.d.ts +70 -7
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
- package/dist/types/main.d.ts +32 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +4 -2
- package/dist/types/modes/components/welcome.d.ts +2 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +7 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +4 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +26 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +1 -1
- package/dist/types/sdk/host/query/handlers.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/startup-capability.d.ts +3 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +30 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
- package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-manager.d.ts +25 -4
- package/dist/types/session/session-storage.d.ts +18 -0
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +94 -0
- package/dist/types/tools/ask.d.ts +454 -174
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +63 -1
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/prompt-suggestion.d.ts +33 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/args.ts +2 -92
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +52 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +44 -11
- package/src/config/keybindings.ts +158 -25
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-profiles.ts +18 -0
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +73 -7
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
- package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/py/executor.ts +30 -11
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/launch-tmux.ts +1 -1
- package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/gjc-runtime/ralplan-runtime.ts +75 -3
- package/src/gjc-runtime/repository-binding.ts +267 -0
- package/src/gjc-runtime/session-state-sidecar.ts +25 -9
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
- package/src/gjc-runtime/tmux-sessions.ts +45 -6
- package/src/gjc-runtime/ultragoal-guard.ts +93 -15
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +12 -10
- package/src/internal-urls/local-protocol.ts +64 -21
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +111 -37
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +73 -11
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/tool-status-header.ts +25 -4
- package/src/modes/components/welcome.ts +22 -18
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +97 -54
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/input-controller.ts +38 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +149 -63
- package/src/modes/interactive-mode.ts +57 -34
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +4 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/prompts/agents/architect.md +0 -1
- package/src/prompts/agents/critic.md +0 -1
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +6 -4
- package/src/prompts/tools/cron.md +3 -1
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/manager.ts +51 -53
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/transports/http.ts +63 -22
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/bus/chat-daemon-control.ts +19 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +9 -1
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/index.ts +244 -70
- package/src/sdk/bus/lifecycle-commands.ts +63 -10
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +68 -20
- package/src/sdk/bus/prompt-reconciliation.ts +239 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
- package/src/sdk/bus/telegram-daemon-control.ts +147 -35
- package/src/sdk/bus/telegram-daemon.ts +2463 -450
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +1 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +2 -6
- package/src/sdk/host/query/handlers.ts +70 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +58 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +31 -5
- package/src/sdk/startup-capability.ts +9 -1
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +292 -154
- package/src/session/blob-store.ts +25 -7
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +1163 -137
- package/src/session/internal/managed-session-storage.ts +397 -234
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +620 -403
- package/src/session/session-storage.ts +128 -11
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/task/gjc-command.ts +2 -2
- package/src/task/index.ts +121 -15
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +34 -3
- package/src/tools/ask.ts +199 -89
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +476 -23
- package/src/tools/index.ts +4 -0
- package/src/tools/read.ts +2 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +140 -27
- package/src/web/scrapers/docs-rs.ts +30 -5
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
|
@@ -49,6 +49,49 @@ const USAGE = [
|
|
|
49
49
|
"/session_recent [create|resume]",
|
|
50
50
|
].join("\n");
|
|
51
51
|
|
|
52
|
+
function tokenizeLifecycleCommand(value: string): string[] | undefined {
|
|
53
|
+
const tokens: string[] = [];
|
|
54
|
+
let token = "";
|
|
55
|
+
let quote: '"' | "'" | undefined;
|
|
56
|
+
let started = false;
|
|
57
|
+
for (let index = 0; index < value.length; index++) {
|
|
58
|
+
const character = value[index]!;
|
|
59
|
+
if (character === "\\") {
|
|
60
|
+
const next = value[index + 1];
|
|
61
|
+
if (next !== undefined && (next === "\\" || next === '"' || next === "'" || /\s/u.test(next))) {
|
|
62
|
+
token += next;
|
|
63
|
+
index++;
|
|
64
|
+
} else token += character;
|
|
65
|
+
started = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (quote) {
|
|
69
|
+
if (character === quote) quote = undefined;
|
|
70
|
+
else token += character;
|
|
71
|
+
started = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (character === '"' || character === "'") {
|
|
75
|
+
quote = character;
|
|
76
|
+
started = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (/\s/u.test(character)) {
|
|
80
|
+
if (started) {
|
|
81
|
+
tokens.push(token);
|
|
82
|
+
token = "";
|
|
83
|
+
started = false;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
token += character;
|
|
88
|
+
started = true;
|
|
89
|
+
}
|
|
90
|
+
if (quote) return undefined;
|
|
91
|
+
if (started) tokens.push(token);
|
|
92
|
+
return tokens;
|
|
93
|
+
}
|
|
94
|
+
|
|
52
95
|
/** True when the text begins with any /session_* token, regardless of addressability. */
|
|
53
96
|
export function isLifecycleCommandLikeText(text: string | undefined): boolean {
|
|
54
97
|
if (!text) return false;
|
|
@@ -98,8 +141,10 @@ export function parseLifecycleCommand(
|
|
|
98
141
|
text: string | undefined,
|
|
99
142
|
ctx: { chatType?: string; botUsername?: string } = {},
|
|
100
143
|
): ParsedLifecycleCommand {
|
|
144
|
+
const tokens = tokenizeLifecycleCommand((text ?? "").trim());
|
|
145
|
+
if (!tokens) return { kind: "usage", message: USAGE };
|
|
146
|
+
const [rawCommand, ...args] = tokens;
|
|
101
147
|
const raw = (text ?? "").trim();
|
|
102
|
-
const [rawCommand, ...args] = raw.split(/\s+/);
|
|
103
148
|
const command = normalizeLifecycleCommandToken(rawCommand ?? "", ctx);
|
|
104
149
|
if (command === undefined) return { kind: "none" };
|
|
105
150
|
|
|
@@ -140,9 +185,8 @@ export function parseLifecycleCommand(
|
|
|
140
185
|
|
|
141
186
|
// /session_create <kind> ... [--mpreset <profile>]
|
|
142
187
|
const { positional, modelPreset } = extractModelPreset(args);
|
|
143
|
-
if (modelPreset !== undefined &&
|
|
188
|
+
if (modelPreset !== undefined && modelPreset.length === 0)
|
|
144
189
|
return { kind: "reject", reason: "invalid_target", message: `Invalid model preset name.\n\n${USAGE}` };
|
|
145
|
-
}
|
|
146
190
|
const kind = positional[0];
|
|
147
191
|
if (kind === "path") {
|
|
148
192
|
if (positional.length !== 2) return { kind: "usage", message: USAGE };
|
|
@@ -238,11 +282,12 @@ function isSafeBranch(value: string): boolean {
|
|
|
238
282
|
/**
|
|
239
283
|
* Map a lifecycle response/error to a user-facing Telegram message (G010).
|
|
240
284
|
*
|
|
241
|
-
* Only derives text from sessionId, mode, reason, a safe message,
|
|
242
|
-
* {sessionId,path} — never a token or prompt. Each
|
|
243
|
-
* actionable copy; an "in progress" pending response
|
|
285
|
+
* Only derives text from sessionId, mode, reason, a safe message, the originating
|
|
286
|
+
* lifecycle verb, and candidate {sessionId,path} — never a token or prompt. Each
|
|
287
|
+
* error reason gets tailored, actionable copy; an "in progress" pending response
|
|
288
|
+
* is surfaced distinctly.
|
|
244
289
|
*/
|
|
245
|
-
export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
|
|
290
|
+
export function formatLifecycleOutcome(r: SessionLifecycleResponse, verb?: LifecycleCommandVerb): string {
|
|
246
291
|
switch (r.type) {
|
|
247
292
|
case "session_create_response":
|
|
248
293
|
return `\u{1f680} Launching session ${r.sessionId} in tmux. It will appear once ready \u2014 check /session_recent.`;
|
|
@@ -282,9 +327,17 @@ export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
|
|
|
282
327
|
case "unsupported_platform":
|
|
283
328
|
return "Remote session lifecycle is unavailable on this psmux host because GJC cannot prove immutable session identity. No lifecycle action was performed. Use a local GJC terminal with a supported tmux provider.";
|
|
284
329
|
case "terminal_uncertain":
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
330
|
+
if (/in progress/i.test(r.message)) return "\u23f3 That request is already in progress \u2014 hold on.";
|
|
331
|
+
switch (verb) {
|
|
332
|
+
case "session_create":
|
|
333
|
+
return "\u26a0\ufe0f Create outcome uncertain. The session may already be starting \u2014 check /session_recent before retrying to avoid starting it twice.";
|
|
334
|
+
case "session_close":
|
|
335
|
+
return "\u26a0\ufe0f Close outcome uncertain. The session may already be closed \u2014 check /session_recent before retrying.";
|
|
336
|
+
case "session_resume":
|
|
337
|
+
return "\u26a0\ufe0f Resume outcome uncertain. The session may already be reattached or restarting \u2014 check /session_recent before retrying.";
|
|
338
|
+
default:
|
|
339
|
+
return "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying.";
|
|
340
|
+
}
|
|
288
341
|
default:
|
|
289
342
|
return `\u26a0\ufe0f ${r.reason}: ${r.message}`;
|
|
290
343
|
}
|
|
@@ -1178,16 +1178,25 @@ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: Lifec
|
|
|
1178
1178
|
if (frame.type === "session_close") {
|
|
1179
1179
|
if (typeof e.sessionId !== "string" || typeof e.processGone !== "boolean")
|
|
1180
1180
|
throw new Error("invalid close success ledger entry");
|
|
1181
|
+
if (!e.processGone) {
|
|
1182
|
+
return {
|
|
1183
|
+
type: "session_lifecycle_error",
|
|
1184
|
+
requestId: frame.requestId,
|
|
1185
|
+
status: "error",
|
|
1186
|
+
reason: "terminal_uncertain",
|
|
1187
|
+
message: "close did not confirm process termination",
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1181
1190
|
return {
|
|
1182
1191
|
type: "session_close_response",
|
|
1183
1192
|
requestId: frame.requestId,
|
|
1184
1193
|
status: "ok",
|
|
1185
1194
|
sessionId: e.sessionId,
|
|
1186
|
-
processGone:
|
|
1195
|
+
processGone: true,
|
|
1187
1196
|
historyPreserved: true,
|
|
1188
1197
|
// The killed session's per-session endpoint record is reaped by the
|
|
1189
1198
|
// daemon's dead-PID scan (scanRoots), so it is effectively stale.
|
|
1190
|
-
endpointStale:
|
|
1199
|
+
endpointStale: true,
|
|
1191
1200
|
};
|
|
1192
1201
|
}
|
|
1193
1202
|
if (
|
|
@@ -309,6 +309,21 @@ export async function handleLifecycleRequest(
|
|
|
309
309
|
|
|
310
310
|
// 4. Durable idempotency.
|
|
311
311
|
const doc = await deps.store.read();
|
|
312
|
+
const existing = doc.entries[key];
|
|
313
|
+
if (
|
|
314
|
+
frame.type === "session_close" &&
|
|
315
|
+
existing?.verb === "session_close" &&
|
|
316
|
+
existing.requestHash === hash &&
|
|
317
|
+
existing.state === "success" &&
|
|
318
|
+
typeof existing.sessionId === "string" &&
|
|
319
|
+
existing.processGone === false
|
|
320
|
+
) {
|
|
321
|
+
existing.state = "terminal_uncertain";
|
|
322
|
+
existing.reason = "terminal_uncertain";
|
|
323
|
+
existing.updatedAt = deps.now();
|
|
324
|
+
delete existing.processGone;
|
|
325
|
+
await deps.store.write(doc);
|
|
326
|
+
}
|
|
312
327
|
const dup = classifyDuplicate(doc.entries[key], hash);
|
|
313
328
|
switch (dup.kind) {
|
|
314
329
|
case "conflict":
|
|
@@ -394,12 +409,28 @@ export async function handleLifecycleRequest(
|
|
|
394
409
|
|
|
395
410
|
if (frame.type === "session_close") {
|
|
396
411
|
const closed = await deps.closeSession(frame.target);
|
|
412
|
+
if (!closed.processGone) {
|
|
413
|
+
Object.assign(entry, {
|
|
414
|
+
state: "terminal_uncertain",
|
|
415
|
+
updatedAt: deps.now(),
|
|
416
|
+
reason: "terminal_uncertain",
|
|
417
|
+
sessionId: frame.target.sessionId,
|
|
418
|
+
tmuxSession: frame.target.tmuxSession,
|
|
419
|
+
});
|
|
420
|
+
await deps.store.write(doc);
|
|
421
|
+
await deps.audit({ ...baseAudit, event: "terminal_uncertain", reason: "terminal_uncertain" });
|
|
422
|
+
return {
|
|
423
|
+
status: "error",
|
|
424
|
+
reason: "terminal_uncertain",
|
|
425
|
+
message: "session_close could not confirm process disappearance; manual check",
|
|
426
|
+
};
|
|
427
|
+
}
|
|
397
428
|
Object.assign(entry, {
|
|
398
429
|
state: "success",
|
|
399
430
|
updatedAt: deps.now(),
|
|
400
431
|
sessionId: frame.target.sessionId,
|
|
401
432
|
tmuxSession: frame.target.tmuxSession,
|
|
402
|
-
processGone:
|
|
433
|
+
processGone: true,
|
|
403
434
|
});
|
|
404
435
|
await deps.store.write(doc);
|
|
405
436
|
await deps.audit({ ...baseAudit, event: "success" });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CasRestoreResult } from "../../config/atomic-yaml-patch";
|
|
2
2
|
import type { RawSettings, SettingsAtomicPatch, SettingsAtomicReceipt } from "../../config/settings";
|
|
3
|
+
import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
|
|
3
4
|
import {
|
|
4
5
|
getNotificationConfig,
|
|
5
6
|
hasNonBlankValue,
|
|
@@ -53,6 +54,7 @@ export interface ProposedTelegramIdentityPreflightInput {
|
|
|
53
54
|
deps?: {
|
|
54
55
|
fs?: TelegramDaemonFs;
|
|
55
56
|
pidAlive?: (pid: number) => boolean;
|
|
57
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -109,6 +111,16 @@ export async function proposedTelegramIdentity(
|
|
|
109
111
|
|
|
110
112
|
const pidAlive = input.deps?.pidAlive ?? defaultPidAlive;
|
|
111
113
|
if (!pidAlive(metadata.pid)) return { status: "absent" };
|
|
114
|
+
const pidIncarnation = input.deps?.pidIncarnation ?? processIncarnation;
|
|
115
|
+
const persistedIncarnation = state.incarnation;
|
|
116
|
+
const currentIncarnation = pidIncarnation(metadata.pid);
|
|
117
|
+
if (
|
|
118
|
+
!isProcessIncarnation(persistedIncarnation) ||
|
|
119
|
+
!isProcessIncarnation(currentIncarnation) ||
|
|
120
|
+
persistedIncarnation !== currentIncarnation
|
|
121
|
+
) {
|
|
122
|
+
return { status: "absent" };
|
|
123
|
+
}
|
|
112
124
|
|
|
113
125
|
if (state.tokenFingerprint === tokenFingerprint(input.botToken) && state.chatId === input.chatId) {
|
|
114
126
|
return {
|
|
@@ -251,19 +251,29 @@ export function formatNotificationStatusReport(report: NotificationStatusReport)
|
|
|
251
251
|
|
|
252
252
|
// --- endpoint / daemon file readers -------------------------------------
|
|
253
253
|
|
|
254
|
-
interface
|
|
254
|
+
export interface NotificationEndpointView {
|
|
255
255
|
sessionId: string;
|
|
256
256
|
pid: number | undefined;
|
|
257
257
|
stale: boolean;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
type
|
|
261
|
-
|
|
260
|
+
export type NotificationEndpointLiveness = "live" | "dead" | "unknown";
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Classification used by recovery and startup takeover. A file is an endpoint
|
|
264
|
+
* only when it has endpoint authority fields; lifecycle/audit records are never
|
|
265
|
+
* candidates for endpoint cleanup.
|
|
266
|
+
*/
|
|
267
|
+
export type NotificationEndpointClassification =
|
|
268
|
+
| {
|
|
269
|
+
kind: "endpoint";
|
|
270
|
+
view: NotificationEndpointView;
|
|
271
|
+
liveness: NotificationEndpointLiveness;
|
|
272
|
+
identity: NotificationEndpointFileIdentity;
|
|
273
|
+
}
|
|
262
274
|
| { kind: "non-endpoint" }
|
|
263
275
|
| { kind: "unreadable" };
|
|
264
276
|
|
|
265
|
-
type EndpointLiveness = "live" | "dead" | "unknown";
|
|
266
|
-
|
|
267
277
|
/**
|
|
268
278
|
* Classify an endpoint using owner-proof semantics. An endpoint is only `dead`
|
|
269
279
|
* with positive proof: an explicit `stale` tombstone, or a recorded pid that is
|
|
@@ -271,7 +281,10 @@ type EndpointLiveness = "live" | "dead" | "unknown";
|
|
|
271
281
|
* must never be treated as dead — removing it could delete a live session's
|
|
272
282
|
* discovery file that simply omitted a pid.
|
|
273
283
|
*/
|
|
274
|
-
function
|
|
284
|
+
export function notificationEndpointLiveness(
|
|
285
|
+
view: NotificationEndpointView,
|
|
286
|
+
pidAlive: (pid: number) => boolean,
|
|
287
|
+
): NotificationEndpointLiveness {
|
|
275
288
|
if (view.stale) return "dead";
|
|
276
289
|
if (view.pid === undefined) return "unknown";
|
|
277
290
|
return pidAlive(view.pid) ? "live" : "dead";
|
|
@@ -314,11 +327,19 @@ function isCanonicalLifecycleArtifactName(name: string): boolean {
|
|
|
314
327
|
);
|
|
315
328
|
}
|
|
316
329
|
|
|
317
|
-
function unreadableEndpointResult(file: string):
|
|
330
|
+
function unreadableEndpointResult(file: string): NotificationEndpointClassification {
|
|
318
331
|
return isCanonicalLifecycleArtifactName(path.basename(file)) ? { kind: "non-endpoint" } : { kind: "unreadable" };
|
|
319
332
|
}
|
|
320
333
|
|
|
321
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Read and classify one endpoint candidate. The returned identity belongs to
|
|
336
|
+
* exactly the bytes inspected and is required for any later deletion.
|
|
337
|
+
*/
|
|
338
|
+
export async function classifyNotificationEndpoint(
|
|
339
|
+
fs: Pick<NotificationServiceFs, "readEndpointFile">,
|
|
340
|
+
file: string,
|
|
341
|
+
pidAlive: (pid: number) => boolean,
|
|
342
|
+
): Promise<NotificationEndpointClassification> {
|
|
322
343
|
let endpoint: NotificationEndpointFile;
|
|
323
344
|
let raw: string;
|
|
324
345
|
try {
|
|
@@ -334,16 +355,19 @@ async function readEndpointView(fs: NotificationServiceFs, file: string): Promis
|
|
|
334
355
|
return unreadableEndpointResult(file);
|
|
335
356
|
}
|
|
336
357
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "non-endpoint" };
|
|
358
|
+
if (path.basename(file) === "broker.json") return { kind: "non-endpoint" };
|
|
337
359
|
const rec = parsed as Record<string, unknown>;
|
|
338
360
|
if (isLifecycleArtifact(rec) || typeof rec.url !== "string" || typeof rec.token !== "string")
|
|
339
361
|
return { kind: "non-endpoint" };
|
|
362
|
+
const view: NotificationEndpointView = {
|
|
363
|
+
sessionId: typeof rec.sessionId === "string" ? rec.sessionId : path.basename(file, ".json"),
|
|
364
|
+
pid: safePositiveInteger(rec.pid),
|
|
365
|
+
stale: rec.stale === true,
|
|
366
|
+
};
|
|
340
367
|
return {
|
|
341
368
|
kind: "endpoint",
|
|
342
|
-
view
|
|
343
|
-
|
|
344
|
-
pid: safePositiveInteger(rec.pid),
|
|
345
|
-
stale: rec.stale === true,
|
|
346
|
-
},
|
|
369
|
+
view,
|
|
370
|
+
liveness: notificationEndpointLiveness(view, pidAlive),
|
|
347
371
|
identity: endpoint.identity,
|
|
348
372
|
};
|
|
349
373
|
}
|
|
@@ -604,14 +628,13 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
604
628
|
let unknownEndpoints = 0;
|
|
605
629
|
let unreadable = 0;
|
|
606
630
|
for (const name of files) {
|
|
607
|
-
const record = await
|
|
631
|
+
const record = await classifyNotificationEndpoint(fs, path.join(dir, name), pidAlive);
|
|
608
632
|
if (record.kind === "non-endpoint") continue;
|
|
609
633
|
if (record.kind === "unreadable") {
|
|
610
634
|
unreadable += 1;
|
|
611
635
|
continue;
|
|
612
636
|
}
|
|
613
|
-
|
|
614
|
-
switch (endpointLiveness(view, pidAlive)) {
|
|
637
|
+
switch (record.liveness) {
|
|
615
638
|
case "live":
|
|
616
639
|
live += 1;
|
|
617
640
|
break;
|
|
@@ -877,7 +900,16 @@ async function detachTransitionMarker(
|
|
|
877
900
|
): Promise<boolean> {
|
|
878
901
|
if (!snapshot.identity || !fs.exactUnlink) return false;
|
|
879
902
|
try {
|
|
880
|
-
|
|
903
|
+
const removed = await fs.exactUnlink(path, snapshot.identity);
|
|
904
|
+
if (removed.ok) return true;
|
|
905
|
+
// Accept only typed retained authority: a concrete detached quarantine plus
|
|
906
|
+
// a proven-absent canonical marker pathname. Anything else stays fail-closed.
|
|
907
|
+
return (
|
|
908
|
+
removed.code === "cleanup_pending" &&
|
|
909
|
+
typeof removed.detachedPath === "string" &&
|
|
910
|
+
removed.detachedPath.length > 0 &&
|
|
911
|
+
(await fs.readFile(path, "utf8").catch(() => undefined)) === undefined
|
|
912
|
+
);
|
|
881
913
|
} catch {
|
|
882
914
|
return false;
|
|
883
915
|
}
|
|
@@ -1040,7 +1072,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
1040
1072
|
let unreadable = 0;
|
|
1041
1073
|
for (const name of files) {
|
|
1042
1074
|
const file = path.join(dir, name);
|
|
1043
|
-
const record = await
|
|
1075
|
+
const record = await classifyNotificationEndpoint(fs, file, pidAlive);
|
|
1044
1076
|
if (record.kind === "non-endpoint") continue;
|
|
1045
1077
|
if (record.kind === "unreadable") {
|
|
1046
1078
|
// Leave unparseable files untouched: they may be mid-write by a live server.
|
|
@@ -1048,7 +1080,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
1048
1080
|
continue;
|
|
1049
1081
|
}
|
|
1050
1082
|
const view = record.view;
|
|
1051
|
-
if (
|
|
1083
|
+
if (record.liveness !== "dead") {
|
|
1052
1084
|
// Keep live AND unknown (PID-less) endpoints: only positive proof of
|
|
1053
1085
|
// death (a stale tombstone or a dead pid) authorizes removal.
|
|
1054
1086
|
kept += 1;
|
|
@@ -1057,11 +1089,27 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
1057
1089
|
try {
|
|
1058
1090
|
const result = await fs.exactUnlink(file, record.identity);
|
|
1059
1091
|
if (!result.ok) {
|
|
1060
|
-
recoveryFailures += 1;
|
|
1061
1092
|
if (result.detachedPath) detached.push(result.detachedPath);
|
|
1062
1093
|
if (result.retainedSuccessorPath) retainedSuccessors.push(result.retainedSuccessorPath);
|
|
1063
1094
|
if (result.retainedPlaceholderPath) retainedPlaceholders.push(result.retainedPlaceholderPath);
|
|
1064
1095
|
if (result.retainedUnknownPath) retainedUnknown.push(result.retainedUnknownPath);
|
|
1096
|
+
// Typed retained authority with a concrete quarantine and a proven-absent
|
|
1097
|
+
// canonical endpoint counts as removed; the detached path above is the
|
|
1098
|
+
// durable evidence. Anything else is a genuine recovery failure.
|
|
1099
|
+
const retainedRemoved =
|
|
1100
|
+
result.code === "cleanup_pending" &&
|
|
1101
|
+
typeof result.detachedPath === "string" &&
|
|
1102
|
+
result.detachedPath.length > 0 &&
|
|
1103
|
+
(await fs.readFile(file, "utf8").catch(() => undefined)) === undefined;
|
|
1104
|
+
if (retainedRemoved) {
|
|
1105
|
+
removed.push({
|
|
1106
|
+
sessionId: view.sessionId,
|
|
1107
|
+
pid: view.pid,
|
|
1108
|
+
reason: view.stale ? "stale-flag" : "dead-pid",
|
|
1109
|
+
});
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
recoveryFailures += 1;
|
|
1065
1113
|
if (
|
|
1066
1114
|
!result.detachedPath &&
|
|
1067
1115
|
!result.retainedSuccessorPath &&
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded authoritative reconciliation state for Q26 `turn.prompt_status`.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the lifecycle delivery buffers (promptSubmissions /
|
|
5
|
+
* promptTerminalTombstones), which exist to deliver frames and intentionally
|
|
6
|
+
* forget outcomes. This record preserves accepted/in_flight/terminal_ok/failed
|
|
7
|
+
* plus bounded sanitized failure metadata so a caller can reconcile a prior
|
|
8
|
+
* `turn.prompt` after disconnect/reconnect.
|
|
9
|
+
*
|
|
10
|
+
* Semantics (public contract, mirrored in sdk/prompt-status.ts):
|
|
11
|
+
* - Active records are NEVER converted to terminal by age or capacity.
|
|
12
|
+
* - Terminal records are retained for TERMINAL_TTL_MS and bounded to
|
|
13
|
+
* TERMINAL_CAPACITY, evicted oldest-terminal-first by terminalAt; only then
|
|
14
|
+
* does a lookup honestly report `unknown`.
|
|
15
|
+
* - The durability floor is the live session process: restart means `unknown`.
|
|
16
|
+
* - The clientRef index is session-runtime scoped; a ref conflicts only while
|
|
17
|
+
* retained and must never be reused as a retry mechanism.
|
|
18
|
+
* - Terminal transitions settle once: the first terminal outcome wins.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const PROMPT_RECONCILIATION_ACTIVE_CAPACITY = 128;
|
|
22
|
+
export const PROMPT_RECONCILIATION_TERMINAL_CAPACITY = 256;
|
|
23
|
+
export const PROMPT_RECONCILIATION_TERMINAL_TTL_MS = 15 * 60_000;
|
|
24
|
+
export const PROMPT_FAILURE_CODE_MAX = 64;
|
|
25
|
+
|
|
26
|
+
export type PromptReconciliationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed";
|
|
27
|
+
|
|
28
|
+
export interface PromptCorrelation {
|
|
29
|
+
commandId: string;
|
|
30
|
+
turnId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PromptReconciliationRecord extends PromptCorrelation {
|
|
34
|
+
clientRef?: string;
|
|
35
|
+
status: PromptReconciliationStatus;
|
|
36
|
+
error?: { code: string; message: string };
|
|
37
|
+
acceptedAt: number;
|
|
38
|
+
startedAt?: number;
|
|
39
|
+
terminalAt?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type TurnPromptReconciliation =
|
|
43
|
+
| {
|
|
44
|
+
status: "accepted";
|
|
45
|
+
commandId: string;
|
|
46
|
+
turnId: string;
|
|
47
|
+
clientRef?: string;
|
|
48
|
+
acceptedAt: number;
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
status: "in_flight";
|
|
52
|
+
commandId: string;
|
|
53
|
+
turnId: string;
|
|
54
|
+
clientRef?: string;
|
|
55
|
+
acceptedAt: number;
|
|
56
|
+
startedAt: number;
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
status: "terminal_ok";
|
|
60
|
+
commandId: string;
|
|
61
|
+
turnId: string;
|
|
62
|
+
clientRef?: string;
|
|
63
|
+
acceptedAt: number;
|
|
64
|
+
startedAt?: number;
|
|
65
|
+
terminalAt: number;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
status: "failed";
|
|
69
|
+
commandId: string;
|
|
70
|
+
turnId: string;
|
|
71
|
+
clientRef?: string;
|
|
72
|
+
acceptedAt: number;
|
|
73
|
+
startedAt?: number;
|
|
74
|
+
terminalAt: number;
|
|
75
|
+
error: { code: string; message: string };
|
|
76
|
+
}
|
|
77
|
+
| { status: "unknown" };
|
|
78
|
+
|
|
79
|
+
export interface PromptReconciliation {
|
|
80
|
+
/** Fail-closed admission BEFORE any execution; holds an identity-bound reservation. */
|
|
81
|
+
admit(clientRef?: string): void;
|
|
82
|
+
/** Discard one admission reservation without creating a record (rejection/cancellation). */
|
|
83
|
+
releaseAdmission(clientRef?: string): void;
|
|
84
|
+
/** Transition a reservation into the accepted record at preflight acceptance. */
|
|
85
|
+
noteAccepted(correlation: PromptCorrelation, clientRef?: string): void;
|
|
86
|
+
/** Lifecycle transition; terminal outcomes settle exactly once. */
|
|
87
|
+
noteTransition(
|
|
88
|
+
correlation: PromptCorrelation | undefined,
|
|
89
|
+
frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
|
|
90
|
+
): void;
|
|
91
|
+
lookup(selector: { commandId?: string; turnId?: string; clientRef?: string }): TurnPromptReconciliation;
|
|
92
|
+
cleanup(): void;
|
|
93
|
+
activeCount(): number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Safe-token code capped at 64; arbitrary failure text is never retained. */
|
|
97
|
+
export function sanitizePromptFailure(error: unknown): { code: string; message: string } {
|
|
98
|
+
const candidate = error as { code?: unknown } | undefined;
|
|
99
|
+
const rawCode = typeof candidate?.code === "string" ? candidate.code : "";
|
|
100
|
+
const code = rawCode.length <= PROMPT_FAILURE_CODE_MAX && /^[A-Za-z0-9._-]+$/.test(rawCode) ? rawCode : "internal";
|
|
101
|
+
return { code, message: "Prompt submission failed." };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createPromptReconciliation(options: { now?: () => number } = {}): PromptReconciliation {
|
|
105
|
+
const now = options.now ?? Date.now;
|
|
106
|
+
const records = new Map<string, PromptReconciliationRecord>();
|
|
107
|
+
const clientRefIndex = new Map<string, string>();
|
|
108
|
+
const reservedClientRefs = new Set<string>();
|
|
109
|
+
// One identity-bound entry per admission. Transitions consume exactly one
|
|
110
|
+
// matching entry and stale/duplicate transitions are no-ops, so reservation
|
|
111
|
+
// accounting is exactly-once even under repeated calls.
|
|
112
|
+
const reservations: Array<string | undefined> = [];
|
|
113
|
+
const keyOf = (correlation: PromptCorrelation) => `${correlation.commandId}:${correlation.turnId}`;
|
|
114
|
+
|
|
115
|
+
const remove = (key: string) => {
|
|
116
|
+
const record = records.get(key);
|
|
117
|
+
if (!record) return;
|
|
118
|
+
records.delete(key);
|
|
119
|
+
if (record.clientRef !== undefined && clientRefIndex.get(record.clientRef) === key)
|
|
120
|
+
clientRefIndex.delete(record.clientRef);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const cleanup = () => {
|
|
124
|
+
const at = now();
|
|
125
|
+
for (const [key, record] of records)
|
|
126
|
+
if (record.terminalAt !== undefined && record.terminalAt + PROMPT_RECONCILIATION_TERMINAL_TTL_MS <= at)
|
|
127
|
+
remove(key);
|
|
128
|
+
const terminalEntries = [...records.entries()].filter(([, record]) => record.terminalAt !== undefined);
|
|
129
|
+
if (terminalEntries.length <= PROMPT_RECONCILIATION_TERMINAL_CAPACITY) return;
|
|
130
|
+
// Evict oldest-terminal-first by terminalAt, not acceptance/insertion
|
|
131
|
+
// order; the stable sort keeps acceptance order for exact ties.
|
|
132
|
+
terminalEntries.sort((a, b) => (a[1].terminalAt as number) - (b[1].terminalAt as number));
|
|
133
|
+
for (const [key] of terminalEntries.slice(0, terminalEntries.length - PROMPT_RECONCILIATION_TERMINAL_CAPACITY))
|
|
134
|
+
remove(key);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const activeCount = () => {
|
|
138
|
+
let count = 0;
|
|
139
|
+
for (const record of records.values()) if (record.terminalAt === undefined) count++;
|
|
140
|
+
return count;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Consume one reservation matching clientRef; no-op when none is outstanding. */
|
|
144
|
+
const consumeReservation = (clientRef?: string) => {
|
|
145
|
+
const index = reservations.indexOf(clientRef);
|
|
146
|
+
if (index === -1) return;
|
|
147
|
+
reservations.splice(index, 1);
|
|
148
|
+
if (clientRef !== undefined && !reservations.includes(clientRef)) reservedClientRefs.delete(clientRef);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const admit = (clientRef?: string) => {
|
|
152
|
+
cleanup();
|
|
153
|
+
if (clientRef !== undefined && (clientRefIndex.has(clientRef) || reservedClientRefs.has(clientRef)))
|
|
154
|
+
throw Object.assign(
|
|
155
|
+
new Error("A prompt with this clientRef is already retained; never reuse a clientRef for retry."),
|
|
156
|
+
{ code: "client_ref_conflict" },
|
|
157
|
+
);
|
|
158
|
+
if (activeCount() + reservations.length >= PROMPT_RECONCILIATION_ACTIVE_CAPACITY)
|
|
159
|
+
throw Object.assign(new Error("Too many active prompt submissions; reconcile or await terminal state."), {
|
|
160
|
+
code: "reconciliation_capacity",
|
|
161
|
+
});
|
|
162
|
+
reservations.push(clientRef);
|
|
163
|
+
if (clientRef !== undefined) reservedClientRefs.add(clientRef);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const releaseAdmission = (clientRef?: string) => {
|
|
167
|
+
consumeReservation(clientRef);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const noteAccepted = (correlation: PromptCorrelation, clientRef?: string) => {
|
|
171
|
+
cleanup();
|
|
172
|
+
consumeReservation(clientRef);
|
|
173
|
+
const at = now();
|
|
174
|
+
const key = keyOf(correlation);
|
|
175
|
+
records.set(key, {
|
|
176
|
+
commandId: correlation.commandId,
|
|
177
|
+
turnId: correlation.turnId,
|
|
178
|
+
...(clientRef !== undefined ? { clientRef } : {}),
|
|
179
|
+
status: "accepted",
|
|
180
|
+
acceptedAt: at,
|
|
181
|
+
});
|
|
182
|
+
if (clientRef !== undefined) clientRefIndex.set(clientRef, key);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const noteTransition = (
|
|
186
|
+
correlation: PromptCorrelation | undefined,
|
|
187
|
+
frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
|
|
188
|
+
) => {
|
|
189
|
+
if (!correlation) return;
|
|
190
|
+
const record = records.get(keyOf(correlation));
|
|
191
|
+
if (!record || record.terminalAt !== undefined) return;
|
|
192
|
+
if (frame.type === "agent_start") {
|
|
193
|
+
if (record.status === "accepted") {
|
|
194
|
+
record.status = "in_flight";
|
|
195
|
+
record.startedAt = now();
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
record.terminalAt = now();
|
|
200
|
+
if (frame.type === "agent_failed") {
|
|
201
|
+
record.status = "failed";
|
|
202
|
+
record.error = sanitizePromptFailure(frame.error);
|
|
203
|
+
} else {
|
|
204
|
+
record.status = "terminal_ok";
|
|
205
|
+
}
|
|
206
|
+
// Enforce terminal retention immediately at settlement, not lazily.
|
|
207
|
+
cleanup();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const lookup = (selector: { commandId?: string; turnId?: string; clientRef?: string }): TurnPromptReconciliation => {
|
|
211
|
+
cleanup();
|
|
212
|
+
const key =
|
|
213
|
+
selector.clientRef !== undefined
|
|
214
|
+
? clientRefIndex.get(selector.clientRef)
|
|
215
|
+
: selector.commandId !== undefined && selector.turnId !== undefined
|
|
216
|
+
? keyOf({ commandId: selector.commandId, turnId: selector.turnId })
|
|
217
|
+
: undefined;
|
|
218
|
+
const record = key === undefined ? undefined : records.get(key);
|
|
219
|
+
if (!record) return { status: "unknown" };
|
|
220
|
+
const identity = {
|
|
221
|
+
commandId: record.commandId,
|
|
222
|
+
turnId: record.turnId,
|
|
223
|
+
...(record.clientRef !== undefined ? { clientRef: record.clientRef } : {}),
|
|
224
|
+
acceptedAt: record.acceptedAt,
|
|
225
|
+
};
|
|
226
|
+
if (record.status === "accepted") return { status: "accepted", ...identity };
|
|
227
|
+
if (record.status === "in_flight")
|
|
228
|
+
return { status: "in_flight", ...identity, startedAt: record.startedAt as number };
|
|
229
|
+
const terminal = {
|
|
230
|
+
...identity,
|
|
231
|
+
...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}),
|
|
232
|
+
terminalAt: record.terminalAt as number,
|
|
233
|
+
};
|
|
234
|
+
if (record.status === "terminal_ok") return { status: "terminal_ok", ...terminal };
|
|
235
|
+
return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return { admit, releaseAdmission, noteAccepted, noteTransition, lookup, cleanup, activeCount };
|
|
239
|
+
}
|