@gajae-code/coding-agent 0.18.5 → 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 +140 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli-commands.d.ts +10 -0
- package/dist/types/cli-ordinary.d.ts +13 -0
- package/dist/types/cli.d.ts +12 -3
- package/dist/types/commands/contribution-prep.d.ts +0 -3
- package/dist/types/commands/sdk.d.ts +3 -0
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +19 -0
- package/dist/types/config/settings.d.ts +4 -0
- package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/edit/modes/replace.d.ts +3 -3
- package/dist/types/edit/streaming.d.ts +2 -2
- package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
- package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
- package/dist/types/sdk/broker/discovery.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/runtime.d.ts +1 -1
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
- package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
- package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +6 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +5 -1
- 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/dist/types/tools/ask-contract.d.ts +75 -254
- package/dist/types/tools/bash.d.ts +1 -1
- package/package.json +7 -7
- package/scripts/build-sdk-package-smoke.ts +5 -0
- 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/public-command-entry.ts +5 -0
- package/src/cli/ssh-cli.ts +6 -3
- package/src/cli/web-search-cli.ts +9 -3
- package/src/cli-commands.ts +14 -0
- package/src/cli-main.ts +7 -8
- package/src/cli-ordinary.ts +197 -0
- package/src/cli.ts +25 -182
- package/src/commands/contribution-prep.ts +1 -3
- package/src/commands/sdk.ts +171 -21
- package/src/commands/update.ts +15 -1
- package/src/config/file-lock.ts +91 -2
- package/src/config/model-profile-activation.ts +70 -4
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +23 -31
- package/src/config/model-resolver.ts +9 -1
- package/src/config/settings.ts +44 -4
- package/src/coordinator-mcp/event-webhook.ts +19 -14
- package/src/coordinator-mcp/policy.ts +14 -4
- package/src/coordinator-mcp/server.ts +142 -7
- package/src/coordinator-mcp/session-reaper.ts +9 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/discovery/helpers.ts +9 -3
- package/src/discovery/ssh.ts +8 -3
- package/src/edit/modes/replace.ts +6 -6
- package/src/edit/streaming.ts +15 -15
- package/src/exec/bash-shell-supervisor.ts +60 -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 +11 -10
- package/src/memories/index.ts +8 -2
- package/src/modes/acp/acp-agent.ts +724 -370
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/prompts/tools/ask.md +2 -0
- package/src/runtime-mcp/client.ts +13 -4
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/sdk/broker/broker.ts +54 -1
- package/src/sdk/broker/daemon-entry.ts +67 -6
- package/src/sdk/broker/discovery.ts +8 -0
- package/src/sdk/broker/ensure.ts +156 -20
- package/src/sdk/broker/lifecycle-ledger.ts +110 -12
- package/src/sdk/broker/lifecycle.ts +334 -348
- package/src/sdk/broker/runtime.ts +5 -1
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/broker/transport.ts +5 -1
- 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-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +15 -12
- package/src/sdk/bus/telegram-daemon.ts +38 -3
- package/src/sdk/cli/diagnostics-cli.ts +247 -0
- package/src/sdk/diagnostics/observe-broker.ts +654 -0
- package/src/sdk/host/control/dispatch.ts +52 -4
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/session-runtime.test.ts +83 -4
- package/src/sdk/host/session-runtime.ts +15 -7
- package/src/sdk/index.ts +1 -0
- package/src/sdk/lifecycle-session.ts +1 -0
- package/src/sdk/mcp/server.ts +15 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/router/session-router.ts +26 -3
- package/src/sdk/session.ts +51 -8
- package/src/session/agent-session.ts +169 -43
- package/src/session/blob-store.ts +78 -33
- package/src/session/internal/managed-session-storage.ts +203 -4
- 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
- package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
- package/src/setup/hermes-setup.ts +4 -1
- package/src/slash-commands/builtin-registry.ts +8 -5
- package/src/tools/ask-contract.test.ts +76 -1
- package/src/tools/ask-contract.ts +57 -6
- package/src/tools/ask.ts +16 -7
- package/src/tools/bash.ts +38 -29
- package/src/tools/descriptors.test.ts +29 -0
- package/src/tools/tool-catalog.generated.ts +7 -4
- package/src/web/search/index.ts +2 -2
|
@@ -965,7 +965,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
965
965
|
if (this.#expanded && !previews?.some(preview => preview.diff)) {
|
|
966
966
|
const editMode = this.#editMode;
|
|
967
967
|
const strategy = editMode ? EDIT_MODE_STRATEGIES[editMode] : undefined;
|
|
968
|
-
const fallback = strategy?.
|
|
968
|
+
const fallback = strategy?.renderStreamingPlaceholder(this.#args, theme);
|
|
969
969
|
if (fallback) context.editStreamingFallback = fallback;
|
|
970
970
|
}
|
|
971
971
|
context.renderDiff = renderDiff;
|
package/src/prompts/tools/ask.md
CHANGED
|
@@ -8,6 +8,8 @@ Asks user when you need clarification or input during task execution.
|
|
|
8
8
|
- Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
|
|
9
9
|
- Use `questions` for multiple related questions instead of asking one at a time
|
|
10
10
|
- Set `multi: true` on question to allow multiple selections
|
|
11
|
+
- Every question body must contain meaningful, non-whitespace text. Never add empty `metadata`/`ignore` questions.
|
|
12
|
+
- While deep-interview is in the `interviewing` phase, submit exactly one question with the required `deepInterview` metadata on that question. Round 0 needs `round: 0`, `component: "review-topology"`, `dimension: "topology"`, `ambiguity`, and `intent_contract` containing the displayed items and affirmative labels. Later questions need a positive round, component, dimension, and ambiguity. Question prose and `workflowGate` alone cannot record an interview answer. After final-spec persistence transitions to `handoff`, next-workflow/approval choices are ordinary asks without interview round metadata.
|
|
11
13
|
</instruction>
|
|
12
14
|
|
|
13
15
|
<caution>
|
|
@@ -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
|
@@ -1495,6 +1495,16 @@ export class Broker {
|
|
|
1495
1495
|
discovery: BrokerDiscovery | null = null;
|
|
1496
1496
|
#lock: string;
|
|
1497
1497
|
#owner = randomBytes(12).toString("hex");
|
|
1498
|
+
/** Public publication-incarnation id, fixed at startup; never the endpoint generation. */
|
|
1499
|
+
#diagnosticGeneration = randomBytes(16).toString("hex");
|
|
1500
|
+
/** Trusted build snapshot captured at startup; unknown buildId stays null. */
|
|
1501
|
+
#startupBuild: { packageVersion: string; buildId: string | null } = {
|
|
1502
|
+
packageVersion: packageJson.version,
|
|
1503
|
+
buildId:
|
|
1504
|
+
typeof process.env.GJC_BUILD_ID === "string" && process.env.GJC_BUILD_ID.length > 0
|
|
1505
|
+
? process.env.GJC_BUILD_ID
|
|
1506
|
+
: null,
|
|
1507
|
+
};
|
|
1498
1508
|
#sessionListCursors = new Map<string, SessionListCursor>();
|
|
1499
1509
|
#chains = new Map<string, Promise<void>>();
|
|
1500
1510
|
#spawnInFlight = new Map<string, SpawnInFlight>();
|
|
@@ -1515,6 +1525,7 @@ export class Broker {
|
|
|
1515
1525
|
#publishedAt: bigint | null = null;
|
|
1516
1526
|
#startedAt: bigint | null = null;
|
|
1517
1527
|
#watchInFlight = false;
|
|
1528
|
+
#checkpointInFlight = false;
|
|
1518
1529
|
#stopping = false;
|
|
1519
1530
|
#transport: BrokerTransport | null = null;
|
|
1520
1531
|
#heartbeatTimer: NodeJS.Timeout | null = null;
|
|
@@ -3776,6 +3787,7 @@ export class Broker {
|
|
|
3776
3787
|
this.#publishedAt = null;
|
|
3777
3788
|
this.#startedAt = process.hrtime.bigint();
|
|
3778
3789
|
this.#watchInFlight = false;
|
|
3790
|
+
this.#checkpointInFlight = false;
|
|
3779
3791
|
this.#throwIfStartupAborted();
|
|
3780
3792
|
await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
|
|
3781
3793
|
this.#throwIfStartupAborted();
|
|
@@ -3868,6 +3880,10 @@ export class Broker {
|
|
|
3868
3880
|
ownerId: this.#owner,
|
|
3869
3881
|
pid: process.pid,
|
|
3870
3882
|
incarnation,
|
|
3883
|
+
// Observation capability: the startup-fixed generation and the protocol the
|
|
3884
|
+
// broker answers. An old broker simply omits both.
|
|
3885
|
+
diagnosticGeneration: this.#diagnosticGeneration,
|
|
3886
|
+
diagnosticProtocol: 1,
|
|
3871
3887
|
host: "127.0.0.1",
|
|
3872
3888
|
port,
|
|
3873
3889
|
url: `ws://127.0.0.1:${port}`,
|
|
@@ -4178,7 +4194,7 @@ export class Broker {
|
|
|
4178
4194
|
return;
|
|
4179
4195
|
}
|
|
4180
4196
|
this.#startupAdmissions.reopen();
|
|
4181
|
-
if (this.#publicationState === "healthy-owned")
|
|
4197
|
+
if (this.#publicationState === "healthy-owned") void this.#checkpointSessionHeartbeats();
|
|
4182
4198
|
return;
|
|
4183
4199
|
}
|
|
4184
4200
|
this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
|
|
@@ -4219,10 +4235,14 @@ export class Broker {
|
|
|
4219
4235
|
return await this.index.checkpointLiveHeartbeats(now);
|
|
4220
4236
|
}
|
|
4221
4237
|
async #checkpointSessionHeartbeats(): Promise<void> {
|
|
4238
|
+
if (this.#checkpointInFlight || this.#stopping) return;
|
|
4239
|
+
this.#checkpointInFlight = true;
|
|
4222
4240
|
try {
|
|
4223
4241
|
await this.heartbeatSessions();
|
|
4224
4242
|
} catch (error) {
|
|
4225
4243
|
logger.warn(`sdk broker: session heartbeat checkpoint failed: ${String(error)}`);
|
|
4244
|
+
} finally {
|
|
4245
|
+
this.#checkpointInFlight = false;
|
|
4226
4246
|
}
|
|
4227
4247
|
}
|
|
4228
4248
|
async #complete(
|
|
@@ -4246,6 +4266,7 @@ export class Broker {
|
|
|
4246
4266
|
};
|
|
4247
4267
|
(mode === "lost-root" ? logger.warn : logger.info)("sdk broker: exiting", exitRecord);
|
|
4248
4268
|
this.#stopping = true;
|
|
4269
|
+
this.#checkpointInFlight = false;
|
|
4249
4270
|
this.#publicationState = "stopping";
|
|
4250
4271
|
// A lost-root broker has been fenced: it no longer owns the published root, and
|
|
4251
4272
|
// its settlement is bounded, so any startup still queued behind it would be
|
|
@@ -4728,6 +4749,32 @@ export class Broker {
|
|
|
4728
4749
|
? entry.response
|
|
4729
4750
|
: error("terminal_uncertain", "lifecycle outcome has no recorded response");
|
|
4730
4751
|
}
|
|
4752
|
+
/**
|
|
4753
|
+
* Published diagnostics for observation clients (SPEC "Result contract").
|
|
4754
|
+
*
|
|
4755
|
+
* Everything here is captured once at startup and never recomputed, so a later source
|
|
4756
|
+
* or package change cannot alter what a running broker reports. No token, socket
|
|
4757
|
+
* coordinate, argv or environment value is included; the identity fields are the
|
|
4758
|
+
* internal owner/process/root binding the observer already knows from discovery.
|
|
4759
|
+
*/
|
|
4760
|
+
#publishedDiagnostics(): Promise<BrokerResponse> {
|
|
4761
|
+
// Observation is answered ONLY from an already-owned healthy retained publication.
|
|
4762
|
+
// A broker that owns no publication refuses here and recovers nothing: it does not
|
|
4763
|
+
// publish, ensure, restart or touch the authority to answer.
|
|
4764
|
+
if (this.#publication === null || this.#publicationState !== "healthy-owned") {
|
|
4765
|
+
return Promise.resolve(error("unavailable", "broker publication authority is not owned"));
|
|
4766
|
+
}
|
|
4767
|
+
return Promise.resolve({
|
|
4768
|
+
ok: true,
|
|
4769
|
+
result: {
|
|
4770
|
+
diagnosticProtocol: 1,
|
|
4771
|
+
generation: this.#diagnosticGeneration,
|
|
4772
|
+
build: this.#startupBuild,
|
|
4773
|
+
identity: { ownerId: this.#owner, pid: process.pid, agentRoot: this.settings.agentDir },
|
|
4774
|
+
},
|
|
4775
|
+
});
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4731
4778
|
// Wire requests are readiness-gated by BrokerTransport before reaching this dispatcher.
|
|
4732
4779
|
handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
|
|
4733
4780
|
if (operation === "broker.status") return Promise.resolve({ ok: true, result: this.status() });
|
|
@@ -4768,6 +4815,7 @@ export class Broker {
|
|
|
4768
4815
|
const spawnClose = await this.#maybeCloseSpawnChild(input);
|
|
4769
4816
|
if (spawnClose) return spawnClose;
|
|
4770
4817
|
}
|
|
4818
|
+
if (operation === "broker.diagnostics") return this.#publishedDiagnostics();
|
|
4771
4819
|
if (operation === "session.control") return this.#sessionControl(input, idempotencyKey);
|
|
4772
4820
|
if (operation === "session.lookup") {
|
|
4773
4821
|
const lookup = publicLifecycleLookupInput(input);
|
|
@@ -4937,6 +4985,11 @@ export class Broker {
|
|
|
4937
4985
|
...(operation === "session.close" && typeof input.sessionId === "string"
|
|
4938
4986
|
? { intendedSessionId: input.sessionId }
|
|
4939
4987
|
: {}),
|
|
4988
|
+
...(operation === "session.close" &&
|
|
4989
|
+
typeof input.endpointGeneration === "number" &&
|
|
4990
|
+
typeof input.endpointIncarnation === "string"
|
|
4991
|
+
? { closeAuthorityBound: true }
|
|
4992
|
+
: {}),
|
|
4940
4993
|
};
|
|
4941
4994
|
if (!this.ledger.get(identity)) {
|
|
4942
4995
|
const matchingOperation = this.ledger.findAnyByOperationKey(operationKey);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
2
|
import { type BrokerDiscovery, readBrokerDiscovery, readBrokerRestartIntent } from "./discovery";
|
|
3
3
|
import { withBrokerStartupLock } from "./ensure";
|
|
4
|
-
import { observeProcessIncarnation } from "./process-incarnation";
|
|
4
|
+
import { isProcessIncarnation, observeProcessIncarnation } from "./process-incarnation";
|
|
5
5
|
import { resolveSdkInternalSpawnCommand } from "./runtime";
|
|
6
6
|
|
|
7
7
|
export interface AuthorizedBrokerSuccessorOptions {
|
|
@@ -61,12 +61,15 @@ export async function launchAuthorizedBrokerSuccessor(
|
|
|
61
61
|
existing.restartRequestId === options.requestId
|
|
62
62
|
)
|
|
63
63
|
return { kind: "adopted" as const, discovery: existing };
|
|
64
|
-
const command = resolveSdkInternalSpawnCommand(
|
|
64
|
+
const command = resolveSdkInternalSpawnCommand(
|
|
65
|
+
process.platform === "win32" ? "broker-internal" : "broker-trampoline-internal",
|
|
66
|
+
);
|
|
65
67
|
let child: ChildProcess;
|
|
68
|
+
const isTrampoline = process.platform !== "win32";
|
|
66
69
|
try {
|
|
67
70
|
child = spawn(command.file, [...command.args, "--agent-dir", options.agentDir], {
|
|
68
71
|
detached: true,
|
|
69
|
-
stdio: "ignore",
|
|
72
|
+
stdio: isTrampoline ? ["ignore", "pipe", "ignore"] : "ignore",
|
|
70
73
|
env: { ...command.env, GJC_BROKER_RESTART_REQUEST: options.requestId },
|
|
71
74
|
...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
|
|
72
75
|
});
|
|
@@ -78,14 +81,44 @@ export async function launchAuthorizedBrokerSuccessor(
|
|
|
78
81
|
};
|
|
79
82
|
}
|
|
80
83
|
let spawnError: Error | undefined;
|
|
84
|
+
let trampolineOutput = "";
|
|
85
|
+
let trampolineOutputEnded = !isTrampoline;
|
|
86
|
+
if (child.stdout)
|
|
87
|
+
child.stdout
|
|
88
|
+
.setEncoding("utf8")
|
|
89
|
+
.on("data", chunk => (trampolineOutput += chunk))
|
|
90
|
+
.once("end", () => (trampolineOutputEnded = true));
|
|
81
91
|
child.once("error", childError => {
|
|
82
92
|
spawnError = childError;
|
|
83
93
|
});
|
|
84
94
|
child.unref();
|
|
85
|
-
return {
|
|
95
|
+
return {
|
|
96
|
+
kind: "spawned" as const,
|
|
97
|
+
child,
|
|
98
|
+
isTrampoline,
|
|
99
|
+
trampolineOutput: () => trampolineOutput,
|
|
100
|
+
trampolineOutputEnded: () => trampolineOutputEnded,
|
|
101
|
+
spawnError: () => spawnError,
|
|
102
|
+
};
|
|
86
103
|
});
|
|
87
104
|
if (spawnOutcome.kind !== "spawned") return spawnOutcome;
|
|
88
105
|
const { child } = spawnOutcome;
|
|
106
|
+
let successorPid: number | undefined;
|
|
107
|
+
let successorIncarnation: string | undefined;
|
|
108
|
+
let trampolineExited = !spawnOutcome.isTrampoline;
|
|
109
|
+
let trampolineExitCode: number | null = null;
|
|
110
|
+
let trampolineSignal: NodeJS.Signals | null = null;
|
|
111
|
+
if (spawnOutcome.isTrampoline)
|
|
112
|
+
child.once("exit", (code, signal) => {
|
|
113
|
+
trampolineExitCode = code;
|
|
114
|
+
trampolineSignal = signal;
|
|
115
|
+
trampolineExited = true;
|
|
116
|
+
});
|
|
117
|
+
if (spawnOutcome.isTrampoline && (child.exitCode !== null || child.signalCode !== null)) {
|
|
118
|
+
trampolineExitCode = child.exitCode;
|
|
119
|
+
trampolineSignal = child.signalCode;
|
|
120
|
+
trampolineExited = true;
|
|
121
|
+
}
|
|
89
122
|
const until = Math.min(Date.now() + Math.max(1, options.deadlineAt - Date.now()), options.deadlineAt);
|
|
90
123
|
for (;;) {
|
|
91
124
|
if (spawnOutcome.spawnError())
|
|
@@ -94,7 +127,34 @@ export async function launchAuthorizedBrokerSuccessor(
|
|
|
94
127
|
reason: "spawn_failed",
|
|
95
128
|
detail: spawnOutcome.spawnError()?.message,
|
|
96
129
|
};
|
|
97
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
130
|
+
if (!spawnOutcome.isTrampoline && (child.exitCode !== null || child.signalCode !== null))
|
|
131
|
+
return { kind: "refused", reason: "spawn_exited_before_publication" };
|
|
132
|
+
if (spawnOutcome.isTrampoline && !successorPid) {
|
|
133
|
+
const report = spawnOutcome.trampolineOutput().trim();
|
|
134
|
+
const match = /^(\d+)\t([^\r\n]+)$/.exec(report);
|
|
135
|
+
if (match) {
|
|
136
|
+
const pid = Number(match[1]);
|
|
137
|
+
const incarnation = match[2];
|
|
138
|
+
if (Number.isSafeInteger(pid) && pid > 0 && isProcessIncarnation(incarnation)) {
|
|
139
|
+
const observed = observeProcessIncarnation(pid);
|
|
140
|
+
if (observed.status === "present" && observed.incarnation === incarnation) {
|
|
141
|
+
successorPid = pid;
|
|
142
|
+
successorIncarnation = incarnation;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (successorPid) {
|
|
148
|
+
const observed = observeProcessIncarnation(successorPid);
|
|
149
|
+
if (
|
|
150
|
+
observed.status === "absent" ||
|
|
151
|
+
(observed.status === "present" && observed.incarnation !== successorIncarnation)
|
|
152
|
+
)
|
|
153
|
+
return { kind: "refused", reason: "spawn_exited_before_publication" };
|
|
154
|
+
}
|
|
155
|
+
if (spawnOutcome.isTrampoline && trampolineExited && spawnOutcome.trampolineOutputEnded() && !successorPid)
|
|
156
|
+
return { kind: "refused", reason: "spawn_exited_before_publication" };
|
|
157
|
+
if (spawnOutcome.isTrampoline && trampolineExited && (trampolineSignal !== null || trampolineExitCode !== 0))
|
|
98
158
|
return { kind: "refused", reason: "spawn_exited_before_publication" };
|
|
99
159
|
const discovered = await readBrokerDiscovery(options.agentDir);
|
|
100
160
|
if (
|
|
@@ -102,7 +162,8 @@ export async function launchAuthorizedBrokerSuccessor(
|
|
|
102
162
|
discovered.packageGeneration === options.packageGeneration &&
|
|
103
163
|
discovered.restartRequestId === options.requestId
|
|
104
164
|
)
|
|
105
|
-
|
|
165
|
+
if (!spawnOutcome.isTrampoline || (trampolineExited && spawnOutcome.trampolineOutputEnded()))
|
|
166
|
+
return { kind: "spawned", discovery: discovered };
|
|
106
167
|
if (Date.now() >= until) return { kind: "refused", reason: "publication_timeout" };
|
|
107
168
|
await Bun.sleep(SUCCESSOR_SPAWN_POLL_MS);
|
|
108
169
|
}
|
|
@@ -386,6 +386,14 @@ export interface BrokerDiscovery {
|
|
|
386
386
|
ownerId: string;
|
|
387
387
|
pid: number;
|
|
388
388
|
incarnation: string;
|
|
389
|
+
/**
|
|
390
|
+
* Public publication-incarnation id for diagnostic observation, fixed at startup.
|
|
391
|
+
* Absent on publications written before the capability existed; an observer then
|
|
392
|
+
* reports `unsupported` instead of synthesizing compatibility.
|
|
393
|
+
*/
|
|
394
|
+
diagnosticGeneration?: string;
|
|
395
|
+
/** Diagnostic protocol the publisher answers; absent means no capability. */
|
|
396
|
+
diagnosticProtocol?: number;
|
|
389
397
|
host: "127.0.0.1";
|
|
390
398
|
port: number;
|
|
391
399
|
url: string;
|