@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
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
+ import { markNonDispatchedToolEvent } from "@gajae-code/agent-core";
6
7
  import { logger } from "@gajae-code/utils";
7
8
  import { AsyncJobManager } from "../../async";
8
9
  import type { Settings } from "../../config/settings";
@@ -3774,6 +3775,10 @@ async function invocationHarness(
3774
3775
  persistInterceptor?: (transition: { type: string }) => void;
3775
3776
  /** Hold one matching durable transition until the test releases it. */
3776
3777
  persistHold?: { type: string; onEntered: () => void; release: Promise<void> };
3778
+ /** Hold successive matching durable transitions, consumed in arrival order. */
3779
+ persistHolds?: Array<{ type: string; onEntered: () => void; release: Promise<void> }>;
3780
+ /** Throw to inject a publication failure for a matching broadcast frame. */
3781
+ broadcastInterceptor?: (frame: SdkFrame) => void;
3777
3782
  onLifecycleDrainTimeout?: () => void;
3778
3783
  onFailureDiagnosticKeyCount?: (count: number) => void;
3779
3784
  agentFailedWriteFailures?: number;
@@ -3801,7 +3806,7 @@ async function invocationHarness(
3801
3806
  ? createInterceptorReconciliationStore(
3802
3807
  hooks.persistInterceptor,
3803
3808
  hooks.agentFailedWriteFailures,
3804
- hooks.persistHold,
3809
+ hooks.persistHolds ?? (hooks.persistHold ? [hooks.persistHold] : undefined),
3805
3810
  )
3806
3811
  : undefined;
3807
3812
  createSdkSessionRuntimeExtension(api, {
@@ -3845,6 +3850,9 @@ async function invocationHarness(
3845
3850
  if (typeof response.id === "string") waiters.get(response.id)?.(response);
3846
3851
  },
3847
3852
  broadcastFrame(frame) {
3853
+ // Interception precedes recording: a frame whose publication throws never
3854
+ // reached the wire, so it must not appear in the observed broadcasts.
3855
+ hooks.broadcastInterceptor?.(frame);
3848
3856
  broadcasts.push(frame);
3849
3857
  },
3850
3858
  start: async () => ({ url: "ws://127.0.0.1:1" }),
@@ -3931,10 +3939,10 @@ async function invocationHarness(
3931
3939
  function createInterceptorReconciliationStore(
3932
3940
  interceptor: (transition: { type: string }) => void,
3933
3941
  agentFailedWriteFailures = 1,
3934
- persistHold?: { type: string; onEntered: () => void; release: Promise<void> },
3942
+ persistHolds?: Array<{ type: string; onEntered: () => void; release: Promise<void> }>,
3935
3943
  ): SdkOnlyReconciliationStore {
3936
3944
  const failureCounts = new Map<string, number>();
3937
- let holdConsumed = false;
3945
+ let nextHold = 0;
3938
3946
  const backing = createInterceptorBackingStore();
3939
3947
  return {
3940
3948
  path: null,
@@ -3966,10 +3974,11 @@ function createInterceptorReconciliationStore(
3966
3974
  interceptor({ type: "agent_end" });
3967
3975
  }
3968
3976
  }
3969
- if (persistHold && !holdConsumed && persistHold.type === transitionType) {
3970
- holdConsumed = true;
3971
- persistHold.onEntered();
3972
- await persistHold.release;
3977
+ const hold = persistHolds?.[nextHold];
3978
+ if (hold && hold.type === transitionType) {
3979
+ nextHold += 1;
3980
+ hold.onEntered();
3981
+ await hold.release;
3973
3982
  }
3974
3983
  backing.records = records;
3975
3984
  },
@@ -5656,6 +5665,488 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5656
5665
  }
5657
5666
  });
5658
5667
 
5668
+ test("a running prompt deadline publishes one terminal frame pair and keeps normal terminals working", async () => {
5669
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-terminal-frame-"));
5670
+ try {
5671
+ const harness = await invocationHarness("deadline-terminal-frame", cwd, {
5672
+ settings: zeroProgressSettings,
5673
+ sendUserMessage: async (_content, options) => {
5674
+ await options?.onPreflightAcceptCommit?.();
5675
+ await neverSettlingPromise();
5676
+ },
5677
+ });
5678
+ const accepted = await harness.control("turn.prompt", { text: "deadline" });
5679
+ expect(accepted.ok).toBe(true);
5680
+ const correlation = {
5681
+ commandId: accepted.result?.commandId,
5682
+ turnId: accepted.result?.turnId,
5683
+ };
5684
+ await harness.emit("agent_start");
5685
+
5686
+ // An abort can synthesize pairing-only tool boundaries for a call that
5687
+ // never ran. Those are cleanup, not fresh progress that may renew the
5688
+ // deadline currently terminating this prompt.
5689
+ const syntheticStart = {
5690
+ type: "tool_execution_start",
5691
+ toolCallId: "deadline-cleanup",
5692
+ toolName: "read",
5693
+ args: {},
5694
+ };
5695
+ const syntheticEnd = {
5696
+ type: "tool_execution_end",
5697
+ toolCallId: "deadline-cleanup",
5698
+ toolName: "read",
5699
+ result: { content: "aborted" },
5700
+ isError: true,
5701
+ };
5702
+ markNonDispatchedToolEvent(syntheticStart);
5703
+ markNonDispatchedToolEvent(syntheticEnd);
5704
+ await harness.emit("tool_execution_start", syntheticStart);
5705
+ await harness.emit("tool_execution_end", syntheticEnd);
5706
+
5707
+ const settled = await settledStatus(harness, "turn.prompt_status", correlation);
5708
+ expect(settled).toMatchObject({
5709
+ status: "failed",
5710
+ error: { code: "prompt_deadline_exceeded" },
5711
+ outcome: { kind: "failed", code: "prompt_deadline_exceeded", provenance: "deadline" },
5712
+ });
5713
+ const correlated = () =>
5714
+ harness.broadcasts.filter(frame => {
5715
+ const payload = frame.payload as { commandId?: string; turnId?: string } | undefined;
5716
+ return payload?.commandId === correlation.commandId && payload?.turnId === correlation.turnId;
5717
+ });
5718
+ // onExpired publishes the pair only after the durable finalize resolves, so
5719
+ // the terminal status can be observable a few microtasks before the frames
5720
+ // are. Wait for them within a bounded horizon rather than sampling once;
5721
+ // the deep-equality assertions below still pin "exactly one of each".
5722
+ const framesDeadline = Date.now() + 10_000;
5723
+ while (
5724
+ Date.now() < framesDeadline &&
5725
+ !(
5726
+ correlated().some(frame => frame.kind === "agent_failed") &&
5727
+ correlated().some(frame => frame.kind === "agent_end")
5728
+ )
5729
+ )
5730
+ await Bun.sleep(5);
5731
+ expect(correlated().filter(frame => frame.kind === "agent_failed")).toEqual([
5732
+ expect.objectContaining({
5733
+ payload: expect.objectContaining({
5734
+ error: expect.objectContaining({ code: "prompt_deadline_exceeded" }),
5735
+ }),
5736
+ }),
5737
+ ]);
5738
+ expect(correlated().filter(frame => frame.kind === "agent_end")).toEqual([
5739
+ expect.objectContaining({
5740
+ payload: expect.objectContaining({
5741
+ outcome: expect.objectContaining({
5742
+ kind: "failed",
5743
+ code: "prompt_deadline_exceeded",
5744
+ provenance: "deadline",
5745
+ }),
5746
+ }),
5747
+ }),
5748
+ ]);
5749
+
5750
+ // A late real boundary is idempotent for the retired correlation.
5751
+ await harness.emit("agent_end", { messages: [] });
5752
+ expect(correlated().filter(frame => frame.kind === "agent_failed")).toHaveLength(1);
5753
+ expect(correlated().filter(frame => frame.kind === "agent_end")).toHaveLength(1);
5754
+ expect(await harness.query("turn.prompt_status", correlation)).toMatchObject({ result: settled });
5755
+
5756
+ // Control: the ordinary agent-produced terminal path remains live.
5757
+ const control = await harness.control("turn.prompt", { text: "normal terminal" });
5758
+ const controlCorrelation = {
5759
+ commandId: control.result?.commandId,
5760
+ turnId: control.result?.turnId,
5761
+ };
5762
+ await harness.emit("agent_start");
5763
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "done" }] });
5764
+ expect(await settledStatus(harness, "turn.prompt_status", controlCorrelation)).toMatchObject({
5765
+ status: "terminal_ok",
5766
+ });
5767
+ const controlTerminals = harness.broadcasts.filter(frame => {
5768
+ const payload = frame.payload as { commandId?: string; turnId?: string } | undefined;
5769
+ return (
5770
+ frame.kind === "agent_end" &&
5771
+ payload?.commandId === controlCorrelation.commandId &&
5772
+ payload?.turnId === controlCorrelation.turnId
5773
+ );
5774
+ });
5775
+ expect(controlTerminals).toHaveLength(1);
5776
+ await harness.stop();
5777
+ } finally {
5778
+ await Bun.sleep(50);
5779
+ await rm(cwd, { recursive: true, force: true });
5780
+ }
5781
+ });
5782
+
5783
+ /** Frames broadcast for one correlation, in publication order. */
5784
+ const correlatedFrames = (harness: InvocationHarness, correlation: { commandId?: string; turnId?: string }) =>
5785
+ harness.broadcasts.filter(frame => {
5786
+ const payload = frame.payload as { commandId?: string; turnId?: string } | undefined;
5787
+ return payload?.commandId === correlation.commandId && payload?.turnId === correlation.turnId;
5788
+ });
5789
+
5790
+ /** Waits for a bounded horizon until the correlated frames satisfy `ready`. */
5791
+ const awaitCorrelatedFrames = async (
5792
+ harness: InvocationHarness,
5793
+ correlation: { commandId?: string; turnId?: string },
5794
+ ready: (frames: SdkFrame[]) => boolean,
5795
+ ): Promise<void> => {
5796
+ // Terminal frames are published only after the durable finalize resolves, so a
5797
+ // single sample can precede them. Poll for the observable condition within a
5798
+ // bounded horizon; the callers' deep-equality assertions still pin "exactly one".
5799
+ const budgetEndsAt = Date.now() + 10_000;
5800
+ while (Date.now() < budgetEndsAt && !ready(correlatedFrames(harness, correlation))) await Bun.sleep(5);
5801
+ };
5802
+
5803
+ test("the deadline/agent_end overlap publishes exactly one correlated terminal boundary", async () => {
5804
+ // Review P1: the two terminal publishers genuinely overlap. The provider
5805
+ // agent_end handler captures its transitions synchronously and then awaits
5806
+ // durable writes; the deadline callback fires only after its own claim/finalize
5807
+ // awaits. Either can reach the wire first, neither can be stopped by removing
5808
+ // lifecycle references, and noteTransition is a no-op once the record is
5809
+ // terminal — so without a shared claim durable state records one outcome while
5810
+ // clients receive two boundaries. Sweeping the real agent_end across the lease
5811
+ // instant exercises BOTH orderings; whichever publisher loses must stay silent.
5812
+ //
5813
+ // The Bun.sleep below is the RACE DRIVER, not the sampling mechanism: every
5814
+ // observation is a bounded poll on the durable settle plus a quiet window that
5815
+ // proves no second frame follows.
5816
+ const ATTEMPTS = 20;
5817
+ const deadlineWins: number[] = [];
5818
+ const providerWins: number[] = [];
5819
+ for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
5820
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-overlap-"));
5821
+ try {
5822
+ const harness = await invocationHarness(`deadline-overlap-${attempt}`, cwd, {
5823
+ settings: zeroProgressSettings,
5824
+ sendUserMessage: async (_content, options) => {
5825
+ await options?.onPreflightAcceptCommit?.();
5826
+ await neverSettlingPromise();
5827
+ },
5828
+ });
5829
+ const accepted = await harness.control("turn.prompt", { text: "overlap" });
5830
+ expect(accepted.ok).toBe(true);
5831
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
5832
+ await harness.emit("agent_start");
5833
+ // zeroProgressSettings leases 25ms; straddle that instant.
5834
+ await Bun.sleep(24 + attempt * 0.25);
5835
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "done" }] });
5836
+ // Both publishers have run once the durable record is terminal and a
5837
+ // correlated boundary is on the wire; the quiet window then proves that
5838
+ // the loser did not publish a second one behind it.
5839
+ const settled = await settledStatus(harness, "turn.prompt_status", correlation);
5840
+ await awaitCorrelatedFrames(harness, correlation, frames =>
5841
+ frames.some(frame => frame.kind === "agent_end"),
5842
+ );
5843
+ await Bun.sleep(25);
5844
+ const correlated = correlatedFrames(harness, correlation);
5845
+ const boundaries = correlated.filter(frame => frame.kind === "agent_end");
5846
+ const diagnostics = correlated.filter(frame => frame.kind === "agent_failed");
5847
+ expect({ attempt, boundaries: boundaries.length }).toEqual({ attempt, boundaries: 1 });
5848
+ const outcome = (boundaries[0]?.payload as { outcome?: { provenance?: string } } | undefined)?.outcome;
5849
+ if (outcome?.provenance === "deadline") {
5850
+ // The deadline owns the pair atomically: its correlated diagnostic and
5851
+ // its correlated boundary reach the wire together, exactly once, and
5852
+ // the correlation still reconciles to a single durable outcome.
5853
+ deadlineWins.push(attempt);
5854
+ expect({ attempt, diagnostics: diagnostics.length }).toEqual({ attempt, diagnostics: 1 });
5855
+ expect(settled).toMatchObject({ outcome: expect.objectContaining({ kind: expect.any(String) }) });
5856
+ } else {
5857
+ // Control within the sweep: the ordinary provider terminal path still
5858
+ // publishes its one boundary, and no synthetic pair trails it.
5859
+ providerWins.push(attempt);
5860
+ expect({ attempt, diagnostics: diagnostics.length }).toEqual({ attempt, diagnostics: 0 });
5861
+ }
5862
+ await harness.stop();
5863
+ } finally {
5864
+ await Bun.sleep(10);
5865
+ await rm(cwd, { recursive: true, force: true });
5866
+ }
5867
+ }
5868
+ // A sweep that never reached the lease instant would prove nothing.
5869
+ expect(deadlineWins.length).toBeGreaterThan(0);
5870
+ expect(deadlineWins.length + providerWins.length).toBe(ATTEMPTS);
5871
+ });
5872
+
5873
+ test("the deadline publishes its correlated diagnostic before its correlated boundary", async () => {
5874
+ // Ordering is part of the pair's contract: a client that matches the boundary and
5875
+ // stops reading must never have the failure reason arrive behind it.
5876
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-pair-order-"));
5877
+ try {
5878
+ const harness = await invocationHarness("deadline-pair-order", cwd, {
5879
+ settings: zeroProgressSettings,
5880
+ sendUserMessage: async (_content, options) => {
5881
+ await options?.onPreflightAcceptCommit?.();
5882
+ await neverSettlingPromise();
5883
+ },
5884
+ });
5885
+ const accepted = await harness.control("turn.prompt", { text: "order" });
5886
+ expect(accepted.ok).toBe(true);
5887
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
5888
+ await harness.emit("agent_start");
5889
+ await awaitCorrelatedFrames(
5890
+ harness,
5891
+ correlation,
5892
+ frames =>
5893
+ frames.some(frame => frame.kind === "agent_failed") && frames.some(frame => frame.kind === "agent_end"),
5894
+ );
5895
+ const kinds = correlatedFrames(harness, correlation)
5896
+ .map(frame => frame.kind)
5897
+ .filter(kind => kind === "agent_failed" || kind === "agent_end");
5898
+ // One assertion over the indices, so an out-of-order pair cannot pass by
5899
+ // satisfying two independent presence checks.
5900
+ expect({
5901
+ failedAt: kinds.indexOf("agent_failed"),
5902
+ endAt: kinds.indexOf("agent_end"),
5903
+ failedCount: kinds.filter(kind => kind === "agent_failed").length,
5904
+ endCount: kinds.filter(kind => kind === "agent_end").length,
5905
+ }).toEqual({ failedAt: 0, endAt: 1, failedCount: 1, endCount: 1 });
5906
+
5907
+ // Control: the ordinary terminal path still publishes exactly one boundary.
5908
+ const control = await harness.control("turn.prompt", { text: "normal terminal" });
5909
+ const controlCorrelation = { commandId: control.result?.commandId, turnId: control.result?.turnId };
5910
+ await harness.emit("agent_start");
5911
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "done" }] });
5912
+ expect(await settledStatus(harness, "turn.prompt_status", controlCorrelation)).toMatchObject({
5913
+ status: "terminal_ok",
5914
+ });
5915
+ expect(correlatedFrames(harness, controlCorrelation).filter(frame => frame.kind === "agent_end")).toHaveLength(
5916
+ 1,
5917
+ );
5918
+ await harness.stop();
5919
+ } finally {
5920
+ await Bun.sleep(50);
5921
+ await rm(cwd, { recursive: true, force: true });
5922
+ }
5923
+ });
5924
+
5925
+ test.each([
5926
+ { failing: "agent_failed" as const, surviving: "agent_end" as const },
5927
+ { failing: "agent_end" as const, surviving: "agent_failed" as const },
5928
+ ])("a deadline frame that fails to publish never suppresses the other ($failing)", async ({
5929
+ failing,
5930
+ surviving,
5931
+ }) => {
5932
+ // Review P2: the deadline's two frames were emitted as unguarded sequential
5933
+ // calls, so a throwing agent_failed skipped the agent_end while reconciliation
5934
+ // and the lifecycle cleanup still ran -- leaving the ACP request with NO
5935
+ // terminal boundary, the exact failure this path exists to prevent. Each frame
5936
+ // is now published independently, and neither failure may skip the cleanup.
5937
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-frame-failure-"));
5938
+ const warnings: Array<{ message: string; data: Record<string, unknown> | undefined }> = [];
5939
+ const warn = spyOn(logger, "warn").mockImplementation((message, data) => {
5940
+ warnings.push({ message: String(message), data: data as Record<string, unknown> | undefined });
5941
+ });
5942
+ const errors: Array<{ message: string; data: Record<string, unknown> | undefined }> = [];
5943
+ const error = spyOn(logger, "error").mockImplementation((message, data) => {
5944
+ errors.push({ message: String(message), data: data as Record<string, unknown> | undefined });
5945
+ });
5946
+ try {
5947
+ let failCorrelation: { commandId?: string; turnId?: string } = {};
5948
+ const harness = await invocationHarness(`deadline-frame-failure-${failing}`, cwd, {
5949
+ settings: zeroProgressSettings,
5950
+ sendUserMessage: async (_content, options) => {
5951
+ await options?.onPreflightAcceptCommit?.();
5952
+ await neverSettlingPromise();
5953
+ },
5954
+ broadcastInterceptor: frame => {
5955
+ const payload = frame.payload as { commandId?: string; turnId?: string } | undefined;
5956
+ if (
5957
+ frame.kind === failing &&
5958
+ payload?.commandId === failCorrelation.commandId &&
5959
+ payload?.turnId === failCorrelation.turnId
5960
+ )
5961
+ throw Object.assign(new Error("injected publication failure"), { code: "io_error" });
5962
+ },
5963
+ });
5964
+ const accepted = await harness.control("turn.prompt", { text: "frame failure" });
5965
+ expect(accepted.ok).toBe(true);
5966
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
5967
+ failCorrelation = correlation;
5968
+ await harness.emit("agent_start");
5969
+ await awaitCorrelatedFrames(harness, correlation, frames => frames.some(frame => frame.kind === surviving));
5970
+ const correlated = correlatedFrames(harness, correlation);
5971
+ // The frame whose publication threw never reached the wire; the other one
5972
+ // still did, carrying the deadline's terminal identity.
5973
+ expect({
5974
+ failing: correlated.filter(frame => frame.kind === failing).length,
5975
+ surviving: correlated.filter(frame => frame.kind === surviving).length,
5976
+ }).toEqual({ failing: 0, surviving: 1 });
5977
+ if (surviving === "agent_end")
5978
+ expect(correlated.find(frame => frame.kind === "agent_end")?.payload).toMatchObject({
5979
+ outcome: { kind: "failed", code: "prompt_deadline_exceeded", provenance: "deadline" },
5980
+ });
5981
+
5982
+ // The failure is reported, with the correlation and without provider text.
5983
+ expect([...warnings, ...errors]).toContainEqual(
5984
+ expect.objectContaining({
5985
+ message: expect.stringContaining("deadline correlated"),
5986
+ data: expect.objectContaining({ commandId: correlation.commandId, turnId: correlation.turnId }),
5987
+ }),
5988
+ );
5989
+
5990
+ // The cleanup after the publication still ran: the durable record is settled,
5991
+ // a late real boundary stays idempotent for the retired correlation, and the
5992
+ // session still terminalizes a fresh turn.
5993
+ expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
5994
+ status: "failed",
5995
+ error: { code: "prompt_deadline_exceeded" },
5996
+ });
5997
+ await harness.emit("agent_end", { messages: [] });
5998
+ expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toHaveLength(
5999
+ surviving === "agent_end" ? 1 : 0,
6000
+ );
6001
+ const control = await harness.control("turn.prompt", { text: "after frame failure" });
6002
+ const controlIds = { commandId: control.result?.commandId, turnId: control.result?.turnId };
6003
+ await harness.emit("agent_start");
6004
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "done" }] });
6005
+ expect(await settledStatus(harness, "turn.prompt_status", controlIds)).toMatchObject({
6006
+ status: "terminal_ok",
6007
+ });
6008
+ expect(correlatedFrames(harness, controlIds).filter(frame => frame.kind === "agent_end")).toHaveLength(1);
6009
+ await harness.stop();
6010
+ } finally {
6011
+ warn.mockRestore();
6012
+ error.mockRestore();
6013
+ await Bun.sleep(50);
6014
+ await rm(cwd, { recursive: true, force: true });
6015
+ }
6016
+ });
6017
+
6018
+ test("boundary-claim eviction never lets a parked publisher republish a claimed boundary", async () => {
6019
+ // Review P1: the claim registry is FIFO-bounded, while a lifecycle handler
6020
+ // captures its transitions, awaits its durable writes, and only claims
6021
+ // immediately before the emit. Parked on those awaits, its key can be evicted
6022
+ // by later correlations, and its delayed agent_end then publishes a SECOND
6023
+ // correlated boundary for a correlation the deadline already terminalized.
6024
+ //
6025
+ // The interleaving is CONSTRUCTED, not sampled. The deadline's finalize write
6026
+ // is held so the real agent_end lands inside the finalization window and takes
6027
+ // the upgrade path; releasing it lets the deadline publish and claim the pair
6028
+ // while the handler parks on the NEXT invocation's write, before its claim
6029
+ // loop. The pressure then runs in a second session because one session's
6030
+ // durable writes are serialized -- a held write blocks that session entirely.
6031
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-claim-eviction-"));
6032
+ try {
6033
+ const finalizeHold = { entered: Promise.withResolvers<void>(), release: Promise.withResolvers<void>() };
6034
+ const parkHold = { entered: Promise.withResolvers<void>(), release: Promise.withResolvers<void>() };
6035
+ // Read at each lease arming, so only the `expiring` prompt is short-leased.
6036
+ let promptDeadlineMs = 600_000;
6037
+ const settings = {
6038
+ get: (key: string) =>
6039
+ key === "sdk.promptDeadlineMs"
6040
+ ? promptDeadlineMs
6041
+ : key === "sdk.promptMaxRuntimeMs"
6042
+ ? 600_000
6043
+ : undefined,
6044
+ } as unknown as Settings;
6045
+ const promoted: Array<((promotion: { startsOwnRun: boolean }) => void) | undefined> = [];
6046
+ const harness = await invocationHarness("claim-eviction", cwd, {
6047
+ settings,
6048
+ sendUserMessage: async (_content, options) => {
6049
+ await options?.onPreflightAcceptCommit?.();
6050
+ if ((options as { deliverAs?: string } | undefined)?.deliverAs === "followUp") {
6051
+ promoted.push(
6052
+ (options as { onQueuedPromoted?: (promotion: { startsOwnRun: boolean }) => void } | undefined)
6053
+ ?.onQueuedPromoted,
6054
+ );
6055
+ return;
6056
+ }
6057
+ await neverSettlingPromise();
6058
+ },
6059
+ persistInterceptor: () => {},
6060
+ agentFailedWriteFailures: 0,
6061
+ persistHolds: [
6062
+ {
6063
+ type: "agent_end",
6064
+ onEntered: () => finalizeHold.entered.resolve(),
6065
+ release: finalizeHold.release.promise,
6066
+ },
6067
+ { type: "agent_end", onEntered: () => parkHold.entered.resolve(), release: parkHold.release.promise },
6068
+ ],
6069
+ onLifecycleDrainTimeout: () => {},
6070
+ });
6071
+ // A never-settling head turn keeps the session streaming so the two
6072
+ // follow-ups queue and promote into ONE run, giving the terminal handler a
6073
+ // second invocation to park on after the deadline publishes the first.
6074
+ expect((await harness.control("turn.prompt", { text: "head" })).ok).toBe(true);
6075
+ await harness.emit("agent_start");
6076
+ // `leading` is drained FIRST, so the terminal handler's first durable write
6077
+ // is its own -- not the deadline's pending finalization -- and parking on it
6078
+ // leaves `expiring` untouched for the deadline to publish.
6079
+ const leading = await harness.control("turn.follow_up", { text: "leading" });
6080
+ promoted[0]?.({ startsOwnRun: true });
6081
+ promptDeadlineMs = 1_500;
6082
+ const expiring = await harness.control("turn.follow_up", { text: "expiring" });
6083
+ promoted[1]?.({ startsOwnRun: true });
6084
+ promptDeadlineMs = 600_000;
6085
+ const expiringIds = { commandId: expiring.result?.commandId, turnId: expiring.result?.turnId };
6086
+ const leadingIds = { commandId: leading.result?.commandId, turnId: leading.result?.turnId };
6087
+ await harness.emit("agent_start");
6088
+ // Both must be in the drained batch: a lease that fired before the drain
6089
+ // would leave the handler with nothing to park on and prove nothing.
6090
+ expect({
6091
+ expiring: correlatedFrames(harness, expiringIds).filter(frame => frame.kind === "agent_start").length,
6092
+ leading: correlatedFrames(harness, leadingIds).filter(frame => frame.kind === "agent_start").length,
6093
+ }).toEqual({ expiring: 1, leading: 1 });
6094
+
6095
+ await finalizeHold.entered.promise;
6096
+ // The real boundary lands while the deadline's finalization is pending, so
6097
+ // the handler captures BOTH transitions and waits on the finalize commit.
6098
+ const parkedEnd = harness.emit("agent_end", {
6099
+ sdkRunToken: `${leadingIds.commandId}:${leadingIds.turnId}`,
6100
+ messages: [{ role: "assistant", content: "done" }],
6101
+ });
6102
+ finalizeHold.release.resolve();
6103
+ // The deadline now publishes and claims the pair; the handler parks on the
6104
+ // leading invocation's durable write, before its own claim loop.
6105
+ await parkHold.entered.promise;
6106
+ await awaitCorrelatedFrames(harness, expiringIds, frames => frames.some(frame => frame.kind === "agent_end"));
6107
+ const deadlineBoundary = correlatedFrames(harness, expiringIds).find(frame => frame.kind === "agent_end");
6108
+ expect(
6109
+ (deadlineBoundary?.payload as { outcome?: { provenance?: string } } | undefined)?.outcome?.provenance,
6110
+ ).toBe("deadline");
6111
+
6112
+ // Eviction pressure: more than MAX_PUBLISHED_TERMINAL_BOUNDARIES (1024)
6113
+ // other correlations claim their own boundary while the publisher is parked.
6114
+ await harness.switchSession("claim-eviction-pressure");
6115
+ const PRESSURE = 1_100;
6116
+ for (let index = 0; index < PRESSURE; index += 1) {
6117
+ const accepted = await harness.control("turn.prompt", { text: `pressure-${index}` });
6118
+ expect(accepted.ok).toBe(true);
6119
+ const token = `${accepted.result?.commandId}:${accepted.result?.turnId}`;
6120
+ await harness.emit("agent_start", { sdkRunToken: token });
6121
+ await harness.emit("agent_end", { sdkRunToken: token, messages: [{ role: "assistant", content: "ok" }] });
6122
+ }
6123
+ const claimedCorrelations = new Set(
6124
+ harness.broadcasts
6125
+ .filter(frame => frame.kind === "agent_end")
6126
+ .map(frame => {
6127
+ const payload = frame.payload as { commandId?: string; turnId?: string } | undefined;
6128
+ return `${payload?.commandId}:${payload?.turnId}`;
6129
+ }),
6130
+ );
6131
+ // A sweep that never passed the bound would evict nothing and prove nothing.
6132
+ expect(claimedCorrelations.size).toBeGreaterThan(1_024);
6133
+
6134
+ parkHold.release.resolve();
6135
+ await parkedEnd;
6136
+ await Bun.sleep(25);
6137
+ // The parked publisher lost its claim to the deadline before the pressure
6138
+ // ran, and must still lose it after: exactly one boundary on the wire.
6139
+ expect({
6140
+ expiring: correlatedFrames(harness, expiringIds).filter(frame => frame.kind === "agent_end").length,
6141
+ leading: correlatedFrames(harness, leadingIds).filter(frame => frame.kind === "agent_end").length,
6142
+ }).toEqual({ expiring: 1, leading: 1 });
6143
+ await harness.stop();
6144
+ } finally {
6145
+ await Bun.sleep(50);
6146
+ await rm(cwd, { recursive: true, force: true });
6147
+ }
6148
+ });
6149
+
5659
6150
  test("an abort_and_prompt replacement of a zero-execution turn is also bounded", async () => {
5660
6151
  // The issue observed both the original turn.prompt AND the replacement
5661
6152
  // turn.abort_and_prompt accepted with permanently zero activity. Both the