@gajae-code/coding-agent 0.12.19 → 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.
Files changed (97) hide show
  1. package/CHANGELOG.md +20 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +301 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tui/output-block.d.ts +6 -0
  40. package/package.json +8 -8
  41. package/src/cli/args.ts +75 -34
  42. package/src/commands/acp.ts +7 -1
  43. package/src/commands/gc.ts +31 -2
  44. package/src/commands/sdk.ts +141 -7
  45. package/src/config/settings-schema.ts +43 -0
  46. package/src/coordinator-mcp/server.ts +6 -1
  47. package/src/export/html/index.ts +83 -39
  48. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  49. package/src/internal-urls/docs-index.generated.ts +1 -1
  50. package/src/internal-urls/local-protocol.ts +14 -0
  51. package/src/main.ts +40 -9
  52. package/src/modes/acp/acp-agent.ts +564 -37
  53. package/src/modes/acp/terminal-auth.ts +2 -2
  54. package/src/modes/components/bash-execution.ts +14 -0
  55. package/src/modes/components/eval-execution.ts +14 -0
  56. package/src/modes/controllers/command-controller.ts +63 -8
  57. package/src/modes/utils/ui-helpers.ts +81 -11
  58. package/src/sdk/acp/adapter.ts +46 -45
  59. package/src/sdk/acp/index.ts +1 -1
  60. package/src/sdk/broker/broker.ts +392 -11
  61. package/src/sdk/broker/ensure.ts +131 -61
  62. package/src/sdk/broker/index.ts +1 -0
  63. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  64. package/src/sdk/broker/lifecycle.ts +278 -63
  65. package/src/sdk/broker/startup-budget.ts +103 -0
  66. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  67. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  68. package/src/sdk/bus/conversation-store.ts +1 -1
  69. package/src/sdk/bus/discord-daemon.ts +2 -1
  70. package/src/sdk/bus/discord-live-provider.ts +34 -6
  71. package/src/sdk/bus/index.ts +118 -20
  72. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  73. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  74. package/src/sdk/bus/slack-daemon.ts +91 -18
  75. package/src/sdk/cli/session-cli.ts +8 -1
  76. package/src/sdk/host/query/handlers.ts +1 -1
  77. package/src/sdk/host/query/revision-store.ts +48 -12
  78. package/src/sdk/host/session-runtime.test.ts +50 -0
  79. package/src/sdk/host/session-runtime.ts +12 -2
  80. package/src/sdk/lifecycle-session.ts +26 -3
  81. package/src/sdk/mcp/server.ts +6 -1
  82. package/src/sdk/prompt-failure.ts +42 -0
  83. package/src/sdk/prompt-watchdog.ts +162 -0
  84. package/src/sdk/session-reconnect.ts +43 -0
  85. package/src/sdk/startup-capability.ts +13 -4
  86. package/src/session/agent-session.ts +525 -222
  87. package/src/session/blob-store.ts +89 -0
  88. package/src/session/internal/managed-session-scope.ts +36 -26
  89. package/src/session/internal/managed-session-storage.ts +303 -32
  90. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  91. package/src/session/session-manager.ts +7027 -394
  92. package/src/session/session-storage.ts +1134 -11
  93. package/src/task/executor.ts +17 -4
  94. package/src/tools/bash.ts +109 -7
  95. package/src/tools/find.ts +55 -26
  96. package/src/tools/todo-contract.ts +40 -8
  97. 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 type { SdkStartupFailure, SdkStartupRollbackResult } from "../startup-capability";
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 READY_TIMEOUT_MS;
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, timing.now());
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 cmd = command(broker);
3211
- const spawned = spawn(cmd.file, cmd.args, {
3212
- cwd: launch.cwd,
3213
- detached: true,
3214
- stdio: "ignore",
3215
- env: {
3216
- ...("kind" in cmd ? cmd.env : process.env),
3217
- GJC_AGENT_DIR: broker.settings.agentDir,
3218
- GJC_CODING_AGENT_DIR: broker.settings.agentDir,
3219
- GJC_SESSION_ID: launch.id,
3220
- GJC_STATE_ROOT: launch.root,
3221
- GJC_LIFECYCLE_REQUEST_ID: effectMarker,
3222
- GJC_SDK_LIFECYCLE_REQUEST: JSON.stringify(request),
3223
- // Coordinator-correlation env scoped to this designated launch only (#2549).
3224
- // The runtime sidecar reads these to write terminal state to the
3225
- // coordinator-shared file instead of an unread session-local fallback.
3226
- // The broker computes the file path from coordinatorStateDir + launch.id
3227
- // because the session ID is generated at spawn time.
3228
- ...(launch.coordinatorStateDir
3229
- ? {
3230
- [GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: path.join(
3231
- launch.coordinatorStateDir,
3232
- "session-states",
3233
- `${launch.id}.json`,
3234
- ),
3235
- }
3236
- : {}),
3237
- ...(launch.coordinatorSessionId
3238
- ? { [GJC_COORDINATOR_SESSION_ID_ENV]: launch.coordinatorSessionId }
3239
- : {}),
3240
- ...(launch.coordinatorSessionBranch
3241
- ? { [GJC_COORDINATOR_SESSION_BRANCH_ENV]: launch.coordinatorSessionBranch }
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: 27,
73
- slack: 26,
78
+ discord: 29,
79
+ slack: 28,
74
80
  };
75
81
 
76
82
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {