@gajae-code/coding-agent 0.18.6 → 0.18.7
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 +39 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/config/settings.d.ts +4 -0
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +3 -2
- package/dist/types/sdk/host/host.d.ts +6 -0
- package/dist/types/sdk/router/session-router.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
- package/dist/types/session/session-retirement.d.ts +47 -0
- package/dist/types/session/session-storage.d.ts +0 -46
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -0
- package/package.json +7 -7
- package/scripts/resident-memory-bench.ts +174 -20
- package/src/capability/index.ts +1 -0
- package/src/capability/types.ts +4 -0
- package/src/cli/config-cli.ts +8 -18
- package/src/cli-main.ts +7 -8
- package/src/commands/sdk.ts +112 -21
- package/src/config/settings.ts +44 -4
- package/src/discovery/helpers.ts +9 -3
- package/src/discovery/ssh.ts +8 -3
- package/src/gjc-runtime/gc-runtime.ts +2 -1
- package/src/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/acp/acp-agent.ts +75 -3
- package/src/runtime-mcp/client.ts +13 -4
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/sdk/broker/broker.ts +8 -1
- package/src/sdk/broker/lifecycle.ts +109 -7
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +3 -3
- package/src/sdk/bus/telegram-daemon.ts +4 -2
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/session-runtime.test.ts +43 -2
- package/src/sdk/host/session-runtime.ts +2 -2
- package/src/sdk/mcp/server.ts +15 -3
- package/src/sdk/router/session-router.ts +19 -2
- package/src/sdk/session.ts +14 -0
- package/src/session/agent-session.ts +57 -6
- package/src/session/internal/managed-session-storage.ts +107 -1
- package/src/session/internal/task-artifact-owner-access.ts +229 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
- package/src/session/session-retirement.ts +155 -0
- package/src/session/session-storage.ts +1 -160
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +578 -0
- package/src/session/task-artifact-owner-retirement.ts +475 -0
- package/src/session/task-artifact-owner.ts +289 -0
|
@@ -1426,6 +1426,21 @@ const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhi
|
|
|
1426
1426
|
name: value,
|
|
1427
1427
|
}));
|
|
1428
1428
|
|
|
1429
|
+
function thinkingConfigOptions(query: unknown, currentModel: string | undefined): { value: string; name: string }[] {
|
|
1430
|
+
if (!currentModel) return THINKING_CONFIG_OPTIONS;
|
|
1431
|
+
const separator = currentModel.indexOf("/");
|
|
1432
|
+
if (separator <= 0 || separator === currentModel.length - 1) return THINKING_CONFIG_OPTIONS;
|
|
1433
|
+
const provider = currentModel.slice(0, separator);
|
|
1434
|
+
const id = currentModel.slice(separator + 1);
|
|
1435
|
+
const model = pageItems(query)
|
|
1436
|
+
.map(item => object(item))
|
|
1437
|
+
.find(item => item?.provider === provider && item.id === id);
|
|
1438
|
+
const validLevels = object(model?.thinking)?.validLevels;
|
|
1439
|
+
if (!Array.isArray(validLevels) || validLevels.length === 0) return THINKING_CONFIG_OPTIONS;
|
|
1440
|
+
const levels = validLevels.filter((value): value is string => typeof value === "string");
|
|
1441
|
+
return levels.length === 0 ? THINKING_CONFIG_OPTIONS : levels.map(value => ({ value, name: value }));
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1429
1444
|
/** Maps live canonical SDK config and the selected model catalog into the ACP 1.2.1 session state surface. */
|
|
1430
1445
|
export function acpSessionStateFromConfig(
|
|
1431
1446
|
query: unknown,
|
|
@@ -1436,6 +1451,7 @@ export function acpSessionStateFromConfig(
|
|
|
1436
1451
|
const values = configValues(query);
|
|
1437
1452
|
const useModelPresets = modelPreset !== undefined;
|
|
1438
1453
|
const currentModeId = values.get(MODE_CONFIG_ID) === ACP_PLAN_MODE_ID ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
|
|
1454
|
+
const thinkingOptions = thinkingConfigOptions(modelCatalogQuery, values.get(MODEL_CONFIG_ID));
|
|
1439
1455
|
return {
|
|
1440
1456
|
configOptions: [
|
|
1441
1457
|
{
|
|
@@ -1461,7 +1477,7 @@ export function acpSessionStateFromConfig(
|
|
|
1461
1477
|
? modelPresetConfigOptions(modelCatalogQuery, value)
|
|
1462
1478
|
: modelConfigOptions(modelCatalogQuery, value, activeProviders)
|
|
1463
1479
|
: option.id === THINKING_CONFIG_ID
|
|
1464
|
-
?
|
|
1480
|
+
? thinkingOptions
|
|
1465
1481
|
: [...option.options];
|
|
1466
1482
|
return [
|
|
1467
1483
|
{
|
|
@@ -1798,11 +1814,13 @@ export class AcpAgent implements Agent {
|
|
|
1798
1814
|
if (adapter) adapter.acceptFrame(acpFrame);
|
|
1799
1815
|
else this.#pendingRouterFrames.get(attachment.sessionId)?.push(acpFrame);
|
|
1800
1816
|
},
|
|
1801
|
-
onSessionRemoved: attachment => {
|
|
1817
|
+
onSessionRemoved: (attachment, removalReason) => {
|
|
1802
1818
|
const adapter =
|
|
1803
1819
|
this.#sessions.get(attachment.sessionId)?.adapter ??
|
|
1804
1820
|
this.#pendingRouterAdapters.get(attachment.sessionId);
|
|
1805
1821
|
adapter?.revokeAttachment(attachment);
|
|
1822
|
+
if (adapter && removalReason !== "replaced_same_generation")
|
|
1823
|
+
this.#settlePromptAfterHostClose(attachment.sessionId, removalReason ?? "host_exit");
|
|
1806
1824
|
},
|
|
1807
1825
|
},
|
|
1808
1826
|
});
|
|
@@ -2158,7 +2176,22 @@ export class AcpAgent implements Agent {
|
|
|
2158
2176
|
}
|
|
2159
2177
|
break;
|
|
2160
2178
|
case THINKING_CONFIG_ID:
|
|
2161
|
-
|
|
2179
|
+
{
|
|
2180
|
+
const before = await this.#sessionState(params.sessionId);
|
|
2181
|
+
const thinkingOption = before.configOptions?.find(option => option.id === THINKING_CONFIG_ID);
|
|
2182
|
+
if (
|
|
2183
|
+
thinkingOption?.type !== "select" ||
|
|
2184
|
+
!thinkingOption.options.some(option => "value" in option && option.value === params.value)
|
|
2185
|
+
)
|
|
2186
|
+
throw new Error(`Unsupported thinking level: ${params.value}`);
|
|
2187
|
+
await this.#adapter(params.sessionId).control("thinking.set", { level: params.value });
|
|
2188
|
+
const after = await this.#sessionState(params.sessionId);
|
|
2189
|
+
const currentValue = after.configOptions?.find(option => option.id === THINKING_CONFIG_ID)?.currentValue;
|
|
2190
|
+
if (currentValue !== params.value)
|
|
2191
|
+
throw new Error(
|
|
2192
|
+
`Thinking level was not applied: requested ${params.value}, current ${currentValue ?? "unknown"}`,
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2162
2195
|
break;
|
|
2163
2196
|
default: {
|
|
2164
2197
|
const operation = ACP_CONFIG_CONTROL_OPERATIONS[params.configId];
|
|
@@ -4459,6 +4492,37 @@ export class AcpAgent implements Agent {
|
|
|
4459
4492
|
return undefined;
|
|
4460
4493
|
}
|
|
4461
4494
|
|
|
4495
|
+
/** Settles the active prompt when the host explicitly reports that its session closed. */
|
|
4496
|
+
#settlePromptAfterHostClose(id: string, reason: string): void {
|
|
4497
|
+
const record = this.#sessions.get(id);
|
|
4498
|
+
if (!record) return;
|
|
4499
|
+
const waiter = record.activePrompt;
|
|
4500
|
+
if (!waiter || waiter.settled || waiter.terminalReserved) return;
|
|
4501
|
+
if (record.cancelRequested && waiter.cancelAcknowledged) {
|
|
4502
|
+
void this.#settleCancelledPrompt(id, record, waiter);
|
|
4503
|
+
return;
|
|
4504
|
+
}
|
|
4505
|
+
// An accepted mutation without its acknowledgement remains uncertain. Keep the
|
|
4506
|
+
// existing reconciliation path so a host exit never turns an ambiguous prompt
|
|
4507
|
+
// into a retryable failure.
|
|
4508
|
+
if (waiter.dispatched && !waiter.acknowledged) {
|
|
4509
|
+
this.#startUncertainPromptRecovery(id, record, waiter);
|
|
4510
|
+
return;
|
|
4511
|
+
}
|
|
4512
|
+
record.busy = record.backgroundBusy;
|
|
4513
|
+
void this.#rejectPrompt(
|
|
4514
|
+
record,
|
|
4515
|
+
id,
|
|
4516
|
+
waiter,
|
|
4517
|
+
new AcpPromptAbandonedError(
|
|
4518
|
+
"prompt_abandoned",
|
|
4519
|
+
`ACP prompt was abandoned because the SDK session host closed (${reason}). The turn was settled so the ` +
|
|
4520
|
+
`client stops waiting; a new prompt requires a valid or re-established session attachment.`,
|
|
4521
|
+
waiter.planSnapshot,
|
|
4522
|
+
),
|
|
4523
|
+
);
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4462
4526
|
/**
|
|
4463
4527
|
* Settles the ACP prompt only. The agent's own work is left alone: this reports that the
|
|
4464
4528
|
* turn can no longer be observed, it does not cancel or tear down the session.
|
|
@@ -4532,6 +4596,14 @@ export class AcpAgent implements Agent {
|
|
|
4532
4596
|
else logger.warn(`ACP session ${id} dropped an event from a foreign session identity.`);
|
|
4533
4597
|
return;
|
|
4534
4598
|
}
|
|
4599
|
+
if (ingressEvent?.type === "session_closed" || ingressEvent?.type === "session_terminated") {
|
|
4600
|
+
const reason =
|
|
4601
|
+
typeof ingressEvent.reason === "string" && ingressEvent.reason.length > 0
|
|
4602
|
+
? ingressEvent.reason
|
|
4603
|
+
: ingressEvent.type;
|
|
4604
|
+
this.#settlePromptAfterHostClose(id, reason);
|
|
4605
|
+
return;
|
|
4606
|
+
}
|
|
4535
4607
|
if (frame.type !== "hello" && frame.type !== "server_hello" && typeof frame.connectionId === "string")
|
|
4536
4608
|
record.connectionId = frame.connectionId;
|
|
4537
4609
|
// Ingress ordering is recorded before queued work begins.
|
|
@@ -451,6 +451,9 @@ function isModernMethodNotFound(error: unknown): boolean {
|
|
|
451
451
|
* The original request is retried with `inputResponses` and a verbatim
|
|
452
452
|
* `requestState` echo under a fresh JSON-RPC id, at most MAX_MRTR_RETRIES times;
|
|
453
453
|
* the retry is fenced to the originating exchange by a stable correlation id.
|
|
454
|
+
* `roots/list` input requests are answered locally; every other input request
|
|
455
|
+
* requires `options.inputHandler`, and its absence fails the exchange before any
|
|
456
|
+
* retry is sent (no write replay, no implicit accept).
|
|
454
457
|
*/
|
|
455
458
|
async function requestWithInputHandling<T>(
|
|
456
459
|
connection: MCPServerConnection,
|
|
@@ -474,17 +477,20 @@ async function requestWithInputHandling<T>(
|
|
|
474
477
|
if (!inputRequired) return stripResultType(initial);
|
|
475
478
|
|
|
476
479
|
const handler = options?.inputHandler;
|
|
477
|
-
|
|
478
|
-
|
|
480
|
+
const correlationId = crypto.randomUUID();
|
|
481
|
+
|
|
482
|
+
const missingHandler = () =>
|
|
483
|
+
new MCPExpectedFailure(
|
|
479
484
|
new Error(
|
|
480
485
|
`MCP server "${connection.name}" requested additional input (input_required) for ${method}, but no interactive input handler is available`,
|
|
481
486
|
),
|
|
482
487
|
);
|
|
483
|
-
}
|
|
484
|
-
const correlationId = crypto.randomUUID();
|
|
485
488
|
|
|
486
489
|
for (let attempt = 0; attempt < MAX_MRTR_RETRIES; attempt++) {
|
|
487
490
|
const inputResponses: Record<string, unknown> = {};
|
|
491
|
+
// A requestState-only round has no local answer to give; without an
|
|
492
|
+
// interactive handler it is never replayed unattended.
|
|
493
|
+
if (!handler && Object.keys(inputRequired.inputRequests).length === 0) throw missingHandler();
|
|
488
494
|
for (const [key, request] of Object.entries(inputRequired.inputRequests)) {
|
|
489
495
|
if (options?.signal?.aborted) {
|
|
490
496
|
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Aborted");
|
|
@@ -494,6 +500,9 @@ async function requestWithInputHandling<T>(
|
|
|
494
500
|
inputResponses[key] = await defaultRequestHandler("roots/list", undefined);
|
|
495
501
|
continue;
|
|
496
502
|
}
|
|
503
|
+
// Everything else (elicitation, sampling, ...) needs a real interactive
|
|
504
|
+
// handler. It is never answered implicitly; fail before any retry is sent.
|
|
505
|
+
if (!handler) throw missingHandler();
|
|
497
506
|
const outcome = await handler(key, request, {
|
|
498
507
|
serverName: connection.name,
|
|
499
508
|
originMethod: method,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { untilAborted } from "@gajae-code/utils";
|
|
2
|
+
import type { ExtensionUIContext } from "../extensibility/extensions/types";
|
|
3
|
+
import type { AskAnswerSource, AskRemoteReceipt } from "../tools";
|
|
4
|
+
import type { MCPInputRequestHandler } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface MCPFormInputDependencies {
|
|
7
|
+
getUi(): { ui?: Pick<ExtensionUIContext, "select" | "input">; hasUI: boolean };
|
|
8
|
+
getAskAnswerSource(): AskAnswerSource | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface FormField {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
type: "string" | "boolean";
|
|
15
|
+
choices?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function displayText(value: string): string {
|
|
23
|
+
return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").slice(0, 4000);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Unsupported constraints are rejected, never silently discarded. */
|
|
27
|
+
function decodeField(schema: unknown): FormField | undefined {
|
|
28
|
+
if (!isRecord(schema) || schema.type !== "object" || !isRecord(schema.properties)) return undefined;
|
|
29
|
+
const allowed = new Set(["type", "properties", "required", "additionalProperties", "title", "description"]);
|
|
30
|
+
if (Object.keys(schema).some(key => !allowed.has(key))) return undefined;
|
|
31
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") return undefined;
|
|
32
|
+
const entries = Object.entries(schema.properties);
|
|
33
|
+
if (entries.length !== 1) return undefined;
|
|
34
|
+
const [key, property] = entries[0]!;
|
|
35
|
+
if (!isRecord(property) || (property.type !== "string" && property.type !== "boolean")) return undefined;
|
|
36
|
+
if (
|
|
37
|
+
schema.required !== undefined &&
|
|
38
|
+
(!Array.isArray(schema.required) || schema.required.some(value => value !== key))
|
|
39
|
+
)
|
|
40
|
+
return undefined;
|
|
41
|
+
const propertyKeys = new Set(["type", "title", "description", "enum"]);
|
|
42
|
+
if (Object.keys(property).some(name => !propertyKeys.has(name))) return undefined;
|
|
43
|
+
if (property.title !== undefined && typeof property.title !== "string") return undefined;
|
|
44
|
+
if (property.description !== undefined && typeof property.description !== "string") return undefined;
|
|
45
|
+
let choices: string[] | undefined;
|
|
46
|
+
if (property.enum !== undefined) {
|
|
47
|
+
if (
|
|
48
|
+
property.type !== "string" ||
|
|
49
|
+
!Array.isArray(property.enum) ||
|
|
50
|
+
property.enum.length === 0 ||
|
|
51
|
+
!property.enum.every(value => typeof value === "string") ||
|
|
52
|
+
new Set(property.enum).size !== property.enum.length ||
|
|
53
|
+
property.enum.some(value => displayText(value) !== value)
|
|
54
|
+
)
|
|
55
|
+
return undefined;
|
|
56
|
+
choices = property.enum;
|
|
57
|
+
}
|
|
58
|
+
const description = typeof property.description === "string" ? ` — ${property.description}` : "";
|
|
59
|
+
return { key, label: displayText(`${property.title ?? key}${description}`), type: property.type, choices };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Register on an owned manager; dependencies are resolved per call for late UI registration. */
|
|
63
|
+
export function createMCPFormInputHandler(deps: MCPFormInputDependencies): MCPInputRequestHandler {
|
|
64
|
+
return async (_key, request, context) => {
|
|
65
|
+
if (context.signal?.aborted) return { kind: "failed", reason: "cancelled" };
|
|
66
|
+
if (request.method !== "elicitation/create") return { kind: "failed", reason: "unavailable" };
|
|
67
|
+
const params = request.params;
|
|
68
|
+
if (!params || typeof params.message !== "string" || (params.mode !== undefined && params.mode !== "form"))
|
|
69
|
+
return { kind: "failed", reason: "unavailable", message: "Unsupported MCP form request" };
|
|
70
|
+
const field = decodeField(params.requestedSchema);
|
|
71
|
+
if (!field) return { kind: "failed", reason: "unavailable", message: "Unsupported MCP form schema" };
|
|
72
|
+
const local = deps.getUi();
|
|
73
|
+
const ui = local.hasUI ? local.ui : undefined;
|
|
74
|
+
const source = ui ? undefined : deps.getAskAnswerSource();
|
|
75
|
+
if (!ui && !source)
|
|
76
|
+
return { kind: "failed", reason: "unavailable", message: "No user input surface is available" };
|
|
77
|
+
const signal = context.signal;
|
|
78
|
+
const ask = async (question: string, choices?: string[]): Promise<string | undefined> => {
|
|
79
|
+
return untilAborted(signal, async () => {
|
|
80
|
+
if (ui)
|
|
81
|
+
return choices ? ui.select(question, choices, { signal }) : ui.input(question, undefined, { signal });
|
|
82
|
+
if (!source) return undefined;
|
|
83
|
+
const answer = source.awaitAnswerRequest
|
|
84
|
+
? await source.awaitAnswerRequest(
|
|
85
|
+
{
|
|
86
|
+
question,
|
|
87
|
+
options: choices ?? [],
|
|
88
|
+
interaction: choices ? "selector" : "custom_editor",
|
|
89
|
+
controls: [],
|
|
90
|
+
},
|
|
91
|
+
signal,
|
|
92
|
+
)
|
|
93
|
+
: await source.awaitAnswer(question, choices ?? [], signal);
|
|
94
|
+
let receipt: AskRemoteReceipt | undefined;
|
|
95
|
+
let value: string | undefined;
|
|
96
|
+
if (typeof answer === "string") value = answer;
|
|
97
|
+
else if (answer) {
|
|
98
|
+
receipt = answer;
|
|
99
|
+
if (answer.interaction.kind === "value") value = answer.interaction.value;
|
|
100
|
+
}
|
|
101
|
+
if (signal?.aborted) {
|
|
102
|
+
await receipt?.settle({ kind: "resolve_without_commit", reason: "aborted" });
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (receipt?.interaction.kind === "control") {
|
|
106
|
+
await receipt.settle({ kind: "invalid", reason: "invalid_control" });
|
|
107
|
+
throw new Error("Invalid MCP form control");
|
|
108
|
+
}
|
|
109
|
+
if (value !== undefined && choices && !choices.includes(value)) {
|
|
110
|
+
await receipt?.settle({ kind: "invalid", reason: "invalid_option" });
|
|
111
|
+
throw new Error("Invalid MCP form answer");
|
|
112
|
+
}
|
|
113
|
+
const settlement = await receipt?.settle(
|
|
114
|
+
value === undefined ? { kind: "resolve_without_commit", reason: "cancelled" } : { kind: "commit" },
|
|
115
|
+
);
|
|
116
|
+
if (receipt && value !== undefined && settlement?.kind !== "committed") return undefined;
|
|
117
|
+
return value;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
const title = `MCP server ${displayText(context.serverName)} requests input (untrusted server text): ${displayText(params.message)}`;
|
|
122
|
+
const action = await ask(title, ["Accept", "Decline"]);
|
|
123
|
+
if (signal?.aborted) return { kind: "failed", reason: "cancelled" };
|
|
124
|
+
if (action === undefined) return { kind: "result", result: { action: "cancel" } };
|
|
125
|
+
if (action === "Decline") return { kind: "result", result: { action: "decline" } };
|
|
126
|
+
if (action !== "Accept") return { kind: "failed", reason: "error", message: "Invalid MCP form action" };
|
|
127
|
+
const choices = field.type === "boolean" ? ["Yes", "No"] : field.choices;
|
|
128
|
+
const answer = await ask(
|
|
129
|
+
`MCP server ${displayText(context.serverName)} (untrusted server text): ${field.label}`,
|
|
130
|
+
choices,
|
|
131
|
+
);
|
|
132
|
+
if (signal?.aborted) return { kind: "failed", reason: "cancelled" };
|
|
133
|
+
if (answer === undefined) return { kind: "result", result: { action: "cancel" } };
|
|
134
|
+
if (choices && !choices.includes(answer))
|
|
135
|
+
return { kind: "failed", reason: "error", message: "Invalid MCP form answer" };
|
|
136
|
+
const value = field.type === "boolean" ? answer === "Yes" : answer;
|
|
137
|
+
return { kind: "result", result: { action: "accept", content: { [field.key]: value } } };
|
|
138
|
+
} catch {
|
|
139
|
+
return { kind: "failed", reason: signal?.aborted ? "cancelled" : "error" };
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -1525,6 +1525,7 @@ export class Broker {
|
|
|
1525
1525
|
#publishedAt: bigint | null = null;
|
|
1526
1526
|
#startedAt: bigint | null = null;
|
|
1527
1527
|
#watchInFlight = false;
|
|
1528
|
+
#checkpointInFlight = false;
|
|
1528
1529
|
#stopping = false;
|
|
1529
1530
|
#transport: BrokerTransport | null = null;
|
|
1530
1531
|
#heartbeatTimer: NodeJS.Timeout | null = null;
|
|
@@ -3786,6 +3787,7 @@ export class Broker {
|
|
|
3786
3787
|
this.#publishedAt = null;
|
|
3787
3788
|
this.#startedAt = process.hrtime.bigint();
|
|
3788
3789
|
this.#watchInFlight = false;
|
|
3790
|
+
this.#checkpointInFlight = false;
|
|
3789
3791
|
this.#throwIfStartupAborted();
|
|
3790
3792
|
await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
|
|
3791
3793
|
this.#throwIfStartupAborted();
|
|
@@ -4192,7 +4194,7 @@ export class Broker {
|
|
|
4192
4194
|
return;
|
|
4193
4195
|
}
|
|
4194
4196
|
this.#startupAdmissions.reopen();
|
|
4195
|
-
if (this.#publicationState === "healthy-owned")
|
|
4197
|
+
if (this.#publicationState === "healthy-owned") void this.#checkpointSessionHeartbeats();
|
|
4196
4198
|
return;
|
|
4197
4199
|
}
|
|
4198
4200
|
this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
|
|
@@ -4233,10 +4235,14 @@ export class Broker {
|
|
|
4233
4235
|
return await this.index.checkpointLiveHeartbeats(now);
|
|
4234
4236
|
}
|
|
4235
4237
|
async #checkpointSessionHeartbeats(): Promise<void> {
|
|
4238
|
+
if (this.#checkpointInFlight || this.#stopping) return;
|
|
4239
|
+
this.#checkpointInFlight = true;
|
|
4236
4240
|
try {
|
|
4237
4241
|
await this.heartbeatSessions();
|
|
4238
4242
|
} catch (error) {
|
|
4239
4243
|
logger.warn(`sdk broker: session heartbeat checkpoint failed: ${String(error)}`);
|
|
4244
|
+
} finally {
|
|
4245
|
+
this.#checkpointInFlight = false;
|
|
4240
4246
|
}
|
|
4241
4247
|
}
|
|
4242
4248
|
async #complete(
|
|
@@ -4260,6 +4266,7 @@ export class Broker {
|
|
|
4260
4266
|
};
|
|
4261
4267
|
(mode === "lost-root" ? logger.warn : logger.info)("sdk broker: exiting", exitRecord);
|
|
4262
4268
|
this.#stopping = true;
|
|
4269
|
+
this.#checkpointInFlight = false;
|
|
4263
4270
|
this.#publicationState = "stopping";
|
|
4264
4271
|
// A lost-root broker has been fenced: it no longer owns the published root, and
|
|
4265
4272
|
// its settlement is bounded, so any startup still queued behind it would be
|
|
@@ -1667,9 +1667,9 @@ export async function reapDeadLifecycleMarkers(
|
|
|
1667
1667
|
let inspected = 0;
|
|
1668
1668
|
for await (const entry of directoryHandle) {
|
|
1669
1669
|
if (inspected >= inspectionLimit) break;
|
|
1670
|
-
inspected += 1;
|
|
1671
1670
|
const name = entry.name;
|
|
1672
1671
|
if (!entry.isFile() || !name.endsWith(".lifecycle.json")) continue;
|
|
1672
|
+
inspected += 1;
|
|
1673
1673
|
const id = name.slice(0, -".lifecycle.json".length);
|
|
1674
1674
|
if (!isCanonicalSessionId(id)) continue;
|
|
1675
1675
|
const markerPath = path.join(directory, name);
|
|
@@ -1741,6 +1741,108 @@ export async function reapDeadLifecycleMarkers(
|
|
|
1741
1741
|
return reaped;
|
|
1742
1742
|
}
|
|
1743
1743
|
|
|
1744
|
+
/**
|
|
1745
|
+
* Retires one launch id's lifecycle marker pair after its recorded owner has
|
|
1746
|
+
* exited. Unlike the bounded background sweep, this launch-local repair is
|
|
1747
|
+
* not age-gated and does not require the ready sibling to contain the same
|
|
1748
|
+
* effect marker: the primary marker is the owner authority for this id.
|
|
1749
|
+
*/
|
|
1750
|
+
type RetireExitedLifecycleMarkerPairHook = () => void | Promise<void>;
|
|
1751
|
+
|
|
1752
|
+
async function retireExitedLifecycleMarkerPairImpl(
|
|
1753
|
+
root: string,
|
|
1754
|
+
id: string,
|
|
1755
|
+
afterObservation?: RetireExitedLifecycleMarkerPairHook,
|
|
1756
|
+
): Promise<boolean> {
|
|
1757
|
+
if (!isCanonicalSessionId(id)) return false;
|
|
1758
|
+
let directory: string;
|
|
1759
|
+
let directoryIdentity: { dev: bigint; ino: bigint };
|
|
1760
|
+
try {
|
|
1761
|
+
const canonicalRoot = fsSync.realpathSync(root);
|
|
1762
|
+
directory = path.join(canonicalRoot, "sdk");
|
|
1763
|
+
const directoryStat = fsSync.lstatSync(directory, { bigint: true });
|
|
1764
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) return false;
|
|
1765
|
+
directoryIdentity = { dev: directoryStat.dev, ino: directoryStat.ino };
|
|
1766
|
+
} catch {
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1769
|
+
const markerPath = lifecycleMarkerPath(path.dirname(directory), id);
|
|
1770
|
+
const primary = captureLifecycleFile(markerPath, true, true);
|
|
1771
|
+
if (!primary) return false;
|
|
1772
|
+
let marker: EffectMarker;
|
|
1773
|
+
try {
|
|
1774
|
+
const parsed = parseLifecycleJson(primary.bytes);
|
|
1775
|
+
if (!isExactEffectMarker(parsed)) return false;
|
|
1776
|
+
marker = parsed;
|
|
1777
|
+
} catch {
|
|
1778
|
+
return false;
|
|
1779
|
+
}
|
|
1780
|
+
if (observeProcess(marker.pid, marker.incarnation) !== "exited") return false;
|
|
1781
|
+
await afterObservation?.();
|
|
1782
|
+
const readyPath = lifecycleReadyPath(path.dirname(directory), id);
|
|
1783
|
+
const ready = captureLifecycleFile(readyPath, true, true);
|
|
1784
|
+
try {
|
|
1785
|
+
const currentParent = lifecycleParentIdentity(directory);
|
|
1786
|
+
if (
|
|
1787
|
+
!currentParent ||
|
|
1788
|
+
BigInt(currentParent.dev) !== directoryIdentity.dev ||
|
|
1789
|
+
BigInt(currentParent.ino) !== directoryIdentity.ino
|
|
1790
|
+
)
|
|
1791
|
+
return false;
|
|
1792
|
+
const currentPrimary = captureLifecycleFile(markerPath, true, true);
|
|
1793
|
+
await afterObservation?.();
|
|
1794
|
+
if (
|
|
1795
|
+
!currentPrimary ||
|
|
1796
|
+
!sameLifecycleCleanupIdentity(
|
|
1797
|
+
currentPrimary.identity,
|
|
1798
|
+
serializeCleanupIdentity({ ...primary.identity, size: Number(primary.identity.size) }),
|
|
1799
|
+
)
|
|
1800
|
+
)
|
|
1801
|
+
return false;
|
|
1802
|
+
const currentMarker = parseLifecycleJson(currentPrimary.bytes);
|
|
1803
|
+
if (!isExactEffectMarker(currentMarker) || !sameEffectMarker(currentMarker, marker)) return false;
|
|
1804
|
+
if (ready) {
|
|
1805
|
+
const currentReady = captureLifecycleFile(readyPath, true, true);
|
|
1806
|
+
if (
|
|
1807
|
+
!currentReady ||
|
|
1808
|
+
!sameLifecycleCleanupIdentity(
|
|
1809
|
+
currentReady.identity,
|
|
1810
|
+
serializeCleanupIdentity({ ...ready.identity, size: Number(ready.identity.size) }),
|
|
1811
|
+
)
|
|
1812
|
+
)
|
|
1813
|
+
return false;
|
|
1814
|
+
const removedReady = nativeLifecycle().exactUnlinkDirect(readyPath, {
|
|
1815
|
+
...ready.identity,
|
|
1816
|
+
parentDev: directoryIdentity.dev,
|
|
1817
|
+
parentIno: directoryIdentity.ino,
|
|
1818
|
+
quarantineName: `.gjc-reap-${randomUUID()}-${path.basename(readyPath)}`,
|
|
1819
|
+
});
|
|
1820
|
+
if (!removedReady.ok && removedReady.code !== "not_found") return false;
|
|
1821
|
+
}
|
|
1822
|
+
const removedMarker = nativeLifecycle().exactUnlinkDirect(markerPath, {
|
|
1823
|
+
...primary.identity,
|
|
1824
|
+
parentDev: directoryIdentity.dev,
|
|
1825
|
+
parentIno: directoryIdentity.ino,
|
|
1826
|
+
quarantineName: `.gjc-reap-${randomUUID()}-${path.basename(markerPath)}`,
|
|
1827
|
+
});
|
|
1828
|
+
return removedMarker.ok || removedMarker.code === "not_found";
|
|
1829
|
+
} catch {
|
|
1830
|
+
return false;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
export async function retireExitedLifecycleMarkerPair(root: string, id: string): Promise<boolean> {
|
|
1835
|
+
return retireExitedLifecycleMarkerPairImpl(root, id);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
export async function retireExitedLifecycleMarkerPairForTest(
|
|
1839
|
+
root: string,
|
|
1840
|
+
id: string,
|
|
1841
|
+
afterObservation: RetireExitedLifecycleMarkerPairHook,
|
|
1842
|
+
): Promise<boolean> {
|
|
1843
|
+
return retireExitedLifecycleMarkerPairImpl(root, id, afterObservation);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1744
1846
|
export async function writeEffectMarker(root: string, id: string, marker: EffectMarker): Promise<void> {
|
|
1745
1847
|
const directory = path.join(root, "sdk");
|
|
1746
1848
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -5392,12 +5494,11 @@ async function currentReadyAuthority(
|
|
|
5392
5494
|
token?: unknown;
|
|
5393
5495
|
pid?: unknown;
|
|
5394
5496
|
};
|
|
5395
|
-
// Native-alive owned readiness is the admission authority.
|
|
5396
|
-
//
|
|
5397
|
-
//
|
|
5398
|
-
//
|
|
5399
|
-
await broker.
|
|
5400
|
-
await broker.index.refresh();
|
|
5497
|
+
// Native-alive owned readiness is the admission authority. The indexed
|
|
5498
|
+
// projection can lag a just-registered detached Windows host, so the
|
|
5499
|
+
// change-stamp refresh is deliberately separate from heartbeat liveness.
|
|
5500
|
+
// Never admit a terminal/uncertain or native-dead child.
|
|
5501
|
+
await broker.index.refreshIfChanged();
|
|
5401
5502
|
const record = broker.index
|
|
5402
5503
|
.listSessions()
|
|
5403
5504
|
.sessions.find(
|
|
@@ -6650,6 +6751,7 @@ async function executeLifecycleResponse(
|
|
|
6650
6751
|
terminationStartDeadline = childDeadlines.terminationStartDeadlineAt;
|
|
6651
6752
|
lifecycleDeadline = childDeadlines.lifecycleCleanupDeadlineAt;
|
|
6652
6753
|
}
|
|
6754
|
+
await retireExitedLifecycleMarkerPair(launch.root, launch.id);
|
|
6653
6755
|
await reapDeadLifecycleMarkers(launch.root);
|
|
6654
6756
|
} catch (error) {
|
|
6655
6757
|
if (launch.worktreePlan && worktreeReceipt?.created && !worktreeReceipt.reused) {
|