@gajae-code/coding-agent 0.12.18 → 0.12.20
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 +26 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +301 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
- package/src/sdk/bus/topic-registry.ts +7 -1
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +525 -222
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7027 -394
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +40 -8
- package/src/tui/output-block.ts +11 -1
|
@@ -13,7 +13,7 @@ function nativeLifecycle(): typeof import("@gajae-code/natives") {
|
|
|
13
13
|
return nativeLifecycleBindings;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
import { $credentialEnv, resolveEquivalentPath } from "@gajae-code/utils";
|
|
16
|
+
import { $credentialEnv, logger, resolveEquivalentPath } from "@gajae-code/utils";
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
isModelProfileError,
|
|
@@ -51,7 +51,11 @@ import {
|
|
|
51
51
|
type ManagedSessionScope,
|
|
52
52
|
resolveManagedSessionScope,
|
|
53
53
|
} from "../session-directory";
|
|
54
|
-
import
|
|
54
|
+
import {
|
|
55
|
+
normalizeSdkStartupFailure,
|
|
56
|
+
type SdkStartupFailure,
|
|
57
|
+
type SdkStartupRollbackResult,
|
|
58
|
+
} from "../startup-capability";
|
|
55
59
|
import type { Broker, BrokerCleanupEvidence, BrokerCleanupIdentity, BrokerResponse } from "./broker";
|
|
56
60
|
import { decodeLifecycleUtf8, parseLifecycleJson } from "./lifecycle-codec";
|
|
57
61
|
import type {
|
|
@@ -68,6 +72,13 @@ import {
|
|
|
68
72
|
processIncarnation,
|
|
69
73
|
} from "./process-incarnation";
|
|
70
74
|
import { resolveSdkInternalSpawnCommand, type SdkInternalSpawnCommand } from "./runtime";
|
|
75
|
+
import {
|
|
76
|
+
cancellableSleep,
|
|
77
|
+
DEFAULT_READINESS_TIMEOUT_MS,
|
|
78
|
+
isValidReadinessTimeoutMs,
|
|
79
|
+
READINESS_TIMEOUT_INVALID_MESSAGE,
|
|
80
|
+
startupQueueWaitMs,
|
|
81
|
+
} from "./startup-budget";
|
|
71
82
|
|
|
72
83
|
export {
|
|
73
84
|
type ProcessIncarnationCommandRunner,
|
|
@@ -76,9 +87,6 @@ export {
|
|
|
76
87
|
processIncarnation,
|
|
77
88
|
};
|
|
78
89
|
|
|
79
|
-
const READY_TIMEOUT_MS = 10_000;
|
|
80
|
-
const MIN_READY_TIMEOUT_MS = 4_000;
|
|
81
|
-
const MAX_READY_TIMEOUT_MS = 60_000;
|
|
82
90
|
const POLL_MS = 50;
|
|
83
91
|
const CLOSE_TIMEOUT_MS = 2_000;
|
|
84
92
|
const MAX_RECEIVED_AT_SKEW_MS = 5_000;
|
|
@@ -95,12 +103,7 @@ export interface LifecycleDeadlines {
|
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
export function deriveLifecycleDeadlines(receivedAt: number, requestedReadinessTimeoutMs: number): LifecycleDeadlines {
|
|
98
|
-
if (
|
|
99
|
-
!Number.isSafeInteger(receivedAt) ||
|
|
100
|
-
!Number.isSafeInteger(requestedReadinessTimeoutMs) ||
|
|
101
|
-
requestedReadinessTimeoutMs < MIN_READY_TIMEOUT_MS ||
|
|
102
|
-
requestedReadinessTimeoutMs > MAX_READY_TIMEOUT_MS
|
|
103
|
-
)
|
|
106
|
+
if (!Number.isSafeInteger(receivedAt) || !isValidReadinessTimeoutMs(requestedReadinessTimeoutMs))
|
|
104
107
|
throw new Error("Lifecycle timing values must be safe integers in the approved readiness range.");
|
|
105
108
|
const phaseWindowMs = Math.min(1_000, Math.max(500, Math.floor(requestedReadinessTimeoutMs / 4)));
|
|
106
109
|
const lifecycleCleanupDeadlineAt = receivedAt + requestedReadinessTimeoutMs;
|
|
@@ -124,18 +127,17 @@ export function deriveLifecycleDeadlines(receivedAt: number, requestedReadinessT
|
|
|
124
127
|
|
|
125
128
|
export interface LifecycleTiming {
|
|
126
129
|
now(): number;
|
|
127
|
-
sleep(ms: number): Promise<void>;
|
|
130
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
const defaultLifecycleTiming: LifecycleTiming = {
|
|
131
|
-
now: Date.now,
|
|
132
|
-
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
133
|
-
};
|
|
133
|
+
const defaultLifecycleTiming: LifecycleTiming = { now: Date.now, sleep: cancellableSleep };
|
|
134
134
|
const lifecycleTimingsForTest = new WeakMap<Broker, LifecycleTiming>();
|
|
135
135
|
type LifecycleCommand = SdkInternalSpawnCommand | { file: string; args: string[] };
|
|
136
136
|
type LifecycleCommandResolver = () => LifecycleCommand;
|
|
137
137
|
const lifecycleCommandResolversForTest = new WeakMap<Broker, LifecycleCommandResolver>();
|
|
138
138
|
const lifecycleCleanupHooksForTest = new WeakMap<Broker, () => void>();
|
|
139
|
+
const startupAdmittedInputs = new WeakSet<Input>();
|
|
140
|
+
const startupLaunchInputs = new WeakMap<Input, SessionLaunch>();
|
|
139
141
|
|
|
140
142
|
/** Test-only hook for simulating a crash immediately after one exact lifecycle detach. */
|
|
141
143
|
export function setLifecycleCleanupHookForTest(broker: Broker, hook: (() => void) | undefined): void {
|
|
@@ -486,17 +488,8 @@ export function validateBrokerModelPresetForTest(agentDir: string, requestedProf
|
|
|
486
488
|
|
|
487
489
|
function readinessTimeout(input: Input): number | BrokerResponse {
|
|
488
490
|
const value = input.readinessTimeoutMs;
|
|
489
|
-
if (value === undefined) return
|
|
490
|
-
if (
|
|
491
|
-
typeof value !== "number" ||
|
|
492
|
-
!Number.isSafeInteger(value) ||
|
|
493
|
-
value < MIN_READY_TIMEOUT_MS ||
|
|
494
|
-
value > MAX_READY_TIMEOUT_MS
|
|
495
|
-
)
|
|
496
|
-
return fail(
|
|
497
|
-
"invalid_input",
|
|
498
|
-
`readinessTimeoutMs must be an integer between ${MIN_READY_TIMEOUT_MS} and ${MAX_READY_TIMEOUT_MS}.`,
|
|
499
|
-
);
|
|
491
|
+
if (value === undefined) return DEFAULT_READINESS_TIMEOUT_MS;
|
|
492
|
+
if (!isValidReadinessTimeoutMs(value)) return fail("invalid_input", READINESS_TIMEOUT_INVALID_MESSAGE);
|
|
500
493
|
return value;
|
|
501
494
|
}
|
|
502
495
|
|
|
@@ -976,6 +969,7 @@ function isSdkStartupFailure(value: unknown): value is SdkStartupFailure {
|
|
|
976
969
|
failure.reason !== "ineligible" &&
|
|
977
970
|
failure.reason !== "factory_absent" &&
|
|
978
971
|
failure.reason !== "runner_absent" &&
|
|
972
|
+
failure.reason !== "admission_timeout" &&
|
|
979
973
|
failure.reason !== "pending" &&
|
|
980
974
|
failure.reason !== "failed") ||
|
|
981
975
|
typeof failure.message !== "string" ||
|
|
@@ -3109,15 +3103,57 @@ async function executeLifecycleResponse(
|
|
|
3109
3103
|
}
|
|
3110
3104
|
}
|
|
3111
3105
|
const timing = lifecycleTiming(broker);
|
|
3106
|
+
const admissionGranted = startupAdmittedInputs.has(input);
|
|
3107
|
+
if (!admissionGranted) {
|
|
3108
|
+
const suppliedDeadlineFields = [
|
|
3109
|
+
input.receivedAt,
|
|
3110
|
+
input.requestedReadinessTimeoutMs,
|
|
3111
|
+
input.semanticReadyDeadlineAt,
|
|
3112
|
+
input.terminationStartDeadlineAt,
|
|
3113
|
+
input.lifecycleCleanupDeadlineAt,
|
|
3114
|
+
];
|
|
3115
|
+
let requestedReadinessTimeoutMs: number;
|
|
3116
|
+
if (suppliedDeadlineFields.some(value => value !== undefined)) {
|
|
3117
|
+
const supplied = lifecycleDeadlines(input, timing.now());
|
|
3118
|
+
if ("ok" in supplied) return supplied;
|
|
3119
|
+
requestedReadinessTimeoutMs = supplied.requestedReadinessTimeoutMs;
|
|
3120
|
+
} else {
|
|
3121
|
+
const timeout = readinessTimeout(input);
|
|
3122
|
+
if (typeof timeout !== "number") return timeout;
|
|
3123
|
+
requestedReadinessTimeoutMs = timeout;
|
|
3124
|
+
}
|
|
3125
|
+
const queueWaitMs = startupQueueWaitMs(requestedReadinessTimeoutMs);
|
|
3126
|
+
const launch = await launchInput(broker, operation, input);
|
|
3127
|
+
if ("ok" in launch) return launch;
|
|
3128
|
+
const admitted = await broker.runStartup(queueWaitMs, timing, async admittedAt => {
|
|
3129
|
+
const admittedInput = { ...input, ...deriveLifecycleDeadlines(admittedAt, requestedReadinessTimeoutMs) };
|
|
3130
|
+
startupAdmittedInputs.add(admittedInput);
|
|
3131
|
+
startupLaunchInputs.set(admittedInput, launch);
|
|
3132
|
+
try {
|
|
3133
|
+
return await executeLifecycleResponse(broker, operation, admittedInput, identity, cleanup);
|
|
3134
|
+
} finally {
|
|
3135
|
+
startupLaunchInputs.delete(admittedInput);
|
|
3136
|
+
startupAdmittedInputs.delete(admittedInput);
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
if (admitted.status === "completed") return admitted.value;
|
|
3140
|
+
if (admitted.status === "admission_refused")
|
|
3141
|
+
return fail(
|
|
3142
|
+
"startup_admission_refused",
|
|
3143
|
+
"SDK host startup was refused because the broker no longer owns the session root.",
|
|
3144
|
+
);
|
|
3145
|
+
const failure = normalizeSdkStartupFailure("startup", admitted.reason);
|
|
3146
|
+
return fail("startup_admission_timeout", failure.message);
|
|
3147
|
+
}
|
|
3112
3148
|
|
|
3113
|
-
const deadlines = lifecycleDeadlines(input,
|
|
3149
|
+
const deadlines = lifecycleDeadlines(input, input.receivedAt as number);
|
|
3114
3150
|
|
|
3115
3151
|
if ("ok" in deadlines) return deadlines;
|
|
3116
3152
|
const lifecycleDeadline = deadlines.lifecycleCleanupDeadlineAt;
|
|
3117
3153
|
const readinessDeadline = deadlines.semanticReadyDeadlineAt;
|
|
3118
3154
|
const terminationStartDeadline = deadlines.terminationStartDeadlineAt;
|
|
3119
3155
|
|
|
3120
|
-
const launch = await launchInput(broker, operation, input);
|
|
3156
|
+
const launch = startupLaunchInputs.get(input) ?? (await launchInput(broker, operation, input));
|
|
3121
3157
|
if ("ok" in launch) return launch;
|
|
3122
3158
|
if (!hasProcessIncarnationAuthority())
|
|
3123
3159
|
return fail(
|
|
@@ -3207,41 +3243,49 @@ async function executeLifecycleResponse(
|
|
|
3207
3243
|
let child: ChildProcess | undefined;
|
|
3208
3244
|
let spawnedAuthority: EffectMarker | undefined;
|
|
3209
3245
|
try {
|
|
3210
|
-
const
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3246
|
+
const authorizedSpawn = broker.runSynchronousEffectWithFreshPublicationAuthority(() => {
|
|
3247
|
+
const cmd = command(broker);
|
|
3248
|
+
return spawn(cmd.file, cmd.args, {
|
|
3249
|
+
cwd: launch.cwd,
|
|
3250
|
+
detached: true,
|
|
3251
|
+
stdio: "ignore",
|
|
3252
|
+
env: {
|
|
3253
|
+
...("kind" in cmd ? cmd.env : process.env),
|
|
3254
|
+
GJC_AGENT_DIR: broker.settings.agentDir,
|
|
3255
|
+
GJC_CODING_AGENT_DIR: broker.settings.agentDir,
|
|
3256
|
+
GJC_SESSION_ID: launch.id,
|
|
3257
|
+
GJC_STATE_ROOT: launch.root,
|
|
3258
|
+
GJC_LIFECYCLE_REQUEST_ID: effectMarker,
|
|
3259
|
+
GJC_SDK_LIFECYCLE_REQUEST: JSON.stringify(request),
|
|
3260
|
+
// Coordinator-correlation env scoped to this designated launch only (#2549).
|
|
3261
|
+
// The runtime sidecar reads these to write terminal state to the
|
|
3262
|
+
// coordinator-shared file instead of an unread session-local fallback.
|
|
3263
|
+
// The broker computes the file path from coordinatorStateDir + launch.id
|
|
3264
|
+
// because the session ID is generated at spawn time.
|
|
3265
|
+
...(launch.coordinatorStateDir
|
|
3266
|
+
? {
|
|
3267
|
+
[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: path.join(
|
|
3268
|
+
launch.coordinatorStateDir,
|
|
3269
|
+
"session-states",
|
|
3270
|
+
`${launch.id}.json`,
|
|
3271
|
+
),
|
|
3272
|
+
}
|
|
3273
|
+
: {}),
|
|
3274
|
+
...(launch.coordinatorSessionId
|
|
3275
|
+
? { [GJC_COORDINATOR_SESSION_ID_ENV]: launch.coordinatorSessionId }
|
|
3276
|
+
: {}),
|
|
3277
|
+
...(launch.coordinatorSessionBranch
|
|
3278
|
+
? { [GJC_COORDINATOR_SESSION_BRANCH_ENV]: launch.coordinatorSessionBranch }
|
|
3279
|
+
: {}),
|
|
3280
|
+
},
|
|
3281
|
+
});
|
|
3244
3282
|
});
|
|
3283
|
+
if (!authorizedSpawn.authorized)
|
|
3284
|
+
return fail(
|
|
3285
|
+
"startup_admission_refused",
|
|
3286
|
+
"SDK host startup was refused because the broker no longer owns the session root.",
|
|
3287
|
+
);
|
|
3288
|
+
const spawned = authorizedSpawn.value;
|
|
3245
3289
|
child = spawned;
|
|
3246
3290
|
const pid = spawned.pid;
|
|
3247
3291
|
if (!pid) throw new Error("spawned session has no pid");
|
|
@@ -4382,3 +4426,174 @@ export async function executeLifecycle(
|
|
|
4382
4426
|
...(startupFailure ? { startupFailure } : {}),
|
|
4383
4427
|
};
|
|
4384
4428
|
}
|
|
4429
|
+
|
|
4430
|
+
/**
|
|
4431
|
+
* The live client/socket subscription count of the SDK session endpoint this
|
|
4432
|
+
* process serves, or `undefined` while this process serves no endpoint.
|
|
4433
|
+
*
|
|
4434
|
+
* Only the SDK session runtime owns the real socket table, so it publishes a
|
|
4435
|
+
* reader here instead of every consumer re-deriving attachment from the OS.
|
|
4436
|
+
* Consumers must treat `undefined` as "no evidence" and never as "detached".
|
|
4437
|
+
*/
|
|
4438
|
+
export type SessionHostAttachmentReader = () => number;
|
|
4439
|
+
|
|
4440
|
+
/** What one serving runtime reports about itself while its transport is up. */
|
|
4441
|
+
export interface SessionHostRuntimeEvidence {
|
|
4442
|
+
/** This runtime's own live SDK client/socket subscription count. */
|
|
4443
|
+
attachedClients: SessionHostAttachmentReader;
|
|
4444
|
+
/** Whether this runtime currently has agent work in flight. */
|
|
4445
|
+
workInFlight: () => boolean;
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
/** One runtime's live publication, retractable only by its owner. */
|
|
4449
|
+
export interface SessionHostRuntimePublication {
|
|
4450
|
+
/**
|
|
4451
|
+
* Withdraws the evidence this handle published. Idempotent, and never able
|
|
4452
|
+
* to touch a sibling runtime's evidence.
|
|
4453
|
+
*/
|
|
4454
|
+
retract(): void;
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
/**
|
|
4458
|
+
* Every runtime in this process that currently serves an SDK endpoint.
|
|
4459
|
+
*
|
|
4460
|
+
* A process can serve more than one at a time: an identity-rotating control op
|
|
4461
|
+
* starts the successor before the predecessor's deferred stop runs. A single
|
|
4462
|
+
* global slot let that predecessor's teardown retract the live successor's
|
|
4463
|
+
* reader and manufacture "no evidence" for a host with clients attached, so
|
|
4464
|
+
* publications are held per runtime and retracted only through their own handle.
|
|
4465
|
+
*/
|
|
4466
|
+
const sessionHostRuntimes = new Set<SessionHostRuntimeEvidence>();
|
|
4467
|
+
|
|
4468
|
+
/**
|
|
4469
|
+
* Publishes this runtime's own liveness evidence for the lifetime of its
|
|
4470
|
+
* transport. Retraction goes through the returned handle, so a runtime can only
|
|
4471
|
+
* ever withdraw evidence it owns.
|
|
4472
|
+
*/
|
|
4473
|
+
export function publishSessionHostRuntimeEvidence(evidence: SessionHostRuntimeEvidence): SessionHostRuntimePublication {
|
|
4474
|
+
const published: SessionHostRuntimeEvidence = { ...evidence };
|
|
4475
|
+
sessionHostRuntimes.add(published);
|
|
4476
|
+
return {
|
|
4477
|
+
retract(): void {
|
|
4478
|
+
sessionHostRuntimes.delete(published);
|
|
4479
|
+
},
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
/**
|
|
4484
|
+
* This process's currently attached SDK client count, summed across every
|
|
4485
|
+
* serving runtime, or `undefined` when no runtime publishes a readable count
|
|
4486
|
+
* (before startup, after teardown, or when every reader itself fails). Never
|
|
4487
|
+
* guesses: absence of a reader is absence of evidence.
|
|
4488
|
+
*/
|
|
4489
|
+
export function sessionHostAttachedClients(): number | undefined {
|
|
4490
|
+
let total: number | undefined;
|
|
4491
|
+
for (const { attachedClients } of sessionHostRuntimes) {
|
|
4492
|
+
let count: number;
|
|
4493
|
+
try {
|
|
4494
|
+
count = attachedClients();
|
|
4495
|
+
} catch {
|
|
4496
|
+
continue;
|
|
4497
|
+
}
|
|
4498
|
+
if (!Number.isSafeInteger(count) || count < 0) continue;
|
|
4499
|
+
total = (total ?? 0) + count;
|
|
4500
|
+
}
|
|
4501
|
+
return total;
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
/**
|
|
4505
|
+
* Whether any runtime in this process has agent work in flight right now.
|
|
4506
|
+
*
|
|
4507
|
+
* Positive evidence only: a runtime that publishes nothing, or whose reader
|
|
4508
|
+
* fails, reports no work, so this can never keep an abandoned host alive.
|
|
4509
|
+
*/
|
|
4510
|
+
export function sessionHostWorkInFlight(): boolean {
|
|
4511
|
+
for (const { workInFlight } of sessionHostRuntimes) {
|
|
4512
|
+
try {
|
|
4513
|
+
if (workInFlight()) return true;
|
|
4514
|
+
} catch {}
|
|
4515
|
+
}
|
|
4516
|
+
return false;
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
/**
|
|
4520
|
+
* How often a live broker re-checks its own session registrations against OS
|
|
4521
|
+
* process liveness.
|
|
4522
|
+
*
|
|
4523
|
+
* This sweep can never disturb healthy work: a registration is only dropped
|
|
4524
|
+
* when `process.kill(pid, 0)` proves the exact published pid is gone, and a
|
|
4525
|
+
* working host answers that probe for its entire life no matter how long a turn
|
|
4526
|
+
* runs. One minute keeps `gjc_sessions`/`session.get_endpoint` from advertising
|
|
4527
|
+
* a corpse for longer than a single poll while costing one index refresh per
|
|
4528
|
+
* minute on an otherwise idle broker.
|
|
4529
|
+
*/
|
|
4530
|
+
export const BROKER_DEAD_REGISTRATION_SWEEP_MS = 60_000;
|
|
4531
|
+
|
|
4532
|
+
/** One reaped registration, as recorded by {@link reapDeadSessionRegistrations}. */
|
|
4533
|
+
export interface ReapedSessionRegistration {
|
|
4534
|
+
sessionId: string;
|
|
4535
|
+
pid: number;
|
|
4536
|
+
endpointGeneration: number;
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
/**
|
|
4540
|
+
* Drops every indexed session registration whose host process is provably gone.
|
|
4541
|
+
*
|
|
4542
|
+
* Liveness is the session index's own `alive(pid)` probe (`listSessions().live`),
|
|
4543
|
+
* which reports live for both a running pid and an EPERM pid, so an alien or
|
|
4544
|
+
* unreadable process is never mistaken for a dead one. Registrations already
|
|
4545
|
+
* marked `terminalUncertain` are left alone: their disposition belongs to the
|
|
4546
|
+
* terminal-uncertainty reconciliation path, and silently dropping them would
|
|
4547
|
+
* turn a fail-closed `session.close`/`session.delete` refusal into `not_found`.
|
|
4548
|
+
*/
|
|
4549
|
+
export async function reapDeadSessionRegistrations(
|
|
4550
|
+
broker: Pick<Broker, "index">,
|
|
4551
|
+
): Promise<ReapedSessionRegistration[]> {
|
|
4552
|
+
await broker.index.refresh();
|
|
4553
|
+
const dead = broker.index.listSessions().sessions.filter(session => !session.live && !session.terminalUncertain);
|
|
4554
|
+
const reaped: ReapedSessionRegistration[] = [];
|
|
4555
|
+
for (const session of dead) {
|
|
4556
|
+
await broker.index.append({
|
|
4557
|
+
type: "host_unregistered",
|
|
4558
|
+
sessionId: session.sessionId,
|
|
4559
|
+
locator: session.locator,
|
|
4560
|
+
endpointGeneration: session.endpointGeneration,
|
|
4561
|
+
pid: session.pid,
|
|
4562
|
+
...(session.endpointMtimeMs === undefined ? {} : { endpointMtimeMs: session.endpointMtimeMs }),
|
|
4563
|
+
...(session.lifecycleRequestId ? { lifecycleRequestId: session.lifecycleRequestId } : {}),
|
|
4564
|
+
});
|
|
4565
|
+
const record = {
|
|
4566
|
+
sessionId: session.sessionId,
|
|
4567
|
+
pid: session.pid,
|
|
4568
|
+
endpointGeneration: session.endpointGeneration,
|
|
4569
|
+
};
|
|
4570
|
+
reaped.push(record);
|
|
4571
|
+
logger.warn("sdk broker reaped a session registration whose host process is gone", record);
|
|
4572
|
+
}
|
|
4573
|
+
return reaped;
|
|
4574
|
+
}
|
|
4575
|
+
|
|
4576
|
+
/**
|
|
4577
|
+
* Runs {@link reapDeadSessionRegistrations} on {@link BROKER_DEAD_REGISTRATION_SWEEP_MS}.
|
|
4578
|
+
* The timer is unref'd, so it never keeps an otherwise idle broker process alive.
|
|
4579
|
+
* Returns a disposer.
|
|
4580
|
+
*/
|
|
4581
|
+
export function startBrokerDeadRegistrationSweep(
|
|
4582
|
+
broker: Pick<Broker, "index">,
|
|
4583
|
+
intervalMs = BROKER_DEAD_REGISTRATION_SWEEP_MS,
|
|
4584
|
+
): () => void {
|
|
4585
|
+
let running = false;
|
|
4586
|
+
const timer = setInterval(() => {
|
|
4587
|
+
if (running) return;
|
|
4588
|
+
running = true;
|
|
4589
|
+
void reapDeadSessionRegistrations(broker)
|
|
4590
|
+
.catch(error => {
|
|
4591
|
+
logger.warn("sdk broker dead-registration sweep failed", { error: String(error) });
|
|
4592
|
+
})
|
|
4593
|
+
.finally(() => {
|
|
4594
|
+
running = false;
|
|
4595
|
+
});
|
|
4596
|
+
}, intervalMs);
|
|
4597
|
+
timer.unref();
|
|
4598
|
+
return () => clearInterval(timer);
|
|
4599
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Readiness budget the broker grants a lifecycle request that does not size one itself. */
|
|
2
|
+
export const DEFAULT_READINESS_TIMEOUT_MS = 10_000;
|
|
3
|
+
export const MIN_READINESS_TIMEOUT_MS = 4_000;
|
|
4
|
+
export const MAX_READINESS_TIMEOUT_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
export function isValidReadinessTimeoutMs(value: unknown): value is number {
|
|
7
|
+
return (
|
|
8
|
+
typeof value === "number" &&
|
|
9
|
+
Number.isSafeInteger(value) &&
|
|
10
|
+
value >= MIN_READINESS_TIMEOUT_MS &&
|
|
11
|
+
value <= MAX_READINESS_TIMEOUT_MS
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const READINESS_TIMEOUT_INVALID_MESSAGE = `readinessTimeoutMs must be an integer between ${MIN_READINESS_TIMEOUT_MS} and ${MAX_READINESS_TIMEOUT_MS}.`;
|
|
16
|
+
/**
|
|
17
|
+
* Sleep until the duration elapses or the caller cancels the wait. Queue admission
|
|
18
|
+
* uses this so a granted or refused waiter does not retain its cutoff timer for the
|
|
19
|
+
* rest of the readiness budget.
|
|
20
|
+
*/
|
|
21
|
+
export function cancellableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
22
|
+
if (signal?.aborted || ms <= 0) return Promise.resolve();
|
|
23
|
+
const settled = Promise.withResolvers<void>();
|
|
24
|
+
const cancel = (): void => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
signal?.removeEventListener("abort", cancel);
|
|
27
|
+
settled.resolve();
|
|
28
|
+
};
|
|
29
|
+
const timer = setTimeout(cancel, ms);
|
|
30
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
31
|
+
return settled.promise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Slack a caller adds over the broker-side budget so that a startup which runs to the
|
|
36
|
+
* very edge of its window is reported as the broker's own terminal result rather than
|
|
37
|
+
* as the caller's timeout.
|
|
38
|
+
*/
|
|
39
|
+
const CALLER_DEADLINE_SLACK_MS = 1_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether a broker operation waits in the host-startup admission queue before it runs.
|
|
43
|
+
* Only these spawn a host, so only these can be parked behind a full queue; the other
|
|
44
|
+
* lifecycle operations start their readiness clock as soon as they are received.
|
|
45
|
+
*/
|
|
46
|
+
export function isStartupLifecycleOperation(operation: string): boolean {
|
|
47
|
+
return operation === "session.create" || operation === "session.fork" || operation === "session.resume";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bounded time a lifecycle startup may wait for a host-startup admission slot. It
|
|
52
|
+
* is the readiness budget itself: a startup still queued after that long has spent
|
|
53
|
+
* the whole window the request was sized for, so refusing it beats launching a host
|
|
54
|
+
* that is already late.
|
|
55
|
+
*/
|
|
56
|
+
export function startupQueueWaitMs(requestedReadinessTimeoutMs: number): number {
|
|
57
|
+
return requestedReadinessTimeoutMs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Broker-side wall clock a lifecycle startup may consume: the admission wait plus
|
|
62
|
+
* the readiness budget, which is granted fresh at admission and so is never shortened
|
|
63
|
+
* by queueing. Callers MUST size their own request deadline against this rather than
|
|
64
|
+
* `readinessTimeoutMs` alone, or a request admitted late fails client-side while the
|
|
65
|
+
* broker keeps running it to a durably persisted terminal result.
|
|
66
|
+
*/
|
|
67
|
+
export function lifecycleStartupBudgetMs(requestedReadinessTimeoutMs: number): number {
|
|
68
|
+
return startupQueueWaitMs(requestedReadinessTimeoutMs) + requestedReadinessTimeoutMs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Request deadline a caller MUST grant a broker operation, or `undefined` when the
|
|
73
|
+
* operation carries no readiness budget of its own and the client default already
|
|
74
|
+
* covers it.
|
|
75
|
+
*
|
|
76
|
+
* A request that omits `readinessTimeoutMs` is sized against the broker's default
|
|
77
|
+
* rather than left unextended: the broker queues it for exactly as long as one that
|
|
78
|
+
* asked, so the common path would otherwise fail client-side while the broker runs
|
|
79
|
+
* the startup to a durably persisted terminal result.
|
|
80
|
+
*/
|
|
81
|
+
export function lifecycleRequestTimeoutMs(operation: string, input: Record<string, unknown>): number | undefined {
|
|
82
|
+
const deadlineFields = [
|
|
83
|
+
input.receivedAt,
|
|
84
|
+
input.requestedReadinessTimeoutMs,
|
|
85
|
+
input.semanticReadyDeadlineAt,
|
|
86
|
+
input.terminationStartDeadlineAt,
|
|
87
|
+
input.lifecycleCleanupDeadlineAt,
|
|
88
|
+
];
|
|
89
|
+
const hasDeadlineTuple = deadlineFields.some(value => value !== undefined);
|
|
90
|
+
let supplied: number | undefined;
|
|
91
|
+
if (hasDeadlineTuple) {
|
|
92
|
+
if (!deadlineFields.every(value => typeof value === "number" && Number.isSafeInteger(value))) return undefined;
|
|
93
|
+
if (!isValidReadinessTimeoutMs(input.requestedReadinessTimeoutMs)) return undefined;
|
|
94
|
+
supplied = input.requestedReadinessTimeoutMs;
|
|
95
|
+
} else {
|
|
96
|
+
const requested = input.readinessTimeoutMs;
|
|
97
|
+
if (requested !== undefined && !isValidReadinessTimeoutMs(requested)) return undefined;
|
|
98
|
+
supplied = requested as number | undefined;
|
|
99
|
+
}
|
|
100
|
+
if (isStartupLifecycleOperation(operation))
|
|
101
|
+
return lifecycleStartupBudgetMs(supplied ?? DEFAULT_READINESS_TIMEOUT_MS) + CALLER_DEADLINE_SLACK_MS;
|
|
102
|
+
return supplied === undefined ? undefined : supplied + CALLER_DEADLINE_SLACK_MS;
|
|
103
|
+
}
|
|
@@ -66,11 +66,17 @@ export type ChatDaemonAction = "stop" | "reload";
|
|
|
66
66
|
* them against an exact owner tuple, so an owner at an earlier generation may
|
|
67
67
|
* not serve or answer a request captured against this contract. Discord
|
|
68
68
|
* generation 27 / slack generation 26 move shared exact unlink and process-
|
|
69
|
-
* incarnation authority behind lazy native bindings.
|
|
69
|
+
* incarnation authority behind lazy native bindings. Discord generation 28 /
|
|
70
|
+
* slack generation 27 dial attached-session clients on the long-lived session
|
|
71
|
+
* reconnect budget, so an owner at an earlier generation gives up reconnecting
|
|
72
|
+
* before the host heartbeat TTL expires and permanently loses its attachment. Discord
|
|
73
|
+
* generation 29 / slack generation 28 advance the replay cursor only after a frame is
|
|
74
|
+
* published, so an owner at an earlier generation acknowledges an event before delivering
|
|
75
|
+
* it and loses that event for good the first time a surface refuses it.
|
|
70
76
|
*/
|
|
71
77
|
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
72
|
-
discord:
|
|
73
|
-
slack:
|
|
78
|
+
discord: 29,
|
|
79
|
+
slack: 28,
|
|
74
80
|
};
|
|
75
81
|
|
|
76
82
|
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|