@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
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import type { BigIntStats } from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
|
|
7
|
+
import { logger } from "@gajae-code/utils";
|
|
6
8
|
import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
|
|
7
9
|
import {
|
|
8
10
|
type DirectoryMigrationPolicy,
|
|
@@ -62,6 +64,8 @@ export type BrokerErrorCode =
|
|
|
62
64
|
| "resource_gone"
|
|
63
65
|
| "invalid_input"
|
|
64
66
|
| "spawn_failed"
|
|
67
|
+
| "startup_admission_timeout"
|
|
68
|
+
| "startup_admission_refused"
|
|
65
69
|
| "readiness_timeout"
|
|
66
70
|
| "close_refused"
|
|
67
71
|
| "not_found"
|
|
@@ -395,6 +399,148 @@ type BrokerLockSnapshot = {
|
|
|
395
399
|
lockIdentity: string;
|
|
396
400
|
};
|
|
397
401
|
|
|
402
|
+
/** Tombstone prefix used by {@link Broker.reclaimStaleLock} when a dead owner's lock is renamed aside. */
|
|
403
|
+
export const BROKER_LOCK_TOMBSTONE_PREFIX = ".broker.lock.stale-";
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Recovery directories left beside the lock by manual and older automated broker
|
|
407
|
+
* restarts. Nothing writes them today, but installs that ever recovered by hand
|
|
408
|
+
* still carry them, so the reaper owns them alongside its own tombstones.
|
|
409
|
+
*/
|
|
410
|
+
export const BROKER_LOCK_BACKUP_PREFIXES = ["broker-restart-backup-", "broker-stale-backup-"] as const;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Age bound before a reclaimed lock artifact may be removed. Generous enough
|
|
414
|
+
* that a broker still settling after a reclaim can never have its own successor
|
|
415
|
+
* state deleted underneath it.
|
|
416
|
+
*/
|
|
417
|
+
export const BROKER_LOCK_ARTIFACT_GRACE_MS = 24 * 60 * 60 * 1_000;
|
|
418
|
+
|
|
419
|
+
/** Why a candidate lock artifact survived a reap pass. */
|
|
420
|
+
export type BrokerLockArtifactRetentionReason =
|
|
421
|
+
| "within-grace"
|
|
422
|
+
| "owner-alive"
|
|
423
|
+
| "owner-record-unreadable"
|
|
424
|
+
| "owner-record-missing"
|
|
425
|
+
| "not-a-directory"
|
|
426
|
+
| "removal-failed";
|
|
427
|
+
|
|
428
|
+
export interface BrokerLockArtifactRetention {
|
|
429
|
+
path: string;
|
|
430
|
+
reason: BrokerLockArtifactRetentionReason;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface BrokerLockArtifactReapResult {
|
|
434
|
+
removed: string[];
|
|
435
|
+
retained: BrokerLockArtifactRetention[];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isBrokerLockArtifactName(name: string): boolean {
|
|
439
|
+
return (
|
|
440
|
+
name.startsWith(BROKER_LOCK_TOMBSTONE_PREFIX) ||
|
|
441
|
+
BROKER_LOCK_BACKUP_PREFIXES.some(prefix => name.startsWith(prefix))
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Decide whether one candidate directory is provably abandoned.
|
|
447
|
+
*
|
|
448
|
+
* Fail-closed by construction: every branch that cannot prove abandonment
|
|
449
|
+
* returns a retention reason. A tombstone is only abandoned when its owner
|
|
450
|
+
* record parses and names a dead PID — an unreadable, permission-denied, or
|
|
451
|
+
* absent record keeps it forever. Backup directories carry no owner contract,
|
|
452
|
+
* so an absent record there is not ambiguity and age alone governs.
|
|
453
|
+
*/
|
|
454
|
+
async function classifyBrokerLockArtifact(
|
|
455
|
+
directory: string,
|
|
456
|
+
name: string,
|
|
457
|
+
now: number,
|
|
458
|
+
graceMs: number,
|
|
459
|
+
pidAlive: (pid: number) => boolean,
|
|
460
|
+
): Promise<BrokerLockArtifactRetentionReason | "abandoned"> {
|
|
461
|
+
const target = path.join(directory, name);
|
|
462
|
+
// lstat, never stat: a symlink pointing at live state must never be followed
|
|
463
|
+
// into a recursive removal.
|
|
464
|
+
const stat = await fs.lstat(target);
|
|
465
|
+
if (!stat.isDirectory()) return "not-a-directory";
|
|
466
|
+
if (!Number.isFinite(stat.mtimeMs) || now - stat.mtimeMs < graceMs) return "within-grace";
|
|
467
|
+
let raw: string;
|
|
468
|
+
try {
|
|
469
|
+
raw = await fs.readFile(path.join(target, BROKER_LOCK_RECORD), "utf8");
|
|
470
|
+
} catch (error) {
|
|
471
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
472
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return "owner-record-unreadable";
|
|
473
|
+
return name.startsWith(BROKER_LOCK_TOMBSTONE_PREFIX) ? "owner-record-missing" : "abandoned";
|
|
474
|
+
}
|
|
475
|
+
let pid: unknown;
|
|
476
|
+
try {
|
|
477
|
+
pid = (JSON.parse(raw) as { pid?: unknown }).pid;
|
|
478
|
+
} catch {
|
|
479
|
+
return "owner-record-unreadable";
|
|
480
|
+
}
|
|
481
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return "owner-record-unreadable";
|
|
482
|
+
return pidAlive(pid) ? "owner-alive" : "abandoned";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Remove reclaimed broker lock tombstones and legacy restart backups older than
|
|
487
|
+
* the grace window.
|
|
488
|
+
*
|
|
489
|
+
* `#reclaimStaleLock` renames a dead owner's lock to a tombstone named by a hash
|
|
490
|
+
* of the lock's dev+ino, so a machine accrues one directory per dead owner and
|
|
491
|
+
* nothing ever removed them (54 on the install in #3963). Reaping is
|
|
492
|
+
* best-effort and fail-closed: anything live, unreadable, permission-denied, or
|
|
493
|
+
* otherwise ambiguous is kept and the reason is logged.
|
|
494
|
+
*/
|
|
495
|
+
export async function reapStaleBrokerLockArtifacts(input: {
|
|
496
|
+
agentDir: string;
|
|
497
|
+
now?: number;
|
|
498
|
+
graceMs?: number;
|
|
499
|
+
pidAlive?: (pid: number) => boolean;
|
|
500
|
+
}): Promise<BrokerLockArtifactReapResult> {
|
|
501
|
+
const directory = path.join(input.agentDir, "sdk");
|
|
502
|
+
const now = input.now ?? Date.now();
|
|
503
|
+
const graceMs = input.graceMs ?? BROKER_LOCK_ARTIFACT_GRACE_MS;
|
|
504
|
+
const pidAlive = input.pidAlive ?? isPidAlive;
|
|
505
|
+
const removed: string[] = [];
|
|
506
|
+
const retained: BrokerLockArtifactRetention[] = [];
|
|
507
|
+
let names: string[];
|
|
508
|
+
try {
|
|
509
|
+
names = await fs.readdir(directory);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
512
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { removed, retained };
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
for (const name of names) {
|
|
516
|
+
if (!isBrokerLockArtifactName(name)) continue;
|
|
517
|
+
const target = path.join(directory, name);
|
|
518
|
+
let verdict: BrokerLockArtifactRetentionReason | "abandoned";
|
|
519
|
+
try {
|
|
520
|
+
verdict = await classifyBrokerLockArtifact(directory, name, now, graceMs, pidAlive);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
// A vanished candidate needs no decision; anything else is ambiguous.
|
|
523
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
524
|
+
verdict = "owner-record-unreadable";
|
|
525
|
+
}
|
|
526
|
+
if (verdict !== "abandoned") {
|
|
527
|
+
retained.push({ path: target, reason: verdict });
|
|
528
|
+
if (verdict !== "within-grace") logger.warn(`sdk broker: retained stale lock artifact ${name} (${verdict})`);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await fs.rm(target, { recursive: true });
|
|
533
|
+
removed.push(target);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
536
|
+
retained.push({ path: target, reason: "removal-failed" });
|
|
537
|
+
logger.warn(`sdk broker: retained stale lock artifact ${name} (removal-failed)`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (removed.length > 0) logger.info(`sdk broker: reaped ${removed.length} stale lock artifact(s)`);
|
|
541
|
+
return { removed, retained };
|
|
542
|
+
}
|
|
543
|
+
|
|
398
544
|
const BROKER_PUBLICATION_CADENCE_MS = 5_000;
|
|
399
545
|
const BROKER_PUBLICATION_GRACE_MS = 15_000;
|
|
400
546
|
// A broker that cannot observe its own publication is not provably the root, but
|
|
@@ -406,6 +552,138 @@ const BROKER_PUBLICATION_GRACE_MS = 15_000;
|
|
|
406
552
|
// longer than the loss grace.
|
|
407
553
|
const BROKER_AMBIGUITY_GRACE_MS = 120_000;
|
|
408
554
|
const BROKER_SETTLEMENT_MS = 2_000;
|
|
555
|
+
|
|
556
|
+
export interface StartupAdmissionTiming {
|
|
557
|
+
now(): number;
|
|
558
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export type StartupAdmissionResult<T> =
|
|
562
|
+
| { status: "completed"; admittedAt: number; value: T }
|
|
563
|
+
| { status: "admission_timeout"; reason: "admission_timeout" }
|
|
564
|
+
| { status: "admission_refused"; reason: "admission_refused" };
|
|
565
|
+
|
|
566
|
+
interface StartupAdmissionWaiter {
|
|
567
|
+
state: "waiting" | "admitted" | "timed_out" | "refused";
|
|
568
|
+
admissionEpoch?: number;
|
|
569
|
+
ready: PromiseWithResolvers<void>;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Host startup is CPU/IO bursty, so scale with the machine without allowing a full-core launch stampede.
|
|
573
|
+
export function sdkHostStartupConcurrency(availableParallelism = os.availableParallelism()): number {
|
|
574
|
+
if (!Number.isSafeInteger(availableParallelism) || availableParallelism < 1)
|
|
575
|
+
throw new Error("SDK host startup parallelism must be a positive safe integer.");
|
|
576
|
+
return Math.max(1, Math.floor(Math.sqrt(availableParallelism)));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export class StartupAdmissionQueue {
|
|
580
|
+
#inFlight = 0;
|
|
581
|
+
#closed = false;
|
|
582
|
+
#epoch = 0;
|
|
583
|
+
#waiters: StartupAdmissionWaiter[] = [];
|
|
584
|
+
|
|
585
|
+
constructor(readonly limit: number) {
|
|
586
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
587
|
+
throw new Error("SDK host startup concurrency must be a positive safe integer.");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async run<T>(
|
|
591
|
+
queueWaitMs: number,
|
|
592
|
+
timing: StartupAdmissionTiming,
|
|
593
|
+
task: (admittedAt: number) => Promise<T>,
|
|
594
|
+
): Promise<StartupAdmissionResult<T>> {
|
|
595
|
+
if (!Number.isSafeInteger(queueWaitMs) || queueWaitMs < 1)
|
|
596
|
+
throw new Error("SDK host startup queue wait must be a positive safe integer.");
|
|
597
|
+
if (this.#closed) return { status: "admission_refused", reason: "admission_refused" };
|
|
598
|
+
if (this.#inFlight < this.limit) return this.#runAdmitted(timing, task);
|
|
599
|
+
|
|
600
|
+
const ready = Promise.withResolvers<void>();
|
|
601
|
+
const waiter: StartupAdmissionWaiter = { state: "waiting", ready };
|
|
602
|
+
this.#waiters.push(waiter);
|
|
603
|
+
const cutoff = new AbortController();
|
|
604
|
+
let outcome: "admitted" | "timed_out" | "refused";
|
|
605
|
+
try {
|
|
606
|
+
outcome = await Promise.race([
|
|
607
|
+
ready.promise.then(() => (waiter.state === "refused" ? ("refused" as const) : ("admitted" as const))),
|
|
608
|
+
timing.sleep(queueWaitMs, cutoff.signal).then(() => {
|
|
609
|
+
if (waiter.state === "admitted") return "admitted" as const;
|
|
610
|
+
if (waiter.state === "refused") return "refused" as const;
|
|
611
|
+
if (waiter.state === "timed_out") return "timed_out" as const;
|
|
612
|
+
waiter.state = "timed_out";
|
|
613
|
+
const index = this.#waiters.indexOf(waiter);
|
|
614
|
+
if (index >= 0) this.#waiters.splice(index, 1);
|
|
615
|
+
return "timed_out" as const;
|
|
616
|
+
}),
|
|
617
|
+
]);
|
|
618
|
+
} finally {
|
|
619
|
+
cutoff.abort();
|
|
620
|
+
}
|
|
621
|
+
if (outcome === "timed_out") return { status: "admission_timeout", reason: "admission_timeout" };
|
|
622
|
+
if (outcome === "refused") return { status: "admission_refused", reason: "admission_refused" };
|
|
623
|
+
return this.#runGranted(waiter.admissionEpoch!, timing, task);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async #runAdmitted<T>(
|
|
627
|
+
timing: StartupAdmissionTiming,
|
|
628
|
+
task: (admittedAt: number) => Promise<T>,
|
|
629
|
+
): Promise<StartupAdmissionResult<T>> {
|
|
630
|
+
const admissionEpoch = this.#epoch;
|
|
631
|
+
this.#inFlight += 1;
|
|
632
|
+
return this.#runGranted(admissionEpoch, timing, task);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async #runGranted<T>(
|
|
636
|
+
admissionEpoch: number,
|
|
637
|
+
timing: StartupAdmissionTiming,
|
|
638
|
+
task: (admittedAt: number) => Promise<T>,
|
|
639
|
+
): Promise<StartupAdmissionResult<T>> {
|
|
640
|
+
try {
|
|
641
|
+
const admittedAt = timing.now();
|
|
642
|
+
if (this.#closed || admissionEpoch !== this.#epoch)
|
|
643
|
+
return { status: "admission_refused", reason: "admission_refused" };
|
|
644
|
+
return { status: "completed", admittedAt, value: await task(admittedAt) };
|
|
645
|
+
} finally {
|
|
646
|
+
this.#inFlight -= 1;
|
|
647
|
+
this.#grantNext();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
#grantNext(): void {
|
|
652
|
+
if (this.#closed) return;
|
|
653
|
+
while (this.#inFlight < this.limit) {
|
|
654
|
+
const waiter = this.#waiters.shift();
|
|
655
|
+
if (!waiter) return;
|
|
656
|
+
if (waiter.state !== "waiting") continue;
|
|
657
|
+
waiter.state = "admitted";
|
|
658
|
+
waiter.admissionEpoch = this.#epoch;
|
|
659
|
+
this.#inFlight += 1;
|
|
660
|
+
waiter.ready.resolve();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Refuse every queued startup and every later one. A broker that can no longer
|
|
666
|
+
* prove it owns the published root must not spawn children through slots that
|
|
667
|
+
* free up while it is fenced. The epoch also invalidates a waiter that was
|
|
668
|
+
* granted but has not crossed the task execution boundary yet.
|
|
669
|
+
*/
|
|
670
|
+
close(): void {
|
|
671
|
+
if (this.#closed) return;
|
|
672
|
+
this.#closed = true;
|
|
673
|
+
this.#epoch += 1;
|
|
674
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
675
|
+
if (waiter.state !== "waiting") continue;
|
|
676
|
+
waiter.state = "refused";
|
|
677
|
+
waiter.ready.resolve();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Accept later startups after fresh publication ownership has been proven. */
|
|
682
|
+
reopen(): void {
|
|
683
|
+
this.#closed = false;
|
|
684
|
+
this.#grantNext();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
409
687
|
type BrokerPublicationState =
|
|
410
688
|
| "healthy-owned"
|
|
411
689
|
| "suspect-unpublished"
|
|
@@ -417,6 +695,7 @@ type BrokerStopMode = "owned-root" | "lost-root";
|
|
|
417
695
|
const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
|
|
418
696
|
const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
|
|
419
697
|
const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
|
|
698
|
+
const lockArtifactGraceOverridesForTest = new WeakMap<Broker, number>();
|
|
420
699
|
|
|
421
700
|
export class Broker {
|
|
422
701
|
readonly settings: ResolvedBrokerSettings;
|
|
@@ -427,6 +706,7 @@ export class Broker {
|
|
|
427
706
|
#owner = randomBytes(12).toString("hex");
|
|
428
707
|
#chains = new Map<string, Promise<void>>();
|
|
429
708
|
#admitted = new Set<Promise<void>>();
|
|
709
|
+
#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
|
|
430
710
|
#publication: RetainedBrokerDiscovery | null = null;
|
|
431
711
|
#publicationState: BrokerPublicationState = "healthy-owned";
|
|
432
712
|
#lossAt: bigint | null = null;
|
|
@@ -454,6 +734,13 @@ export class Broker {
|
|
|
454
734
|
this.#resolveCompletion = completion.resolve;
|
|
455
735
|
this.#rejectCompletion = completion.reject;
|
|
456
736
|
}
|
|
737
|
+
runStartup<T>(
|
|
738
|
+
queueWaitMs: number,
|
|
739
|
+
timing: StartupAdmissionTiming,
|
|
740
|
+
task: (admittedAt: number) => Promise<T>,
|
|
741
|
+
): Promise<StartupAdmissionResult<T>> {
|
|
742
|
+
return this.#startupAdmissions.run(queueWaitMs, timing, task);
|
|
743
|
+
}
|
|
457
744
|
#lockRecordPath(): string {
|
|
458
745
|
return path.join(this.#lock, BROKER_LOCK_RECORD);
|
|
459
746
|
}
|
|
@@ -565,6 +852,22 @@ export class Broker {
|
|
|
565
852
|
}
|
|
566
853
|
}
|
|
567
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Best-effort startup reap of reclaimed lock tombstones and legacy restart
|
|
857
|
+
* backups. Cleanup debris must never fail an otherwise healthy startup, so a
|
|
858
|
+
* fault here is logged and swallowed.
|
|
859
|
+
*/
|
|
860
|
+
async #reapLockArtifacts(): Promise<void> {
|
|
861
|
+
try {
|
|
862
|
+
await reapStaleBrokerLockArtifacts({
|
|
863
|
+
agentDir: this.settings.agentDir,
|
|
864
|
+
graceMs: lockArtifactGraceOverridesForTest.get(this),
|
|
865
|
+
});
|
|
866
|
+
} catch (error) {
|
|
867
|
+
logger.warn(`sdk broker: stale lock artifact reap failed: ${String(error)}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
568
871
|
async start(): Promise<BrokerDiscovery> {
|
|
569
872
|
if (this.#completionTask) {
|
|
570
873
|
await this.#completionTask;
|
|
@@ -573,6 +876,9 @@ export class Broker {
|
|
|
573
876
|
this.#resolveCompletion = completion.resolve;
|
|
574
877
|
this.#rejectCompletion = completion.reject;
|
|
575
878
|
this.#completionTask = null;
|
|
879
|
+
// A drained queue refuses every later startup by design, so a restarted broker
|
|
880
|
+
// needs a new one or it would admit nothing for the rest of the process.
|
|
881
|
+
this.#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
|
|
576
882
|
}
|
|
577
883
|
this.#stopping = false;
|
|
578
884
|
this.#publicationState = "healthy-owned";
|
|
@@ -590,6 +896,11 @@ export class Broker {
|
|
|
590
896
|
|
|
591
897
|
const live = await readBrokerDiscovery(this.settings.agentDir, this.settings.heartbeatTtlMs);
|
|
592
898
|
if (live) {
|
|
899
|
+
// This process loses the ownership race and its caller only ever sees a
|
|
900
|
+
// clean exit, so name the reason here (#3963).
|
|
901
|
+
logger.info(
|
|
902
|
+
`sdk broker: lock contention, yielding to the live broker owner (ownerId=${live.ownerId}, pid=${live.pid}); this process exits without owning discovery`,
|
|
903
|
+
);
|
|
593
904
|
this.discovery = live;
|
|
594
905
|
return live;
|
|
595
906
|
}
|
|
@@ -598,16 +909,25 @@ export class Broker {
|
|
|
598
909
|
if (snapshot.pid > 0 && isPidAlive(snapshot.pid)) {
|
|
599
910
|
const starting = await this.#waitForBrokerDiscovery();
|
|
600
911
|
if (starting) {
|
|
912
|
+
logger.info(
|
|
913
|
+
`sdk broker: lock contention, yielding to the broker that just started (ownerId=${starting.ownerId}, pid=${starting.pid}); this process exits without owning discovery`,
|
|
914
|
+
);
|
|
601
915
|
this.discovery = starting;
|
|
602
916
|
return starting;
|
|
603
917
|
}
|
|
604
918
|
const current = await this.#readLock();
|
|
605
|
-
if (current && current.identity === snapshot.identity && current.pid > 0 && isPidAlive(current.pid))
|
|
606
|
-
|
|
919
|
+
if (current && current.identity === snapshot.identity && current.pid > 0 && isPidAlive(current.pid)) {
|
|
920
|
+
logger.warn(
|
|
921
|
+
`sdk broker: lock contention, refusing to start because ${this.#lock} is held by live pid ${current.pid} that published no discovery record`,
|
|
922
|
+
);
|
|
923
|
+
throw new Error(`Broker lock is held by a live owner (pid ${current.pid})`);
|
|
924
|
+
}
|
|
607
925
|
continue;
|
|
608
926
|
}
|
|
609
927
|
await this.#reclaimStaleLock(snapshot);
|
|
610
928
|
}
|
|
929
|
+
// Only the lock holder reaps, so concurrent brokers cannot race the removal.
|
|
930
|
+
await this.#reapLockArtifacts();
|
|
611
931
|
try {
|
|
612
932
|
await this.index.open();
|
|
613
933
|
await this.ledger.open();
|
|
@@ -661,6 +981,7 @@ export class Broker {
|
|
|
661
981
|
#fence(kind: "suspect-unpublished" | "observation-ambiguous" | "heartbeat-ambiguous"): void {
|
|
662
982
|
if (this.#publicationState === "stopping") return;
|
|
663
983
|
this.#publicationState = kind;
|
|
984
|
+
this.#startupAdmissions.close();
|
|
664
985
|
if (kind === "suspect-unpublished") {
|
|
665
986
|
this.#lossAt ??= process.hrtime.bigint();
|
|
666
987
|
this.#ambiguousAt = null;
|
|
@@ -693,11 +1014,13 @@ export class Broker {
|
|
|
693
1014
|
return;
|
|
694
1015
|
}
|
|
695
1016
|
if (observation === "owned") {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1017
|
+
// Recover the cached publication state synchronously with the observation
|
|
1018
|
+
// so request admission does not lag behind the awaited heartbeat IO.
|
|
1019
|
+
// The heartbeat write that follows re-checks fresh publication authority
|
|
1020
|
+
// and fences (downgrading this optimistic recovery) if ownership changed
|
|
1021
|
+
// between the observation and the write.
|
|
700
1022
|
this.#publicationState = "healthy-owned";
|
|
1023
|
+
this.#startupAdmissions.reopen();
|
|
701
1024
|
this.#lossAt = null;
|
|
702
1025
|
this.#ambiguousAt = null;
|
|
703
1026
|
if (writeHeartbeat) await this.#writeHeartbeat();
|
|
@@ -718,10 +1041,10 @@ export class Broker {
|
|
|
718
1041
|
this.#fence("heartbeat-ambiguous");
|
|
719
1042
|
return;
|
|
720
1043
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1044
|
+
const recovery = this.runSynchronousEffectWithFreshPublicationAuthority(() => {
|
|
1045
|
+
this.discovery = { ...this.discovery!, heartbeatAt };
|
|
1046
|
+
});
|
|
1047
|
+
if (!recovery.authorized) return;
|
|
725
1048
|
}
|
|
726
1049
|
async heartbeat(): Promise<void> {
|
|
727
1050
|
if (this.#publicationState !== "healthy-owned") return;
|
|
@@ -731,6 +1054,13 @@ export class Broker {
|
|
|
731
1054
|
if (this.#completionTask) return this.#completionTask;
|
|
732
1055
|
this.#stopping = true;
|
|
733
1056
|
this.#publicationState = "stopping";
|
|
1057
|
+
// A lost-root broker has been fenced: it no longer owns the published root, and
|
|
1058
|
+
// its settlement is bounded, so any startup still queued behind it would be
|
|
1059
|
+
// granted after completion and spawn a child the broker has no authority over.
|
|
1060
|
+
// An owned-root stop keeps the queue open on purpose: the broker still owns
|
|
1061
|
+
// everything it admitted, and completion waits unbounded for those startups, so
|
|
1062
|
+
// draining would abandon work that is about to finish correctly.
|
|
1063
|
+
if (mode === "lost-root") this.#startupAdmissions.close();
|
|
734
1064
|
if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
|
|
735
1065
|
this.#heartbeatTimer = null;
|
|
736
1066
|
this.#completionTask = (async () => {
|
|
@@ -757,8 +1087,53 @@ export class Broker {
|
|
|
757
1087
|
void this.#completionTask.then(this.#resolveCompletion, this.#rejectCompletion);
|
|
758
1088
|
return this.#completionTask;
|
|
759
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Fresh, uncached proof that this broker still publishes the discovery root.
|
|
1092
|
+
* The cached state is not proof: the watchdog observes on a cadence, so a
|
|
1093
|
+
* replacement can already be on disk without having been seen yet.
|
|
1094
|
+
*/
|
|
1095
|
+
#provenOwnedRoot(): boolean {
|
|
1096
|
+
if (!this.#publication || this.#publicationState !== "healthy-owned") return false;
|
|
1097
|
+
try {
|
|
1098
|
+
return (publicationObservationOverridesForTest.get(this) ?? this.#publication.observe()) === "owned";
|
|
1099
|
+
} catch {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Revalidate retained publication ownership and begin one synchronous effect in
|
|
1105
|
+
* the same stack. The callback is the authority boundary: callers must perform
|
|
1106
|
+
* the authorized effect inside it, so no awaited work can separate proof from
|
|
1107
|
+
* the effect it authorizes.
|
|
1108
|
+
*/
|
|
1109
|
+
runSynchronousEffectWithFreshPublicationAuthority<T>(
|
|
1110
|
+
effect: () => T,
|
|
1111
|
+
..._synchronousOnly: T extends PromiseLike<unknown> ? [never] : []
|
|
1112
|
+
): { authorized: true; value: T } | { authorized: false } {
|
|
1113
|
+
if (!this.#publication || this.#publicationState === "stopping") return { authorized: false };
|
|
1114
|
+
let observation: BrokerPublicationObservation;
|
|
1115
|
+
try {
|
|
1116
|
+
observation = publicationObservationOverridesForTest.get(this) ?? this.#publication.observe();
|
|
1117
|
+
} catch {
|
|
1118
|
+
this.#fence("observation-ambiguous");
|
|
1119
|
+
if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
|
|
1120
|
+
return { authorized: false };
|
|
1121
|
+
}
|
|
1122
|
+
if (observation !== "owned") {
|
|
1123
|
+
this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
|
|
1124
|
+
if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
|
|
1125
|
+
return { authorized: false };
|
|
1126
|
+
}
|
|
1127
|
+
return { authorized: true, value: effect() };
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* A stop may take the owning path only while it can prove it still owns the root.
|
|
1131
|
+
* Claiming ownership it cannot prove keeps the admission queue open, so a startup
|
|
1132
|
+
* queued behind this broker is granted a slot that frees after completion and
|
|
1133
|
+
* spawns a child the broker has no authority over.
|
|
1134
|
+
*/
|
|
760
1135
|
async stop(): Promise<void> {
|
|
761
|
-
await this.#complete("owned-root");
|
|
1136
|
+
await this.#complete(this.#provenOwnedRoot() ? "owned-root" : "lost-root");
|
|
762
1137
|
}
|
|
763
1138
|
async #endpoint(input: Record<string, unknown>): Promise<BrokerResponse> {
|
|
764
1139
|
const sessionId = input.sessionId;
|
|
@@ -1008,6 +1383,12 @@ export function setAmbiguityGraceForTest(broker: Broker, graceMs: number | undef
|
|
|
1008
1383
|
else ambiguityGraceOverridesForTest.set(broker, graceMs);
|
|
1009
1384
|
}
|
|
1010
1385
|
|
|
1386
|
+
/** Test-only hook for shortening the startup lock-artifact reap bound. */
|
|
1387
|
+
export function setLockArtifactGraceForTest(broker: Broker, graceMs: number | undefined): void {
|
|
1388
|
+
if (graceMs === undefined) lockArtifactGraceOverridesForTest.delete(broker);
|
|
1389
|
+
else lockArtifactGraceOverridesForTest.set(broker, graceMs);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1011
1392
|
/** Test-only hook for forcing the observation the publication watchdog sees. */
|
|
1012
1393
|
export function setPublicationObservationForTest(
|
|
1013
1394
|
broker: Broker,
|