@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/sdk/acp/adapter.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type SessionReconcileUncertainTarget,
|
|
10
10
|
validateSessionReconcileUncertainTarget,
|
|
11
11
|
} from "../lifecycle/service";
|
|
12
|
+
import { ACP_PROMPT_INACTIVITY_TIMEOUT_MS } from "../prompt-watchdog";
|
|
12
13
|
import {
|
|
13
14
|
validateAdapterControl,
|
|
14
15
|
validateAdapterSecretFields,
|
|
@@ -19,6 +20,35 @@ import { type SessionAttachment, type SessionRouter, SessionRouterError } from "
|
|
|
19
20
|
import { ACP_SESSION_RECONNECT, SESSION_ABORT_TIMEOUT_MS } from "../session-reconnect";
|
|
20
21
|
import type { SessionLifecycleMcpServer } from "./mcp";
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Registration rounds one provider activation may spend chasing a moving attachment.
|
|
25
|
+
*
|
|
26
|
+
* The activation loop restarts whenever the Router attachment changes identity while it
|
|
27
|
+
* is registering, because the leases it just took belong to a connection that no longer
|
|
28
|
+
* exists. One reconnect costs one restart, so a handful covers any churn that is actually
|
|
29
|
+
* converging; beyond that the attachment is rotating faster than registration completes
|
|
30
|
+
* and another round only re-takes leases that will be invalidated again. Bounding this is
|
|
31
|
+
* what turns "many session hosts contend for one agent dir" from an unbounded spin into a
|
|
32
|
+
* reported failure (issue #5658).
|
|
33
|
+
*/
|
|
34
|
+
export const PROVIDER_ACTIVATION_MAX_ATTEMPTS = 8;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wall-clock budget for the same loop.
|
|
38
|
+
*
|
|
39
|
+
* The attempt cap alone bounds rounds, not time: every round is a `#requestSession` round
|
|
40
|
+
* trip per provider, so under lease contention attempts are slow rather than fast-spinning
|
|
41
|
+
* and eight of them can still outlast the caller. The budget is sized to a third of
|
|
42
|
+
* {@link ACP_PROMPT_INACTIVITY_TIMEOUT_MS} so a prompt stalled in provider preflight fails
|
|
43
|
+
* with this error — which names the contended capability — with room to spare before the
|
|
44
|
+
* ACP prompt watchdog reports the same stall as undifferentiated silence.
|
|
45
|
+
*
|
|
46
|
+
* It gates *starting* another round, never interrupting one in flight: a single slow but
|
|
47
|
+
* healthy registration keeps whatever time it needs, and only a retry decided after the
|
|
48
|
+
* deadline is refused.
|
|
49
|
+
*/
|
|
50
|
+
export const PROVIDER_ACTIVATION_BUDGET_MS = Math.floor(ACP_PROMPT_INACTIVITY_TIMEOUT_MS / 3);
|
|
51
|
+
|
|
22
52
|
type JsonObject = Record<string, unknown>;
|
|
23
53
|
function object(value: unknown): JsonObject | undefined {
|
|
24
54
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : undefined;
|
|
@@ -600,7 +630,12 @@ export class AcpSdkAdapter {
|
|
|
600
630
|
}
|
|
601
631
|
|
|
602
632
|
const activation = (async () => {
|
|
603
|
-
|
|
633
|
+
const startedAt = Date.now();
|
|
634
|
+
// Exhaustion is checked here, outside the try below, so the throw cannot be caught
|
|
635
|
+
// by the same `continue` branch that spent the budget.
|
|
636
|
+
for (let attempt = 1; ; attempt++) {
|
|
637
|
+
if (attempt > PROVIDER_ACTIVATION_MAX_ATTEMPTS || Date.now() - startedAt >= PROVIDER_ACTIVATION_BUDGET_MS)
|
|
638
|
+
throw this.#providerActivationExhausted(attempt - 1, startedAt);
|
|
604
639
|
const attachment = this.#attachment;
|
|
605
640
|
const connectionId = attachment?.connectionId;
|
|
606
641
|
try {
|
|
@@ -652,6 +687,33 @@ export class AcpSdkAdapter {
|
|
|
652
687
|
}
|
|
653
688
|
}
|
|
654
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Names what the exhausted activation loop was still fighting over, so a stalled
|
|
692
|
+
* preflight is attributable to a capability instead of surfacing as generic silence.
|
|
693
|
+
*/
|
|
694
|
+
#providerActivationExhausted(attempts: number, startedAt: number): AcpSdkAdapterError {
|
|
695
|
+
const elapsedMs = Date.now() - startedAt;
|
|
696
|
+
const contended = this.#providers
|
|
697
|
+
.filter(provider => !this.#leases.has(provider.capability))
|
|
698
|
+
.map(provider => provider.capability);
|
|
699
|
+
const detail =
|
|
700
|
+
contended.length > 0
|
|
701
|
+
? `still unleased: ${contended.join(", ")}`
|
|
702
|
+
: "the SDK session attachment changed identity on every attempt";
|
|
703
|
+
logger.error("acp_provider_activation_exhausted", {
|
|
704
|
+
sessionId: this.#sessionId,
|
|
705
|
+
attempts,
|
|
706
|
+
elapsedMs,
|
|
707
|
+
contended,
|
|
708
|
+
leaseCount: this.#leases.size,
|
|
709
|
+
});
|
|
710
|
+
return new AcpSdkAdapterError(
|
|
711
|
+
"provider_activation_exhausted",
|
|
712
|
+
`SDK provider activation did not converge after ${attempts} attempts over ${Math.round(elapsedMs / 1_000)}s ` +
|
|
713
|
+
`(${detail}). The session host is contending for provider leases it cannot hold.`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
655
717
|
#assertGenericDisposition(kind: "control" | "global", sdkId: string): void {
|
|
656
718
|
const operation = OPERATIONS.find(candidate => candidate.kind === kind && candidate.sdkId === sdkId);
|
|
657
719
|
if (!operation) throw new AcpSdkAdapterError("unknown_operation", `Unknown SDK ${kind}: ${sdkId}`);
|
|
@@ -31,10 +31,27 @@ export function acpFinalTextFromMessage(message: unknown): BoundedAcpFinalText {
|
|
|
31
31
|
return boundAcpFinalText(parts.join(""));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Assistant-content presence is trimmed everywhere in the prompt pipeline: whitespace carries no
|
|
36
|
+
* assistant content. The ACP retry gate, prompt reconciliation
|
|
37
|
+
* (`sdk/bus/prompt-reconciliation.ts`) and terminal publication must agree, otherwise a terminal
|
|
38
|
+
* the retry gate treats as "no output" can still publish a whitespace chunk alongside the retry's
|
|
39
|
+
* real answer (review P2 on #5628).
|
|
40
|
+
*/
|
|
41
|
+
export function hasAcpFinalTextContent(value: string): boolean {
|
|
42
|
+
return value.trim().length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
export function resolveAcpFinalText(streamed: string, finalText: string): AcpFinalTextResolution {
|
|
35
46
|
const final = boundAcpFinalText(finalText);
|
|
36
|
-
if (!final.text || streamed === final.text || streamed.includes(final.text))
|
|
47
|
+
if (!hasAcpFinalTextContent(final.text) || streamed === final.text || streamed.includes(final.text))
|
|
48
|
+
return { kind: "none", final };
|
|
37
49
|
if (!streamed) return { kind: "emit", final, text: final.text };
|
|
38
|
-
if (final.text.startsWith(streamed))
|
|
50
|
+
if (final.text.startsWith(streamed)) {
|
|
51
|
+
// A pure-whitespace delta is the same defect class as whitespace-only final text: it adds no
|
|
52
|
+
// assistant content, so it must not reach consumers either.
|
|
53
|
+
const tail = final.text.slice(streamed.length);
|
|
54
|
+
return hasAcpFinalTextContent(tail) ? { kind: "emit", final, text: tail } : { kind: "none", final };
|
|
55
|
+
}
|
|
39
56
|
return { kind: "divergent", final };
|
|
40
57
|
}
|
package/src/sdk/broker/ensure.ts
CHANGED
|
@@ -70,6 +70,72 @@ const SPAWN_LOCK_RETRY_DELAY_MS = 50;
|
|
|
70
70
|
const SPAWN_LOCK_TARGET_NAME = "broker.spawn";
|
|
71
71
|
const STARTUP_LOCK_TARGET_NAME = "broker.startup";
|
|
72
72
|
|
|
73
|
+
export interface BrokerStartupLockTestHooks {
|
|
74
|
+
onAcquired?: () => void;
|
|
75
|
+
onContended?: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Test-only phase observation for detached broker startup regressions. The
|
|
80
|
+
* signal is opt-in through the child environment and never affects broker
|
|
81
|
+
* ownership or error handling when the test seam is absent.
|
|
82
|
+
*/
|
|
83
|
+
export async function emitBrokerStartupTestSignal(signal: string): Promise<void> {
|
|
84
|
+
const directory = process.env.GJC_SDK_TEST_BROKER_SIGNAL_DIR;
|
|
85
|
+
if (!directory || !/^[a-z0-9-]+$/.test(signal)) return;
|
|
86
|
+
try {
|
|
87
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
88
|
+
await fs.writeFile(path.join(directory, signal), `${process.pid}\n`);
|
|
89
|
+
} catch {
|
|
90
|
+
// Test observability must never change broker startup semantics.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The parent discovery budget published as a composition rather than a number.
|
|
96
|
+
* A test that stages a fence contention has to time itself against these legs,
|
|
97
|
+
* and retyping the sum makes it win or lose on scheduler noise while letting a
|
|
98
|
+
* change to any single leg silently re-tune it (#5604). This is the one source
|
|
99
|
+
* of truth for how the budget is built; nothing may restate the arithmetic.
|
|
100
|
+
*/
|
|
101
|
+
export const BROKER_DISCOVERY_BUDGET = {
|
|
102
|
+
staleRetirementMs: STALE_BROKER_RETIREMENT_TIMEOUT_MS,
|
|
103
|
+
startupLockWaitMs: STARTUP_LOCK_WAIT_MS,
|
|
104
|
+
publicationMs: BROKER_PUBLICATION_TIMEOUT_MS,
|
|
105
|
+
/** What the startup fence grants its holder: retirement plus one publication attempt. */
|
|
106
|
+
fenceOperationMs: STALE_BROKER_RETIREMENT_TIMEOUT_MS + BROKER_PUBLICATION_TIMEOUT_MS,
|
|
107
|
+
/**
|
|
108
|
+
* When a child spawned under the parent's spawn lock gives up on the fence,
|
|
109
|
+
* measured from that lock acquisition: the parent retires the stale incumbent
|
|
110
|
+
* first, then the child exhausts its own fence wait. Not a production
|
|
111
|
+
* constant -- an emergent property of the two legs that precede publication.
|
|
112
|
+
*/
|
|
113
|
+
childFenceWaitMs: STALE_BROKER_RETIREMENT_TIMEOUT_MS + STARTUP_LOCK_WAIT_MS,
|
|
114
|
+
discoveryMs: DISCOVERY_TIMEOUT_MS,
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Clock the discovery budget is measured on. The default is the real one; a test
|
|
121
|
+
* installs a virtual clock to assert the composition order -- stale retirement,
|
|
122
|
+
* then the child fence, then startup and publication -- in-process instead of
|
|
123
|
+
* racing three wall clocks against each other (#5604). Reaping keeps its own
|
|
124
|
+
* {@link ReapTiming} seam and is deliberately not driven from here.
|
|
125
|
+
*/
|
|
126
|
+
export interface EnsureBrokerTiming {
|
|
127
|
+
now(): number;
|
|
128
|
+
sleep(ms: number): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const REAL_ENSURE_BROKER_TIMING: EnsureBrokerTiming = { now: Date.now, sleep };
|
|
132
|
+
let ensureBrokerTiming: EnsureBrokerTiming = REAL_ENSURE_BROKER_TIMING;
|
|
133
|
+
|
|
134
|
+
/** Test hook: drives the discovery budget on a controllable clock. */
|
|
135
|
+
export function setEnsureBrokerTimingForTest(timing: EnsureBrokerTiming | undefined): void {
|
|
136
|
+
ensureBrokerTiming = timing ?? REAL_ENSURE_BROKER_TIMING;
|
|
137
|
+
}
|
|
138
|
+
|
|
73
139
|
type SpawnLockOptions = Pick<FileLockOptions, "retries" | "retryDelayMs" | "signal">;
|
|
74
140
|
|
|
75
141
|
interface BrokerLockHostIdentity {
|
|
@@ -104,7 +170,7 @@ async function readBrokerDiscoveryBeforeDeadline(
|
|
|
104
170
|
heartbeatTtlMs: number | undefined,
|
|
105
171
|
deadline: number,
|
|
106
172
|
): Promise<BrokerDiscovery | null> {
|
|
107
|
-
const remainingMs = deadline -
|
|
173
|
+
const remainingMs = deadline - ensureBrokerTiming.now();
|
|
108
174
|
if (!Number.isFinite(remainingMs)) return await readBrokerDiscovery(agentDir, heartbeatTtlMs);
|
|
109
175
|
if (remainingMs <= 0) throw new Error("Timed out reading SDK broker discovery.");
|
|
110
176
|
const read = readBrokerDiscovery(agentDir, heartbeatTtlMs);
|
|
@@ -151,14 +217,17 @@ async function acquireSpawnLock(agentDir: string, options: SpawnLockOptions = {}
|
|
|
151
217
|
export async function withBrokerStartupLock<T>(
|
|
152
218
|
agentDir: string,
|
|
153
219
|
operation: (deadline: number) => Promise<T>,
|
|
220
|
+
testHooks: BrokerStartupLockTestHooks = {},
|
|
154
221
|
): Promise<T> {
|
|
155
222
|
const hostIdentity = await brokerLockHostIdentity(agentDir);
|
|
156
223
|
return withFileLock(
|
|
157
224
|
path.join(agentDir, "sdk", STARTUP_LOCK_TARGET_NAME),
|
|
158
|
-
() => operation(
|
|
225
|
+
() => operation(ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.fenceOperationMs),
|
|
159
226
|
{
|
|
160
227
|
retries: Math.ceil(STARTUP_LOCK_WAIT_MS / SPAWN_LOCK_RETRY_DELAY_MS),
|
|
161
228
|
retryDelayMs: SPAWN_LOCK_RETRY_DELAY_MS,
|
|
229
|
+
onAcquired: testHooks.onAcquired,
|
|
230
|
+
onContended: testHooks.onContended,
|
|
162
231
|
...hostIdentity,
|
|
163
232
|
},
|
|
164
233
|
);
|
|
@@ -275,7 +344,6 @@ const DEFAULT_REAP_TIMING: ReapTiming = {
|
|
|
275
344
|
gracefulMs: REAP_GRACEFUL_MS,
|
|
276
345
|
killVerifyMs: REAP_SIGKILL_CAP_MS,
|
|
277
346
|
};
|
|
278
|
-
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
279
347
|
|
|
280
348
|
/**
|
|
281
349
|
* Terminate and reap a detached broker this process spawned, targeting the exact
|
|
@@ -410,9 +478,9 @@ export async function closeBrokerClientBeforeDeadline(
|
|
|
410
478
|
): Promise<void> {
|
|
411
479
|
const close = client.close();
|
|
412
480
|
void close.catch(() => undefined);
|
|
413
|
-
const remainingMs = deadline -
|
|
481
|
+
const remainingMs = deadline - ensureBrokerTiming.now();
|
|
414
482
|
if (remainingMs <= 0) return;
|
|
415
|
-
await Promise.race([close,
|
|
483
|
+
await Promise.race([close, ensureBrokerTiming.sleep(remainingMs)]);
|
|
416
484
|
}
|
|
417
485
|
|
|
418
486
|
async function retireUnusableBroker(
|
|
@@ -420,7 +488,7 @@ async function retireUnusableBroker(
|
|
|
420
488
|
settings: EnsureBrokerSettings,
|
|
421
489
|
outerDeadline = Number.POSITIVE_INFINITY,
|
|
422
490
|
): Promise<void> {
|
|
423
|
-
const deadline = Math.min(outerDeadline,
|
|
491
|
+
const deadline = Math.min(outerDeadline, ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.staleRetirementMs);
|
|
424
492
|
let shutdownSucceeded = false;
|
|
425
493
|
try {
|
|
426
494
|
const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
@@ -443,10 +511,10 @@ async function retireUnusableBroker(
|
|
|
443
511
|
} catch {}
|
|
444
512
|
}
|
|
445
513
|
// Wait for the stale identity to disappear (owner-fenced: pid+incarnation).
|
|
446
|
-
while (
|
|
514
|
+
while (ensureBrokerTiming.now() < deadline) {
|
|
447
515
|
const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
448
516
|
if (!sameBrokerOwner(current, stale)) break;
|
|
449
|
-
await sleep(STALE_BROKER_POLL_MS);
|
|
517
|
+
await ensureBrokerTiming.sleep(STALE_BROKER_POLL_MS);
|
|
450
518
|
}
|
|
451
519
|
}
|
|
452
520
|
|
|
@@ -459,6 +527,7 @@ export async function reconcileBrokerGenerationForStartup(
|
|
|
459
527
|
if (!current) return undefined;
|
|
460
528
|
if (await isBrokerReusable(current)) return current;
|
|
461
529
|
await retireUnusableBroker(current, settings, deadline);
|
|
530
|
+
await emitBrokerStartupTestSignal("retirement-finished");
|
|
462
531
|
const replacement = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
463
532
|
return replacement && (await isBrokerReusable(replacement)) ? replacement : undefined;
|
|
464
533
|
}
|
|
@@ -506,7 +575,7 @@ function createFixtureLease(owner: BrokerOwner, child: ChildProcess): ExactFixtu
|
|
|
506
575
|
|
|
507
576
|
async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: EnsureInitiator): Promise<EnsureOutcome> {
|
|
508
577
|
const initialDiscoveryDeadline =
|
|
509
|
-
|
|
578
|
+
ensureBrokerTiming.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
|
|
510
579
|
const priorOwner = owners.get(settings.agentDir);
|
|
511
580
|
const existing = await readBrokerDiscoveryBeforeDeadline(
|
|
512
581
|
settings.agentDir,
|
|
@@ -514,7 +583,11 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
514
583
|
initialDiscoveryDeadline,
|
|
515
584
|
);
|
|
516
585
|
const restartIntent = await readBrokerRestartIntent(settings.agentDir);
|
|
517
|
-
if (
|
|
586
|
+
if (
|
|
587
|
+
restartIntent &&
|
|
588
|
+
restartIntent.expiresAt > ensureBrokerTiming.now() &&
|
|
589
|
+
restartIntent.requestId !== settings.restartRequestId
|
|
590
|
+
)
|
|
518
591
|
throw new Error("broker_restart_in_progress");
|
|
519
592
|
if (initiator === "fixture-lease" && (priorOwner || existing)) throw fixtureLeaseUnavailable();
|
|
520
593
|
if (priorOwner) {
|
|
@@ -543,9 +616,14 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
543
616
|
const releaseSpawnLock = initiator === "fixture-lease" ? async () => {} : await acquireSpawnLock(settings.agentDir);
|
|
544
617
|
try {
|
|
545
618
|
const deadline =
|
|
546
|
-
|
|
619
|
+
ensureBrokerTiming.now() +
|
|
620
|
+
(initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
|
|
547
621
|
const lockedIntent = await readBrokerRestartIntent(settings.agentDir);
|
|
548
|
-
if (
|
|
622
|
+
if (
|
|
623
|
+
lockedIntent &&
|
|
624
|
+
lockedIntent.expiresAt > ensureBrokerTiming.now() &&
|
|
625
|
+
lockedIntent.requestId !== settings.restartRequestId
|
|
626
|
+
)
|
|
549
627
|
throw new Error("broker_restart_in_progress");
|
|
550
628
|
// The lock winner may still find a discovery published by an earlier winner
|
|
551
629
|
// that finished between our first read and the lock acquisition.
|
|
@@ -572,7 +650,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
572
650
|
// parent's diagnostic handle must not discard exact ownership of a live child.
|
|
573
651
|
await spawnLog?.handle.close().catch(() => undefined);
|
|
574
652
|
let discoveryError: unknown;
|
|
575
|
-
while (
|
|
653
|
+
while (ensureBrokerTiming.now() < deadline) {
|
|
576
654
|
if (spawnError || child.exitCode !== null || child.signalCode !== null) break;
|
|
577
655
|
try {
|
|
578
656
|
const discovered = await readBrokerDiscoveryBeforeDeadline(
|
|
@@ -582,7 +660,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
582
660
|
);
|
|
583
661
|
if (discovered) {
|
|
584
662
|
if (!(await isBrokerReusable(discovered))) {
|
|
585
|
-
await sleep(50);
|
|
663
|
+
await ensureBrokerTiming.sleep(50);
|
|
586
664
|
continue;
|
|
587
665
|
}
|
|
588
666
|
if (owner.markReady(discovered)) {
|
|
@@ -596,7 +674,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
596
674
|
} catch (error) {
|
|
597
675
|
discoveryError = error;
|
|
598
676
|
}
|
|
599
|
-
await sleep(50);
|
|
677
|
+
await ensureBrokerTiming.sleep(50);
|
|
600
678
|
}
|
|
601
679
|
const exitedBeforeDiscovery = child.exitCode !== null || child.signalCode !== null;
|
|
602
680
|
if (exitedBeforeDiscovery && child.exitCode === 0) {
|
|
@@ -616,7 +694,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
616
694
|
await owner.stop();
|
|
617
695
|
return { kind: "external-discovery", discovery: winner };
|
|
618
696
|
}
|
|
619
|
-
await sleep(50);
|
|
697
|
+
await ensureBrokerTiming.sleep(50);
|
|
620
698
|
}
|
|
621
699
|
} catch {
|
|
622
700
|
// fall through to cleanup + failure
|
|
@@ -649,8 +727,8 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
649
727
|
// reaches the end of its publication allowance, so keep the parent attached
|
|
650
728
|
// to the exact agent dir and give that incumbent one final bounded chance to
|
|
651
729
|
// publish before treating the replacement failure as terminal.
|
|
652
|
-
const recoveryDeadline = Math.min(deadline,
|
|
653
|
-
while (
|
|
730
|
+
const recoveryDeadline = Math.min(deadline, ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.publicationMs);
|
|
731
|
+
while (ensureBrokerTiming.now() < recoveryDeadline) {
|
|
654
732
|
try {
|
|
655
733
|
const incumbent = await readBrokerDiscoveryBeforeDeadline(
|
|
656
734
|
settings.agentDir,
|
|
@@ -665,7 +743,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
665
743
|
// Keep the bounded retry alive; a transient read failure is not
|
|
666
744
|
// authority to classify the incumbent as failed.
|
|
667
745
|
}
|
|
668
|
-
await sleep(50);
|
|
746
|
+
await ensureBrokerTiming.sleep(50);
|
|
669
747
|
}
|
|
670
748
|
}
|
|
671
749
|
const spawnLogTail = exitedBeforeDiscovery && spawnLog ? await readBrokerSpawnLogTail(spawnLog.path) : "";
|
package/src/sdk/bus/index.ts
CHANGED
|
@@ -4803,10 +4803,19 @@ export function createNotificationsExtension(
|
|
|
4803
4803
|
* by the firing deadline timer, cleared when the attempt completes or
|
|
4804
4804
|
* is superseded). Fresh attributable progress recorded while the
|
|
4805
4805
|
* attempt awaits durable reconciliation advances the lease generation;
|
|
4806
|
-
* the attempt must then back off instead of
|
|
4807
|
-
* (mirrors PromptDeadlineManager).
|
|
4806
|
+
* before self-abort starts, the attempt must then back off instead of
|
|
4807
|
+
* terminalizing a live prompt (mirrors PromptDeadlineManager).
|
|
4808
4808
|
*/
|
|
4809
|
-
deadlineAttempt?: {
|
|
4809
|
+
deadlineAttempt?: {
|
|
4810
|
+
lease: PromptDeadlineLease;
|
|
4811
|
+
generation: number;
|
|
4812
|
+
/**
|
|
4813
|
+
* Set synchronously immediately before this expiry attempt aborts
|
|
4814
|
+
* its own run. Progress from that abort belongs to this attempt,
|
|
4815
|
+
* not to an independently live prompt.
|
|
4816
|
+
*/
|
|
4817
|
+
selfAbortStarted?: true;
|
|
4818
|
+
};
|
|
4810
4819
|
phase: "active" | "outcome_claimed" | "terminalizing" | "publication_closed" | "delivered";
|
|
4811
4820
|
outcome?: SdkPromptTerminalOutcome;
|
|
4812
4821
|
/** Agent-owned resource run captured at acceptance; cleanup targets only this handle. */
|
|
@@ -5108,10 +5117,24 @@ export function createNotificationsExtension(
|
|
|
5108
5117
|
const deadlineAttemptStatus = (
|
|
5109
5118
|
key: string,
|
|
5110
5119
|
submission: PromptSubmission,
|
|
5111
|
-
attempt: { lease: PromptDeadlineLease; generation: number },
|
|
5120
|
+
attempt: { lease: PromptDeadlineLease; generation: number; selfAbortStarted?: true },
|
|
5112
5121
|
): DeadlineAttemptStatus => {
|
|
5113
5122
|
if (promptSubmissions.get(key) !== submission || submission.deadlineLease !== attempt.lease) return "stale";
|
|
5114
5123
|
const lease = submission.deadlineLease;
|
|
5124
|
+
// A deadline expiry attempt must not be superseded by progress that its
|
|
5125
|
+
// OWN abort caused. Terminal fencing aborts the running tool, and the
|
|
5126
|
+
// agent loop pairs that tool off with a `tool_execution_end` which is
|
|
5127
|
+
// NOT marked non-dispatched — the marker is reserved for calls that
|
|
5128
|
+
// never started, and this one did (`dispatched = record.started`,
|
|
5129
|
+
// agent-loop.ts). So the abort's teardown reaches renewPromptDeadline as
|
|
5130
|
+
// ordinary attributable progress and advances the generation of the very
|
|
5131
|
+
// lease this attempt is expiring. Reading that as supersession makes the
|
|
5132
|
+
// attempt back off, re-arm, and walk into the identical trap forever: the
|
|
5133
|
+
// prompt never gets a terminal frame and its durable claim stays pending
|
|
5134
|
+
// (#5583). Progress observed BEFORE the self-abort is still authoritative
|
|
5135
|
+
// — that is the post-claim fence, and it keeps a genuinely live prompt
|
|
5136
|
+
// from being killed.
|
|
5137
|
+
if (attempt.selfAbortStarted) return "current";
|
|
5115
5138
|
if (lease.generation !== attempt.generation && Date.now() < promptDeadlineAt(lease)) return "superseded";
|
|
5116
5139
|
return "current";
|
|
5117
5140
|
};
|
|
@@ -5403,6 +5426,10 @@ export function createNotificationsExtension(
|
|
|
5403
5426
|
}
|
|
5404
5427
|
let proof: RunSettlementProof;
|
|
5405
5428
|
try {
|
|
5429
|
+
// Mark immediately before the abort seam, with no await between:
|
|
5430
|
+
// abort-time attributable completion is caused by this deadline
|
|
5431
|
+
// attempt and must not supersede its own expiry terminal.
|
|
5432
|
+
if (deadlineAttempt) deadlineAttempt.selfAbortStarted = true;
|
|
5406
5433
|
proof = await terminalAbortSeams.abortPromptAndWaitWithTerminal(submission.executionHandle, {
|
|
5407
5434
|
graceMs: PROMPT_TERMINALIZATION_GRACE_MS,
|
|
5408
5435
|
// Terminal abort registers the continuation fence for the
|