@gajae-code/coding-agent 0.17.1 → 0.17.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 +72 -0
- package/dist/types/async/job-manager.d.ts +7 -6
- package/dist/types/config/autorouting-tier-map.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +18 -3
- package/dist/types/config/model-registry.d.ts +9 -0
- package/dist/types/coordinator-mcp/server.d.ts +3 -0
- package/dist/types/modes/acp/acp-agent.d.ts +40 -1
- package/dist/types/modes/components/thinking-selector.d.ts +2 -1
- package/dist/types/sdk/acp/adapter.d.ts +27 -0
- package/dist/types/sdk/acp/final-text.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +46 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
- package/dist/types/sdk/prompt-failure.d.ts +30 -1
- package/dist/types/sdk/prompt-status.d.ts +8 -0
- package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
- package/dist/types/thinking.d.ts +3 -1
- package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
- package/package.json +7 -7
- package/src/async/job-manager.ts +38 -8
- package/src/commands/sdk.ts +78 -4
- package/src/config/autorouting-tier-map.ts +2 -0
- package/src/config/file-lock.ts +52 -17
- package/src/config/model-profile-activation.ts +72 -10
- package/src/config/model-registry.ts +21 -0
- package/src/coordinator-mcp/server.ts +21 -6
- package/src/debug/report-bundle.ts +114 -24
- package/src/gjc-runtime/managed-owner-admission.ts +67 -50
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/modes/acp/acp-agent.ts +187 -14
- package/src/modes/acp/acp-event-mapper.ts +9 -0
- package/src/modes/components/model-selector.ts +31 -15
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/components/thinking-selector.ts +7 -3
- package/src/modes/controllers/selector-controller.ts +16 -3
- package/src/sdk/acp/adapter.ts +63 -1
- package/src/sdk/acp/final-text.ts +19 -2
- package/src/sdk/broker/ensure.ts +97 -19
- package/src/sdk/bus/index.ts +31 -4
- package/src/sdk/host/session-runtime.test.ts +498 -7
- package/src/sdk/host/session-runtime.ts +188 -21
- package/src/sdk/prompt-deadline-manager.ts +3 -3
- package/src/sdk/prompt-failure.ts +43 -1
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
- package/src/sdk/router/session-router.ts +22 -3
- package/src/sdk/session.ts +12 -9
- package/src/sdk/transport/serve-cli.ts +105 -15
- package/src/session-import/codex.ts +12 -3
- package/src/thinking.ts +21 -2
- package/src/utils/mupdf-wasm-embedded.ts +3 -0
- package/src/utils/mupdf-wasm.ts +24 -2
package/src/async/job-manager.ts
CHANGED
|
@@ -569,14 +569,22 @@ export class AsyncJobManager {
|
|
|
569
569
|
* instead of the last-created process-global instance (review thread P1).
|
|
570
570
|
* Returns TRUE when the mapping was installed (or already held by this
|
|
571
571
|
* manager); returns FALSE when the endpoint id is already held by a
|
|
572
|
-
* FOREIGN
|
|
573
|
-
* resumed under that endpoint must then
|
|
574
|
-
* silently replacing the first manager, which
|
|
575
|
-
* session's tools resolve the second manager and let
|
|
576
|
-
* queried, registered, or cancelled across sessions
|
|
572
|
+
* FOREIGN manager whose authority has not fully settled — a second
|
|
573
|
+
* top-level session constructed or resumed under that endpoint must then
|
|
574
|
+
* fail construction instead of silently replacing the first manager, which
|
|
575
|
+
* would make the first session's tools resolve the second manager and let
|
|
576
|
+
* same-id jobs be queried, registered, or cancelled across sessions
|
|
577
|
+
* (review thread P1). */
|
|
577
578
|
static registerForEndpoint(endpointId: string, manager: AsyncJobManager): boolean {
|
|
578
579
|
const holder = AsyncJobManager.#byEndpoint.get(endpointId);
|
|
579
|
-
if (
|
|
580
|
+
if (manager.#disposed || manager.#registrationClosed) return false;
|
|
581
|
+
if (holder !== undefined && holder !== manager) {
|
|
582
|
+
// A manager remains the endpoint owner until all authority retained
|
|
583
|
+
// past its disposal deadline has physically settled. Logical shutdown
|
|
584
|
+
// alone must not admit a replacement while late callbacks can still run.
|
|
585
|
+
if (!holder.#disposed || !holder.#disposalSettled) return false;
|
|
586
|
+
AsyncJobManager.#byEndpoint.delete(endpointId);
|
|
587
|
+
}
|
|
580
588
|
AsyncJobManager.#byEndpoint.set(endpointId, manager);
|
|
581
589
|
return true;
|
|
582
590
|
}
|
|
@@ -586,7 +594,8 @@ export class AsyncJobManager {
|
|
|
586
594
|
return AsyncJobManager.#byEndpoint.get(endpointId);
|
|
587
595
|
}
|
|
588
596
|
|
|
589
|
-
static unregisterForEndpoint(endpointId: string): void {
|
|
597
|
+
static unregisterForEndpoint(endpointId: string, manager?: AsyncJobManager): void {
|
|
598
|
+
if (manager !== undefined && AsyncJobManager.#byEndpoint.get(endpointId) !== manager) return;
|
|
590
599
|
AsyncJobManager.#byEndpoint.delete(endpointId);
|
|
591
600
|
}
|
|
592
601
|
|
|
@@ -619,9 +628,13 @@ export class AsyncJobManager {
|
|
|
619
628
|
manager: AsyncJobManager | undefined,
|
|
620
629
|
): boolean {
|
|
621
630
|
if (!manager || predecessorEndpointId === successorEndpointId) return true;
|
|
631
|
+
if (manager.#disposed || manager.#registrationClosed) return false;
|
|
622
632
|
if (AsyncJobManager.#byEndpoint.get(predecessorEndpointId) !== manager) return true;
|
|
623
633
|
const successorOwner = AsyncJobManager.#byEndpoint.get(successorEndpointId);
|
|
624
|
-
if (successorOwner !== undefined && successorOwner !== manager)
|
|
634
|
+
if (successorOwner !== undefined && successorOwner !== manager) {
|
|
635
|
+
if (!successorOwner.#disposed || !successorOwner.#disposalSettled) return false;
|
|
636
|
+
AsyncJobManager.#byEndpoint.delete(successorEndpointId);
|
|
637
|
+
}
|
|
625
638
|
AsyncJobManager.#byEndpoint.delete(predecessorEndpointId);
|
|
626
639
|
AsyncJobManager.#byEndpoint.set(successorEndpointId, manager);
|
|
627
640
|
return true;
|
|
@@ -651,6 +664,7 @@ export class AsyncJobManager {
|
|
|
651
664
|
|
|
652
665
|
readonly #jobs = new Map<string, AsyncJob>();
|
|
653
666
|
#retainedDisposalCompletion: Promise<void> = Promise.resolve();
|
|
667
|
+
#disposePromise: Promise<boolean> | undefined;
|
|
654
668
|
readonly #deliveries: AsyncJobDelivery[] = [];
|
|
655
669
|
readonly #inFlightDeliveries: AsyncJobDelivery[] = [];
|
|
656
670
|
readonly #suppressedDeliveries = new Set<string>();
|
|
@@ -674,6 +688,8 @@ export class AsyncJobManager {
|
|
|
674
688
|
readonly #maxDeadLetterOverflowOwners: number;
|
|
675
689
|
#deliveryLoop: Promise<void> | undefined;
|
|
676
690
|
#disposed = false;
|
|
691
|
+
/** Set only after all authority retained past a disposal deadline settles. */
|
|
692
|
+
#disposalSettled = false;
|
|
677
693
|
#runningOwnerCleanups = false;
|
|
678
694
|
readonly #subagentRecords = new Map<string, SubagentRecord>();
|
|
679
695
|
readonly #terminalEvents = new Map<string, TerminalEvent>();
|
|
@@ -2773,6 +2789,13 @@ export class AsyncJobManager {
|
|
|
2773
2789
|
}
|
|
2774
2790
|
|
|
2775
2791
|
async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
|
|
2792
|
+
if (this.#disposePromise) return this.#disposePromise;
|
|
2793
|
+
const disposal = this.#disposeInternal(options);
|
|
2794
|
+
this.#disposePromise = disposal;
|
|
2795
|
+
return disposal;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
async #disposeInternal(options?: { timeoutMs?: number }): Promise<boolean> {
|
|
2776
2799
|
// Close registration FIRST, then run owner cleanups, and only then mark the
|
|
2777
2800
|
// manager disposed. Setting #disposed before the cleanups made
|
|
2778
2801
|
// #ensureDeliveryLoop a no-op, so a failure an owner cleanup settled through
|
|
@@ -2865,6 +2888,13 @@ export class AsyncJobManager {
|
|
|
2865
2888
|
this.#notifyChange();
|
|
2866
2889
|
this.#changeListeners.clear();
|
|
2867
2890
|
this.#foldListeners.clear();
|
|
2891
|
+
const releaseEndpointOwnership = (): void => {
|
|
2892
|
+
this.#disposalSettled = true;
|
|
2893
|
+
AsyncJobManager.unregisterManager(this);
|
|
2894
|
+
if (AsyncJobManager.instance() === this) AsyncJobManager.setInstance(undefined);
|
|
2895
|
+
};
|
|
2896
|
+
if (disposalCompleted) releaseEndpointOwnership();
|
|
2897
|
+
else void this.#retainedDisposalCompletion.then(releaseEndpointOwnership);
|
|
2868
2898
|
return disposalCompleted;
|
|
2869
2899
|
}
|
|
2870
2900
|
|
package/src/commands/sdk.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
@@ -13,7 +14,11 @@ import { initTheme } from "../modes/theme/theme";
|
|
|
13
14
|
import { ACP_MCP_REQUEST_TIMEOUT_MS, ACP_MCP_STARTUP_HEADROOM_MS } from "../sdk/acp/mcp";
|
|
14
15
|
import { Broker } from "../sdk/broker/broker";
|
|
15
16
|
import { readBrokerDiscovery } from "../sdk/broker/discovery";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
emitBrokerStartupTestSignal,
|
|
19
|
+
reconcileBrokerGenerationForStartup,
|
|
20
|
+
withBrokerStartupLock,
|
|
21
|
+
} from "../sdk/broker/ensure";
|
|
17
22
|
import { completeBrokerProcess } from "../sdk/broker/internal";
|
|
18
23
|
import {
|
|
19
24
|
type LifecycleTranscriptEvidence,
|
|
@@ -39,7 +44,7 @@ import {
|
|
|
39
44
|
type SdkStartupRollbackResult,
|
|
40
45
|
SdkStartupRollbackTracker,
|
|
41
46
|
} from "../sdk/startup-capability";
|
|
42
|
-
import { runSdkServe } from "../sdk/transport/serve-cli";
|
|
47
|
+
import { runSdkServe, SdkServeError } from "../sdk/transport/serve-cli";
|
|
43
48
|
import { isSessionDisposalIncompleteError } from "../session/agent-session";
|
|
44
49
|
import {
|
|
45
50
|
type CapturedSessionTranscriptSnapshot,
|
|
@@ -94,6 +99,43 @@ export async function lifecycleArgs(
|
|
|
94
99
|
export const SESSION_HOST_BROKER_ABSENCE_GRACE_MS = 10 * 60_000;
|
|
95
100
|
const SESSION_HOST_BROKER_POLL_MS = 15_000;
|
|
96
101
|
|
|
102
|
+
async function waitForBrokerStartupTestGate(gateFile: string): Promise<void> {
|
|
103
|
+
if (await Bun.file(gateFile).exists()) return;
|
|
104
|
+
const directory = path.dirname(gateFile);
|
|
105
|
+
const filename = path.basename(gateFile);
|
|
106
|
+
await new Promise<void>((resolve, reject) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
let watcher: nodeFs.FSWatcher | undefined;
|
|
109
|
+
const finish = (error?: unknown): void => {
|
|
110
|
+
if (settled) return;
|
|
111
|
+
settled = true;
|
|
112
|
+
watcher?.close();
|
|
113
|
+
if (error === undefined) resolve();
|
|
114
|
+
else reject(error);
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
watcher = nodeFs.watch(directory, (_eventType, changed) => {
|
|
118
|
+
if (String(changed) !== filename) return;
|
|
119
|
+
void Bun.file(gateFile)
|
|
120
|
+
.exists()
|
|
121
|
+
.then(exists => {
|
|
122
|
+
if (exists) finish();
|
|
123
|
+
})
|
|
124
|
+
.catch(finish);
|
|
125
|
+
});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
finish(error);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
void Bun.file(gateFile)
|
|
131
|
+
.exists()
|
|
132
|
+
.then(exists => {
|
|
133
|
+
if (exists) finish();
|
|
134
|
+
})
|
|
135
|
+
.catch(finish);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
97
139
|
/**
|
|
98
140
|
* Identity of the broker incarnation an observation describes, or `null` when
|
|
99
141
|
* it carries no usable identity.
|
|
@@ -1115,7 +1157,29 @@ export default class Sdk extends Command {
|
|
|
1115
1157
|
return;
|
|
1116
1158
|
}
|
|
1117
1159
|
if (action === "serve") {
|
|
1118
|
-
|
|
1160
|
+
try {
|
|
1161
|
+
await runSdkServe(this.argv.slice(1));
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (!(error instanceof SdkServeError)) throw error;
|
|
1164
|
+
// stdout is the frame channel in --stdio mode, so the envelope goes to
|
|
1165
|
+
// stderr; returning instead of rethrowing is what keeps a session-selection
|
|
1166
|
+
// failure from reaching the embedder as an uncaught exception.
|
|
1167
|
+
process.stderr.write(
|
|
1168
|
+
`${JSON.stringify({
|
|
1169
|
+
ok: false,
|
|
1170
|
+
error: {
|
|
1171
|
+
code: error.code,
|
|
1172
|
+
message: error.message,
|
|
1173
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
1174
|
+
// A broker teardown that failed alongside the primary failure is
|
|
1175
|
+
// recorded on the error; dropping it here would hide from the
|
|
1176
|
+
// embedder that the session may not have been released cleanly.
|
|
1177
|
+
...(error.cleanupError === undefined ? {} : { cleanupError: error.cleanupError }),
|
|
1178
|
+
},
|
|
1179
|
+
})}\n`,
|
|
1180
|
+
);
|
|
1181
|
+
process.exitCode = error.exitCode;
|
|
1182
|
+
}
|
|
1119
1183
|
return;
|
|
1120
1184
|
}
|
|
1121
1185
|
if (action !== "broker-internal" && action !== "session-host-internal")
|
|
@@ -1128,7 +1192,7 @@ export default class Sdk extends Command {
|
|
|
1128
1192
|
const agentDir = path.resolve(internal.agentDir);
|
|
1129
1193
|
let broker: Broker | undefined;
|
|
1130
1194
|
try {
|
|
1131
|
-
|
|
1195
|
+
const startupOperation = async (deadline: number): Promise<Broker | undefined> => {
|
|
1132
1196
|
const remainingMs = Math.max(1, deadline - Date.now());
|
|
1133
1197
|
const testWatchdogMs = Number(process.env.GJC_SDK_TEST_BROKER_STARTUP_WATCHDOG_MS ?? 0);
|
|
1134
1198
|
const watchdogMs =
|
|
@@ -1145,6 +1209,12 @@ export default class Sdk extends Command {
|
|
|
1145
1209
|
const stalled = Promise.withResolvers<void>();
|
|
1146
1210
|
await stalled.promise;
|
|
1147
1211
|
}
|
|
1212
|
+
const startupGateFile = process.env.GJC_SDK_TEST_BROKER_STARTUP_GATE_FILE;
|
|
1213
|
+
if (startupGateFile) {
|
|
1214
|
+
await emitBrokerStartupTestSignal("startup-gate-waiting");
|
|
1215
|
+
await waitForBrokerStartupTestGate(startupGateFile);
|
|
1216
|
+
await emitBrokerStartupTestSignal("startup-gate-released");
|
|
1217
|
+
}
|
|
1148
1218
|
const startupDelayMs = Number(process.env.GJC_SDK_TEST_BROKER_STARTUP_DELAY_MS ?? 0);
|
|
1149
1219
|
if (Number.isSafeInteger(startupDelayMs) && startupDelayMs > 0 && startupDelayMs <= 10_000)
|
|
1150
1220
|
await Bun.sleep(startupDelayMs);
|
|
@@ -1179,6 +1249,10 @@ export default class Sdk extends Command {
|
|
|
1179
1249
|
} finally {
|
|
1180
1250
|
clearTimeout(startupWatchdog);
|
|
1181
1251
|
}
|
|
1252
|
+
};
|
|
1253
|
+
broker = await withBrokerStartupLock(agentDir, startupOperation, {
|
|
1254
|
+
onAcquired: () => void emitBrokerStartupTestSignal("fence-acquired"),
|
|
1255
|
+
onContended: () => void emitBrokerStartupTestSignal("fence-contended"),
|
|
1182
1256
|
});
|
|
1183
1257
|
} catch (error) {
|
|
1184
1258
|
if (broker) {
|
|
@@ -378,11 +378,13 @@ export const TIER_MAP_SKIP_LIST = {
|
|
|
378
378
|
},
|
|
379
379
|
"opencode-go/qwen3.8-flash": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
380
380
|
"opencode-go/qwen3.8-max": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
381
|
+
"opencode-go/union-alpha": { rationale: "stealth model; capability tier not yet independently reviewed" },
|
|
381
382
|
"opencode-zen/grok-4.6": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
382
383
|
"opencode-zen/hy3-free": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
383
384
|
"opencode-zen/kimi-k3": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
384
385
|
"opencode-zen/ling-3.0-tiny-free": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
385
386
|
"opencode-zen/nemotron-3.5-lightning-free": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
387
|
+
"opencode-zen/union-alpha": { rationale: "stealth model; capability tier not yet independently reviewed" },
|
|
386
388
|
"openrouter/anthropic/claude-fable-5:batch": { rationale: "post-rebase catalog addition from dev; not yet curated" },
|
|
387
389
|
"openrouter/anthropic/claude-haiku-4.5:batch": {
|
|
388
390
|
rationale: "post-rebase catalog addition from dev; not yet curated",
|
package/src/config/file-lock.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface FileLockOptions {
|
|
|
27
27
|
retryDelayMs?: number;
|
|
28
28
|
signal?: AbortSignal;
|
|
29
29
|
onAcquired?: () => void;
|
|
30
|
+
onContended?: () => void;
|
|
30
31
|
/** Stable host identity required to safely reclaim locks on a shared volume. */
|
|
31
32
|
ownerHostId?: string;
|
|
32
33
|
/** Previous local host identities accepted only when deciding stale-owner reclamation. */
|
|
@@ -84,7 +85,7 @@ export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcqui
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const DEFAULT_OPTIONS: Required<
|
|
87
|
-
Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired">
|
|
88
|
+
Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired" | "onContended">
|
|
88
89
|
> = {
|
|
89
90
|
staleMs: 10_000,
|
|
90
91
|
retries: 50,
|
|
@@ -1363,6 +1364,28 @@ function ownerLiveness(pid: number): OwnerLiveness {
|
|
|
1363
1364
|
}
|
|
1364
1365
|
}
|
|
1365
1366
|
|
|
1367
|
+
/**
|
|
1368
|
+
* Does this owner record belong to a machine other than the acquirer's?
|
|
1369
|
+
*
|
|
1370
|
+
* A host-qualified record is foreign unless the acquirer can prove it is its own:
|
|
1371
|
+
* either the identity matches, or it matches an identity this installation used
|
|
1372
|
+
* before. An acquirer carrying no host identity can prove nothing, so every
|
|
1373
|
+
* host-qualified record stays foreign to it. Foreign records fail closed — their
|
|
1374
|
+
* PID values and clocks are not meaningful here, so they are neither reclaimable
|
|
1375
|
+
* nor locally probeable for liveness.
|
|
1376
|
+
*
|
|
1377
|
+
* Shared by the reclamation verdict and the exhaustion diagnostic so the two can
|
|
1378
|
+
* never disagree about whose pid a record names.
|
|
1379
|
+
*/
|
|
1380
|
+
function lockRecordIsForeignHost(
|
|
1381
|
+
info: LockInfo,
|
|
1382
|
+
ownerHostId: string | undefined,
|
|
1383
|
+
previousOwnerHostIds: readonly string[],
|
|
1384
|
+
): boolean {
|
|
1385
|
+
if (ownerHostId === undefined) return info.owner_host_id !== undefined;
|
|
1386
|
+
return info.owner_host_id !== ownerHostId && !previousOwnerHostIds.includes(info.owner_host_id ?? "");
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1366
1389
|
async function staleLockSnapshot(
|
|
1367
1390
|
lockPath: string,
|
|
1368
1391
|
_staleMs: number,
|
|
@@ -1402,13 +1425,7 @@ async function staleLockSnapshot(
|
|
|
1402
1425
|
// A host-qualified lock may only be reclaimed after proving that its owner is
|
|
1403
1426
|
// local. Foreign and malformed host-qualified records fail closed: PID values
|
|
1404
1427
|
// and clocks are not meaningful across hosts.
|
|
1405
|
-
if (
|
|
1406
|
-
ownerHostId !== undefined &&
|
|
1407
|
-
info.owner_host_id !== ownerHostId &&
|
|
1408
|
-
!previousOwnerHostIds.includes(info.owner_host_id ?? "")
|
|
1409
|
-
)
|
|
1410
|
-
return { stale: false };
|
|
1411
|
-
if (ownerHostId === undefined && info.owner_host_id !== undefined) return { stale: false };
|
|
1428
|
+
if (lockRecordIsForeignHost(info, ownerHostId, previousOwnerHostIds)) return { stale: false };
|
|
1412
1429
|
if (ownerIncarnationChanged(info, startTimeCache)) {
|
|
1413
1430
|
if (!judgedIdentity) return { stale: false };
|
|
1414
1431
|
let currentIdentity: GenericFileLockDirIdentity | null;
|
|
@@ -1962,14 +1979,22 @@ async function lockHolderDescription(
|
|
|
1962
1979
|
info = bytes === null ? null : parseLockInfoBytes(bytes);
|
|
1963
1980
|
}
|
|
1964
1981
|
if (info) {
|
|
1965
|
-
// A lock record carrying a
|
|
1982
|
+
// A lock record carrying a FOREIGN owner_host_id belongs to another
|
|
1966
1983
|
// machine (shared-volume topic registry): its pid is meaningful only
|
|
1967
1984
|
// on that host, so probing the same numeric pid here could mislabel a
|
|
1968
1985
|
// coincident local process as the holder. Report the owner host with
|
|
1969
|
-
// unknown liveness instead.
|
|
1970
|
-
|
|
1986
|
+
// unknown liveness instead. The same predicate that decides whether
|
|
1987
|
+
// the record is reclaimable decides whether its pid is probeable, so a
|
|
1988
|
+
// holder this acquirer *may* reclaim is never described as opaque.
|
|
1989
|
+
if (lockRecordIsForeignHost(info, ownerHostId, previousOwnerHostIds)) {
|
|
1990
|
+
// An unqualified record carries no host provenance at all. A host-aware
|
|
1991
|
+
// acquirer still fails closed on it — its pid is never probed and its
|
|
1992
|
+
// lock is never reclaimed — but the diagnostic must say the provenance
|
|
1993
|
+
// is missing instead of naming a host it does not have.
|
|
1994
|
+
const provenance =
|
|
1995
|
+
info.owner_host_id === undefined ? "on an unrecorded host" : `on host ${info.owner_host_id}`;
|
|
1971
1996
|
return (
|
|
1972
|
-
`held by pid ${info.pid}
|
|
1997
|
+
`held by pid ${info.pid} ${provenance} (liveness unknown from this host)` +
|
|
1973
1998
|
` since ${new Date(info.timestamp).toISOString()}`
|
|
1974
1999
|
);
|
|
1975
2000
|
}
|
|
@@ -1977,13 +2002,18 @@ async function lockHolderDescription(
|
|
|
1977
2002
|
// record carries a start_time, the start-time identity match) so a dead
|
|
1978
2003
|
// holder whose pid was already reused is not mislabeled "(live)".
|
|
1979
2004
|
const alive = ownerIsAlive(info);
|
|
2005
|
+
const liveness = alive
|
|
2006
|
+
? "live"
|
|
2007
|
+
: ownerLiveness(info.pid) === "dead"
|
|
2008
|
+
? "dead but not reaped"
|
|
2009
|
+
: "liveness unknown";
|
|
1980
2010
|
return (
|
|
1981
2011
|
`held by pid ${info.pid}` +
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2012
|
+
// Keep the host on a host-qualified local record: on a shared volume the
|
|
2013
|
+
// same pathname is contended from several hosts, so naming the one whose
|
|
2014
|
+
// pid space this verdict came from is what makes it actionable.
|
|
2015
|
+
(info.owner_host_id === undefined ? "" : " on this host") +
|
|
2016
|
+
` (${liveness})` +
|
|
1987
2017
|
` since ${new Date(info.timestamp).toISOString()}`
|
|
1988
2018
|
);
|
|
1989
2019
|
}
|
|
@@ -2022,6 +2052,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2022
2052
|
}
|
|
2023
2053
|
const ownerToken = crypto.randomUUID();
|
|
2024
2054
|
const contentionStartTimes = new Map<string, string | null>();
|
|
2055
|
+
let contentionObserved = false;
|
|
2025
2056
|
let staleRemovalFailure: RecordedStaleRemovalFailure | undefined;
|
|
2026
2057
|
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
2027
2058
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
@@ -2052,6 +2083,10 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2052
2083
|
localLockStates.set(localKey, { owner: result, status: "held" });
|
|
2053
2084
|
return () => releaseLock(lockPath, result, localKey);
|
|
2054
2085
|
}
|
|
2086
|
+
if (!contentionObserved) {
|
|
2087
|
+
contentionObserved = true;
|
|
2088
|
+
opts.onContended?.();
|
|
2089
|
+
}
|
|
2055
2090
|
const pendingKey = await pendingLocalReleaseKey(lockPath, localKey);
|
|
2056
2091
|
const localState = localLockStates.get(pendingKey ?? localKey);
|
|
2057
2092
|
if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
2
|
-
import type
|
|
2
|
+
import { type Api, isKnownProvider, type Model } from "@gajae-code/ai/core";
|
|
3
3
|
import { logger } from "@gajae-code/utils";
|
|
4
4
|
import type { AgentSession, DefaultFallbackRuntimeState } from "../session/agent-session";
|
|
5
5
|
import { clampExplicitThinkingLevelForModel, formatClampedModelSelector } from "../thinking";
|
|
@@ -105,6 +105,7 @@ export interface PrepareModelProfileActivationOptions {
|
|
|
105
105
|
| "getSessionCanonicalVariant"
|
|
106
106
|
| "restoreSessionCanonicalVariant"
|
|
107
107
|
| "getConfiguredProviderIds"
|
|
108
|
+
| "isKnownProvider"
|
|
108
109
|
>
|
|
109
110
|
> & {
|
|
110
111
|
getError?: ModelRegistry["getError"];
|
|
@@ -449,7 +450,7 @@ export function materializeActiveModelProfileAssignments(options: MaterializeMod
|
|
|
449
450
|
}
|
|
450
451
|
|
|
451
452
|
export class ModelProfileCredentialError extends Error {
|
|
452
|
-
readonly code = "authentication_failed";
|
|
453
|
+
readonly code: string = "authentication_failed";
|
|
453
454
|
readonly profileLabel: string;
|
|
454
455
|
readonly providers: readonly string[];
|
|
455
456
|
readonly role: string | undefined;
|
|
@@ -466,6 +467,28 @@ export class ModelProfileCredentialError extends Error {
|
|
|
466
467
|
export function formatModelProfileCredentialError(profileLabel: string, providers: readonly string[]): string {
|
|
467
468
|
return `Model profile "${profileLabel}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
|
|
468
469
|
}
|
|
470
|
+
export function formatModelProfileUnknownProviderError(profileLabel: string, providers: readonly string[]): string {
|
|
471
|
+
return `Model profile "${profileLabel}" requires provider(s) this build does not know: ${providers.join(", ")}. The profile likely targets a newer or custom build; declare the provider(s) in models.yml or use a build that ships them.`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A required profile provider that this build cannot know: it is neither a
|
|
476
|
+
* built-in provider id nor declared in models.yml. That is a build/config
|
|
477
|
+
* mismatch (for example a profile authored on a newer or custom build), not a
|
|
478
|
+
* credential gap, so the diagnosis must not send the user to /login.
|
|
479
|
+
*
|
|
480
|
+
* Extends {@link ModelProfileCredentialError} so existing startup recovery
|
|
481
|
+
* (interactive toast-and-continue) keeps working with the sharper message.
|
|
482
|
+
*/
|
|
483
|
+
export class ModelProfileUnknownProviderError extends ModelProfileCredentialError {
|
|
484
|
+
readonly code = "unknown_provider";
|
|
485
|
+
|
|
486
|
+
constructor(profileLabel: string, providers: readonly string[], role?: string) {
|
|
487
|
+
super(profileLabel, providers, role);
|
|
488
|
+
this.name = "ModelProfileUnknownProviderError";
|
|
489
|
+
this.message = formatModelProfileUnknownProviderError(profileLabel, providers);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
469
492
|
|
|
470
493
|
/**
|
|
471
494
|
* Rewrite a selector only within the selector provider's own alternative group.
|
|
@@ -534,6 +557,14 @@ function rewriteBindingsProviders(
|
|
|
534
557
|
),
|
|
535
558
|
};
|
|
536
559
|
}
|
|
560
|
+
export function isModelProfileProxyConfigured(
|
|
561
|
+
provider: string,
|
|
562
|
+
configuredProviders: readonly string[] | undefined,
|
|
563
|
+
credentialless: boolean,
|
|
564
|
+
): boolean {
|
|
565
|
+
return configuredProviders?.includes(provider) === true || (provider === "opencodex" && credentialless);
|
|
566
|
+
}
|
|
567
|
+
|
|
537
568
|
/**
|
|
538
569
|
* Resolve the explicitly configured OpenAI-compatible proxy provider id for a
|
|
539
570
|
* preset. Returns undefined when unset or empty. Passwords/labels are never
|
|
@@ -620,9 +651,13 @@ export function rewriteSelectorForProxy(
|
|
|
620
651
|
const baseSelector = suffix.selector;
|
|
621
652
|
const slash = baseSelector.indexOf("/");
|
|
622
653
|
const proxyModels = allModels.filter(model => model.provider === proxyProvider);
|
|
654
|
+
const matchingProxyModels = (id: string): Model<Api>[] => {
|
|
655
|
+
const publicMatches = proxyModels.filter(model => model.id === id);
|
|
656
|
+
return publicMatches.length > 0 ? publicMatches : proxyModels.filter(model => model.wireModelId === id);
|
|
657
|
+
};
|
|
623
658
|
if (slash < 0) {
|
|
624
659
|
if (proxyMode === "fallback") return selector;
|
|
625
|
-
const exactMatches =
|
|
660
|
+
const exactMatches = matchingProxyModels(baseSelector);
|
|
626
661
|
const finalSegmentMatches = proxyModels.filter(model => model.id.split("/").at(-1) === baseSelector);
|
|
627
662
|
const matches = exactMatches.length > 0 ? exactMatches : finalSegmentMatches;
|
|
628
663
|
if (matches.length !== 1) {
|
|
@@ -640,8 +675,8 @@ export function rewriteSelectorForProxy(
|
|
|
640
675
|
throw new Error(`Configured proxy "${proxyProvider}" cannot route its own direct selector "${baseSelector}"`);
|
|
641
676
|
}
|
|
642
677
|
const directModelId = baseSelector.substring(slash + 1);
|
|
643
|
-
const exactMatches =
|
|
644
|
-
const flatMatches =
|
|
678
|
+
const exactMatches = matchingProxyModels(`${directProvider}/${directModelId}`);
|
|
679
|
+
const flatMatches = matchingProxyModels(directModelId);
|
|
645
680
|
const matches = exactMatches.length > 0 ? exactMatches : flatMatches;
|
|
646
681
|
if (matches.length === 0) {
|
|
647
682
|
throw new Error(`Configured proxy "${proxyProvider}" does not expose a model for "${baseSelector}"`);
|
|
@@ -957,6 +992,33 @@ export async function prepareModelProfileActivation(
|
|
|
957
992
|
...alternativeSet,
|
|
958
993
|
...deriveModelProfileMappedProviders(profile),
|
|
959
994
|
]);
|
|
995
|
+
// A required provider this build cannot know is a build/config mismatch,
|
|
996
|
+
// not a credential gap: diagnose it before any auth probing so the user
|
|
997
|
+
// is never sent to /login for a provider this binary cannot serve (for
|
|
998
|
+
// example a models.yml profile authored on a newer or custom build). A
|
|
999
|
+
// registry without provider visibility keeps the legacy
|
|
1000
|
+
// credential diagnosis.
|
|
1001
|
+
const configuredProviderIds = options.modelRegistry.getConfiguredProviderIds?.();
|
|
1002
|
+
if (configuredProviderIds !== undefined || options.modelRegistry.isKnownProvider !== undefined) {
|
|
1003
|
+
const isProviderKnown = (provider: string): boolean =>
|
|
1004
|
+
(options.modelRegistry.isKnownProvider?.(provider) ?? false) ||
|
|
1005
|
+
isKnownProvider(provider) ||
|
|
1006
|
+
configuredProviderIds?.includes(provider) === true;
|
|
1007
|
+
const unknownRequiredProviders = new Set<string>();
|
|
1008
|
+
for (const provider of requiredProviders) {
|
|
1009
|
+
if (!alternativeSet.has(provider) && !isProviderKnown(provider)) unknownRequiredProviders.add(provider);
|
|
1010
|
+
}
|
|
1011
|
+
for (const group of alternativeGroups) {
|
|
1012
|
+
if (group.some(isProviderKnown)) continue;
|
|
1013
|
+
for (const provider of group) {
|
|
1014
|
+
if (!isProviderKnown(provider)) unknownRequiredProviders.add(provider);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const unknownProviderIds = [...unknownRequiredProviders].sort();
|
|
1018
|
+
if (unknownProviderIds.length > 0) {
|
|
1019
|
+
throw new ModelProfileUnknownProviderError(profileLabel, unknownProviderIds);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
960
1022
|
|
|
961
1023
|
const missingProviders: string[] = [];
|
|
962
1024
|
const authenticatedProviders: string[] = [];
|
|
@@ -995,18 +1057,18 @@ export async function prepareModelProfileActivation(
|
|
|
995
1057
|
if (proxyMode === "always" && proxyProvider === undefined) {
|
|
996
1058
|
throw new Error('modelProfile.proxyMode "always" requires modelProfile.proxyProvider');
|
|
997
1059
|
}
|
|
1060
|
+
const proxyApiKey =
|
|
1061
|
+
proxyProvider === undefined
|
|
1062
|
+
? undefined
|
|
1063
|
+
: await options.modelRegistry.getApiKeyForProvider(proxyProvider, credentialSessionId);
|
|
998
1064
|
if (proxyProvider !== undefined) {
|
|
999
1065
|
const configuredProxyProviders = options.modelRegistry.getConfiguredProviderIds?.();
|
|
1000
|
-
if (!
|
|
1066
|
+
if (!isModelProfileProxyConfigured(proxyProvider, configuredProxyProviders, proxyApiKey === kNoAuth)) {
|
|
1001
1067
|
throw new Error(
|
|
1002
1068
|
`modelProfile.proxyProvider "${proxyProvider}" is not configured. Configure it with \`gjc setup provider\` before activating a preset.`,
|
|
1003
1069
|
);
|
|
1004
1070
|
}
|
|
1005
1071
|
}
|
|
1006
|
-
const proxyApiKey =
|
|
1007
|
-
proxyProvider === undefined
|
|
1008
|
-
? undefined
|
|
1009
|
-
: await options.modelRegistry.getApiKeyForProvider(proxyProvider, credentialSessionId);
|
|
1010
1072
|
const proxyAuthenticated =
|
|
1011
1073
|
proxyProvider !== undefined &&
|
|
1012
1074
|
proxyApiKey !== undefined &&
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getBundledModels,
|
|
19
19
|
getBundledProviders,
|
|
20
20
|
getEnvApiKey,
|
|
21
|
+
getMiniMaxThinkingMode,
|
|
21
22
|
googleAntigravityModelManagerOptions,
|
|
22
23
|
googleGeminiCliModelManagerOptions,
|
|
23
24
|
isCodexGpt56Tier,
|
|
@@ -280,6 +281,7 @@ export const GJC_MODEL_ASSIGNMENT_TARGETS: Record<GjcModelAssignmentTargetId, Gj
|
|
|
280
281
|
|
|
281
282
|
export function requiresExplicitThinkingChoice(model: Model, role: GjcModelAssignmentTargetId | null): boolean {
|
|
282
283
|
if (!modelSupportsReasoningControl(model)) return false;
|
|
284
|
+
if (getMiniMaxThinkingMode(model) === "toggle") return true;
|
|
283
285
|
if (model.provider === "openai" || model.provider === "openai-codex" || isDirectXaiReasoningEffortModel(model))
|
|
284
286
|
return true;
|
|
285
287
|
if (role === null) return false;
|
|
@@ -4770,6 +4772,25 @@ export class ModelRegistry {
|
|
|
4770
4772
|
return [...this.#configuredProviderIds];
|
|
4771
4773
|
}
|
|
4772
4774
|
|
|
4775
|
+
/**
|
|
4776
|
+
* Whether this registry can serve a provider id from any active source.
|
|
4777
|
+
*
|
|
4778
|
+
* Built-in providers are backed by the bundled AI catalog, configured
|
|
4779
|
+
* providers come from models.yml, and extension registrations are retained
|
|
4780
|
+
* in the runtime provider stores below. Runtime model/override state is
|
|
4781
|
+
* included for direct registrations that do not have an extension source id.
|
|
4782
|
+
*/
|
|
4783
|
+
isKnownProvider(provider: string): boolean {
|
|
4784
|
+
return (
|
|
4785
|
+
isKnownProvider(provider) ||
|
|
4786
|
+
this.#configuredProviderIds.has(provider) ||
|
|
4787
|
+
this.#runtimeProviderSourceByName.has(provider) ||
|
|
4788
|
+
this.#runtimeModelOverlays.some(model => model.provider === provider) ||
|
|
4789
|
+
this.#runtimeProviderApiKeys.has(provider) ||
|
|
4790
|
+
this.#runtimeProviderOverrides.has(provider)
|
|
4791
|
+
);
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4773
4794
|
#isModelAvailable(model: Model<Api>, disabledProviders?: ReadonlySet<string>): boolean {
|
|
4774
4795
|
const disabled = disabledProviders ?? getDisabledProviderIdsFromSettings(this.#settings);
|
|
4775
4796
|
return (
|
|
@@ -307,6 +307,9 @@ interface CoordinatorServices {
|
|
|
307
307
|
canonicalizePath?: (value: string) => Promise<string>;
|
|
308
308
|
codexTransportFactory?: CodexTransportFactory;
|
|
309
309
|
eventWebhookDelivery?: WebhookDelivery;
|
|
310
|
+
/** Observable transitions for consumers coordinating with the filesystem-backed event wait. */
|
|
311
|
+
onCoordinatorEventWatchReady?: () => void;
|
|
312
|
+
onCoordinatorEventWake?: () => void;
|
|
310
313
|
/** Test barrier invoked after an accepted prompt receipt is durable and before turn finalization. */
|
|
311
314
|
afterPromptReceiptPersisted?: (sessionId: string) => void | Promise<void>;
|
|
312
315
|
/** Test barrier invoked after an answer dispatch is claimed and before its final admission check. */
|
|
@@ -3188,18 +3191,24 @@ function waitForTurnStateChange(namespaceDir: string, turn: TurnRecord, timeoutM
|
|
|
3188
3191
|
return deferred.promise;
|
|
3189
3192
|
}
|
|
3190
3193
|
|
|
3191
|
-
async function waitForCoordinatorEvents(
|
|
3194
|
+
async function waitForCoordinatorEvents(
|
|
3195
|
+
namespaceDir: string,
|
|
3196
|
+
timeoutMs: number,
|
|
3197
|
+
onWatchReady?: () => void,
|
|
3198
|
+
onWake?: () => void,
|
|
3199
|
+
): Promise<void> {
|
|
3192
3200
|
const deferred = Promise.withResolvers<void>();
|
|
3193
3201
|
const watchers: nodeFs.FSWatcher[] = [];
|
|
3194
3202
|
let settled = false;
|
|
3195
|
-
const finish = () => {
|
|
3203
|
+
const finish = (woke: boolean) => {
|
|
3196
3204
|
if (settled) return;
|
|
3197
3205
|
settled = true;
|
|
3198
3206
|
for (const watcher of watchers) watcher.close();
|
|
3199
3207
|
clearTimeout(timer);
|
|
3208
|
+
if (woke) onWake?.();
|
|
3200
3209
|
deferred.resolve();
|
|
3201
3210
|
};
|
|
3202
|
-
const timer = setTimeout(finish, Math.max(timeoutMs, 0));
|
|
3211
|
+
const timer = setTimeout(() => finish(false), Math.max(timeoutMs, 0));
|
|
3203
3212
|
timer.unref?.();
|
|
3204
3213
|
const eventDir = eventsDir(namespaceDir);
|
|
3205
3214
|
const watchedDirs = [
|
|
@@ -3213,16 +3222,17 @@ async function waitForCoordinatorEvents(namespaceDir: string, timeoutMs: number)
|
|
|
3213
3222
|
try {
|
|
3214
3223
|
const watcher = nodeFs.watch(dir, (_eventType, filename) => {
|
|
3215
3224
|
if (dir === eventDir) {
|
|
3216
|
-
if (filename === "event-journal.jsonl" || filename === "latest-seq.json") finish();
|
|
3225
|
+
if (filename === "event-journal.jsonl" || filename === "latest-seq.json") finish(true);
|
|
3217
3226
|
return;
|
|
3218
3227
|
}
|
|
3219
|
-
if (typeof filename === "string" && filename.endsWith(".json")) finish();
|
|
3228
|
+
if (typeof filename === "string" && filename.endsWith(".json")) finish(true);
|
|
3220
3229
|
});
|
|
3221
3230
|
watchers.push(watcher);
|
|
3222
3231
|
} catch {
|
|
3223
3232
|
// Directory may not be watchable on this platform; the timeout remains a bounded fallback.
|
|
3224
3233
|
}
|
|
3225
3234
|
}
|
|
3235
|
+
onWatchReady?.();
|
|
3226
3236
|
return deferred.promise;
|
|
3227
3237
|
}
|
|
3228
3238
|
|
|
@@ -8645,7 +8655,12 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
8645
8655
|
if (matched.length === 0 && timeoutMs > 0) {
|
|
8646
8656
|
const deadline = absoluteDeadline;
|
|
8647
8657
|
while (matched.length === 0 && Date.now() < deadline && !watchController.signal.aborted) {
|
|
8648
|
-
await waitForCoordinatorEvents(
|
|
8658
|
+
await waitForCoordinatorEvents(
|
|
8659
|
+
namespaceDir,
|
|
8660
|
+
Math.min(50, Math.max(1, deadline - Date.now())),
|
|
8661
|
+
services.onCoordinatorEventWatchReady,
|
|
8662
|
+
services.onCoordinatorEventWake,
|
|
8663
|
+
);
|
|
8649
8664
|
if (Date.now() >= deadline || watchController.signal.aborted) break;
|
|
8650
8665
|
try {
|
|
8651
8666
|
await exportRetainedDeliveries(32, watchController.signal);
|