@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/async/job-manager.d.ts +7 -6
  3. package/dist/types/config/autorouting-tier-map.d.ts +6 -0
  4. package/dist/types/config/file-lock.d.ts +1 -0
  5. package/dist/types/config/model-profile-activation.d.ts +18 -3
  6. package/dist/types/config/model-registry.d.ts +9 -0
  7. package/dist/types/coordinator-mcp/server.d.ts +3 -0
  8. package/dist/types/modes/acp/acp-agent.d.ts +40 -1
  9. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +27 -0
  11. package/dist/types/sdk/acp/final-text.d.ts +8 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +46 -1
  13. package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
  14. package/dist/types/sdk/prompt-failure.d.ts +30 -1
  15. package/dist/types/sdk/prompt-status.d.ts +8 -0
  16. package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
  17. package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
  18. package/dist/types/thinking.d.ts +3 -1
  19. package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
  20. package/package.json +7 -7
  21. package/src/async/job-manager.ts +38 -8
  22. package/src/commands/sdk.ts +78 -4
  23. package/src/config/autorouting-tier-map.ts +2 -0
  24. package/src/config/file-lock.ts +52 -17
  25. package/src/config/model-profile-activation.ts +72 -10
  26. package/src/config/model-registry.ts +21 -0
  27. package/src/coordinator-mcp/server.ts +21 -6
  28. package/src/debug/report-bundle.ts +114 -24
  29. package/src/gjc-runtime/managed-owner-admission.ts +67 -50
  30. package/src/internal-urls/docs-index.generated.ts +7 -6
  31. package/src/modes/acp/acp-agent.ts +187 -14
  32. package/src/modes/acp/acp-event-mapper.ts +9 -0
  33. package/src/modes/components/model-selector.ts +31 -15
  34. package/src/modes/components/status-line/segments.ts +6 -1
  35. package/src/modes/components/thinking-selector.ts +7 -3
  36. package/src/modes/controllers/selector-controller.ts +16 -3
  37. package/src/sdk/acp/adapter.ts +63 -1
  38. package/src/sdk/acp/final-text.ts +19 -2
  39. package/src/sdk/broker/ensure.ts +97 -19
  40. package/src/sdk/bus/index.ts +31 -4
  41. package/src/sdk/host/session-runtime.test.ts +498 -7
  42. package/src/sdk/host/session-runtime.ts +188 -21
  43. package/src/sdk/prompt-deadline-manager.ts +3 -3
  44. package/src/sdk/prompt-failure.ts +43 -1
  45. package/src/sdk/prompt-status.ts +8 -0
  46. package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
  47. package/src/sdk/router/session-router.ts +22 -3
  48. package/src/sdk/session.ts +12 -9
  49. package/src/sdk/transport/serve-cli.ts +105 -15
  50. package/src/session-import/codex.ts +12 -3
  51. package/src/thinking.ts +21 -2
  52. package/src/utils/mupdf-wasm-embedded.ts +3 -0
  53. package/src/utils/mupdf-wasm.ts +24 -2
@@ -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
- for (;;) {
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)) return { kind: "none", final };
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)) return { kind: "emit", final, text: final.text.slice(streamed.length) };
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
  }
@@ -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 - Date.now();
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(Date.now() + STALE_BROKER_RETIREMENT_TIMEOUT_MS + BROKER_PUBLICATION_TIMEOUT_MS),
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 - Date.now();
481
+ const remainingMs = deadline - ensureBrokerTiming.now();
414
482
  if (remainingMs <= 0) return;
415
- await Promise.race([close, Bun.sleep(remainingMs)]);
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, Date.now() + STALE_BROKER_RETIREMENT_TIMEOUT_MS);
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 (Date.now() < deadline) {
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
- Date.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
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 (restartIntent && restartIntent.expiresAt > Date.now() && restartIntent.requestId !== settings.restartRequestId)
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
- Date.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
619
+ ensureBrokerTiming.now() +
620
+ (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
547
621
  const lockedIntent = await readBrokerRestartIntent(settings.agentDir);
548
- if (lockedIntent && lockedIntent.expiresAt > Date.now() && lockedIntent.requestId !== settings.restartRequestId)
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 (Date.now() < deadline) {
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, Date.now() + BROKER_PUBLICATION_TIMEOUT_MS);
653
- while (Date.now() < recoveryDeadline) {
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) : "";
@@ -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 terminalizing a live prompt
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?: { lease: PromptDeadlineLease; generation: number };
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