@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
|
@@ -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
|
-
|
|
3942
|
+
persistHolds?: Array<{ type: string; onEntered: () => void; release: Promise<void> }>,
|
|
3935
3943
|
): SdkOnlyReconciliationStore {
|
|
3936
3944
|
const failureCounts = new Map<string, number>();
|
|
3937
|
-
let
|
|
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
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
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
|