@gajae-code/coding-agent 0.17.4 → 0.17.6
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 +82 -0
- package/dist/types/config/autorouting-tier-map.d.ts +1031 -3
- package/dist/types/config/file-lock.d.ts +4 -2
- package/dist/types/config/model-registry.d.ts +3 -1
- package/dist/types/config/model-resolver.d.ts +1 -1
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
- package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
- package/dist/types/harness-control-plane/finalize.d.ts +14 -3
- package/dist/types/main.d.ts +1 -1
- package/dist/types/runtime-mcp/loader.d.ts +2 -0
- package/dist/types/runtime-mcp/manager.d.ts +13 -6
- package/dist/types/sdk/broker/broker.d.ts +4 -2
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/identity.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +11 -3
- package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
- package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
- package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
- package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +3 -3
- package/dist/types/session/agent-session.d.ts +7 -2
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tools/bash-interactive.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +10 -1
- package/dist/types/tools/subagent.d.ts +8 -1
- package/package.json +7 -7
- package/src/cli/public-command-registry.ts +8 -2
- package/src/cli/setup-cli.ts +1 -0
- package/src/cli/stats-cli.ts +11 -1
- package/src/config/autorouting-tier-map.ts +529 -3
- package/src/config/file-lock.ts +59 -14
- package/src/config/model-discovery-manager.ts +4 -4
- package/src/config/model-profile-activation.ts +83 -23
- package/src/config/model-registry.ts +72 -12
- package/src/config/model-resolver.ts +6 -1
- package/src/coordinator-mcp/question-state.ts +4 -1
- package/src/coordinator-mcp/server.ts +14 -6
- package/src/exec/bash-executor.ts +3 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
- package/src/gjc-runtime/state-writer.ts +317 -6
- package/src/harness-control-plane/control-endpoint.ts +24 -6
- package/src/harness-control-plane/finalize.ts +312 -25
- package/src/harness-control-plane/owner.ts +323 -89
- package/src/internal-urls/docs-index.generated.ts +6 -4
- package/src/main.ts +69 -19
- package/src/modes/acp/acp-agent.ts +581 -37
- package/src/modes/components/gjc-bundle-settings.ts +21 -1
- package/src/modes/components/tool-status-header.ts +72 -6
- package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
- package/src/runtime-mcp/loader.ts +6 -3
- package/src/runtime-mcp/manager.ts +177 -75
- package/src/sdk/broker/broker.ts +1058 -150
- package/src/sdk/broker/ensure.ts +65 -8
- package/src/sdk/broker/identity.ts +13 -0
- package/src/sdk/broker/lifecycle-ledger.ts +16 -4
- package/src/sdk/broker/managed-task-dag.ts +1927 -0
- package/src/sdk/broker/managed-task-verification.ts +642 -0
- package/src/sdk/broker/spawn-authority.ts +140 -7
- package/src/sdk/broker/startup-failure.ts +80 -1
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +22 -6
- package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
- package/src/sdk/cli/session-cli.ts +18 -0
- package/src/sdk/host/session-runtime.test.ts +46 -0
- package/src/sdk/host/session-runtime.ts +13 -6
- package/src/sdk/lifecycle/service.ts +2 -0
- package/src/sdk/prompt-deadline-flush.ts +26 -4
- package/src/sdk/session.ts +587 -115
- package/src/session/agent-session.ts +60 -14
- package/src/session/internal/managed-session-scope.ts +10 -0
- package/src/session/internal/managed-session-storage.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/task/executor.ts +46 -17
- package/src/task/types.ts +2 -0
- package/src/tools/bash-interactive.ts +2 -0
- package/src/tools/bash.ts +20 -0
- package/src/tools/fetch.ts +21 -5
- package/src/tools/subagent-render.ts +24 -1
- package/src/tools/subagent.ts +27 -1
- package/src/utils/mupdf.ts +11 -4
|
@@ -170,7 +170,8 @@ export type SpawnClaimDecision =
|
|
|
170
170
|
| { kind: "replay"; claim: SpawnClaimV2 }
|
|
171
171
|
| { kind: "terminal"; claim: SpawnClaimV2 }
|
|
172
172
|
| { kind: "terminal_uncertain"; claim: SpawnClaimV2 }
|
|
173
|
-
| { kind: "idempotency_conflict"; claim: SpawnClaimV2 }
|
|
173
|
+
| { kind: "idempotency_conflict"; claim: SpawnClaimV2 }
|
|
174
|
+
| { kind: "managed_key_conflict" };
|
|
174
175
|
|
|
175
176
|
type StoredClaim = {
|
|
176
177
|
version: 1;
|
|
@@ -179,6 +180,15 @@ type StoredClaim = {
|
|
|
179
180
|
integrity: string;
|
|
180
181
|
};
|
|
181
182
|
|
|
183
|
+
type ManagedKeyReservation = {
|
|
184
|
+
version: 1;
|
|
185
|
+
kind: "managed_key_reservation";
|
|
186
|
+
aliasIdentities: string[];
|
|
187
|
+
managedIdentity: string;
|
|
188
|
+
controlRoot: string;
|
|
189
|
+
integrity: string;
|
|
190
|
+
};
|
|
191
|
+
|
|
182
192
|
type SpawnAuthorityDurableWrite = {
|
|
183
193
|
claim: SpawnClaimV2;
|
|
184
194
|
authority?: SpawnAuthorityV1;
|
|
@@ -560,6 +570,8 @@ export class SpawnAuthorityStore {
|
|
|
560
570
|
readonly #options: SpawnAuthorityStoreOptions;
|
|
561
571
|
readonly #latest = new Map<string, SpawnClaimV2>();
|
|
562
572
|
readonly #authorities = new Map<string, SpawnAuthorityV1>();
|
|
573
|
+
readonly #managedKeyAliases = new Map<string, Map<string, string>>();
|
|
574
|
+
readonly #managedIdentityBindings = new Map<string, { aliasIdentities: string[]; controlRoot: string }>();
|
|
563
575
|
readonly #active = new Set<string>();
|
|
564
576
|
#tail: Promise<void> = Promise.resolve();
|
|
565
577
|
|
|
@@ -592,6 +604,8 @@ export class SpawnAuthorityStore {
|
|
|
592
604
|
await fs.mkdir(path.dirname(this.#file), { recursive: true, mode: 0o700 });
|
|
593
605
|
this.#latest.clear();
|
|
594
606
|
this.#authorities.clear();
|
|
607
|
+
this.#managedKeyAliases.clear();
|
|
608
|
+
this.#managedIdentityBindings.clear();
|
|
595
609
|
let source = "";
|
|
596
610
|
try {
|
|
597
611
|
source = await Bun.file(this.#file).text();
|
|
@@ -606,6 +620,12 @@ export class SpawnAuthorityStore {
|
|
|
606
620
|
} catch {
|
|
607
621
|
throw new Error("Spawn authority journal contains malformed durable evidence.");
|
|
608
622
|
}
|
|
623
|
+
if (this.#isManagedKeyReservation(row)) {
|
|
624
|
+
const reservation = row as ManagedKeyReservation;
|
|
625
|
+
if (!this.#rememberManagedKeyReservation(reservation))
|
|
626
|
+
throw new Error("Spawn authority journal managed key history is invalid.");
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
609
629
|
if (!this.#isStoredClaim(row))
|
|
610
630
|
throw new Error("Spawn authority journal contains invalid durable evidence.");
|
|
611
631
|
const stored = row as StoredClaim;
|
|
@@ -616,6 +636,52 @@ export class SpawnAuthorityStore {
|
|
|
616
636
|
if (stored.authority) this.#authorities.set(stored.claim.lifecycleIdentity, stored.authority);
|
|
617
637
|
else this.#authorities.delete(stored.claim.lifecycleIdentity);
|
|
618
638
|
}
|
|
639
|
+
for (const alias of this.#managedKeyAliases.keys())
|
|
640
|
+
if (this.#latest.has(alias)) throw new Error("Spawn authority journal aliases an ordinary claim.");
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Durably reserves an ordinary session.spawn idempotency identity for one
|
|
646
|
+
* managed native identity. Multiple managed roots may share the alias, but an
|
|
647
|
+
* ordinary claim and a managed reservation can never both win.
|
|
648
|
+
*/
|
|
649
|
+
async reserveManagedKeyAliases(
|
|
650
|
+
aliasIdentities: readonly string[],
|
|
651
|
+
managedIdentity: string,
|
|
652
|
+
controlRoot: string,
|
|
653
|
+
): Promise<boolean> {
|
|
654
|
+
const aliases = [...new Set(aliasIdentities)].sort();
|
|
655
|
+
if (
|
|
656
|
+
aliases.length === 0 ||
|
|
657
|
+
aliases.some(identity => !/^[0-9a-f]{64}$/i.test(identity)) ||
|
|
658
|
+
!/^[0-9a-f]{64}$/i.test(managedIdentity) ||
|
|
659
|
+
!path.isAbsolute(controlRoot) ||
|
|
660
|
+
path.normalize(controlRoot) !== controlRoot
|
|
661
|
+
)
|
|
662
|
+
throw new Error("Invalid managed spawn key reservation.");
|
|
663
|
+
return await this.#serial(async () => {
|
|
664
|
+
if (aliases.some(alias => this.#latest.has(alias))) return false;
|
|
665
|
+
const prior = this.#managedIdentityBindings.get(managedIdentity);
|
|
666
|
+
if (prior)
|
|
667
|
+
return prior.controlRoot === controlRoot && canonicalJson(prior.aliasIdentities) === canonicalJson(aliases);
|
|
668
|
+
for (const alias of aliases) {
|
|
669
|
+
const roots = this.#managedKeyAliases.get(alias);
|
|
670
|
+
const existingRoot = roots?.get(managedIdentity);
|
|
671
|
+
if (existingRoot !== undefined && existingRoot !== controlRoot) return false;
|
|
672
|
+
}
|
|
673
|
+
const reservation: ManagedKeyReservation = {
|
|
674
|
+
version: 1,
|
|
675
|
+
kind: "managed_key_reservation",
|
|
676
|
+
aliasIdentities: aliases,
|
|
677
|
+
managedIdentity,
|
|
678
|
+
controlRoot,
|
|
679
|
+
integrity: "",
|
|
680
|
+
};
|
|
681
|
+
reservation.integrity = this.#managedKeyReservationIntegrity(reservation);
|
|
682
|
+
await this.#appendRow(reservation);
|
|
683
|
+
this.#rememberManagedKeyReservation(reservation);
|
|
684
|
+
return true;
|
|
619
685
|
});
|
|
620
686
|
}
|
|
621
687
|
|
|
@@ -632,6 +698,7 @@ export class SpawnAuthorityStore {
|
|
|
632
698
|
)
|
|
633
699
|
throw new Error("Invalid opaque spawn claim key.");
|
|
634
700
|
return await this.#serial(async () => {
|
|
701
|
+
if (this.#managedKeyAliases.has(lifecycleIdentity)) return { kind: "managed_key_conflict" };
|
|
635
702
|
const current = this.#latest.get(lifecycleIdentity);
|
|
636
703
|
if (!current) {
|
|
637
704
|
if (bindingMac === undefined) throw new Error("A canonical spawn binding is required for a new claim.");
|
|
@@ -964,6 +1031,68 @@ export class SpawnAuthorityStore {
|
|
|
964
1031
|
.digest("hex");
|
|
965
1032
|
}
|
|
966
1033
|
|
|
1034
|
+
#managedKeyReservationIntegrity(reservation: ManagedKeyReservation): string {
|
|
1035
|
+
const { integrity: _integrity, ...facts } = reservation;
|
|
1036
|
+
return createHmac("sha256", this.#identityKey).update(canonicalJson(facts)).digest("hex");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
#isManagedKeyReservation(value: unknown): value is ManagedKeyReservation {
|
|
1040
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1041
|
+
const row = value as Partial<ManagedKeyReservation>;
|
|
1042
|
+
return (
|
|
1043
|
+
Object.keys(row).every(
|
|
1044
|
+
key =>
|
|
1045
|
+
key === "version" ||
|
|
1046
|
+
key === "kind" ||
|
|
1047
|
+
key === "aliasIdentities" ||
|
|
1048
|
+
key === "managedIdentity" ||
|
|
1049
|
+
key === "controlRoot" ||
|
|
1050
|
+
key === "integrity",
|
|
1051
|
+
) &&
|
|
1052
|
+
row.version === 1 &&
|
|
1053
|
+
row.kind === "managed_key_reservation" &&
|
|
1054
|
+
Array.isArray(row.aliasIdentities) &&
|
|
1055
|
+
row.aliasIdentities.length > 0 &&
|
|
1056
|
+
row.aliasIdentities.every(identity => typeof identity === "string" && /^[0-9a-f]{64}$/i.test(identity)) &&
|
|
1057
|
+
new Set(row.aliasIdentities).size === row.aliasIdentities.length &&
|
|
1058
|
+
canonicalJson(row.aliasIdentities) === canonicalJson([...row.aliasIdentities].sort()) &&
|
|
1059
|
+
typeof row.managedIdentity === "string" &&
|
|
1060
|
+
/^[0-9a-f]{64}$/i.test(row.managedIdentity) &&
|
|
1061
|
+
typeof row.controlRoot === "string" &&
|
|
1062
|
+
path.isAbsolute(row.controlRoot) &&
|
|
1063
|
+
path.normalize(row.controlRoot) === row.controlRoot &&
|
|
1064
|
+
typeof row.integrity === "string" &&
|
|
1065
|
+
/^[0-9a-f]{64}$/i.test(row.integrity) &&
|
|
1066
|
+
timingSafeEqual(
|
|
1067
|
+
Buffer.from(row.integrity, "hex"),
|
|
1068
|
+
Buffer.from(this.#managedKeyReservationIntegrity(row as ManagedKeyReservation), "hex"),
|
|
1069
|
+
)
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
#rememberManagedKeyReservation(reservation: ManagedKeyReservation): boolean {
|
|
1074
|
+
const prior = this.#managedIdentityBindings.get(reservation.managedIdentity);
|
|
1075
|
+
if (
|
|
1076
|
+
prior &&
|
|
1077
|
+
(prior.controlRoot !== reservation.controlRoot ||
|
|
1078
|
+
canonicalJson(prior.aliasIdentities) !== canonicalJson(reservation.aliasIdentities))
|
|
1079
|
+
)
|
|
1080
|
+
return false;
|
|
1081
|
+
if (prior) return false;
|
|
1082
|
+
this.#managedIdentityBindings.set(reservation.managedIdentity, {
|
|
1083
|
+
aliasIdentities: [...reservation.aliasIdentities],
|
|
1084
|
+
controlRoot: reservation.controlRoot,
|
|
1085
|
+
});
|
|
1086
|
+
for (const alias of reservation.aliasIdentities) {
|
|
1087
|
+
const roots = this.#managedKeyAliases.get(alias) ?? new Map<string, string>();
|
|
1088
|
+
const existingRoot = roots.get(reservation.managedIdentity);
|
|
1089
|
+
if (existingRoot !== undefined && existingRoot !== reservation.controlRoot) return false;
|
|
1090
|
+
roots.set(reservation.managedIdentity, reservation.controlRoot);
|
|
1091
|
+
this.#managedKeyAliases.set(alias, roots);
|
|
1092
|
+
}
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
967
1096
|
#isStoredClaim(value: unknown): value is StoredClaim {
|
|
968
1097
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
969
1098
|
const row = value as Partial<StoredClaim>;
|
|
@@ -989,6 +1118,13 @@ export class SpawnAuthorityStore {
|
|
|
989
1118
|
throw new Error("Refusing to persist invalid spawn authority evidence.");
|
|
990
1119
|
const write: SpawnAuthorityDurableWrite = { claim, ...(authority === undefined ? {} : { authority }) };
|
|
991
1120
|
const row: StoredClaim = { version: 1, ...write, integrity: this.#integrity(claim, authority) };
|
|
1121
|
+
await this.#appendRow(row, write);
|
|
1122
|
+
this.#latest.set(claim.lifecycleIdentity, claim);
|
|
1123
|
+
if (authority) this.#authorities.set(claim.lifecycleIdentity, authority);
|
|
1124
|
+
else this.#authorities.delete(claim.lifecycleIdentity);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async #appendRow(row: unknown, write?: SpawnAuthorityDurableWrite): Promise<void> {
|
|
992
1128
|
// The row is appended before the durability barrier, so a barrier failure
|
|
993
1129
|
// can leave a physically written line that in-memory state never accepted.
|
|
994
1130
|
// A retry would then append a second row for the same claim and reopen
|
|
@@ -1009,13 +1145,13 @@ export class SpawnAuthorityStore {
|
|
|
1009
1145
|
try {
|
|
1010
1146
|
try {
|
|
1011
1147
|
await handle.writeFile(`${canonicalJson(row)}\n`);
|
|
1012
|
-
await this.#options.beforeSyncForTest?.(write);
|
|
1148
|
+
if (write) await this.#options.beforeSyncForTest?.(write);
|
|
1013
1149
|
await handle.sync();
|
|
1014
1150
|
// The parent-directory barrier belongs INSIDE the rolled-back region:
|
|
1015
1151
|
// a throw here would otherwise leave a durable row that in-memory
|
|
1016
1152
|
// state never accepted, and the retry would append a duplicate that
|
|
1017
1153
|
// makes the journal unreopenable.
|
|
1018
|
-
await this.#options.beforeDirectorySyncForTest?.(write);
|
|
1154
|
+
if (write) await this.#options.beforeDirectorySyncForTest?.(write);
|
|
1019
1155
|
const directory = await fs.open(path.dirname(this.#file), fsSync.constants.O_RDONLY);
|
|
1020
1156
|
try {
|
|
1021
1157
|
await directory.sync();
|
|
@@ -1039,10 +1175,7 @@ export class SpawnAuthorityStore {
|
|
|
1039
1175
|
} finally {
|
|
1040
1176
|
await handle.close();
|
|
1041
1177
|
}
|
|
1042
|
-
this.#
|
|
1043
|
-
if (authority) this.#authorities.set(claim.lifecycleIdentity, authority);
|
|
1044
|
-
else this.#authorities.delete(claim.lifecycleIdentity);
|
|
1045
|
-
await this.#options.afterSyncForTest?.(write);
|
|
1178
|
+
if (write) await this.#options.afterSyncForTest?.(write);
|
|
1046
1179
|
}
|
|
1047
1180
|
|
|
1048
1181
|
async #serial<T>(operation: () => Promise<T>): Promise<T> {
|
|
@@ -5,6 +5,8 @@ import { processIncarnation } from "./process-incarnation";
|
|
|
5
5
|
export interface BrokerStartupFailureMarker {
|
|
6
6
|
version: 2;
|
|
7
7
|
reason: string;
|
|
8
|
+
/** Validated manual lock cleanup command, stored separately when it cannot fit in `reason`. */
|
|
9
|
+
cleanupCommand?: string;
|
|
8
10
|
exitCode: number | null;
|
|
9
11
|
signal: string | null;
|
|
10
12
|
writtenAt: number;
|
|
@@ -23,11 +25,82 @@ export interface BrokerStartupFailureMarker {
|
|
|
23
25
|
|
|
24
26
|
const BROKER_STARTUP_FAILURE_FILE = "broker.startup-failure.json";
|
|
25
27
|
const MAX_BROKER_STARTUP_FAILURE_REASON = 512;
|
|
28
|
+
// Windows extended paths can be 32,767 UTF-16 code units and PowerShell quoting
|
|
29
|
+
// can double every apostrophe; this also comfortably covers POSIX PATH_MAX paths.
|
|
30
|
+
const MAX_BROKER_STARTUP_FAILURE_CLEANUP_COMMAND = 66_000;
|
|
31
|
+
const FILE_LOCK_CLEANUP_GUIDANCE =
|
|
32
|
+
"; manual cleanup is appropriate only after independently verifying this exact directory still belongs to the dead owner and no successor has taken it: ";
|
|
33
|
+
const FILE_LOCK_OWNER_GUIDANCE =
|
|
34
|
+
"; a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying";
|
|
35
|
+
const COMPACT_FILE_LOCK_CLEANUP_GUIDANCE =
|
|
36
|
+
"Manual cleanup is appropriate only after independently verifying this exact directory still belongs to the dead owner and no successor has taken it: ";
|
|
37
|
+
|
|
38
|
+
function decodeFileLockCleanupCommand(command: string): string | undefined {
|
|
39
|
+
if (command.length === 0 || command.length > MAX_BROKER_STARTUP_FAILURE_CLEANUP_COMMAND) return undefined;
|
|
40
|
+
const prefix = process.platform === "win32" ? "Remove-Item -LiteralPath '" : "rm -rf -- '";
|
|
41
|
+
const suffix = process.platform === "win32" ? "' -Recurse -Force" : "'";
|
|
42
|
+
if (!command.startsWith(prefix) || !command.endsWith(suffix)) return undefined;
|
|
43
|
+
|
|
44
|
+
const encodedPath = command.slice(prefix.length, -suffix.length);
|
|
45
|
+
const quoteEscape = process.platform === "win32" ? "''" : "'\\''";
|
|
46
|
+
const decodedPath = encodedPath.replaceAll(quoteEscape, "'");
|
|
47
|
+
if (!decodedPath || decodedPath.replaceAll("'", quoteEscape) !== encodedPath) return undefined;
|
|
48
|
+
return decodedPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validatedFileLockCleanupCommand(reason: string): string | undefined {
|
|
52
|
+
let guidanceIndex = reason.indexOf(FILE_LOCK_CLEANUP_GUIDANCE);
|
|
53
|
+
while (guidanceIndex >= 0) {
|
|
54
|
+
const command = reason.slice(guidanceIndex + FILE_LOCK_CLEANUP_GUIDANCE.length);
|
|
55
|
+
const lockPath = decodeFileLockCleanupCommand(command);
|
|
56
|
+
if (lockPath) {
|
|
57
|
+
const beforeCleanup = reason.slice(0, guidanceIndex);
|
|
58
|
+
const lockAndOwnerGuidance = ` (${lockPath})${FILE_LOCK_OWNER_GUIDANCE}`;
|
|
59
|
+
const acquisition = beforeCleanup.endsWith(lockAndOwnerGuidance)
|
|
60
|
+
? beforeCleanup.slice(0, -lockAndOwnerGuidance.length)
|
|
61
|
+
: "";
|
|
62
|
+
if (/^Failed to acquire lock for [\s\S]+ after \d+ attempts: [\s\S]+$/.test(acquisition)) {
|
|
63
|
+
return command;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
guidanceIndex = reason.indexOf(FILE_LOCK_CLEANUP_GUIDANCE, guidanceIndex + 1);
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function boundedFailureDetails(reason: string): { reason: string; cleanupCommand?: string } {
|
|
72
|
+
if (reason.length <= MAX_BROKER_STARTUP_FAILURE_REASON) return { reason };
|
|
73
|
+
|
|
74
|
+
const cleanupCommand = validatedFileLockCleanupCommand(reason);
|
|
75
|
+
if (cleanupCommand) {
|
|
76
|
+
const compactReason = `${COMPACT_FILE_LOCK_CLEANUP_GUIDANCE}${cleanupCommand}`;
|
|
77
|
+
if (compactReason.length <= MAX_BROKER_STARTUP_FAILURE_REASON) return { reason: compactReason };
|
|
78
|
+
return { reason: COMPACT_FILE_LOCK_CLEANUP_GUIDANCE, cleanupCommand };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { reason: reason.slice(0, MAX_BROKER_STARTUP_FAILURE_REASON) };
|
|
82
|
+
}
|
|
26
83
|
|
|
27
84
|
export function brokerStartupFailurePath(agentDir: string): string {
|
|
28
85
|
return path.join(agentDir, "sdk", BROKER_STARTUP_FAILURE_FILE);
|
|
29
86
|
}
|
|
30
87
|
|
|
88
|
+
/** Whether validated compact cleanup guidance targets the exact lock path. */
|
|
89
|
+
export function brokerStartupFailureCleanupTargetsLock(
|
|
90
|
+
marker: BrokerStartupFailureMarker | undefined,
|
|
91
|
+
lockPath: string,
|
|
92
|
+
): boolean {
|
|
93
|
+
if (!marker) return false;
|
|
94
|
+
if (marker.cleanupCommand !== undefined) {
|
|
95
|
+
return (
|
|
96
|
+
marker.reason === COMPACT_FILE_LOCK_CLEANUP_GUIDANCE &&
|
|
97
|
+
decodeFileLockCleanupCommand(marker.cleanupCommand) === lockPath
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!marker.reason.startsWith(COMPACT_FILE_LOCK_CLEANUP_GUIDANCE)) return false;
|
|
101
|
+
return decodeFileLockCleanupCommand(marker.reason.slice(COMPACT_FILE_LOCK_CLEANUP_GUIDANCE.length)) === lockPath;
|
|
102
|
+
}
|
|
103
|
+
|
|
31
104
|
function boundedMarker(
|
|
32
105
|
reason: string,
|
|
33
106
|
exitCode: number | null,
|
|
@@ -35,9 +108,10 @@ function boundedMarker(
|
|
|
35
108
|
pid: number,
|
|
36
109
|
incarnation: string,
|
|
37
110
|
): BrokerStartupFailureMarker {
|
|
111
|
+
const boundedFailure = boundedFailureDetails(reason);
|
|
38
112
|
return {
|
|
39
113
|
version: 2,
|
|
40
|
-
|
|
114
|
+
...boundedFailure,
|
|
41
115
|
exitCode,
|
|
42
116
|
signal,
|
|
43
117
|
writtenAt: Date.now(),
|
|
@@ -84,6 +158,11 @@ export async function readBrokerStartupFailureMarker(
|
|
|
84
158
|
marker.version !== 2 ||
|
|
85
159
|
typeof marker.reason !== "string" ||
|
|
86
160
|
marker.reason.length === 0 ||
|
|
161
|
+
marker.reason.length > MAX_BROKER_STARTUP_FAILURE_REASON ||
|
|
162
|
+
(marker.cleanupCommand !== undefined &&
|
|
163
|
+
(typeof marker.cleanupCommand !== "string" ||
|
|
164
|
+
marker.reason !== COMPACT_FILE_LOCK_CLEANUP_GUIDANCE ||
|
|
165
|
+
decodeFileLockCleanupCommand(marker.cleanupCommand) === undefined)) ||
|
|
87
166
|
(marker.exitCode !== null && typeof marker.exitCode !== "number") ||
|
|
88
167
|
(marker.signal !== null && typeof marker.signal !== "string") ||
|
|
89
168
|
typeof marker.writtenAt !== "number" ||
|
|
@@ -141,8 +141,8 @@ export type ChatDaemonAction = "stop" | "reload";
|
|
|
141
141
|
* crashes, recovery, and attachment retirement cannot replay ambiguous work.
|
|
142
142
|
*/
|
|
143
143
|
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
144
|
-
discord:
|
|
145
|
-
slack:
|
|
144
|
+
discord: 80,
|
|
145
|
+
slack: 87,
|
|
146
146
|
};
|
|
147
147
|
|
|
148
148
|
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|
package/src/sdk/bus/index.ts
CHANGED
|
@@ -4874,6 +4874,8 @@ export function createNotificationsExtension(
|
|
|
4874
4874
|
const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? true);
|
|
4875
4875
|
gatePresentations.setPublicationSuspended(true);
|
|
4876
4876
|
let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
|
|
4877
|
+
let negotiatedCapabilitiesHandler: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;
|
|
4878
|
+
let connectionCloseHandler: ((connectionId: string) => void) | undefined;
|
|
4877
4879
|
const inFlightGateResolutions = new Set<Promise<void>>();
|
|
4878
4880
|
const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
|
|
4879
4881
|
const quiesced = resolution.then(
|
|
@@ -6009,6 +6011,7 @@ export function createNotificationsExtension(
|
|
|
6009
6011
|
const explicitOptIn = hasExplicitSetting === true && configured === true;
|
|
6010
6012
|
await runBoundedDeadlineFlush(signal =>
|
|
6011
6013
|
flushWorktreeOnPromptDeadline(ctx.cwd, {
|
|
6014
|
+
agentDir: settings?.getAgentDir(),
|
|
6012
6015
|
explicitOptIn,
|
|
6013
6016
|
isCurrent: () =>
|
|
6014
6017
|
deadlineAttemptStatus(promptSubmissionKey(correlation), submission, deadlineAttempt) ===
|
|
@@ -7402,6 +7405,18 @@ export function createNotificationsExtension(
|
|
|
7402
7405
|
start: async () => await server.start(),
|
|
7403
7406
|
stop: async () => await server.stopAndWait(),
|
|
7404
7407
|
broadcastFrame: frame => broadcastEventFrame(frame),
|
|
7408
|
+
onConnectionClose(handler) {
|
|
7409
|
+
connectionCloseHandler = handler;
|
|
7410
|
+
return () => {
|
|
7411
|
+
if (connectionCloseHandler === handler) connectionCloseHandler = undefined;
|
|
7412
|
+
};
|
|
7413
|
+
},
|
|
7414
|
+
onNegotiatedCapabilities(handler) {
|
|
7415
|
+
negotiatedCapabilitiesHandler = handler;
|
|
7416
|
+
return () => {
|
|
7417
|
+
if (negotiatedCapabilitiesHandler === handler) negotiatedCapabilitiesHandler = undefined;
|
|
7418
|
+
};
|
|
7419
|
+
},
|
|
7405
7420
|
},
|
|
7406
7421
|
...(preparesExistingThread ? { readiness: "deferred" as const } : {}),
|
|
7407
7422
|
...(activationGate ? { activationGate } : {}),
|
|
@@ -7985,19 +8000,20 @@ export function createNotificationsExtension(
|
|
|
7985
8000
|
const tuple = Array.isArray(connectionId) ? (connectionId as unknown[]) : undefined;
|
|
7986
8001
|
const id = tuple?.[0] ?? connectionId;
|
|
7987
8002
|
const negotiated = tuple?.[1] ?? capabilities;
|
|
7988
|
-
if (typeof id
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
8003
|
+
if (typeof id !== "string" || !Array.isArray(negotiated)) return;
|
|
8004
|
+
const normalized = negotiated.filter((capability): capability is string => typeof capability === "string");
|
|
8005
|
+
rememberHostCapabilities(id, normalized);
|
|
8006
|
+
if (liveHostCapabilities(id) === undefined) return;
|
|
8007
|
+
hostAttachedConnections.add(id);
|
|
8008
|
+
negotiatedCapabilitiesHandler?.(id, normalized);
|
|
7993
8009
|
});
|
|
7994
8010
|
server.onConnectionClose((_err, connectionId) => {
|
|
7995
8011
|
if (!connectionId) return;
|
|
7996
8012
|
closeHostConnection(connectionId);
|
|
8013
|
+
connectionCloseHandler?.(connectionId);
|
|
7997
8014
|
void controlSurface
|
|
7998
8015
|
.cancelPendingPreflightsForConnection(connectionId)
|
|
7999
8016
|
.catch(error => logger.warn(`sdk: failed to cancel disconnected preflight: ${String(error)}`));
|
|
8000
|
-
host.handleDisconnect(connectionId);
|
|
8001
8017
|
// The socket is gone, so its fence has nothing left to refuse. Dropping the
|
|
8002
8018
|
// entry keeps the set bounded by live connections instead of growing forever.
|
|
8003
8019
|
fencedConnections.delete(connectionId);
|
|
@@ -292,8 +292,12 @@ export const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1;
|
|
|
292
292
|
* replaced across this upgrade.
|
|
293
293
|
* Generation 190 acknowledges the protected prompt-deadline boundary change,
|
|
294
294
|
* so generation-189 owners are replaced instead of retaining the old path.
|
|
295
|
+
* Generation 192 makes session-host liveness a reference-counted work lease,
|
|
296
|
+
* so generation-191 owners cannot reap hosts with queued or promoted work.
|
|
297
|
+
* Generation 193 gates streamed content on negotiated observer capabilities,
|
|
298
|
+
* so existing owners are replaced before exposing the new observer path.
|
|
295
299
|
*/
|
|
296
|
-
export const DAEMON_GENERATION =
|
|
300
|
+
export const DAEMON_GENERATION = 194;
|
|
297
301
|
|
|
298
302
|
/**
|
|
299
303
|
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 7
|
|
@@ -2341,6 +2341,20 @@ function rawKind(action: string, args: SdkSessionCliArgs): SdkSessionCliRawKind
|
|
|
2341
2341
|
return action === "control" || action === "query" || action === "global" ? action : undefined;
|
|
2342
2342
|
}
|
|
2343
2343
|
|
|
2344
|
+
function warnIfRepoIgnoredForExactSession(args: SdkSessionCliArgs): void {
|
|
2345
|
+
const action = args.action;
|
|
2346
|
+
const exactSessionAction =
|
|
2347
|
+
action === "inspect" ||
|
|
2348
|
+
action === "send" ||
|
|
2349
|
+
action === "status" ||
|
|
2350
|
+
action === "query" ||
|
|
2351
|
+
(action === "raw" && args.rawAction === "query");
|
|
2352
|
+
if (args.repo !== undefined && exactSessionAction)
|
|
2353
|
+
process.stderr.write(
|
|
2354
|
+
"Warning: --repo is ignored for exact-session commands; the session ID selects the broker target.\n",
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2344
2358
|
/** Runs the broker-bound `gjc sdk session` command family without exposing endpoint credentials. */
|
|
2345
2359
|
export async function runSdkSessionCli(
|
|
2346
2360
|
args: SdkSessionCliArgs,
|
|
@@ -2383,10 +2397,12 @@ export async function runSdkSessionCli(
|
|
|
2383
2397
|
}
|
|
2384
2398
|
if (action === "inspect") {
|
|
2385
2399
|
writeOutput(stripSecretFields(await runInspect(agentDir, requireValue(args.sessionId, "<sessionId>"))));
|
|
2400
|
+
warnIfRepoIgnoredForExactSession(args);
|
|
2386
2401
|
return;
|
|
2387
2402
|
}
|
|
2388
2403
|
if (action === "send") {
|
|
2389
2404
|
writeOutput(stripSecretFields(await runSend(agentDir, requireValue(args.sessionId, "<sessionId>"), args)));
|
|
2405
|
+
warnIfRepoIgnoredForExactSession(args);
|
|
2390
2406
|
return;
|
|
2391
2407
|
}
|
|
2392
2408
|
if (action === "status") {
|
|
@@ -2400,6 +2416,7 @@ export async function runSdkSessionCli(
|
|
|
2400
2416
|
),
|
|
2401
2417
|
),
|
|
2402
2418
|
);
|
|
2419
|
+
warnIfRepoIgnoredForExactSession(args);
|
|
2403
2420
|
return;
|
|
2404
2421
|
}
|
|
2405
2422
|
if (action === "tail") {
|
|
@@ -2506,6 +2523,7 @@ export async function runSdkSessionCli(
|
|
|
2506
2523
|
: await runRawQuery(agentDir, sessionId, operation, input, args),
|
|
2507
2524
|
),
|
|
2508
2525
|
);
|
|
2526
|
+
warnIfRepoIgnoredForExactSession(args);
|
|
2509
2527
|
} catch (error) {
|
|
2510
2528
|
throw normalizeSessionFailure(error, args);
|
|
2511
2529
|
}
|
|
@@ -1282,6 +1282,52 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1282
1282
|
expect(transport.sent).toContainEqual({ type: "event", kind: "message_update", payload: {} });
|
|
1283
1283
|
await runtime.stop();
|
|
1284
1284
|
});
|
|
1285
|
+
test("ignores late negotiated capabilities after the authoritative connection closes", async () => {
|
|
1286
|
+
const transport = memoryTransport();
|
|
1287
|
+
let closeConnection: ((connectionId: string) => void) | undefined;
|
|
1288
|
+
let negotiateCapabilities: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;
|
|
1289
|
+
const hookedTransport: SessionSdkTransport = {
|
|
1290
|
+
...transport,
|
|
1291
|
+
onConnectionClose(handler) {
|
|
1292
|
+
closeConnection = handler;
|
|
1293
|
+
return () => {
|
|
1294
|
+
if (closeConnection === handler) closeConnection = undefined;
|
|
1295
|
+
};
|
|
1296
|
+
},
|
|
1297
|
+
onNegotiatedCapabilities(handler) {
|
|
1298
|
+
negotiateCapabilities = handler;
|
|
1299
|
+
return () => {
|
|
1300
|
+
if (negotiateCapabilities === handler) negotiateCapabilities = undefined;
|
|
1301
|
+
};
|
|
1302
|
+
},
|
|
1303
|
+
};
|
|
1304
|
+
const liveCapabilities = new Map<string, ReadonlySet<string>>();
|
|
1305
|
+
const providerReads: string[] = [];
|
|
1306
|
+
const runtime = new SessionSdkSessionRuntime({
|
|
1307
|
+
transport: hookedTransport,
|
|
1308
|
+
connectionCapabilities: connectionId => {
|
|
1309
|
+
providerReads.push(connectionId);
|
|
1310
|
+
return liveCapabilities.get(connectionId);
|
|
1311
|
+
},
|
|
1312
|
+
});
|
|
1313
|
+
await runtime.start();
|
|
1314
|
+
try {
|
|
1315
|
+
if (!closeConnection || !negotiateCapabilities) throw new Error("transport callbacks were not registered");
|
|
1316
|
+
const capabilities = [TURN_STREAM_CAPABILITY, SESSION_HOST_OBSERVER_CAPABILITY];
|
|
1317
|
+
liveCapabilities.set("observer", new Set(capabilities));
|
|
1318
|
+
negotiateCapabilities("observer", capabilities);
|
|
1319
|
+
expect(runtime.connectionIdsWithCapabilities(capabilities)).toEqual(["observer"]);
|
|
1320
|
+
|
|
1321
|
+
liveCapabilities.delete("observer");
|
|
1322
|
+
closeConnection("observer");
|
|
1323
|
+
negotiateCapabilities("observer", capabilities);
|
|
1324
|
+
providerReads.length = 0;
|
|
1325
|
+
expect(runtime.connectionIdsWithCapabilities(capabilities)).toEqual([]);
|
|
1326
|
+
expect(providerReads).toEqual([]);
|
|
1327
|
+
} finally {
|
|
1328
|
+
await runtime.stop();
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1285
1331
|
test("SDK-only host publishes one replayable bash_folded frame per fold and drops the subscription on shutdown", async () => {
|
|
1286
1332
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-bash-folded-"));
|
|
1287
1333
|
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
@@ -437,6 +437,7 @@ export class SessionSdkSessionRuntime {
|
|
|
437
437
|
this.host.handleDisconnect(connectionId);
|
|
438
438
|
});
|
|
439
439
|
this.#capabilitiesDisposer = options.transport.onNegotiatedCapabilities?.((connectionId, negotiated) => {
|
|
440
|
+
if (options.connectionCapabilities && this.#connectionCapabilitiesProvider(connectionId) === undefined) return;
|
|
440
441
|
this.#connectionIds.add(connectionId);
|
|
441
442
|
this.#connectionCapabilities.set(connectionId, new Set(negotiated));
|
|
442
443
|
});
|
|
@@ -500,7 +501,6 @@ export class SessionSdkSessionRuntime {
|
|
|
500
501
|
: [];
|
|
501
502
|
});
|
|
502
503
|
}
|
|
503
|
-
|
|
504
504
|
/** Deliver a non-replayable frame to connections with an explicit capability intersection. */
|
|
505
505
|
sendFrameToCapabilities(
|
|
506
506
|
required: readonly string[],
|
|
@@ -4535,10 +4535,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
4535
4535
|
return task;
|
|
4536
4536
|
};
|
|
4537
4537
|
/**
|
|
4538
|
-
* Publish one content frame per owning invocation,
|
|
4539
|
-
*
|
|
4540
|
-
*
|
|
4541
|
-
*
|
|
4538
|
+
* Publish one correlated content frame per owning invocation, plus one
|
|
4539
|
+
* unpositioned copy for connections that explicitly negotiated observer
|
|
4540
|
+
* streaming. A shared run therefore lets every submitter attribute the content
|
|
4541
|
+
* to its own prompt without exposing it to ordinary attached clients.
|
|
4542
|
+
* Content is best-effort and bypasses the lifecycle replay ring; the turn
|
|
4543
|
+
* producing it is authoritative.
|
|
4542
4544
|
*/
|
|
4543
4545
|
const publishContentFrames = (
|
|
4544
4546
|
current: RuntimeState,
|
|
@@ -5438,7 +5440,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
5438
5440
|
? options.settings.has("sdk.flushWorktreeOnDeadline" as never)
|
|
5439
5441
|
: configured === true;
|
|
5440
5442
|
const explicitOptIn = hasExplicitSetting === true && configured === true;
|
|
5441
|
-
await flushWorktreeOnPromptDeadline(ctx.cwd, {
|
|
5443
|
+
await flushWorktreeOnPromptDeadline(ctx.cwd, {
|
|
5444
|
+
agentDir: options.settings?.getAgentDir(),
|
|
5445
|
+
explicitOptIn,
|
|
5446
|
+
isCurrent,
|
|
5447
|
+
signal,
|
|
5448
|
+
});
|
|
5442
5449
|
},
|
|
5443
5450
|
onExpired: (correlation, deadlineOutcome) => {
|
|
5444
5451
|
const owner = lifecycleOwnerHolder.state;
|
|
@@ -804,6 +804,7 @@ const TRANSPORT_ERROR_CODES = new Set([
|
|
|
804
804
|
"timeout",
|
|
805
805
|
"connection_closed",
|
|
806
806
|
"reconnect_exhausted",
|
|
807
|
+
"uncertain_after_send",
|
|
807
808
|
"unavailable",
|
|
808
809
|
"protocol_error",
|
|
809
810
|
]);
|
|
@@ -812,6 +813,7 @@ function certaintyForThrownError(error: {
|
|
|
812
813
|
readonly code: string;
|
|
813
814
|
readonly requestSent?: boolean;
|
|
814
815
|
}): SessionLifecycleCertainty {
|
|
816
|
+
if (error.code === "uncertain_after_send") return "uncertain";
|
|
815
817
|
if (error.code === "protocol_error") return error.requestSent === false ? "retryable" : "uncertain";
|
|
816
818
|
if (!TRANSPORT_ERROR_CODES.has(error.code)) return certaintyForBrokerCode(error.code);
|
|
817
819
|
if (error.requestSent === false) return "retryable";
|