@gajae-code/coding-agent 0.13.0 → 0.13.2
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 +34 -0
- package/dist/types/config/file-lock.d.ts +2 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +17 -5
- package/dist/types/config/settings.d.ts +10 -1
- package/dist/types/edit/streaming.d.ts +5 -5
- package/dist/types/eval/py/kernel.d.ts +2 -2
- package/dist/types/eval/py/runtime.d.ts +5 -1
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
- package/dist/types/modes/components/model-selector.d.ts +3 -2
- package/dist/types/runtime/process-lifecycle.d.ts +12 -31
- package/dist/types/sdk/broker/identity.d.ts +2 -1
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
- package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
- package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
- package/dist/types/sdk/host/query/cursor.d.ts +4 -0
- package/dist/types/sdk/host/query/handlers.d.ts +6 -0
- package/dist/types/sdk/host/session-runtime.d.ts +7 -6
- package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/turn-result.d.ts +37 -0
- package/dist/types/session/agent-session.d.ts +8 -11
- package/dist/types/session/blob-store.d.ts +6 -3
- package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
- package/dist/types/task/executor.d.ts +4 -7
- package/dist/types/tools/index.d.ts +1 -1
- package/dist/types/tools/read.d.ts +1 -1
- package/dist/types/tools/search.d.ts +1 -1
- package/dist/types/utils/edit-mode.d.ts +69 -0
- package/package.json +8 -8
- package/scripts/generate-hotkeys-docs.ts +8 -6
- package/src/commands/sdk.ts +23 -2
- package/src/config/file-lock.ts +25 -5
- package/src/config/keybindings.ts +4 -2
- package/src/config/model-registry.ts +20 -0
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +29 -22
- package/src/config/settings.ts +42 -10
- package/src/coordinator-mcp/server.ts +87 -106
- package/src/edit/index.ts +5 -19
- package/src/edit/renderer.ts +3 -2
- package/src/edit/streaming.ts +28 -15
- package/src/eval/py/executor.ts +108 -10
- package/src/eval/py/kernel.ts +86 -12
- package/src/eval/py/runtime.ts +200 -16
- package/src/exec/bash-executor.ts +36 -37
- package/src/gjc-runtime/gc-runtime.ts +25 -3
- package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
- package/src/gjc-runtime/session-state-sidecar.ts +110 -4
- package/src/gjc-runtime/tmux-sessions.ts +34 -5
- package/src/internal-urls/docs-index.generated.ts +9 -9
- package/src/modes/acp/acp-agent.ts +10 -2
- package/src/modes/components/model-selector.ts +28 -8
- package/src/modes/controllers/plan-mode-controller.ts +3 -3
- package/src/modes/controllers/selector-controller.ts +4 -2
- package/src/modes/interactive-mode.ts +4 -0
- package/src/runtime/process-lifecycle.ts +126 -27
- package/src/runtime-mcp/transports/stdio.ts +11 -6
- package/src/sdk/acp/adapter.ts +18 -10
- package/src/sdk/broker/broker.ts +55 -4
- package/src/sdk/broker/ensure.ts +10 -7
- package/src/sdk/broker/identity.ts +10 -0
- package/src/sdk/broker/lifecycle-ledger.ts +56 -6
- package/src/sdk/broker/lifecycle.ts +53 -10
- package/src/sdk/broker/process-incarnation.ts +6 -1
- package/src/sdk/broker/session-index.ts +28 -11
- package/src/sdk/broker/startup-failure.ts +101 -0
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
- package/src/sdk/bus/conversation-store.ts +171 -50
- package/src/sdk/bus/index.ts +101 -47
- package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
- package/src/sdk/bus/notification-service.ts +3 -3
- package/src/sdk/bus/reconciliation-store.ts +16 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +13 -2
- package/src/sdk/bus/telegram-daemon.ts +151 -41
- package/src/sdk/bus/topic-registry.ts +38 -9
- package/src/sdk/cli/session-cli.ts +20 -7
- package/src/sdk/client/discovery.ts +23 -2
- package/src/sdk/host/query/cursor.ts +4 -0
- package/src/sdk/host/query/handlers.ts +99 -100
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +98 -1
- package/src/sdk/host/session-runtime.ts +57 -15
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
- package/src/sdk/host/websocket-transport.ts +54 -30
- package/src/sdk/index.ts +1 -0
- package/src/sdk/mcp/server.ts +19 -6
- package/src/sdk/protocol/operation-inventory.generated.json +1 -1
- package/src/sdk/protocol/operation-registry.ts +8 -12
- package/src/sdk/session.ts +5 -14
- package/src/sdk/turn-result.ts +53 -0
- package/src/session/agent-session.ts +112 -69
- package/src/session/blob-store.ts +91 -21
- package/src/session/internal/managed-session-storage.ts +136 -21
- package/src/session/session-manager.ts +408 -81
- package/src/session/session-storage.ts +68 -10
- package/src/slash-commands/builtin-registry.ts +43 -5
- package/src/slash-commands/helpers/fast-status-report.ts +24 -8
- package/src/task/executor.ts +25 -8
- package/src/task/index.ts +5 -4
- package/src/tools/bash.ts +11 -9
- package/src/tools/index.ts +1 -1
- package/src/tools/read.ts +12 -11
- package/src/tools/search.ts +7 -7
- package/src/utils/edit-mode.ts +180 -9
- package/src/web/search/providers/insane.ts +55 -1
|
@@ -66,6 +66,8 @@ export interface LifecycleDurableEffectsReceipt {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export interface LifecycleLedgerEntry {
|
|
69
|
+
operationKey?: string;
|
|
70
|
+
fingerprint?: string;
|
|
69
71
|
version: typeof SDK_STATE_VERSION;
|
|
70
72
|
identity: string;
|
|
71
73
|
requestHash: string;
|
|
@@ -153,7 +155,9 @@ function isLifecycleLedgerEntry(value: unknown): value is LifecycleLedgerEntry {
|
|
|
153
155
|
entry.state === "terminal_error" ||
|
|
154
156
|
entry.state === "terminal_uncertain") &&
|
|
155
157
|
typeof entry.ts === "number" &&
|
|
156
|
-
Number.isSafeInteger(entry.ts)
|
|
158
|
+
Number.isSafeInteger(entry.ts) &&
|
|
159
|
+
(entry.operationKey === undefined || typeof entry.operationKey === "string") &&
|
|
160
|
+
(entry.fingerprint === undefined || typeof entry.fingerprint === "string")
|
|
157
161
|
);
|
|
158
162
|
}
|
|
159
163
|
function canonicalJson(value: unknown): string {
|
|
@@ -661,6 +665,35 @@ export class LifecycleLedger {
|
|
|
661
665
|
}
|
|
662
666
|
return replacement !== undefined;
|
|
663
667
|
}
|
|
668
|
+
findByOperationKey(operationKey: string): LifecycleLedgerEntry | undefined {
|
|
669
|
+
return [...this.#byIdentity.values()].find(entry => entry.operationKey === operationKey);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Legacy target-inclusive rows predate the operation/key index. Their opaque
|
|
673
|
+
* identities cannot establish that a different target is safe, so callers
|
|
674
|
+
* must reject rather than create a second admission.
|
|
675
|
+
*/
|
|
676
|
+
hasLegacyIdentity(): boolean {
|
|
677
|
+
return [...this.#byIdentity.values()].some(entry => entry.operationKey === undefined);
|
|
678
|
+
}
|
|
679
|
+
async migrateIdentity(
|
|
680
|
+
from: string,
|
|
681
|
+
to: string,
|
|
682
|
+
metadata: { operationKey: string; fingerprint: string },
|
|
683
|
+
): Promise<LifecycleLedgerEntry | undefined> {
|
|
684
|
+
return this.#mutate(async () => {
|
|
685
|
+
if (this.#byIdentity.has(to)) return this.#byIdentity.get(to);
|
|
686
|
+
const entry = this.#byIdentity.get(from);
|
|
687
|
+
if (!entry) return undefined;
|
|
688
|
+
const migrated = await this.#append({ ...entry, identity: to, ...metadata, ts: Date.now() });
|
|
689
|
+
// Retire the legacy row so hasLegacyIdentity() returns false and future
|
|
690
|
+
// unrelated lifecycle requests are not globally blocked. The legacy
|
|
691
|
+
// identity gets a metadata-bearing replacement row in the append-only
|
|
692
|
+
// log, superseding the original metadata-free entry in #byIdentity.
|
|
693
|
+
if (entry.operationKey === undefined) await this.#append({ ...entry, ...metadata, ts: Date.now() });
|
|
694
|
+
return migrated;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
664
697
|
get warnings(): readonly string[] {
|
|
665
698
|
return this.#warnings;
|
|
666
699
|
}
|
|
@@ -687,11 +720,19 @@ export class LifecycleLedger {
|
|
|
687
720
|
this.#byteCount += line.length;
|
|
688
721
|
return entry;
|
|
689
722
|
}
|
|
690
|
-
async begin(
|
|
691
|
-
|
|
723
|
+
async begin(
|
|
724
|
+
identity: string,
|
|
725
|
+
requestHash: string,
|
|
726
|
+
metadata: { operationKey?: string; fingerprint?: string } = {},
|
|
727
|
+
): Promise<BeginResult> {
|
|
728
|
+
return this.#mutate(async () => this.#begin(identity, requestHash, metadata));
|
|
692
729
|
}
|
|
693
730
|
|
|
694
|
-
async #begin(
|
|
731
|
+
async #begin(
|
|
732
|
+
identity: string,
|
|
733
|
+
requestHash: string,
|
|
734
|
+
metadata: { operationKey?: string; fingerprint?: string },
|
|
735
|
+
): Promise<BeginResult> {
|
|
695
736
|
const prior = this.#byIdentity.get(identity);
|
|
696
737
|
if (!prior)
|
|
697
738
|
return {
|
|
@@ -700,15 +741,24 @@ export class LifecycleLedger {
|
|
|
700
741
|
version: SDK_STATE_VERSION,
|
|
701
742
|
identity,
|
|
702
743
|
requestHash,
|
|
744
|
+
operationKey: metadata.operationKey,
|
|
745
|
+
fingerprint: metadata.fingerprint,
|
|
703
746
|
state: "accepted",
|
|
704
747
|
ts: Date.now(),
|
|
705
748
|
}),
|
|
706
749
|
};
|
|
707
|
-
if (
|
|
750
|
+
if (
|
|
751
|
+
prior.requestHash !== requestHash ||
|
|
752
|
+
(prior.operationKey !== undefined &&
|
|
753
|
+
metadata.operationKey !== undefined &&
|
|
754
|
+
prior.operationKey !== metadata.operationKey)
|
|
755
|
+
)
|
|
756
|
+
return { kind: "idempotency_conflict" };
|
|
708
757
|
if (terminal(prior.state) || (prior.state === "effect_started" && this.#isCleanupPending(prior)))
|
|
709
758
|
return { kind: "replay", entry: prior };
|
|
710
759
|
if (prior.state === "terminal_uncertain") return { kind: "terminal_uncertain", entry: prior };
|
|
711
|
-
//
|
|
760
|
+
// Accepted proves admission only. The effect_started marker is the boundary
|
|
761
|
+
// after which retrying the operation is unsafe.
|
|
712
762
|
if (prior.state === "accepted") return { kind: "new", entry: prior };
|
|
713
763
|
return { kind: "in_progress", entry: prior };
|
|
714
764
|
}
|
|
@@ -89,6 +89,8 @@ export {
|
|
|
89
89
|
|
|
90
90
|
const POLL_MS = 50;
|
|
91
91
|
const CLOSE_TIMEOUT_MS = 2_000;
|
|
92
|
+
const STARTUP_CLEANUP_PROOF_GRACE_MS = 500;
|
|
93
|
+
|
|
92
94
|
const MAX_RECEIVED_AT_SKEW_MS = 5_000;
|
|
93
95
|
const MAX_LIFECYCLE_METADATA_BYTES = 4096;
|
|
94
96
|
const MAX_EFFECT_MARKER_LENGTH = 128;
|
|
@@ -1957,6 +1959,7 @@ type SignalTarget = {
|
|
|
1957
1959
|
pid: number;
|
|
1958
1960
|
lifecycleRequestId?: string;
|
|
1959
1961
|
processIncarnation?: string;
|
|
1962
|
+
hostIncarnation?: string;
|
|
1960
1963
|
};
|
|
1961
1964
|
|
|
1962
1965
|
/**
|
|
@@ -1979,7 +1982,11 @@ async function hasDurableProcessIdentity(target: SignalTarget, id: string, expec
|
|
|
1979
1982
|
marker.incarnation === processIncarnation(target.pid)
|
|
1980
1983
|
);
|
|
1981
1984
|
if (expected && (expected.pid !== target.pid || target.lifecycleRequestId !== expected.effectMarker)) return false;
|
|
1982
|
-
|
|
1985
|
+
// Hosts that pass an explicit incarnation record it as `processIncarnation`;
|
|
1986
|
+
// the index's append path auto-stamps `hostIncarnation` for everything else.
|
|
1987
|
+
// Either binding is the same pid-to-incarnation proof.
|
|
1988
|
+
const recorded = target.hostIncarnation ?? target.processIncarnation;
|
|
1989
|
+
return recorded !== undefined && recorded === processIncarnation(target.pid);
|
|
1983
1990
|
}
|
|
1984
1991
|
|
|
1985
1992
|
async function hasOwnedReadinessEvidence(
|
|
@@ -3062,7 +3069,9 @@ async function revalidateCloseGeneration(
|
|
|
3062
3069
|
function isTransportFailure(error: unknown): error is SdkClientError {
|
|
3063
3070
|
return (
|
|
3064
3071
|
error instanceof SdkClientError &&
|
|
3065
|
-
["unavailable", "timeout", "connection_closed", "reconnect_exhausted"].includes(
|
|
3072
|
+
["unavailable", "timeout", "connection_closed", "uncertain_after_send", "reconnect_exhausted"].includes(
|
|
3073
|
+
error.code,
|
|
3074
|
+
)
|
|
3066
3075
|
);
|
|
3067
3076
|
}
|
|
3068
3077
|
|
|
@@ -3438,6 +3447,11 @@ async function executeLifecycleResponse(
|
|
|
3438
3447
|
let record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
|
|
3439
3448
|
if (operation === "session.close") {
|
|
3440
3449
|
if (!record) return fail("not_found", "session is not indexed");
|
|
3450
|
+
// DR-1 retains closed/unregistered rows for inspect and offline tail.
|
|
3451
|
+
// Pre-DR-1 they vanished from the listing and close answered not_found;
|
|
3452
|
+
// a terminal row is the same already-gone outcome, never an escalation
|
|
3453
|
+
// target — signalling its recorded pid could hit a reused process.
|
|
3454
|
+
if (record.terminal) return fail("resource_gone", "Session is already closed and unregistered.");
|
|
3441
3455
|
if (record.terminalUncertain)
|
|
3442
3456
|
return fail("terminal_uncertain", "Session ownership is uncertain and cannot be closed safely.");
|
|
3443
3457
|
const requestedAuthority = requestedCloseAuthority(input);
|
|
@@ -4248,6 +4262,23 @@ async function exactCleanupProof(
|
|
|
4248
4262
|
: undefined;
|
|
4249
4263
|
}
|
|
4250
4264
|
|
|
4265
|
+
async function waitForExactCleanupProof(
|
|
4266
|
+
broker: Broker,
|
|
4267
|
+
root: string | undefined,
|
|
4268
|
+
id: string | undefined,
|
|
4269
|
+
expected: EffectMarker | undefined,
|
|
4270
|
+
evidence: { artifact: LifecycleFailureArtifact } | undefined,
|
|
4271
|
+
): Promise<LifecycleCleanupProof | undefined> {
|
|
4272
|
+
const timing = lifecycleTiming(broker);
|
|
4273
|
+
const deadline = timing.now() + STARTUP_CLEANUP_PROOF_GRACE_MS;
|
|
4274
|
+
for (;;) {
|
|
4275
|
+
const proof = await exactCleanupProof(broker, root, id, expected, evidence);
|
|
4276
|
+
if (proof) return proof;
|
|
4277
|
+
const remaining = deadline - timing.now();
|
|
4278
|
+
if (remaining <= 0) return undefined;
|
|
4279
|
+
await timing.sleep(Math.min(POLL_MS, remaining));
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4251
4282
|
function validateLifecycleDeleteMetadataBinding(
|
|
4252
4283
|
broker: Broker,
|
|
4253
4284
|
operation: string,
|
|
@@ -4361,7 +4392,7 @@ export async function executeLifecycle(
|
|
|
4361
4392
|
expected,
|
|
4362
4393
|
)
|
|
4363
4394
|
: undefined;
|
|
4364
|
-
const cleanupProof = await
|
|
4395
|
+
const cleanupProof = await waitForExactCleanupProof(broker, root, entry?.intendedSessionId, expected, evidence);
|
|
4365
4396
|
const startupFailure: LifecycleStartupFailureReceipt | undefined = evidence
|
|
4366
4397
|
? {
|
|
4367
4398
|
artifactDigest: evidence.digest,
|
|
@@ -4580,18 +4611,30 @@ export interface ReapedSessionRegistration {
|
|
|
4580
4611
|
/**
|
|
4581
4612
|
* Drops every indexed session registration whose host process is provably gone.
|
|
4582
4613
|
*
|
|
4583
|
-
*
|
|
4584
|
-
*
|
|
4585
|
-
*
|
|
4586
|
-
*
|
|
4587
|
-
*
|
|
4588
|
-
*
|
|
4614
|
+
* Proof of death is positive, never inferred from a missing liveness proof:
|
|
4615
|
+
* `observeProcess` reports "exited" only on ESRCH or on a readable OS process
|
|
4616
|
+
* incarnation that differs from the recorded one (a reused pid). A stale or
|
|
4617
|
+
* missing heartbeat merely makes a session read as not-live — the host may
|
|
4618
|
+
* still be running ahead of the next heartbeat checkpoint pass, so it is never
|
|
4619
|
+
* grounds for a reap. EPERM and unreadable incarnations stay "uncertain", so an
|
|
4620
|
+
* alien or unreadable process is never mistaken for a dead one. Registrations
|
|
4621
|
+
* already terminal or marked `terminalUncertain` are left alone: their
|
|
4622
|
+
* disposition belongs to the terminal-uncertainty reconciliation path, and
|
|
4623
|
+
* silently dropping them would turn a fail-closed `session.close`/
|
|
4624
|
+
* `session.delete` refusal into `not_found`.
|
|
4589
4625
|
*/
|
|
4590
4626
|
export async function reapDeadSessionRegistrations(
|
|
4591
4627
|
broker: Pick<Broker, "index">,
|
|
4592
4628
|
): Promise<ReapedSessionRegistration[]> {
|
|
4593
4629
|
await broker.index.refresh();
|
|
4594
|
-
const dead = broker.index
|
|
4630
|
+
const dead = broker.index
|
|
4631
|
+
.listSessions()
|
|
4632
|
+
.sessions.filter(
|
|
4633
|
+
session =>
|
|
4634
|
+
!session.terminal &&
|
|
4635
|
+
!session.terminalUncertain &&
|
|
4636
|
+
observeProcess(session.pid, session.hostIncarnation ?? session.processIncarnation) === "exited",
|
|
4637
|
+
);
|
|
4595
4638
|
const reaped: ReapedSessionRegistration[] = [];
|
|
4596
4639
|
for (const session of dead) {
|
|
4597
4640
|
await broker.index.append({
|
|
@@ -40,7 +40,12 @@ export interface ProcessIncarnationOptions {
|
|
|
40
40
|
|
|
41
41
|
function runProcessIncarnationCommand(command: string, args: readonly string[]): ProcessIncarnationCommandResult {
|
|
42
42
|
try {
|
|
43
|
-
const result = Bun.spawnSync([command, ...args], {
|
|
43
|
+
const result = Bun.spawnSync([command, ...args], {
|
|
44
|
+
stdin: "ignore",
|
|
45
|
+
stdout: "pipe",
|
|
46
|
+
stderr: "ignore",
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
44
49
|
return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout).toString("utf8") };
|
|
45
50
|
} catch {
|
|
46
51
|
return undefined;
|
|
@@ -278,8 +278,10 @@ function auditRecords(events: SessionIndexEvent[], ts: number): SessionIndexAudi
|
|
|
278
278
|
* highest (generation, indexSeq); only `session_deleted` hides a row (DR-1 retains
|
|
279
279
|
* stopped/terminal credential-free rows so inspect/offline tail can work). Heartbeats
|
|
280
280
|
* inherit locator/endpoint metadata from their identity's prior event. Liveness (C2)
|
|
281
|
-
* requires a heartbeat
|
|
282
|
-
*
|
|
281
|
+
* requires host-written evidence — a heartbeat, or the host's own `host_registered`
|
|
282
|
+
* append — observed within 2x the checkpoint interval AND a live host whose OS
|
|
283
|
+
* incarnation still matches: a missing or stale evidence trail (e.g. after a broker
|
|
284
|
+
* restart) is unknown, never fresh forever.
|
|
283
285
|
*/
|
|
284
286
|
function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[] {
|
|
285
287
|
const { admitted } = admitEvents(events);
|
|
@@ -325,7 +327,15 @@ function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[
|
|
|
325
327
|
if (latest.type === "session_deleted") continue;
|
|
326
328
|
const heartbeat = latestHeartbeatByIdentity.get(chosen.identity);
|
|
327
329
|
const pidAlive = alive(latest.pid);
|
|
328
|
-
|
|
330
|
+
// Liveness evidence is host-written: a checkpointed heartbeat, or the
|
|
331
|
+
// `host_registered` event the host appended itself. Counting registration
|
|
332
|
+
// closes the up-to-one-interval window where a just-registered session
|
|
333
|
+
// would read not-live before the first C2 pass — without weakening the
|
|
334
|
+
// pid-reuse fence, because the incarnation match below is still required.
|
|
335
|
+
// Broker-written events (reconciliation, terminal records) are not host
|
|
336
|
+
// evidence and never refresh liveness.
|
|
337
|
+
const evidenceTs = Math.max(heartbeat?.ts ?? 0, latest.type === "host_registered" ? latest.ts : 0);
|
|
338
|
+
const heartbeatFresh = evidenceTs > 0 && now - evidenceTs < 2 * SESSION_HEARTBEAT_INTERVAL_MS;
|
|
329
339
|
const recordedIncarnation = effectiveIncarnation(latest);
|
|
330
340
|
const currentIncarnation = recordedIncarnation === undefined ? undefined : processIncarnation(latest.pid);
|
|
331
341
|
const incarnationMatches = currentIncarnation !== undefined && currentIncarnation === recordedIncarnation;
|
|
@@ -351,6 +361,13 @@ function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[
|
|
|
351
361
|
return sessions;
|
|
352
362
|
}
|
|
353
363
|
|
|
364
|
+
// Global launch bursts may queue behind legitimate long index transactions. Keep
|
|
365
|
+
// this bounded at one minute while the shared lock's exact dead-owner recovery runs.
|
|
366
|
+
const SESSION_INDEX_LOCK_OPTIONS = { retries: 600, retryDelayMs: 100 } as const;
|
|
367
|
+
|
|
368
|
+
function withSessionIndexLock<T>(agentDir: string, callback: () => Promise<T>): Promise<T> {
|
|
369
|
+
return withFileLock(logFor(agentDir), callback, SESSION_INDEX_LOCK_OPTIONS);
|
|
370
|
+
}
|
|
354
371
|
function isValidSnapshot(snapshot: unknown): snapshot is { indexSeq: number; events: SessionIndexEvent[] } {
|
|
355
372
|
if (!snapshot || typeof snapshot !== "object") return false;
|
|
356
373
|
const { indexSeq, events } = snapshot as { indexSeq?: unknown; events?: unknown };
|
|
@@ -557,7 +574,7 @@ export class SessionIndex {
|
|
|
557
574
|
group.promise = SessionIndex.#enqueue(indexPath, () => this.#prepareOpenGroup(indexPath, group!));
|
|
558
575
|
}
|
|
559
576
|
await group.promise;
|
|
560
|
-
await SessionIndex.#enqueue(indexPath, () =>
|
|
577
|
+
await SessionIndex.#enqueue(indexPath, () => withSessionIndexLock(this.#agentDir, () => this.#replayUnderLock()));
|
|
561
578
|
return this;
|
|
562
579
|
}
|
|
563
580
|
async #prepareOpenGroup(indexPath: string, group: SessionIndexOpenGroup): Promise<void> {
|
|
@@ -571,7 +588,7 @@ export class SessionIndex {
|
|
|
571
588
|
}
|
|
572
589
|
async replay(): Promise<void> {
|
|
573
590
|
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
574
|
-
await SessionIndex.#enqueue(indexPath, () =>
|
|
591
|
+
await SessionIndex.#enqueue(indexPath, () => withSessionIndexLock(this.#agentDir, () => this.#replayUnderLock()));
|
|
575
592
|
}
|
|
576
593
|
async #replayUnderLock(): Promise<void> {
|
|
577
594
|
const scan = await this.#scan();
|
|
@@ -762,14 +779,14 @@ export class SessionIndex {
|
|
|
762
779
|
}),
|
|
763
780
|
);
|
|
764
781
|
if (!exists.some(Boolean)) return { status: "healthy", validPrefixSeq: 0, snapshotSeq: 0 };
|
|
765
|
-
return await
|
|
782
|
+
return await withSessionIndexLock(this.#agentDir, async () => (await this.#scan()).diagnosis);
|
|
766
783
|
});
|
|
767
784
|
}
|
|
768
785
|
async repair(): Promise<SessionIndexRepairResult> {
|
|
769
786
|
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
770
787
|
return await SessionIndex.#enqueue(indexPath, async () => {
|
|
771
788
|
await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
|
|
772
|
-
return await
|
|
789
|
+
return await withSessionIndexLock(this.#agentDir, async () => {
|
|
773
790
|
const scan = await this.#scan();
|
|
774
791
|
if (scan.diagnosis.status === "unsupported") return { ...scan.diagnosis, repaired: false };
|
|
775
792
|
if (scan.diagnosis.status === "healthy") return { ...scan.diagnosis, repaired: false };
|
|
@@ -852,7 +869,7 @@ export class SessionIndex {
|
|
|
852
869
|
async refresh(): Promise<void> {
|
|
853
870
|
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
854
871
|
await SessionIndex.#enqueue(indexPath, () =>
|
|
855
|
-
|
|
872
|
+
withSessionIndexLock(this.#agentDir, () => this.#refreshUnderLock()),
|
|
856
873
|
);
|
|
857
874
|
}
|
|
858
875
|
async #refreshUnderLock(): Promise<void> {
|
|
@@ -869,7 +886,7 @@ export class SessionIndex {
|
|
|
869
886
|
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
870
887
|
return await SessionIndex.#enqueue(indexPath, async () => {
|
|
871
888
|
await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
|
|
872
|
-
return await
|
|
889
|
+
return await withSessionIndexLock(this.#agentDir, async () => {
|
|
873
890
|
await this.#replayUnderLock();
|
|
874
891
|
if (this.#corruptSuffix)
|
|
875
892
|
throw new Error(
|
|
@@ -905,7 +922,7 @@ export class SessionIndex {
|
|
|
905
922
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return await callback();
|
|
906
923
|
throw error;
|
|
907
924
|
}
|
|
908
|
-
return await
|
|
925
|
+
return await withSessionIndexLock(this.#agentDir, async () => {
|
|
909
926
|
await this.#replayUnderLock();
|
|
910
927
|
if (this.#corruptSuffix) throw new Error("Cannot use corrupt session index for artifact reclamation");
|
|
911
928
|
return await callback();
|
|
@@ -915,7 +932,7 @@ export class SessionIndex {
|
|
|
915
932
|
async snapshot(): Promise<void> {
|
|
916
933
|
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
917
934
|
await SessionIndex.#enqueue(indexPath, () =>
|
|
918
|
-
|
|
935
|
+
withSessionIndexLock(this.#agentDir, () => this.#snapshotUnderLock()),
|
|
919
936
|
);
|
|
920
937
|
}
|
|
921
938
|
async #snapshotUnderLock(): Promise<void> {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface BrokerStartupFailureMarker {
|
|
5
|
+
version: 1;
|
|
6
|
+
reason: string;
|
|
7
|
+
exitCode: number | null;
|
|
8
|
+
signal: string | null;
|
|
9
|
+
writtenAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const BROKER_STARTUP_FAILURE_FILE = "broker.startup-failure.json";
|
|
13
|
+
const MAX_BROKER_STARTUP_FAILURE_REASON = 512;
|
|
14
|
+
|
|
15
|
+
export function brokerStartupFailurePath(agentDir: string): string {
|
|
16
|
+
return path.join(agentDir, "sdk", BROKER_STARTUP_FAILURE_FILE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function boundedMarker(reason: string, exitCode: number | null, signal: string | null): BrokerStartupFailureMarker {
|
|
20
|
+
return {
|
|
21
|
+
version: 1,
|
|
22
|
+
reason: reason.slice(0, MAX_BROKER_STARTUP_FAILURE_REASON),
|
|
23
|
+
exitCode,
|
|
24
|
+
signal,
|
|
25
|
+
writtenAt: Date.now(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Best-effort durable marker write; never throws (diagnostics must not mask the exit itself). */
|
|
30
|
+
export async function writeBrokerStartupFailureMarker(
|
|
31
|
+
agentDir: string,
|
|
32
|
+
failure: { reason: string; exitCode: number | null; signal: string | null },
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
await fs.mkdir(path.dirname(brokerStartupFailurePath(agentDir)), { recursive: true, mode: 0o700 });
|
|
36
|
+
await Bun.write(
|
|
37
|
+
brokerStartupFailurePath(agentDir),
|
|
38
|
+
JSON.stringify(boundedMarker(failure.reason, failure.exitCode, failure.signal)),
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
// Best-effort only.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reads a bounded startup-failure marker; `undefined` when absent or malformed. */
|
|
46
|
+
export async function readBrokerStartupFailureMarker(
|
|
47
|
+
agentDir: string,
|
|
48
|
+
): Promise<BrokerStartupFailureMarker | undefined> {
|
|
49
|
+
try {
|
|
50
|
+
const file = Bun.file(brokerStartupFailurePath(agentDir));
|
|
51
|
+
if (!(await file.exists())) return undefined;
|
|
52
|
+
const raw = await file.text();
|
|
53
|
+
const value: unknown = JSON.parse(raw);
|
|
54
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
55
|
+
const marker = value as Partial<BrokerStartupFailureMarker>;
|
|
56
|
+
if (
|
|
57
|
+
marker.version !== 1 ||
|
|
58
|
+
typeof marker.reason !== "string" ||
|
|
59
|
+
marker.reason.length === 0 ||
|
|
60
|
+
(marker.exitCode !== null && typeof marker.exitCode !== "number") ||
|
|
61
|
+
(marker.signal !== null && typeof marker.signal !== "string") ||
|
|
62
|
+
typeof marker.writtenAt !== "number"
|
|
63
|
+
)
|
|
64
|
+
return undefined;
|
|
65
|
+
return marker as BrokerStartupFailureMarker;
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Best-effort marker removal; used at spawn so a stale marker never misattributes an older failure. */
|
|
72
|
+
export async function clearBrokerStartupFailureMarker(agentDir: string): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
const file = Bun.file(brokerStartupFailurePath(agentDir));
|
|
75
|
+
if (await file.exists()) await file.unlink();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
78
|
+
// Best-effort only.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Typed error returned when a detached broker exits before discovery. */
|
|
84
|
+
export class BrokerStartupError extends Error {
|
|
85
|
+
readonly code = "broker_startup_failed";
|
|
86
|
+
readonly exitCode: number | null;
|
|
87
|
+
readonly signal: string | null;
|
|
88
|
+
readonly reason: string;
|
|
89
|
+
readonly stderrExcerpt?: string;
|
|
90
|
+
|
|
91
|
+
constructor(fields: { exitCode: number | null; signal: string | null; reason: string; stderrExcerpt?: string }) {
|
|
92
|
+
super(
|
|
93
|
+
`Detached SDK broker exited before discovery (code=${fields.exitCode}, signal=${fields.signal}): ${fields.reason}${fields.stderrExcerpt ? ` Broker stderr: ${fields.stderrExcerpt}` : ""}`,
|
|
94
|
+
);
|
|
95
|
+
this.name = "BrokerStartupError";
|
|
96
|
+
this.exitCode = fields.exitCode;
|
|
97
|
+
this.signal = fields.signal;
|
|
98
|
+
this.reason = fields.reason;
|
|
99
|
+
this.stderrExcerpt = fields.stderrExcerpt;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -72,11 +72,12 @@ export type ChatDaemonAction = "stop" | "reload";
|
|
|
72
72
|
* before the host heartbeat TTL expires and permanently loses its attachment. Discord
|
|
73
73
|
* generation 29 / slack generation 28 advance the replay cursor only after a frame is
|
|
74
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.
|
|
75
|
+
* it and loses that event for good the first time a surface refuses it. Discord generation 30 /
|
|
76
|
+
* slack generation 29 invalidate existing owners after SDK discovery validation changes.
|
|
76
77
|
*/
|
|
77
78
|
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
78
|
-
discord:
|
|
79
|
-
slack:
|
|
79
|
+
discord: 30,
|
|
80
|
+
slack: 29,
|
|
80
81
|
};
|
|
81
82
|
|
|
82
83
|
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|
|
@@ -67,7 +67,14 @@ function chatDeliveryPhase(error: unknown): ChatDeliveryPhase | undefined {
|
|
|
67
67
|
if (!(error instanceof SdkClientError)) return undefined;
|
|
68
68
|
// `connection_closed` conveys no send-progress guarantee: SdkClient also emits it
|
|
69
69
|
// when a pending, already-sent request loses its response.
|
|
70
|
-
return [
|
|
70
|
+
return [
|
|
71
|
+
"connection_closed",
|
|
72
|
+
"unavailable",
|
|
73
|
+
"timeout",
|
|
74
|
+
"uncertain_after_send",
|
|
75
|
+
"reconnect_exhausted",
|
|
76
|
+
"protocol_error",
|
|
77
|
+
].includes(error.code)
|
|
71
78
|
? "ambiguous"
|
|
72
79
|
: undefined;
|
|
73
80
|
}
|