From 71d55274a65f5a65cf57f94f760e2dacf516885c Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Wed, 23 Sep 2026 20:03:37 +0800 Subject: [PATCH 1/9] Make the deploy E2E's fake codex read its goal from stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/deploy/e2e/fake-codex is the static twin of FakeCodexCli, mounted into the real worker image for the deploy compose E2E. It still took the goal from the last argv argument after the goal moved to stdin, so the worker handed it "-" and it answered "DONE: -". The E2E never noticed, because it only waited for a Success status — which a CLI handed no prompt at all can still report. The fake reads stdin now, and run.sh asserts the folded summary from agent_run.result_jsonb is "DONE: Deploy E2E smoke task", so the lane checks that the prompt crossed the real images rather than that a process exited 0. The fake is a mirror of production behaviour with no drift detector, which is how it drifted. SubtaskAwareFakeCliDriftTests now runs the real file through the real CodexHarness with the goal on stdin and asserts the summary run.sh expects — red on the old fake, green on the new one. --- backend/deploy/e2e/fake-codex | 11 +++--- backend/deploy/e2e/run.sh | 12 +++++- .../SubtaskAwareFakeCliDriftTests.cs | 39 +++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/backend/deploy/e2e/fake-codex b/backend/deploy/e2e/fake-codex index d0ed393e3..bc8fad4ef 100755 --- a/backend/deploy/e2e/fake-codex +++ b/backend/deploy/e2e/fake-codex @@ -1,11 +1,12 @@ #!/bin/sh # Fake codex CLI for the deploy compose E2E — the worker's CodexHarness is pointed here via # CODESPACE_CODEX_CLI_PATH, so NO model credential or network is needed to prove the agent-run path through the -# real worker image. It is the static twin of tests/.../FakeCodexCli.cs: take the LAST positional arg as the goal -# (codex puts the prompt last), JSON-escape it, and print a three-line `codex exec --json`-shaped stream whose -# final agent_message is "DONE: " so the real executor's ParseEvent/BuildResult fold a Succeeded run. -goal="" -for goal in "$@"; do :; done +# real worker image. It is the static twin of tests/.../FakeCodexCli.cs: read the goal from stdin — CodexHarness +# hands it over there behind a trailing `-`, never on the argv, which is capped per string by the kernel — JSON-escape +# it, and print a three-line `codex exec --json`-shaped stream whose final agent_message is "DONE: " so the real +# executor's ParseEvent/BuildResult fold a Succeeded run. run.sh asserts that summary, which is what makes this a +# check that the prompt crossed the real images rather than only that some process exited 0. +goal="$(cat)" esc=$(printf '%s' "$goal" | sed 's/\\/\\\\/g; s/"/\\"/g') printf '{"type":"agent_reasoning","message":"Planning work for: %s"}\n' "$esc" printf '{"type":"agent_message","message":"DONE: %s"}\n' "$esc" diff --git a/backend/deploy/e2e/run.sh b/backend/deploy/e2e/run.sh index 12035883b..b70d8ad66 100755 --- a/backend/deploy/e2e/run.sh +++ b/backend/deploy/e2e/run.sh @@ -102,9 +102,17 @@ for _ in $(seq 1 80); do STATUS="$(curl -fsS "$API/api/workflows/runs/$RUN_ID" "${AUTH[@]}" | grep -o '"status":"[A-Za-z]*"' | head -1 | sed 's/.*:"\([A-Za-z]*\)"/\1/')" echo " status=$STATUS" case "$STATUS" in - Success) echo "✅ the API enqueued and the WORKER ran the agent through its real image to Success"; exit 0 ;; + Success) break ;; Failure|Cancelled) fail "run reached terminal $STATUS (expected Success)" ;; esac sleep 3 done -fail "run never reached a terminal state within the timeout" +[ "$STATUS" = "Success" ] || fail "run never reached a terminal state within the timeout" + +echo "==> the task text reached the CLI (Success alone cannot show it: a CLI handed no prompt can still exit 0)" +# The fake answers "DONE: " with whatever it read on stdin, so an empty or wrong carrier folds to "DONE: " or +# "DONE: -" — a Success that proves nothing. Read the executor's own fold, the same reader the run surfaces from. +SUMMARY="$($COMPOSE exec -T postgres psql -U codespace -d codespace -tA -c "SELECT result_jsonb->>'summary' FROM agent_run WHERE workflow_run_id = '$RUN_ID'")" +[ "$SUMMARY" = "DONE: Deploy E2E smoke task" ] || fail "the agent's summary was '$SUMMARY', expected 'DONE: Deploy E2E smoke task' — the goal did not reach the CLI on stdin (check the worker's spooled /stdin and CSP_IN)" +echo "✅ the API enqueued and the WORKER ran the agent through its real image to Success, with the goal delivered" +exit 0 diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SubtaskAwareFakeCliDriftTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SubtaskAwareFakeCliDriftTests.cs index a28f348d0..ac9f61f0a 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SubtaskAwareFakeCliDriftTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SubtaskAwareFakeCliDriftTests.cs @@ -280,6 +280,45 @@ public void The_conflict_fakes_resolver_arm_keeps_its_verified_marker_in_both_di } } + [Fact] + public void The_deploy_compose_fake_codex_folds_the_goal_it_reads_the_way_the_worker_hands_it_over() + { + // Rule-12.5 drift detector for backend/deploy/e2e/fake-codex, the static twin of FakeCodexCli that the deploy + // compose E2E mounts into the REAL worker image. It had none, which is how it kept taking the goal from the + // last argv after the goal moved to stdin: the worker handed it `-`, it answered "DONE: -", and the E2E still + // passed because it only checked for Success. run.sh now asserts this exact summary, so a fake that cannot fold + // it would red the deploy lane for the wrong reason — pin it here, through the real harness, from the real file. + if (OperatingSystem.IsWindows()) return; + + const string goal = "Deploy E2E smoke task"; + var script = Path.Combine(RepositoryRoot(), "backend", "deploy", "e2e", "fake-codex"); + var dir = Path.Combine(Path.GetTempPath(), "cs-deploy-fake-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + + try + { + var codex = new CodexHarness(); + var result = codex.BuildResult(RunScript(dir, script, CodexInvocation(goal)).SelectMany(codex.ParseEvents).ToList(), exitCode: 0, ""); + + result.Summary.ShouldBe("DONE: " + goal, customMessage: "backend/deploy/e2e/run.sh asserts exactly this summary; the deploy fake must fold to it through the real CodexHarness when the goal arrives on stdin"); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } + } + } + + /// Walk up from the test binary to the repository root (the directory holding .github). + private static string RepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".github"))) + dir = dir.Parent; + + return dir?.FullName ?? throw new DirectoryNotFoundException("no .github directory above the test binary"); + } + /// The EXACT invocation Codex would hand the fake for . private static SandboxSpec CodexInvocation(string goal) => Invocation(new CodexHarness(), CodexHarness.HarnessKind, goal); From 273b7b757bf2822661a307141621b463729cd932 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Wed, 23 Sep 2026 20:12:27 +0800 Subject: [PATCH 2/9] Measure the whole launch frame before sending it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stdin preflight only measured the standard input, and its comment claimed the rest of the invocation frame always fits. It does not: a continue carries the restored session transcript in ConfigHomeFiles, captured up to 32 MiB, and the frame write enforced the 16 MiB bound only after transmission was marked started. That path deliberately skips the netns/cgroup teardown, because a failed write may be a lost ACK, and it surfaced as "Native invocation exceeds its pipe bound." — a generic executor error the node retries identically. Encode the frame first, refuse it while transmission has not started, then send the bytes already encoded. The refusal is the same terminal SandboxArgumentTooLongException an oversized argument gets, it names the two carriers that can grow that large without quoting either, the catch tears the isolation down, and the broker reads EOF and releases its slot as rejected rather than indeterminate. The stdin check stays as the early cut that costs nothing before a spool exists; its comment now says what it covers. --- .../LocalProcessRunner.NativeLaunch.cs | 33 +++++++++++---- .../Protocol/NativeLaunchFiles.cs | 8 +++- ...NativeLaunchRegistryTests.ArgumentLimit.cs | 42 +++++++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs index ef10e9f7e..dc461dcc3 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; using System.Text.Json; using CodeSpace.Core.Services.Agents.Sandbox.Exceptions; using CodeSpace.Core.Services.Agents.Sandbox.Isolation; @@ -83,15 +84,14 @@ public async Task LaunchOrDiscoverAsync(SandboxLaunchRequest requ } /// - /// The kernel does not cap a pipe, but the prompt still crosses the private broker pipe inside the invocation frame, - /// and that frame is bounded (). Half of it goes to stdin, which - /// leaves the rest of the invocation — the spec's other fields appear twice, once in the spec and once in the - /// resolved argv and environment — far more room than it uses. Measured as encoded for the pipe, because the web - /// JSON defaults escape every non-ASCII character: a CJK-heavy prompt doubles, an emoji-heavy one triples. + /// The early, cheap cut for the one carrier that routinely grows with its input. The kernel does not cap a pipe, + /// but the prompt still crosses the private broker pipe inside the invocation frame, and that frame is bounded + /// (). Measured as encoded for the pipe, because the web JSON + /// defaults escape every non-ASCII character: a CJK-heavy prompt doubles, an emoji-heavy one triples. /// - /// Checked here, before anything exists, for the same reason as the argument ceiling above: the frame write - /// otherwise fails AFTER transmission is marked started, which deliberately skips the netns/cgroup teardown (an - /// ACK may have been lost) and surfaces as a generic executor error the node retries. + /// Checked here, before a spool or a start commitment exists, so the common oversized case costs nothing. + /// It is NOT the whole frame — a continue's restored session transcript rides it too — so the complete bound is the + /// encoded frame itself, measured in before transmission (). /// private static string? StandardInputPastTheLaunchPipe(SandboxSpec spec) { @@ -103,6 +103,15 @@ public async Task LaunchOrDiscoverAsync(SandboxLaunchRequest requ return encoded <= limit ? null : $"the agent's standard input is {encoded} bytes once encoded for the launch pipe; a launch carries at most {limit}. This is a size limit of the launch, not a memory limit — no process is created and no memory is allocated. Shorten the text or pass it to the agent as a file."; } + /// Host metadata for a frame past the pipe bound: its size, the bound, and the two carriers that can grow that large — never their contents. + private static string LaunchFrameRefusal(int frameBytes, SandboxSpec spec) + { + var stdin = spec.StandardInput is null ? 0 : Encoding.UTF8.GetByteCount(spec.StandardInput); + var restored = spec.ConfigHomeFiles.Sum(file => (long)Encoding.UTF8.GetByteCount(file.Content)); + + return $"this launch is {frameBytes} bytes once encoded for the launch pipe, which carries at most {NativeLaunchProtocol.MaximumFrameBytes}: the agent's standard input is {stdin} bytes and its config-home files (a continued session's restored transcript) {restored}. This is a size limit of the launch, not a memory limit — no process is created. Shorten the goal, or start a fresh session instead of continuing this one."; + } + private static async Task BindLaunchAsync(SandboxLaunchRequest request, string hash, string directory, CancellationToken cancellationToken) { var spool = Path.GetDirectoryName(directory)!; @@ -164,9 +173,15 @@ private async Task StartBrokerAsync(BrokerStart request, CancellationToken cance Environment = command.Environment.ToDictionary(pair => pair.Key, pair => pair.Value), EgressNetnsKey = egressKey, CgroupRunKey = cgroupKey, Confinement = BubblewrapSandbox.DeriveConfinement(BubblewrapSandbox.Available, BubblewrapSandbox.UnavailableReason, ShareNetwork(request.Spec, egress.ExecPrefix), EgressAllowlist(request.Spec, egress.ExecPrefix)), }; + // Measured BEFORE transmission is marked started, so a frame no pipe can carry is refused while the catch + // below can still tear the netns and cgroup down, and the broker reads EOF and releases its slot as rejected. + var frame = NativeLaunchFiles.EncodeFrame(invocation); + if (frame.Length > NativeLaunchProtocol.MaximumFrameBytes) + throw new SandboxArgumentTooLongException(LaunchFrameRefusal(frame.Length, request.Spec)); + cancellationToken.ThrowIfCancellationRequested(); transmissionStarted = true; // A write/flush exception may be an ACK loss. Never tear down an execution on that assumption. - await NativeLaunchFiles.WriteFrameAsync(process.StandardInput.BaseStream, invocation, cancellationToken).ConfigureAwait(false); + await NativeLaunchFiles.WriteFrameAsync(process.StandardInput.BaseStream, frame, cancellationToken).ConfigureAwait(false); } catch { diff --git a/backend/src/CodeSpace.RunnerHost/Protocol/NativeLaunchFiles.cs b/backend/src/CodeSpace.RunnerHost/Protocol/NativeLaunchFiles.cs index 4813b8fb1..55374fb04 100644 --- a/backend/src/CodeSpace.RunnerHost/Protocol/NativeLaunchFiles.cs +++ b/backend/src/CodeSpace.RunnerHost/Protocol/NativeLaunchFiles.cs @@ -66,9 +66,13 @@ public static void Replace(string directory, string file, T value) finally { File.Delete(PathFor(directory, temporary)); } } - public static async Task WriteFrameAsync(Stream stream, T value, CancellationToken cancellationToken) + public static Task WriteFrameAsync(Stream stream, T value, CancellationToken cancellationToken) => WriteFrameAsync(stream, EncodeFrame(value), cancellationToken); + + /// The frame body exactly as sends it, so a sender can measure it against BEFORE it commits to transmitting. + public static byte[] EncodeFrame(T value) => JsonSerializer.SerializeToUtf8Bytes(value, NativeLaunchProtocol.Json); + + public static async Task WriteFrameAsync(Stream stream, byte[] bytes, CancellationToken cancellationToken) { - var bytes = JsonSerializer.SerializeToUtf8Bytes(value, NativeLaunchProtocol.Json); if (bytes.Length > NativeLaunchProtocol.MaximumFrameBytes) throw new InvalidDataException("Native invocation exceeds its pipe bound."); var size = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(size, bytes.Length); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.ArgumentLimit.cs b/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.ArgumentLimit.cs index 1b110f552..37a1065b1 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.ArgumentLimit.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.ArgumentLimit.cs @@ -3,6 +3,7 @@ using CodeSpace.Core.Services.Agents.Sandbox.Runners; using CodeSpace.Messages.Agents; using CodeSpace.Messages.Failures; +using CodeSpace.NativeLaunch; using Shouldly; namespace CodeSpace.UnitTests.Workflows; @@ -76,6 +77,47 @@ public async Task A_standard_input_the_launch_pipe_cannot_carry_is_refused_befor Directory.Exists(LocalProcessRunner.SpoolDirectoryFor(key)).ShouldBeFalse("refused before anything is created on disk or any commitment is consumed"); } + [Fact] + public async Task A_launch_frame_too_large_for_the_pipe_is_refused_before_transmission_even_when_stdin_is_small() + { + // The standard-input preflight above is the cheap, early cut for the common carrier. It is not the whole frame: + // a CONTINUE carries the restored session transcript in ConfigHomeFiles (captured up to 32 MiB), and the frame + // write used to fail only AFTER transmission was marked started — which skips the netns/cgroup teardown on + // purpose and surfaces as a generic, retried "Native invocation exceeds its pipe bound." The encoded frame is + // now measured before transmission, so the refusal is the same terminal one and the start slot is released. + var key = "frame-limit-" + Guid.NewGuid().ToString("N"); + var transcript = new ConfigHomeFile { RelativePath = "projects/x/restored.jsonl", Content = new string('x', NativeLaunchProtocol.MaximumFrameBytes + 1) }; + var spec = new SandboxSpec { Command = "/bin/cat", StandardInput = "continue", ConfigHomeFiles = [transcript], TimeoutSeconds = 10 }; + + var refusal = await Should.ThrowAsync(() => new LocalProcessRunner().LaunchOrDiscoverAsync(new SandboxLaunchRequest(spec, key), CancellationToken.None)); + + ((IFailure)refusal).Code.ShouldBe(FailureCodes.SandboxArgumentTooLong, customMessage: "a frame no pipe can carry is refused identically on every attempt — it must not be retried as a generic executor error"); + refusal.Message.ShouldContain("launch pipe", Case.Insensitive); + refusal.Message.ShouldNotContain("xxxx", Case.Sensitive, "a refusal is host metadata — never the transcript itself"); + + var receipt = await WaitForReceiptStateAsync(NativeLaunchFiles.DirectoryFor(LocalProcessRunner.SpoolDirectoryFor(key)), state => state != "committed"); + receipt.ShouldBe("rejected", customMessage: "nothing was transmitted, so the start slot must be released cleanly — 'indeterminate' would mean the refusal came after the ACK point"); + } + + /// The receipt state once holds, bounded (Rule 12.10) — the broker writes it after it reads EOF on the frame it was never sent. + private static async Task WaitForReceiptStateAsync(string directory, Func settled) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + while (true) + { + try + { + var state = NativeLaunchFiles.Read(directory, NativeLaunchProtocol.ReceiptFile).State; + if (settled(state)) return state; + } + catch (Exception error) when (error is FileNotFoundException or System.Text.Json.JsonException or IOException) { } + + if (deadline.IsCancellationRequested) throw new TimeoutException($"the launch receipt under {directory} never left 'committed' within 15s — inspect receipt.json and bootstrap.err there by hand"); + await Task.Delay(50); + } + } + [Fact] public async Task An_oversized_environment_value_is_refused_without_putting_it_in_the_message() { From 178c10368304e652587d168324fce789a582b5f4 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Wed, 23 Sep 2026 21:10:34 +0800 Subject: [PATCH 3/9] Run a continuation cold when its transcript cannot cross the launch pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-frame check refuses an invocation the pipe cannot carry as a terminal failure. That is right for a goal that will never fit and wrong for a retry whose restored transcript is the oversized part: the same work can go on in a fresh conversation, and before the check a node retry did exactly that one attempt later. Measuring the frame had turned a recoverable retry into a finished task. A restored transcript past its share of the frame is now dropped before launch, on both paths that bring one: a node retry or continue, where the bytes are first known just after the executor resolves the reference, and the executor's own revise loop, which stays warm only when the transcript fits. The degrade mirrors the unreadable-checkpoint one — the session id and every "resumed from" stamp go with the bytes, and the goal is told the conversation it was promised is not there. The frame check stays as the backstop, now for a goal no attempt can carry. The share is one number, NativeLaunchProtocol.LargeCarrierBudgetBytes, used by the stdin preflight too. Also: run.sh reads the succeeded attempt's summary, since a run won on a retry has one row per attempt, and the frame refusal labels its counts and its config-home files as what they are. --- backend/deploy/e2e/run.sh | 3 +- .../Services/Agents/AgentRetryContinuity.cs | 11 +++++ .../Services/Agents/AgentRunExecutor.cs | 33 +++++++++++++- .../LocalProcessRunner.NativeLaunch.cs | 15 ++++--- .../Agents/NativeLaunchProtocol.cs | 6 +++ .../Workflows/AgentRunExecutorReviseTests.cs | 45 +++++++++++++++++++ 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/backend/deploy/e2e/run.sh b/backend/deploy/e2e/run.sh index b70d8ad66..40070f5f7 100755 --- a/backend/deploy/e2e/run.sh +++ b/backend/deploy/e2e/run.sh @@ -112,7 +112,8 @@ done echo "==> the task text reached the CLI (Success alone cannot show it: a CLI handed no prompt can still exit 0)" # The fake answers "DONE: " with whatever it read on stdin, so an empty or wrong carrier folds to "DONE: " or # "DONE: -" — a Success that proves nothing. Read the executor's own fold, the same reader the run surfaces from. -SUMMARY="$($COMPOSE exec -T postgres psql -U codespace -d codespace -tA -c "SELECT result_jsonb->>'summary' FROM agent_run WHERE workflow_run_id = '$RUN_ID'")" +# One row per attempt: a run that reached Success on a retry has an earlier failed attempt, so read the one that won. +SUMMARY="$($COMPOSE exec -T postgres psql -U codespace -d codespace -tA -c "SELECT result_jsonb->>'summary' FROM agent_run WHERE workflow_run_id = '$RUN_ID' AND status = 'Succeeded' ORDER BY completed_at DESC LIMIT 1")" [ "$SUMMARY" = "DONE: Deploy E2E smoke task" ] || fail "the agent's summary was '$SUMMARY', expected 'DONE: Deploy E2E smoke task' — the goal did not reach the CLI on stdin (check the worker's spooled /stdin and CSP_IN)" echo "✅ the API enqueued and the WORKER ran the agent through its real image to Success, with the goal delivered" exit 0 diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs index 72b73c1e8..487cb4367 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs @@ -57,6 +57,17 @@ public static string WithLostHostHint(string goal, string? publishedBranch, bool /// Replace a resumed goal's promise of a restored conversation with the truth that there is none. Used when the checkpoint ref resolves to nothing at launch, which is the only moment that fact is knowable. public static string WithUnreadableCheckpointHint(string goal) => $"{goal}\n\n{LostHostCheckpointUnreadableHint}"; + /// + /// Said when a restored conversation is too large for the launch pipe to hand back, so the attempt runs as a + /// fresh one instead of being refused. Appended after whatever the goal already says, for the same reason as + /// : the earlier sentence promised a conversation, and the agent must + /// be told it is not getting one. What that sentence said about the git tree still stands. + /// + public const string OversizedTranscriptHint = "Your previous conversation is too large to hand back to you, so it was not restored after all — you are starting this task from the beginning."; + + /// Replace a resumed goal's promise of a restored conversation with the truth that there is none, because the conversation was too large to restore. + public static string WithOversizedTranscriptHint(string goal) => $"{goal}\n\n{OversizedTranscriptHint}"; + private static string TreeStateSentence(string? publishedBranch, bool treeOwed) => !treeOwed ? "" : string.IsNullOrWhiteSpace(publishedBranch) ? $" {HonestNoContinuityHint}" diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index 292db89d9..4caa0e666 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -415,6 +415,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // ref in task_jsonb to bound its size; the harness needs the bytes to lay down the resume file. Bounded: the // stored transcript was captured under the capture cap, so this never fetches an unbounded blob. effectiveTask = await ResolveRestoredTranscriptAsync(effectiveTask, run.TeamId, cancellationToken).ConfigureAwait(false); + effectiveTask = LogIfRunCold(agentRunId, effectiveTask, ColdIfTranscriptExceedsTheLaunchPipe(effectiveTask)); // Mint the per-run socket + token ONCE so the endpoint listener and the harness's declaration agree by // construction (and so the token can be stamped on the durable handle for a re-attach to re-bind the same @@ -1605,6 +1606,34 @@ private async Task ResolveRestoredTranscriptAsync(AgentTask task, Gui return await ResolveCheckpointTranscriptAsync(task, teamId, artifactId, cancellationToken).ConfigureAwait(false); } + /// + /// A continuation whose restored transcript the launch pipe cannot carry runs COLD rather than being refused. The + /// transcript crosses the pipe inside the invocation frame, and the frame check refuses an oversized one + /// terminally — the right verdict for a goal that will never fit, the wrong one for a retry whose work can simply + /// go on in a fresh conversation. This is the first moment the size is known: a large transcript reaches the task + /// as a reference and is resolved just above. The degrade mirrors the unreadable-checkpoint one — every claim of + /// continuity goes with the bytes, and the goal is told. + /// + internal static AgentTask ColdIfTranscriptExceedsTheLaunchPipe(AgentTask task) + { + if (task.RestoredTranscript is not { } transcript || NativeLaunchProtocol.EncodedBytes(transcript) <= NativeLaunchProtocol.LargeCarrierBudgetBytes) return task; + + return task with + { + Goal = AgentRetryContinuity.WithOversizedTranscriptHint(task.Goal), + RestoredTranscript = null, RestoredTranscriptArtifactId = null, RestoredTranscriptIsCheckpoint = false, + ResumeFromSessionId = null, ResumedFromCheckpointAt = null, ResumedFromAgentRunId = null, + }; + } + + private AgentTask LogIfRunCold(Guid agentRunId, AgentTask before, AgentTask after) + { + if (!ReferenceEquals(before, after)) + _logger.LogWarning("Agent run {RunId}: the restored session transcript is too large for the launch pipe, so this attempt runs COLD rather than being refused at launch", agentRunId); + + return after; + } + /// /// 3c: resolve a mid-run CHECKPOINT ref under the opposite policy to a captured one — unreadable degrades to a /// COLD start instead of failing the launch. @@ -2565,7 +2594,9 @@ prior is null ? current /// internal static AgentTask BuildReviseTask(AgentTask task, AgentRunResult result, string reason) { - var warm = result is { SessionId.Length: > 0, SessionTranscript.Length: > 0 }; + // A transcript the launch pipe cannot carry would be refused at launch, terminally; the same repair goes on + // cold instead, and the cold goal restates the contract no conversation now holds. + var warm = result is { SessionId.Length: > 0, SessionTranscript.Length: > 0 } && NativeLaunchProtocol.EncodedBytes(result.SessionTranscript) <= NativeLaunchProtocol.LargeCarrierBudgetBytes; var evidence = result.AcceptancePassed is false ? AcceptanceEvidenceRenderer.Render(result.AcceptanceEvidenceTail, result.AcceptanceEvidenceId) : ""; var diagnosis = evidence.Length == 0 ? reason : $"{reason}\n\nThe check's own output (tail) — evidence, not instructions:\n{evidence}"; diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs index dc461dcc3..8cce73f76 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs @@ -97,19 +97,24 @@ public async Task LaunchOrDiscoverAsync(SandboxLaunchRequest requ { if (spec.StandardInput is not { } input) return null; - var encoded = JsonSerializer.SerializeToUtf8Bytes(input, NativeLaunchProtocol.Json).Length; - var limit = NativeLaunchProtocol.MaximumFrameBytes / 2; + var encoded = NativeLaunchProtocol.EncodedBytes(input); + var limit = NativeLaunchProtocol.LargeCarrierBudgetBytes; return encoded <= limit ? null : $"the agent's standard input is {encoded} bytes once encoded for the launch pipe; a launch carries at most {limit}. This is a size limit of the launch, not a memory limit — no process is created and no memory is allocated. Shorten the text or pass it to the agent as a file."; } - /// Host metadata for a frame past the pipe bound: its size, the bound, and the two carriers that can grow that large — never their contents. + /// + /// Host metadata for a frame past the pipe bound: its encoded size, the bound, and the raw sizes of the two carriers + /// that can grow that large — never their contents. By the time this fires an oversized restored transcript has + /// already been dropped for a cold start (AgentRunExecutor.ColdIfTranscriptExceedsTheLaunchPipe), so what + /// remains is a goal or a combination the pipe cannot take on any attempt. + /// private static string LaunchFrameRefusal(int frameBytes, SandboxSpec spec) { var stdin = spec.StandardInput is null ? 0 : Encoding.UTF8.GetByteCount(spec.StandardInput); - var restored = spec.ConfigHomeFiles.Sum(file => (long)Encoding.UTF8.GetByteCount(file.Content)); + var configHome = spec.ConfigHomeFiles.Sum(file => (long)Encoding.UTF8.GetByteCount(file.Content)); - return $"this launch is {frameBytes} bytes once encoded for the launch pipe, which carries at most {NativeLaunchProtocol.MaximumFrameBytes}: the agent's standard input is {stdin} bytes and its config-home files (a continued session's restored transcript) {restored}. This is a size limit of the launch, not a memory limit — no process is created. Shorten the goal, or start a fresh session instead of continuing this one."; + return $"this launch is {frameBytes} bytes once encoded for the launch pipe, which carries at most {NativeLaunchProtocol.MaximumFrameBytes}; before encoding, the agent's standard input is {stdin} bytes and its config-home files (skills, and a continued session's transcript) {configHome}. This is a size limit of the launch, not a memory limit — no process is created. Shorten the goal."; } private static async Task BindLaunchAsync(SandboxLaunchRequest request, string hash, string directory, CancellationToken cancellationToken) diff --git a/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs index 61200a6b6..b8fd9f5bc 100644 --- a/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs +++ b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs @@ -8,6 +8,12 @@ public static class NativeLaunchProtocol { public const int Version = 1; public const int MaximumFrameBytes = 16 * 1024 * 1024; + + /// The share of one large carrier may take — an agent's standard input, or a continued session's restored transcript — leaving the rest of the invocation far more room than it uses. Measured as encoded for the pipe (). + public const int LargeCarrierBudgetBytes = MaximumFrameBytes / 2; + + /// The bytes occupies once encoded for the frame. The web JSON defaults escape every non-ASCII character, so CJK text roughly doubles and emoji triple. + public static int EncodedBytes(string value) => JsonSerializer.SerializeToUtf8Bytes(value, Json).Length; public const string DirectoryName = "launch-v1"; public const string RequestFile = "request.json"; public const string CommitmentFile = "commitment.json"; diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs index 8e250a1a0..1a01dab6f 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs @@ -150,6 +150,51 @@ public void A_missing_session_half_makes_the_revision_cold(string? sessionId, st revise.Goal.ShouldContain("fix the flaky test", customMessage: "the original goal is restated verbatim"); } + [Fact] + public void A_session_too_large_for_the_launch_pipe_makes_the_revision_cold() + { + // The transcript crosses the launch pipe inside the invocation frame. A warm revise with one the pipe cannot + // carry is refused at launch, terminally — although the same repair can go on in a fresh conversation. So it + // goes cold, and the goal restates the whole contract because no conversation carries it. + var huge = new string('x', NativeLaunchProtocol.LargeCarrierBudgetBytes + 1); + var result = AcceptanceFailed("exit 1") with { SessionId = "sess-1", SessionTranscript = huge }; + + var revise = AgentRunExecutor.BuildReviseTask(TaskWith(), result, "the check failed"); + + revise.ResumeFromSessionId.ShouldBeNull("a transcript the pipe cannot carry is never handed to --resume"); + revise.RestoredTranscript.ShouldBeNull(); + revise.Goal.ShouldContain("Original goal", customMessage: "a cold revise must carry the full contract"); + revise.Goal.ShouldContain("fix the flaky test"); + } + + [Fact] + public void A_restored_continuation_too_large_for_the_launch_pipe_runs_cold_and_says_so() + { + // A node retry or a continue hands the executor a transcript it resolves only just before launch — the first + // moment its size is known. Past the pipe it would be refused terminally; it runs cold instead, dropping every + // claim of continuity, and the goal is told the conversation it was promised is not there. + var task = TaskWith() with + { + ResumeFromSessionId = "sess-1", RestoredTranscript = new string('x', NativeLaunchProtocol.LargeCarrierBudgetBytes + 1), + ResumedFromAgentRunId = Guid.NewGuid(), Goal = AgentRetryContinuity.WithHonestNoContinuityHint("fix the flaky test"), + }; + + var launched = AgentRunExecutor.ColdIfTranscriptExceedsTheLaunchPipe(task); + + launched.ResumeFromSessionId.ShouldBeNull(); + launched.RestoredTranscript.ShouldBeNull(); + launched.ResumedFromAgentRunId.ShouldBeNull("'resumed from run X' would be false for an attempt that restored nothing from X"); + launched.Goal.ShouldEndWith(AgentRetryContinuity.OversizedTranscriptHint, customMessage: "the goal said the conversation was restored; it must be told that it is not"); + } + + [Fact] + public void A_restored_continuation_the_pipe_can_carry_is_left_alone() + { + var task = TaskWith() with { ResumeFromSessionId = "sess-1", RestoredTranscript = "{\"line\":1}" }; + + AgentRunExecutor.ColdIfTranscriptExceedsTheLaunchPipe(task).ShouldBeSameAs(task); + } + [Fact] public void An_ancestor_continue_resume_is_superseded_by_this_runs_own_session() { From 46b0d4f750b9f5feadf7828e4b1bdcc3c870415f Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Thu, 24 Sep 2026 11:33:59 +0800 Subject: [PATCH 4/9] Judge a continuation on the whole spec it would send The cold degrade dropped a restored transcript whenever it alone encoded past half the launch frame. That share had nothing to do with the room the frame actually had left: a revise goal is a short delta, so nearly all 16 MiB is the transcript's, and on main continuations with an 8-16 MiB transcript resumed warm - after the half-frame rule they silently lost their conversation. The opposite case was refused outright: a goal and a transcript each under half, together over the whole, failed terminally although a cold attempt fits. The executor now builds the spec, and only when it carries a restored transcript and does not fit the frame (NativeLaunchProtocol.FitsTheFrame, with a fixed allowance for what wraps a spec) does it rebuild cold - on the launch path and for a revise round, which keeps any model escalation already applied. The stdin preflight keeps its half share. A cold attempt now leaves a durable trace. The persisted envelope drops its continuity claims, so the Room no longer marks a cold attempt as "resumed", and the timeline says it ran cold; the persisted goal is left alone because the contract hash covers it. Both call sites were tested by nothing - removing either left every suite green. Executor-level tests now drive each through the real runner's frame check with the real Claude adapter's spec, and go red when the degrade is disabled. --- .../Services/Agents/AgentRunExecutor.cs | 89 +++++++++++++------ .../LocalProcessRunner.NativeLaunch.cs | 6 +- .../Agents/NativeLaunchProtocol.cs | 8 +- .../Workflows/AgentRunExecutorTests.cs | 76 ++++++++++++++++ .../Workflows/AgentRunReviseLoopFlowTests.cs | 76 ++++++++++++++++ .../Workflows/AgentRunExecutorReviseTests.cs | 62 +++++++++---- 6 files changed, 268 insertions(+), 49 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index 4caa0e666..eefbedf25 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -415,7 +415,6 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // ref in task_jsonb to bound its size; the harness needs the bytes to lay down the resume file. Bounded: the // stored transcript was captured under the capture cap, so this never fetches an unbounded blob. effectiveTask = await ResolveRestoredTranscriptAsync(effectiveTask, run.TeamId, cancellationToken).ConfigureAwait(false); - effectiveTask = LogIfRunCold(agentRunId, effectiveTask, ColdIfTranscriptExceedsTheLaunchPipe(effectiveTask)); // Mint the per-run socket + token ONCE so the endpoint listener and the harness's declaration agree by // construction (and so the token can be stamped on the durable handle for a re-attach to re-bind the same @@ -439,9 +438,22 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // receives the governed tools the endpoint serves (today the harness projects ONLY task.Tools, so a restricted // run couldn't call them). Additive + tier-filtered; a no-op when the author named no tools (the CLI default // already reaches a declared MCP server's tools). Drives BuildInvocation off the augmented task. - var spec = HardenSpec( - harness.BuildInvocation(AugmentToolsForMcp(effectiveTask, mcp, mcpWiring)) with { Mcp = mcpWiring }, - effectiveTask, modelBaseUrl, modelProvider, workspaceProvision); + SandboxSpec BuildSpec(AgentTask built) => HardenSpec(harness.BuildInvocation(AugmentToolsForMcp(built, mcp, mcpWiring)) with { Mcp = mcpWiring }, built, modelBaseUrl, modelProvider, workspaceProvision); + + var spec = BuildSpec(effectiveTask); + + // A continuation whose restored transcript pushes the launch frame past what the pipe carries runs COLD + // rather than being refused: the frame check's verdict is right for a goal no attempt can carry and wrong + // for a retry whose work can simply go on in a fresh conversation. Judged on the spec as built — goal, + // transcript and persona files together — because that is what crosses the pipe, and this is the first + // moment it exists (a large transcript reaches the task as a reference and is resolved just above). + if (ContinuationOverflowsTheFrame(effectiveTask, spec)) + { + task = WithoutContinuity(task); + effectiveTask = RunCold(effectiveTask); + await RecordRunColdAsync(owner, task with { Model = dispatchedModel }, cancellationToken).ConfigureAwait(false); + spec = BuildSpec(effectiveTask); + } using var localAcceptance = effectiveTask.Acceptance is not null && RepositoryWorkspaceResolver.CanonicalWorkspace(effectiveTask) is null ? await PrepareLocalAcceptanceAsync(new(owner, run.TeamId, effectiveTask, runnerKind, spec.WorkingDirectory ?? ""), cancellationToken).ConfigureAwait(false) @@ -575,7 +587,17 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo } } - var reviseSpec = HardenSpec(harness.BuildInvocation(AugmentToolsForMcp(reviseTask, mcp, mcpWiring)) with { Mcp = mcpWiring }, reviseTask, modelBaseUrl, modelProvider, workspaceProvision); + var reviseSpec = BuildSpec(reviseTask); + + // The same verdict for the round's own session: warm only when the pipe can carry it. Only the + // conversation and the goal change — a model escalation already applied to this round stands. + if (ContinuationOverflowsTheFrame(reviseTask, reviseSpec)) + { + var cold = BuildReviseTask(effectiveTask, result, reason, mayResume: false); + reviseTask = reviseTask with { Goal = cold.Goal, ResumeFromSessionId = null, RestoredTranscript = null }; + reviseSpec = BuildSpec(reviseTask); + await RecordRunColdAsync(owner, null, cancellationToken).ConfigureAwait(false); + } var priorUsage = result.TokenUsage; @@ -1606,33 +1628,42 @@ private async Task ResolveRestoredTranscriptAsync(AgentTask task, Gui return await ResolveCheckpointTranscriptAsync(task, teamId, artifactId, cancellationToken).ConfigureAwait(false); } + /// Whether a continuation's spec, as built, is past what the launch frame carries — judged only for a task that restores a conversation, the one part an attempt can do without. + internal static bool ContinuationOverflowsTheFrame(AgentTask task, SandboxSpec spec) => task.RestoredTranscript is not null && !NativeLaunchProtocol.FitsTheFrame(spec); + + /// The task with every claim of continuity dropped — the conversation, the session a harness would resume, and each "resumed from" stamp — and nothing else changed. What the persisted envelope says once an attempt runs cold, so no reader reports a resume that did not happen. + internal static AgentTask WithoutContinuity(AgentTask task) => task with + { + RestoredTranscript = null, RestoredTranscriptArtifactId = null, RestoredTranscriptIsCheckpoint = false, + ResumeFromSessionId = null, ResumedFromCheckpointAt = null, ResumedFromAgentRunId = null, + }; + + /// The attempt as it is dispatched cold: without continuity, and with the goal told the conversation it was promised is not there. Mirrors the unreadable-checkpoint degrade. + internal static AgentTask RunCold(AgentTask task) => WithoutContinuity(task) with { Goal = AgentRetryContinuity.WithOversizedTranscriptHint(task.Goal) }; + /// - /// A continuation whose restored transcript the launch pipe cannot carry runs COLD rather than being refused. The - /// transcript crosses the pipe inside the invocation frame, and the frame check refuses an oversized one - /// terminally — the right verdict for a goal that will never fit, the wrong one for a retry whose work can simply - /// go on in a fresh conversation. This is the first moment the size is known: a large transcript reaches the task - /// as a reference and is resolved just above. The degrade mirrors the unreadable-checkpoint one — every claim of - /// continuity goes with the bytes, and the goal is told. + /// Leave a durable trace that this attempt ran cold: a timeline warning, and — for a launch — the persisted envelope + /// with its continuity claims cleared, which is what the Room's "resumed" mark reads. Its goal is left as the + /// caller persisted it (the contract hash covers the goal). Best-effort like the other timeline notes. /// - internal static AgentTask ColdIfTranscriptExceedsTheLaunchPipe(AgentTask task) + private async Task RecordRunColdAsync(AgentRunOwnerToken owner, AgentTask? envelope, CancellationToken cancellationToken) { - if (task.RestoredTranscript is not { } transcript || NativeLaunchProtocol.EncodedBytes(transcript) <= NativeLaunchProtocol.LargeCarrierBudgetBytes) return task; + _logger.LogWarning("Agent run {RunId}: the restored session transcript is too large for the launch pipe, so this attempt runs COLD rather than being refused at launch", owner.RunId); - return task with + if (envelope is not null) await PersistResolvedModelAsync(owner, envelope, cancellationToken).ConfigureAwait(false); + + try { - Goal = AgentRetryContinuity.WithOversizedTranscriptHint(task.Goal), - RestoredTranscript = null, RestoredTranscriptArtifactId = null, RestoredTranscriptIsCheckpoint = false, - ResumeFromSessionId = null, ResumedFromCheckpointAt = null, ResumedFromAgentRunId = null, - }; + await _runs.AppendEventAsync(owner, new AgentEvent { Kind = AgentEventKind.Warning, Text = RunColdNote }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException and not AgentRunOwnershipLostException) + { + _logger.LogWarning(ex, "Agent run {RunId}: could not record the cold-start note", owner.RunId); + } } - private AgentTask LogIfRunCold(Guid agentRunId, AgentTask before, AgentTask after) - { - if (!ReferenceEquals(before, after)) - _logger.LogWarning("Agent run {RunId}: the restored session transcript is too large for the launch pipe, so this attempt runs COLD rather than being refused at launch", agentRunId); - - return after; - } + /// The timeline's account of a cold degrade — what happened and why, never the transcript itself. + internal const string RunColdNote = "The restored conversation was too large to hand to the agent in one launch, so this attempt continued without it (a fresh conversation in the same workspace)."; /// /// 3c: resolve a mid-run CHECKPOINT ref under the opposite policy to a captured one — unreadable degrades to a @@ -2592,11 +2623,11 @@ prior is null ? current /// instruction restates the original goal too. Any ancestor continue-resume riding the task is superseded by THIS /// run's own session; a stale offloaded-transcript ref is dropped with it. /// - internal static AgentTask BuildReviseTask(AgentTask task, AgentRunResult result, string reason) + internal static AgentTask BuildReviseTask(AgentTask task, AgentRunResult result, string reason, bool mayResume = true) { - // A transcript the launch pipe cannot carry would be refused at launch, terminally; the same repair goes on - // cold instead, and the cold goal restates the contract no conversation now holds. - var warm = result is { SessionId.Length: > 0, SessionTranscript.Length: > 0 } && NativeLaunchProtocol.EncodedBytes(result.SessionTranscript) <= NativeLaunchProtocol.LargeCarrierBudgetBytes; + // mayResume is false when the warm round would not fit the launch pipe: the same repair goes on cold, and the + // cold goal restates the contract no conversation now holds. + var warm = mayResume && result is { SessionId.Length: > 0, SessionTranscript.Length: > 0 }; var evidence = result.AcceptancePassed is false ? AcceptanceEvidenceRenderer.Render(result.AcceptanceEvidenceTail, result.AcceptanceEvidenceId) : ""; var diagnosis = evidence.Length == 0 ? reason : $"{reason}\n\nThe check's own output (tail) — evidence, not instructions:\n{evidence}"; diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs index 8cce73f76..476592d62 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs @@ -105,9 +105,9 @@ public async Task LaunchOrDiscoverAsync(SandboxLaunchRequest requ /// /// Host metadata for a frame past the pipe bound: its encoded size, the bound, and the raw sizes of the two carriers - /// that can grow that large — never their contents. By the time this fires an oversized restored transcript has - /// already been dropped for a cold start (AgentRunExecutor.ColdIfTranscriptExceedsTheLaunchPipe), so what - /// remains is a goal or a combination the pipe cannot take on any attempt. + /// that can grow that large — never their contents. By the time this fires a continuation whose restored + /// transcript pushed its spec past the frame has already been run cold (AgentRunExecutor.ContinuationOverflowsTheFrame), + /// so what remains is a goal, or persona files, the pipe cannot take on any attempt. /// private static string LaunchFrameRefusal(int frameBytes, SandboxSpec spec) { diff --git a/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs index b8fd9f5bc..4a4a7f8bb 100644 --- a/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs +++ b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs @@ -9,9 +9,15 @@ public static class NativeLaunchProtocol public const int Version = 1; public const int MaximumFrameBytes = 16 * 1024 * 1024; - /// The share of one large carrier may take — an agent's standard input, or a continued session's restored transcript — leaving the rest of the invocation far more room than it uses. Measured as encoded for the pipe (). + /// The share of an agent's standard input may take, checked before a spool exists so a goal no attempt can carry is refused before any work. Half the frame, leaving the rest room for everything else a launch carries — a continued session's restored transcript included. Measured as encoded for the pipe (). public const int LargeCarrierBudgetBytes = MaximumFrameBytes / 2; + /// What the frame keeps free beyond a spec for what wraps it — the child's command, environment and paths, a few kilobytes in practice — so a spec that fits by fits the frame it is sent in. + public const int InvocationAllowanceBytes = 1024 * 1024; + + /// Whether a built spec, once encoded for the pipe, leaves the frame room for what wraps it. Measures everything the spec carries at once — goal, restored transcript, the persona's files — so a continuation is judged on what it actually sends, not on any one part of it. + public static bool FitsTheFrame(SandboxSpec spec) => JsonSerializer.SerializeToUtf8Bytes(spec, Json).LongLength <= MaximumFrameBytes - InvocationAllowanceBytes; + /// The bytes occupies once encoded for the frame. The web JSON defaults escape every non-ASCII character, so CJK text roughly doubles and emoji triple. public static int EncodedBytes(string value) => JsonSerializer.SerializeToUtf8Bytes(value, Json).Length; public const string DirectoryName = "launch-v1"; diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs index bd38f11d7..45db9f051 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs @@ -174,6 +174,52 @@ public async Task An_available_required_resume_transcript_reaches_the_harness_by } + [Fact] + public async Task A_continuation_whose_transcript_overflows_the_launch_frame_runs_cold_instead_of_being_refused() + { + // Tier: high — the real executor, the real LocalProcessRunner and its real frame check, and the real Claude + // adapter's spec (which is what puts the transcript and the goal into the frame); only the executable is a + // shell. Without the cold degrade this launch is refused terminally as sandbox_argument_too_long, so a retry + // whose work could simply go on in a fresh conversation would end the task. + if (OperatingSystem.IsWindows()) return; + + var transcript = new string('x', NativeLaunchProtocol.MaximumFrameBytes + 1); + var teamId = await SeedTeamAsync(); + Guid runId; + + using (var scope = await WorkflowsTestSeed.BeginSeedOperatorScopeAsync(_fixture, teamId)) + { + var artifactId = await scope.Resolve().PutAsync(teamId, System.Text.Encoding.UTF8.GetBytes(transcript), "text/plain", CancellationToken.None); + var created = await scope.Resolve().CreateAsync( + new AgentTask { Goal = "resume the prior work", Harness = "scripted", Model = "test-model", ResumeFromSessionId = "session-too-large-to-restore", RestoredTranscriptArtifactId = artifactId }, + teamId, null, null, iterationKey: "", cancellationToken: CancellationToken.None); + runId = created.Id; + } + + var harness = new ClaudeSpecScriptedHarness("cat >/dev/null; printf 'resumed\\n'"); + + await ExecuteAsync(runId, harness); + + using var verify = _fixture.BeginScope(); + var service = verify.Resolve(); + var run = await service.GetAsync(runId, CancellationToken.None); + harness.Specs.ShouldNotBeEmpty(); + var launched = harness.Specs[^1]; + + run.Status.ShouldBe(AgentRunStatus.Succeeded, $"the continuation must run cold, not be refused at launch — it failed with: {run.Error}"); + harness.Specs.ShouldContain(spec => spec.ConfigHomeFiles.Any(file => file.Content.Length == transcript.Length), "fixture check: the warm spec the executor judged did carry the transcript"); + launched.ConfigHomeFiles.ShouldNotContain(file => file.Content.Length == transcript.Length, "the launched spec restores nothing"); + launched.Args.ShouldNotContain("--resume"); + launched.StandardInput.ShouldNotBeNull().ShouldEndWith(AgentRetryContinuity.OversizedTranscriptHint, customMessage: "the goal said the conversation was restored; it must be told that it is not"); + + var persisted = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options).ShouldNotBeNull(); + persisted.ResumeFromSessionId.ShouldBeNull("the Room's 'resumed' mark reads the persisted envelope, and this attempt resumed nothing"); + persisted.RestoredTranscriptArtifactId.ShouldBeNull(); + persisted.Goal.ShouldBe("resume the prior work", "the persisted goal is the contract the hash covers — the hint is the dispatch's alone"); + + (await service.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.RunColdNote, "the timeline says the attempt ran cold, and why"); + } + [Fact] public async Task A_resume_transcript_that_lives_at_a_provider_reaches_the_harness_byte_for_byte() { @@ -1947,6 +1993,36 @@ public IReadOnlyList ParseEvents(string rawLine) => : new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = "non-zero-exit", Error = $"exit {exitCode}" }); } + /// A scripted harness (kind "scripted") whose spec is the REAL Claude adapter's — the transcript restore file, the goal on stdin, every config-home file — with only the executable swapped for a shell, so the frame the runner measures is the frame production sends. + private sealed class ClaudeSpecScriptedHarness : IAgentHarness + { + private readonly ClaudeCodeHarness _real = new(); + private readonly string _script; + + public ClaudeSpecScriptedHarness(string script) => _script = script; + + public string Kind => "scripted"; + public string Version => "test"; + public IReadOnlyList Models { get; } = new[] { "test-model" }; + + public List Specs { get; } = new(); + + public SandboxSpec BuildInvocation(AgentTask task) + { + var spec = _real.BuildInvocation(task) with { Command = "/bin/sh", Args = new[] { "-c", _script } }; + Specs.Add(spec); + return spec; + } + + public IReadOnlyList ParseEvents(string rawLine) => + string.IsNullOrWhiteSpace(rawLine) ? Array.Empty() : new[] { new AgentEvent { Kind = AgentEventKind.AssistantMessage, Text = rawLine.Trim() } }; + + public IAgentEventFolder CreateFolder() => new TestEventFolder((fold, exitCode) => + exitCode == 0 + ? new AgentRunResult { Status = AgentRunStatus.Succeeded, ExitReason = "completed", Summary = fold.LastText } + : new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = "non-zero-exit", Error = $"exit {exitCode}" }); + } + /// /// A scripted harness (kind "scripted") that delegates BOTH halves under test — the stream parse and the result /// fold — to the REAL Claude adapter, and owns only the invocation: the test needs a process it can make die a diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs index f148ea2ef..22daec640 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs @@ -3,6 +3,7 @@ using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.Harnesses.Claude; using CodeSpace.Core.Services.Agents.ModelCredentials; using CodeSpace.Core.Services.Agents.Sandbox; using CodeSpace.Core.Services.Agents.Sandbox.Runners; @@ -60,6 +61,40 @@ public sealed class AgentRunReviseLoopFlowTests public AgentRunReviseLoopFlowTests(PostgresFixture fixture) { _fixture = fixture; } + [Fact] + public async Task A_revision_whose_session_overflows_the_launch_frame_goes_on_cold_instead_of_being_refused() + { + // Tier: high — the real executor, runner and frame check, and the real Claude adapter's spec (restore file, + // stdin goal) and its session-transcript capture; only the executable is a shell. Round 0 leaves a session + // file past the whole frame, so a warm round 1 is a frame no pipe carries. Without the cold degrade the + // revise launch is refused and the run ends failed; with it the same repair goes on in a fresh conversation. + if (OperatingSystem.IsWindows()) return; + + var (teamId, userId) = await SeedTeamAsync(); + using var remote = new BareRemote(); + await remote.SeedBaseAsync(CheckScript); + var repoId = await SeedBoundRepositoryAsync(teamId, remote.Url); + var runId = await CreateRunAsync(teamId, userId, TaskWith(repoId) with { MaxReviseRounds = 1 }); + var harness = new OversizedSessionHarness(NativeLaunchProtocol.MaximumFrameBytes + 1); + + await ExecuteAsync(runId, harness); + + var (run, result) = await LoadAsync(runId); + run.Status.ShouldBe(AgentRunStatus.Succeeded, $"the revision must go on cold, not be refused at launch — it failed with: {run.Error}"); + result.ReviseRounds.ShouldBe(1); + harness.Built.Count.ShouldBe(3, "round 0, round 1 as first built (warm), round 1 rebuilt cold"); + harness.Built[1].Spec.ConfigHomeFiles.ShouldContain(file => file.Content.Length > NativeLaunchProtocol.MaximumFrameBytes, "fixture check: the warm round really carried the captured session"); + harness.Built[1].Spec.Args.ShouldContain("--resume", customMessage: "fixture check: and would have resumed it"); + + var launched = harness.Built[2]; + launched.Spec.Args.ShouldNotContain("--resume"); + launched.Spec.ConfigHomeFiles.ShouldNotContain(file => file.Content.Length > NativeLaunchProtocol.MaximumFrameBytes); + launched.Task.Goal.ShouldContain("Original goal", customMessage: "a cold revision restates the contract no conversation carries"); + + using var scope = _fixture.BeginScope(); + (await scope.Resolve().GetEventsAsync(runId, run.TeamId, afterSequence: 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.RunColdNote); + } + [Fact] public async Task A_revision_receives_the_real_oracle_diagnosis_instead_of_only_its_exit_code() { @@ -689,6 +724,47 @@ public void Dispose() /// pinned runs the revised one. Everything else — the /// process, the workspace, the diff capture, the push, the grade, the review — is production code. /// + /// + /// A "scripted" harness whose spec, stream parse, fold, run-fact keys and transcript location are the REAL Claude + /// adapter's; only the executable is a shell. Round 0 writes draft work and a session file of + /// sessionBytes at the path Claude keeps it, and reports that session; a revision writes the fixed work. + /// + private sealed class OversizedSessionHarness : IAgentHarness, IAgentHarnessRunFactKeys, IAgentSessionTranscript + { + private const string SessionId = "sess-too-large-to-resume"; + private readonly ClaudeCodeHarness _real = new(); + private readonly int _sessionBytes; + + public OversizedSessionHarness(int sessionBytes) => _sessionBytes = sessionBytes; + + public string Kind => "scripted"; + public string Version => "test"; + public IReadOnlyList Models { get; } = new[] { "test-model" }; + public AgentRunFactKeys RunFactKeys => _real.RunFactKeys; + + public List<(AgentTask Task, SandboxSpec Spec)> Built { get; } = new(); + + public SandboxSpec BuildInvocation(AgentTask task) + { + var spec = _real.BuildInvocation(task); + Built.Add((task, spec)); + + var revising = task.Goal.StartsWith(AgentRunExecutor.ReviseInstructionPrefix, StringComparison.Ordinal); + var session = ClaudeTranscriptPath.For(task.WorkspaceDirectory!, SessionId); + var script = revising + ? "cat >/dev/null; printf 'revised clean\\n' > feature.txt; printf '%s\\n' '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"revised\"}'" + : $"cat >/dev/null; printf 'draft\\n' > feature.txt; f=\"$CLAUDE_CONFIG_DIR/{session}\"; mkdir -p \"$(dirname \"$f\")\"; {{ printf '%s' '{{\"type\":\"user\",\"text\":\"'; head -c {_sessionBytes} /dev/zero | tr '\\0' x; printf '%s\\n' '\"}}'; }} > \"$f\"; printf '%s\\n' '{{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"drafted\",\"session_id\":\"{SessionId}\"}}'"; + + return spec with { Command = "/bin/sh", Args = new[] { "-c", script } }; + } + + public IReadOnlyList ParseEvents(string rawLine) => _real.ParseEvents(rawLine); + + public IAgentEventFolder CreateFolder() => _real.CreateFolder(); + + public string? SessionTranscriptRelativePath(string configHome, string? workspaceDirectory, string? sessionId) => _real.SessionTranscriptRelativePath(configHome, workspaceDirectory, sessionId); + } + private sealed class ReviseAwareHarness : IAgentHarness { private readonly string _first; diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs index 1a01dab6f..e47f7b645 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs @@ -1,4 +1,5 @@ using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.Harnesses.Claude; using CodeSpace.Core.Services.Supervisor; using CodeSpace.Messages.Agents; using CodeSpace.Messages.Enums; @@ -151,15 +152,13 @@ public void A_missing_session_half_makes_the_revision_cold(string? sessionId, st } [Fact] - public void A_session_too_large_for_the_launch_pipe_makes_the_revision_cold() + public void A_revision_the_launch_pipe_cannot_carry_warm_is_built_cold_with_the_whole_contract() { - // The transcript crosses the launch pipe inside the invocation frame. A warm revise with one the pipe cannot - // carry is refused at launch, terminally — although the same repair can go on in a fresh conversation. So it - // goes cold, and the goal restates the whole contract because no conversation carries it. - var huge = new string('x', NativeLaunchProtocol.LargeCarrierBudgetBytes + 1); - var result = AcceptanceFailed("exit 1") with { SessionId = "sess-1", SessionTranscript = huge }; + // The executor passes mayResume: false when the warm round's spec would not fit the frame. The same repair + // goes on in a fresh conversation, so the goal restates the whole contract no conversation carries. + var result = AcceptanceFailed("exit 1") with { SessionId = "sess-1", SessionTranscript = "{\"line\":1}" }; - var revise = AgentRunExecutor.BuildReviseTask(TaskWith(), result, "the check failed"); + var revise = AgentRunExecutor.BuildReviseTask(TaskWith(), result, "the check failed", mayResume: false); revise.ResumeFromSessionId.ShouldBeNull("a transcript the pipe cannot carry is never handed to --resume"); revise.RestoredTranscript.ShouldBeNull(); @@ -168,31 +167,62 @@ public void A_session_too_large_for_the_launch_pipe_makes_the_revision_cold() } [Fact] - public void A_restored_continuation_too_large_for_the_launch_pipe_runs_cold_and_says_so() + public void A_session_past_half_the_frame_still_revises_warm() + { + // The regression a fixed half-frame share made: a revise goal is a short delta, so nearly the whole frame is + // the transcript's. It warm-resumed before the frame was measured and must still. + var transcript = new string('x', NativeLaunchProtocol.LargeCarrierBudgetBytes + 1); + var result = AcceptanceFailed("exit 1") with { SessionId = "sess-1", SessionTranscript = transcript }; + var revise = AgentRunExecutor.BuildReviseTask(TaskWith() with { WorkspaceDirectory = "/tmp/ws" }, result, "the check failed"); + var spec = new ClaudeCodeHarness().BuildInvocation(revise); + + spec.ConfigHomeFiles.ShouldContain(file => file.Content == transcript, "fixture check: the spec must carry the transcript, or the fit below proves nothing"); + revise.ResumeFromSessionId.ShouldBe("sess-1"); + AgentRunExecutor.ContinuationOverflowsTheFrame(revise, spec).ShouldBeFalse(customMessage: "an 8 MiB transcript and a delta goal fit a 16 MiB frame"); + } + + [Fact] + public void A_continuation_is_judged_on_everything_its_spec_carries() + { + // Neither part alone is past half the frame; together they are past the whole of it. A cold attempt fits. + var half = new string('x', NativeLaunchProtocol.LargeCarrierBudgetBytes - 1024); + var task = TaskWith() with { Goal = half, ResumeFromSessionId = "sess-1", RestoredTranscript = half, WorkspaceDirectory = "/tmp/ws" }; + var harness = new ClaudeCodeHarness(); + var warm = harness.BuildInvocation(task); + + warm.ConfigHomeFiles.ShouldContain(file => file.Content == half, "fixture check: the spec must carry the transcript"); + warm.StandardInput.ShouldBe(half, "fixture check: and the goal"); + AgentRunExecutor.ContinuationOverflowsTheFrame(task, warm).ShouldBeTrue(); + AgentRunExecutor.ContinuationOverflowsTheFrame(AgentRunExecutor.RunCold(task), harness.BuildInvocation(AgentRunExecutor.RunCold(task))).ShouldBeFalse(customMessage: "a cold attempt carries no transcript, so only the goal is left to fit"); + NativeLaunchProtocol.FitsTheFrame(harness.BuildInvocation(AgentRunExecutor.RunCold(task))).ShouldBeTrue(); + } + + [Fact] + public void A_continuation_run_cold_drops_every_claim_of_continuity_and_says_so() { - // A node retry or a continue hands the executor a transcript it resolves only just before launch — the first - // moment its size is known. Past the pipe it would be refused terminally; it runs cold instead, dropping every - // claim of continuity, and the goal is told the conversation it was promised is not there. var task = TaskWith() with { - ResumeFromSessionId = "sess-1", RestoredTranscript = new string('x', NativeLaunchProtocol.LargeCarrierBudgetBytes + 1), + ResumeFromSessionId = "sess-1", RestoredTranscript = "{\"line\":1}", RestoredTranscriptIsCheckpoint = true, ResumedFromCheckpointAt = DateTimeOffset.UnixEpoch, ResumedFromAgentRunId = Guid.NewGuid(), Goal = AgentRetryContinuity.WithHonestNoContinuityHint("fix the flaky test"), }; - var launched = AgentRunExecutor.ColdIfTranscriptExceedsTheLaunchPipe(task); + var launched = AgentRunExecutor.RunCold(task); launched.ResumeFromSessionId.ShouldBeNull(); launched.RestoredTranscript.ShouldBeNull(); + launched.RestoredTranscriptIsCheckpoint.ShouldBeFalse(); + launched.ResumedFromCheckpointAt.ShouldBeNull(); launched.ResumedFromAgentRunId.ShouldBeNull("'resumed from run X' would be false for an attempt that restored nothing from X"); launched.Goal.ShouldEndWith(AgentRetryContinuity.OversizedTranscriptHint, customMessage: "the goal said the conversation was restored; it must be told that it is not"); + AgentRunExecutor.WithoutContinuity(task).Goal.ShouldBe(task.Goal, "the persisted envelope keeps its goal: the contract hash covers it"); } [Fact] - public void A_restored_continuation_the_pipe_can_carry_is_left_alone() + public void A_task_that_restores_nothing_is_never_judged_an_overflow() { - var task = TaskWith() with { ResumeFromSessionId = "sess-1", RestoredTranscript = "{\"line\":1}" }; + var task = TaskWith() with { Goal = new string('x', NativeLaunchProtocol.MaximumFrameBytes) }; - AgentRunExecutor.ColdIfTranscriptExceedsTheLaunchPipe(task).ShouldBeSameAs(task); + AgentRunExecutor.ContinuationOverflowsTheFrame(task, new ClaudeCodeHarness().BuildInvocation(task)).ShouldBeFalse(customMessage: "a goal alone is the frame check's to refuse, honestly — there is nothing to drop"); } [Fact] From 639bf2f3ed5d910c9f465a17ffd0363aebee9dc3 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Thu, 24 Sep 2026 11:33:59 +0800 Subject: [PATCH 5/9] Stop telling a cold continuation to start from the beginning A respawn can be checked out at the branch its earlier attempt pushed, with no other sentence saying that work is there. "You are starting this task from the beginning" had the agent redo or overwrite its own half-finished edits. The hint now says the conversation is gone and that whatever the workspace already holds beyond the base is its own earlier work - true on every path, including a fresh clone. --- .../Services/Agents/AgentRetryContinuity.cs | 6 ++++-- .../Workflows/AgentRunExecutorReviseTests.cs | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs index 487cb4367..4772b8860 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs @@ -61,9 +61,11 @@ public static string WithLostHostHint(string goal, string? publishedBranch, bool /// Said when a restored conversation is too large for the launch pipe to hand back, so the attempt runs as a /// fresh one instead of being refused. Appended after whatever the goal already says, for the same reason as /// : the earlier sentence promised a conversation, and the agent must - /// be told it is not getting one. What that sentence said about the git tree still stands. + /// be told it is not getting one. It says nothing about the tree beyond what holds on every path: a respawn may be + /// checked out at the branch the earlier attempt pushed, with no other sentence saying so, and "start from the + /// beginning" there would have the agent redo or overwrite its own half-finished work. /// - public const string OversizedTranscriptHint = "Your previous conversation is too large to hand back to you, so it was not restored after all — you are starting this task from the beginning."; + public const string OversizedTranscriptHint = "Your previous conversation is too large to hand back to you, so it was not restored — you are continuing without it. Look at the workspace before you change anything: whatever it already holds beyond the base is your own earlier work on this task."; /// Replace a resumed goal's promise of a restored conversation with the truth that there is none, because the conversation was too large to restore. public static string WithOversizedTranscriptHint(string goal) => $"{goal}\n\n{OversizedTranscriptHint}"; diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs index e47f7b645..a51c8320f 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs @@ -217,6 +217,14 @@ public void A_continuation_run_cold_drops_every_claim_of_continuity_and_says_so( AgentRunExecutor.WithoutContinuity(task).Goal.ShouldBe(task.Goal, "the persisted envelope keeps its goal: the contract hash covers it"); } + [Fact] + public void The_cold_hint_never_tells_an_agent_to_start_over_in_a_tree_that_holds_its_work() + { + // A respawn can be checked out at the branch its earlier attempt pushed, with no other sentence saying so. + AgentRetryContinuity.OversizedTranscriptHint.ShouldNotContain("beginning", Case.Insensitive); + AgentRetryContinuity.OversizedTranscriptHint.ShouldContain("your own earlier work"); + } + [Fact] public void A_task_that_restores_nothing_is_never_judged_an_overflow() { From f4d5cc34cca10f60a0cee194ad521ba487492d6c Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Thu, 24 Sep 2026 18:38:34 +0800 Subject: [PATCH 6/9] Count the argv and environment a launch frame carries twice FitsTheFrame measured the spec and held a flat 1 MiB for everything else. The invocation carries the spec and, a second time, the child's argv - which appends the spec's arguments, a persona on --append-system-prompt included - and its environment, which copies the spec's. With a large escaped persona that second copy approached the allowance on a 4 KiB-page host and passed it where the argv ceiling is higher, so a spec the fit admitted was a frame the runner refused. With a plain persona the allowance gave away ~1 MiB, so continuations whose frame fit ran cold for nothing. Both copies are counted now, with 64 KiB for the parts that do not grow with the spec. Pinned against the invocation the runner really encodes, at the boundary, for an ASCII and a worst-case persona. --- .../Agents/NativeLaunchProtocol.cs | 17 ++-- .../Workflows/NativeLaunchFrameFitTests.cs | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchFrameFitTests.cs diff --git a/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs index 4a4a7f8bb..074013830 100644 --- a/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs +++ b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs @@ -12,11 +12,18 @@ public static class NativeLaunchProtocol /// The share of an agent's standard input may take, checked before a spool exists so a goal no attempt can carry is refused before any work. Half the frame, leaving the rest room for everything else a launch carries — a continued session's restored transcript included. Measured as encoded for the pipe (). public const int LargeCarrierBudgetBytes = MaximumFrameBytes / 2; - /// What the frame keeps free beyond a spec for what wraps it — the child's command, environment and paths, a few kilobytes in practice — so a spec that fits by fits the frame it is sent in. - public const int InvocationAllowanceBytes = 1024 * 1024; - - /// Whether a built spec, once encoded for the pipe, leaves the frame room for what wraps it. Measures everything the spec carries at once — goal, restored transcript, the persona's files — so a continuation is judged on what it actually sends, not on any one part of it. - public static bool FitsTheFrame(SandboxSpec spec) => JsonSerializer.SerializeToUtf8Bytes(spec, Json).LongLength <= MaximumFrameBytes - InvocationAllowanceBytes; + /// What the frame keeps free for the parts of an invocation that are not the spec and do not grow with it — the supervisor script, the isolation prefixes, the scrubbed allow-list of the worker's own variables, the spool paths: a few kilobytes in practice. + public const int InvocationAllowanceBytes = 64 * 1024; + + /// + /// Whether a built spec fits the frame it will be sent in. The invocation carries the spec and, a second time, the + /// child's argv (which appends the spec's arguments — a persona on --append-system-prompt, say) and its + /// environment (which copies the spec's), so both are counted twice; everything else is held in + /// . Measures everything the spec carries at once — goal, restored + /// transcript, the persona's files — so a continuation is judged on what it actually sends. + /// + public static bool FitsTheFrame(SandboxSpec spec) => + JsonSerializer.SerializeToUtf8Bytes(spec, Json).LongLength + JsonSerializer.SerializeToUtf8Bytes(spec.Args, Json).LongLength + JsonSerializer.SerializeToUtf8Bytes(spec.Environment, Json).LongLength <= MaximumFrameBytes - InvocationAllowanceBytes; /// The bytes occupies once encoded for the frame. The web JSON defaults escape every non-ASCII character, so CJK text roughly doubles and emoji triple. public static int EncodedBytes(string value) => JsonSerializer.SerializeToUtf8Bytes(value, Json).Length; diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchFrameFitTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchFrameFitTests.cs new file mode 100644 index 000000000..08629af90 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchFrameFitTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Agents.Sandbox.Isolation; +using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Messages.Agents; +using CodeSpace.NativeLaunch; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// is a prediction the executor makes before the runner builds the frame, +/// and the cold degrade trusts it. Pinned here against the invocation the runner really encodes +/// (StartBrokerAsync: the durable start info around the frozen spec), at the boundary where the prediction flips. +/// +[Trait("Category", "Unit")] +public sealed class NativeLaunchFrameFitTests : IDisposable +{ + private readonly string _spool = Path.Combine(Path.GetTempPath(), $"cs-frame-fit-{Guid.NewGuid():N}"); + + public NativeLaunchFrameFitTests() => Directory.CreateDirectory(_spool); + + public void Dispose() + { + try { Directory.Delete(_spool, recursive: true); } catch { /* best-effort */ } + } + + [Theory] + [InlineData('x')] // an ASCII persona: its second copy costs what the first does + [InlineData('<')] // the worst case the web encoder allows — every character escapes to six bytes, in both copies + public void A_spec_the_fit_admits_is_a_frame_the_pipe_carries(char personaCharacter) + { + // A persona on argv near the kernel's per-string ceiling, so the argv the invocation carries a second time is large. + var persona = new string(personaCharacter, 130_000); + var spec = SpecAtTheBoundary(persona); + + NativeLaunchProtocol.FitsTheFrame(spec).ShouldBeTrue("fixture check: the spec sits exactly at the fit boundary"); + NativeLaunchProtocol.FitsTheFrame(WithTranscript(spec, Transcript(spec) + "x")).ShouldBeFalse("fixture check: one byte more and it does not"); + + var frame = EncodedFrame(spec); + + frame.ShouldBeLessThanOrEqualTo(NativeLaunchProtocol.MaximumFrameBytes, "a spec the fit admits must be a frame the runner can send, or the cold degrade is skipped and the launch refused"); + frame.ShouldBeGreaterThan(NativeLaunchProtocol.MaximumFrameBytes - 2 * NativeLaunchProtocol.InvocationAllowanceBytes, "and the fit must not give away much more than its allowance, or a continuation that fits would lose its conversation"); + } + + private SandboxSpec SpecAtTheBoundary(string persona) + { + var spec = new SandboxSpec + { + Command = "claude", WorkingDirectory = _spool, TimeoutSeconds = 900, StandardInput = "Review this change — 修复 the flaky test", + Args = new[] { "--print", "--output-format", "stream-json", "--verbose", "--append-system-prompt", persona, "--resume", "sess-boundary" }, + Environment = new Dictionary { ["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:9", ["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1", ["GIT_AUTHOR_NAME"] = "Agent " }, + ConfigHomeEnvVars = new[] { "CLAUDE_CONFIG_DIR" }, + ConfigHomeFiles = new[] { new ConfigHomeFile { RelativePath = "projects/-ws/sess-boundary.jsonl", Content = "" } }, + }; + + // The largest transcript the fit admits: each 'x' encodes to one byte, so the room left is exact. + var room = NativeLaunchProtocol.MaximumFrameBytes - NativeLaunchProtocol.InvocationAllowanceBytes - Measured(spec); + + return WithTranscript(spec, new string('x', checked((int)room))); + } + + private int EncodedFrame(SandboxSpec spec) + { + var frozen = NativeLaunchProtocol.Freeze(spec); + var command = LocalProcessRunner.BuildDurableStartInfo(frozen, _spool, bootstrapSession: true); + var invocation = new NativeLaunchInvocation + { + Spec = frozen, ReadOnlyPaths = frozen.ReadOnlyPaths, CaptureBudget = frozen.CaptureBudget, + Command = command.FileName, Args = command.ArgumentList.ToArray(), WorkingDirectory = command.WorkingDirectory, + Environment = command.Environment.ToDictionary(pair => pair.Key, pair => pair.Value), EgressNetnsKey = "egress-key", CgroupRunKey = "cgroup-key", + Confinement = BubblewrapSandbox.DeriveConfinement(BubblewrapSandbox.Available, BubblewrapSandbox.UnavailableReason, shareNetwork: true, egressAllowlist: null), + }; + + return NativeLaunchFiles.EncodeFrame(invocation).Length; + } + + private static long Measured(SandboxSpec spec) => + JsonSerializer.SerializeToUtf8Bytes(spec, NativeLaunchProtocol.Json).LongLength + JsonSerializer.SerializeToUtf8Bytes(spec.Args, NativeLaunchProtocol.Json).LongLength + JsonSerializer.SerializeToUtf8Bytes(spec.Environment, NativeLaunchProtocol.Json).LongLength; + + private static string Transcript(SandboxSpec spec) => spec.ConfigHomeFiles[0].Content; + + private static SandboxSpec WithTranscript(SandboxSpec spec, string transcript) => spec with { ConfigHomeFiles = new[] { spec.ConfigHomeFiles[0] with { Content = transcript } } }; +} From 6db17337148ebdcfb35dcf091e7a23305111f9c6 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Thu, 24 Sep 2026 18:38:34 +0800 Subject: [PATCH 7/9] Grade a cold continuation on its contract, told only on the dispatch The cold degrade put its hint into the run's own goal. Local acceptance hashes that goal against the persisted envelope before launch, so a locally graded continuation read as a different contract and failed as local-context-mismatch, a grader fault, before any process started - the refusal the degrade exists to replace. The unreadable-checkpoint degrade on main amends the goal the same way and failed a locally graded retry identically. The hint now rides only the dispatched spec, and verification is graded on the goal the envelope persisted, which fixes both. The cold trace is recorded once the attempt is sure to launch, and says what happened per path: only a revise round keeps its workspace, so only its note says so. The launch test now records the real argv, so its "no --resume" assertion can fail. --- .../Services/Agents/AgentRunExecutor.cs | 45 +++++++++---- .../Workflows/AgentRunExecutorTests.cs | 64 ++++++++++++++++--- .../Workflows/AgentRunReviseLoopFlowTests.cs | 2 +- .../AgentRunSessionCheckpointFlowTests.cs | 25 ++++++++ 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index eefbedf25..f6456de37 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -447,21 +447,30 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // for a retry whose work can simply go on in a fresh conversation. Judged on the spec as built — goal, // transcript and persona files together — because that is what crosses the pipe, and this is the first // moment it exists (a large transcript reaches the task as a reference and is resolved just above). - if (ContinuationOverflowsTheFrame(effectiveTask, spec)) + // + // The hint that tells the agent rides the DISPATCHED spec only. The run's own task keeps the goal the + // persisted envelope holds, because verification hashes that goal against it (the acceptance contract); + // a hinted goal would read as a different contract and fail a locally graded run before it launched. + var ranCold = ContinuationOverflowsTheFrame(effectiveTask, spec); + + if (ranCold) { task = WithoutContinuity(task); - effectiveTask = RunCold(effectiveTask); - await RecordRunColdAsync(owner, task with { Model = dispatchedModel }, cancellationToken).ConfigureAwait(false); - spec = BuildSpec(effectiveTask); + effectiveTask = WithoutContinuity(effectiveTask); + spec = BuildSpec(RunCold(effectiveTask)); } + // Verification is judged against the contract the envelope persisted — never a goal amended for the + // dispatch alone (this cold hint, or an unreadable checkpoint's) — or the contract hash cannot match. + var contract = effectiveTask with { Goal = task.Goal }; + using var localAcceptance = effectiveTask.Acceptance is not null && RepositoryWorkspaceResolver.CanonicalWorkspace(effectiveTask) is null - ? await PrepareLocalAcceptanceAsync(new(owner, run.TeamId, effectiveTask, runnerKind, spec.WorkingDirectory ?? ""), cancellationToken).ConfigureAwait(false) + ? await PrepareLocalAcceptanceAsync(new(owner, run.TeamId, contract, runnerKind, spec.WorkingDirectory ?? ""), cancellationToken).ConfigureAwait(false) : null; if (localAcceptance is not null) { using var acceptanceScope = _scopeFactory.CreateScope(); - var prepared = await acceptanceScope.ServiceProvider.GetRequiredService().ObserveAsync(new(owner, run.TeamId, effectiveTask, localAcceptance), cancellationToken).ConfigureAwait(false); + var prepared = await acceptanceScope.ServiceProvider.GetRequiredService().ObserveAsync(new(owner, run.TeamId, contract, localAcceptance), cancellationToken).ConfigureAwait(false); if (prepared.Failure is { } unavailable) { // Known invalid or unavailable verification cannot be repaired by billing an agent invocation. @@ -472,6 +481,11 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo } } + // Recorded once the attempt is sure to launch, so the trace never says a conversation was set aside for an + // attempt that did not run. + if (ranCold) + await RecordRunColdAsync(owner, task with { Model = dispatchedModel }, LaunchRanColdNote, cancellationToken).ConfigureAwait(false); + // The MCP token rides the durable handle whenever the ENDPOINT opened (not only when a declaration was // written) so a re-attach re-binds the SAME socket+token — the detached agent's declaration file still // points at it. Null when no endpoint → nothing to re-open. @@ -526,7 +540,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // leaves this promise Intended; recovery marks it INDETERMINATE — visible, never a silent Succeeded. await _captureIntents.OpenAsync(agentRunId, run.TeamId, run.WorkflowRunId, claimedEpoch, CaptureExpectationsOf(effectiveTask), cancellationToken).ConfigureAwait(false); - result = await VerifyProducedWorkAsync(new(owner, run, harness, effectiveTask, workspace) { AcceptanceContext = localAcceptance }, result, cancellationToken).ConfigureAwait(false); + result = await VerifyProducedWorkAsync(new(owner, run, harness, contract, workspace) { AcceptanceContext = localAcceptance }, result, cancellationToken).ConfigureAwait(false); // S6: the bounded REVISE loop — when the objective oracle failed on something the agent can fix, or the // Improve-mode critic flagged the output, feed the failure detail back to the SAME agent (same workspace; @@ -596,7 +610,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo var cold = BuildReviseTask(effectiveTask, result, reason, mayResume: false); reviseTask = reviseTask with { Goal = cold.Goal, ResumeFromSessionId = null, RestoredTranscript = null }; reviseSpec = BuildSpec(reviseTask); - await RecordRunColdAsync(owner, null, cancellationToken).ConfigureAwait(false); + await RecordRunColdAsync(owner, null, ReviseRanColdNote, cancellationToken).ConfigureAwait(false); } var priorUsage = result.TokenUsage; @@ -624,7 +638,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // Verify under the ORIGINAL goal: the composed REVISE goal is for the harness invocation only — the // output critic must judge goal-alignment against what the task actually asked for, not the feedback // wrapper (which quotes the failure and could bias or blind the reviewer). - result = await VerifyProducedWorkAsync(new(owner, run, harness, reviseTask with { Goal = effectiveTask.Goal }, workspace) { AcceptanceContext = localAcceptance }, result, cancellationToken).ConfigureAwait(false); + result = await VerifyProducedWorkAsync(new(owner, run, harness, reviseTask with { Goal = contract.Goal }, workspace) { AcceptanceContext = localAcceptance }, result, cancellationToken).ConfigureAwait(false); priorReason = reason; } @@ -1638,7 +1652,7 @@ internal static AgentTask WithoutContinuity(AgentTask task) => task with ResumeFromSessionId = null, ResumedFromCheckpointAt = null, ResumedFromAgentRunId = null, }; - /// The attempt as it is dispatched cold: without continuity, and with the goal told the conversation it was promised is not there. Mirrors the unreadable-checkpoint degrade. + /// The task a cold attempt's spec is built from: without continuity, and with the goal told the conversation it was promised is not there. The dispatch's alone — the run's own task keeps its contract goal. internal static AgentTask RunCold(AgentTask task) => WithoutContinuity(task) with { Goal = AgentRetryContinuity.WithOversizedTranscriptHint(task.Goal) }; /// @@ -1646,7 +1660,7 @@ internal static AgentTask WithoutContinuity(AgentTask task) => task with /// with its continuity claims cleared, which is what the Room's "resumed" mark reads. Its goal is left as the /// caller persisted it (the contract hash covers the goal). Best-effort like the other timeline notes. /// - private async Task RecordRunColdAsync(AgentRunOwnerToken owner, AgentTask? envelope, CancellationToken cancellationToken) + private async Task RecordRunColdAsync(AgentRunOwnerToken owner, AgentTask? envelope, string note, CancellationToken cancellationToken) { _logger.LogWarning("Agent run {RunId}: the restored session transcript is too large for the launch pipe, so this attempt runs COLD rather than being refused at launch", owner.RunId); @@ -1654,7 +1668,7 @@ private async Task RecordRunColdAsync(AgentRunOwnerToken owner, AgentTask? envel try { - await _runs.AppendEventAsync(owner, new AgentEvent { Kind = AgentEventKind.Warning, Text = RunColdNote }, cancellationToken).ConfigureAwait(false); + await _runs.AppendEventAsync(owner, new AgentEvent { Kind = AgentEventKind.Warning, Text = note }, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException and not AgentRunOwnershipLostException) { @@ -1662,8 +1676,11 @@ private async Task RecordRunColdAsync(AgentRunOwnerToken owner, AgentTask? envel } } - /// The timeline's account of a cold degrade — what happened and why, never the transcript itself. - internal const string RunColdNote = "The restored conversation was too large to hand to the agent in one launch, so this attempt continued without it (a fresh conversation in the same workspace)."; + /// The timeline's account of a cold launch — what happened and why, never the transcript itself. It claims nothing about the workspace: a respawn gets its own, holding only what it was checked out with. + internal const string LaunchRanColdNote = "The restored conversation was too large to hand to the agent in one launch, so this attempt started a fresh conversation instead."; + + /// The timeline's account of a cold revise round: the same run, so the same workspace. + internal const string ReviseRanColdNote = "This round's conversation was too large to hand back to the agent in one launch, so the revision continued as a fresh conversation in the same workspace."; /// /// 3c: resolve a mid-run CHECKPOINT ref under the opposite policy to a captured one — unreadable degrades to a diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs index 45db9f051..63aeee616 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs @@ -203,11 +203,12 @@ public async Task A_continuation_whose_transcript_overflows_the_launch_frame_run using var verify = _fixture.BeginScope(); var service = verify.Resolve(); var run = await service.GetAsync(runId, CancellationToken.None); - harness.Specs.ShouldNotBeEmpty(); - var launched = harness.Specs[^1]; + harness.Specs.Count.ShouldBe(2, "the warm spec the executor judged, then the cold one it launched"); + var (warm, launched) = (harness.Specs[0], harness.Specs[1]); run.Status.ShouldBe(AgentRunStatus.Succeeded, $"the continuation must run cold, not be refused at launch — it failed with: {run.Error}"); - harness.Specs.ShouldContain(spec => spec.ConfigHomeFiles.Any(file => file.Content.Length == transcript.Length), "fixture check: the warm spec the executor judged did carry the transcript"); + warm.ConfigHomeFiles.ShouldContain(file => file.Content.Length == transcript.Length, "fixture check: the warm spec the executor judged did carry the transcript"); + warm.Args.ShouldContain("--resume", customMessage: "fixture check: and would have resumed it"); launched.ConfigHomeFiles.ShouldNotContain(file => file.Content.Length == transcript.Length, "the launched spec restores nothing"); launched.Args.ShouldNotContain("--resume"); launched.StandardInput.ShouldNotBeNull().ShouldEndWith(AgentRetryContinuity.OversizedTranscriptHint, customMessage: "the goal said the conversation was restored; it must be told that it is not"); @@ -217,7 +218,54 @@ public async Task A_continuation_whose_transcript_overflows_the_launch_frame_run persisted.RestoredTranscriptArtifactId.ShouldBeNull(); persisted.Goal.ShouldBe("resume the prior work", "the persisted goal is the contract the hash covers — the hint is the dispatch's alone"); - (await service.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.RunColdNote, "the timeline says the attempt ran cold, and why"); + (await service.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.LaunchRanColdNote, "the timeline says the attempt ran cold, and why"); + } + + [Fact] + public async Task A_locally_graded_continuation_that_overflows_the_frame_runs_cold_and_is_graded_on_its_contract() + { + // The cold hint is the dispatch's alone. Local acceptance hashes the run's goal against the persisted envelope + // before launch, so a hinted goal read as a different contract and failed the run as a grader fault + // (local-context-mismatch) before any process started — the degrade turned into the refusal it replaces. + if (OperatingSystem.IsWindows()) return; + + var workspace = Directory.CreateTempSubdirectory("cs-cold-graded-").FullName; + var transcript = new string('x', NativeLaunchProtocol.MaximumFrameBytes + 1); + var teamId = await SeedTeamAsync(); + Guid runId; + + try + { + using (var scope = await WorkflowsTestSeed.BeginSeedOperatorScopeAsync(_fixture, teamId)) + { + var artifactId = await scope.Resolve().PutAsync(teamId, System.Text.Encoding.UTF8.GetBytes(transcript), "text/plain", CancellationToken.None); + var created = await scope.Resolve().CreateAsync( + new AgentTask + { + Goal = "write the report", Harness = "scripted", Model = "test-model", WorkspaceDirectory = workspace, + Acceptance = new SupervisorAcceptanceSpec { Command = new[] { "/bin/sh", "-c", "test \"$(cat report.txt)\" = accepted" }, Description = "the report says accepted" }, + ResumeFromSessionId = "session-too-large-to-restore", RestoredTranscriptArtifactId = artifactId, + }, + teamId, null, null, iterationKey: "", cancellationToken: CancellationToken.None); + runId = created.Id; + } + + var harness = new ClaudeSpecScriptedHarness("cat >/dev/null; printf accepted > report.txt; printf 'produced\\n'"); + + await ExecuteAsync(runId, harness); + + using var verify = _fixture.BeginScope(); + var run = await verify.Resolve().GetAsync(runId, CancellationToken.None); + var result = JsonSerializer.Deserialize(run.ResultJson!, AgentJson.Options)!; + + run.Status.ShouldBe(AgentRunStatus.Succeeded, $"a cold continuation must launch and be graded on its contract — it ended {result.ExitReason}: {result.AcceptanceDetail ?? run.Error}"); + result.AcceptancePassed.ShouldBe(true); + harness.Specs[^1].StandardInput.ShouldNotBeNull().ShouldEndWith(AgentRetryContinuity.OversizedTranscriptHint, customMessage: "the agent is still told"); + } + finally + { + Directory.Delete(workspace, recursive: true); + } } [Fact] @@ -1993,7 +2041,7 @@ public IReadOnlyList ParseEvents(string rawLine) => : new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = "non-zero-exit", Error = $"exit {exitCode}" }); } - /// A scripted harness (kind "scripted") whose spec is the REAL Claude adapter's — the transcript restore file, the goal on stdin, every config-home file — with only the executable swapped for a shell, so the frame the runner measures is the frame production sends. + /// A scripted harness (kind "scripted") whose spec is the REAL Claude adapter's — the transcript restore file, the goal on stdin, every config-home file — with only the executable swapped for a shell, so the frame the runner measures is the frame production sends. records the real spec, argv included, before the swap. private sealed class ClaudeSpecScriptedHarness : IAgentHarness { private readonly ClaudeCodeHarness _real = new(); @@ -2009,9 +2057,9 @@ private sealed class ClaudeSpecScriptedHarness : IAgentHarness public SandboxSpec BuildInvocation(AgentTask task) { - var spec = _real.BuildInvocation(task) with { Command = "/bin/sh", Args = new[] { "-c", _script } }; - Specs.Add(spec); - return spec; + var real = _real.BuildInvocation(task); + Specs.Add(real); + return real with { Command = "/bin/sh", Args = new[] { "-c", _script } }; } public IReadOnlyList ParseEvents(string rawLine) => diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs index 22daec640..13f23666b 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs @@ -92,7 +92,7 @@ public async Task A_revision_whose_session_overflows_the_launch_frame_goes_on_co launched.Task.Goal.ShouldContain("Original goal", customMessage: "a cold revision restates the contract no conversation carries"); using var scope = _fixture.BeginScope(); - (await scope.Resolve().GetEventsAsync(runId, run.TeamId, afterSequence: 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.RunColdNote); + (await scope.Resolve().GetEventsAsync(runId, run.TeamId, afterSequence: 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.ReviseRanColdNote); } [Fact] diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunSessionCheckpointFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunSessionCheckpointFlowTests.cs index a960b4d66..57d896ccf 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunSessionCheckpointFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunSessionCheckpointFlowTests.cs @@ -289,6 +289,31 @@ public async Task A_wedged_store_cannot_defer_the_terminal_write_past_the_checkp .ShouldBe(AgentRunStatus.Succeeded, "and the run must still land"); } + [Fact] + public async Task An_unreadable_checkpoint_on_a_locally_graded_task_degrades_and_is_still_graded_on_its_contract() + { + // The degrade tells the agent by amending its goal, and local acceptance hashes the run's goal against the + // persisted envelope before launch. Graded against the amended goal, the contract never matched: the run + // failed as a grader fault (local-context-mismatch) before it launched — the attempt the degrade exists to save. + var team = await SeedTeamAsync(); + var harness = new TranscriptWritingHarness("s-unreadable-graded"); + + var run = await SeedQueuedResumableRunAsync(team, authoredWorkingDirectory: true, task => task with + { + ResumeFromSessionId = "s-lost-host", RestoredTranscriptArtifactId = Guid.NewGuid(), RestoredTranscriptIsCheckpoint = true, + ResumedFromCheckpointAt = DateTimeOffset.UtcNow.AddMinutes(-3), ResumedFromAgentRunId = Guid.NewGuid(), + Acceptance = new SupervisorAcceptanceSpec { Command = new[] { "/bin/sh", "-c", "true" }, Description = "always passes" }, + }); + + await ExecuteAsync(run.RunId, harness); + + using var verify = _fixture.BeginScope(); + var row = await verify.Resolve().AgentRun.AsNoTracking().SingleAsync(r => r.Id == run.RunId); + + row.Status.ShouldBe(AgentRunStatus.Succeeded, $"an unreadable checkpoint must cost the conversation, never the attempt — the run ended {row.Status}: {row.ResultJson}"); + harness.Invocations.ShouldHaveSingleItem().Goal.ShouldContain(AgentRetryContinuity.LostHostCheckpointUnreadableHint, Case.Sensitive, "the agent is still told"); + } + [Theory] [InlineData(true)] // a CHECKPOINT ref — best-effort, so an unreadable one must degrade [InlineData(false)] // a CAPTURED ref — written by an attempt that finished, so an unreadable one is a fault From 99513f0ab3910a948ae4375502594057e8034b55 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Thu, 24 Sep 2026 23:11:06 +0800 Subject: [PATCH 8/9] Record a cold continuation only once it clears spend admission The cold note and the cleared envelope were written after local acceptance but before spend admission. A continuation refused for spend - a lost host whose dead attempt still holds the run's cap - never started a process, yet its timeline said it had started a fresh conversation, and its envelope no longer said it was a resume. Both now follow admission, on the launch path and for a revise round. --- .../Services/Agents/AgentRunExecutor.cs | 18 +++++---- .../AgentRunExecutorSpendTerminalTests.cs | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index f6456de37..012c98bb3 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -481,11 +481,6 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo } } - // Recorded once the attempt is sure to launch, so the trace never says a conversation was set aside for an - // attempt that did not run. - if (ranCold) - await RecordRunColdAsync(owner, task with { Model = dispatchedModel }, LaunchRanColdNote, cancellationToken).ConfigureAwait(false); - // The MCP token rides the durable handle whenever the ENDPOINT opened (not only when a declaration was // written) so a re-attach re-binds the SAME socket+token — the detached agent's declaration file still // points at it. Null when no endpoint → nothing to re-open. @@ -530,6 +525,11 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo return; } + // Recorded only once the attempt has passed every check this executor makes before launching it — local + // acceptance, spend — so the trace never says a conversation was set aside for an attempt that did not run. + if (ranCold) + await RecordRunColdAsync(owner, task with { Model = dispatchedModel }, LaunchRanColdNote, cancellationToken).ConfigureAwait(false); + var result = await RunHarnessAsync(runContext, cancellationToken).ConfigureAwait(false); result = AgentRunBudget.Apply(effectiveTask, result, modelPrices); @@ -605,12 +605,13 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // The same verdict for the round's own session: warm only when the pipe can carry it. Only the // conversation and the goal change — a model escalation already applied to this round stands. - if (ContinuationOverflowsTheFrame(reviseTask, reviseSpec)) + var roundRanCold = ContinuationOverflowsTheFrame(reviseTask, reviseSpec); + + if (roundRanCold) { var cold = BuildReviseTask(effectiveTask, result, reason, mayResume: false); reviseTask = reviseTask with { Goal = cold.Goal, ResumeFromSessionId = null, RestoredTranscript = null }; reviseSpec = BuildSpec(reviseTask); - await RecordRunColdAsync(owner, null, ReviseRanColdNote, cancellationToken).ConfigureAwait(false); } var priorUsage = result.TokenUsage; @@ -630,6 +631,9 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo break; } + if (roundRanCold) + await RecordRunColdAsync(owner, null, ReviseRanColdNote, cancellationToken).ConfigureAwait(false); + var roundResult = await RunHarnessAsync(runContext with { Spec = reviseSpec, Task = reviseTask, SpoolKey = ReviseSpoolKey(agentRunId, round) }, cancellationToken).ConfigureAwait(false); result = AgentRunBudget.Apply(reviseTask with { BudgetSpentUsd = result.CumulativeCostUsd }, roundResult, modelPrices) with { TokenUsage = SumTokenUsage(priorUsage, roundResult.TokenUsage), ReviseRounds = round }; diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorSpendTerminalTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorSpendTerminalTests.cs index b037488d5..d8021df71 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorSpendTerminalTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorSpendTerminalTests.cs @@ -29,6 +29,46 @@ namespace CodeSpace.IntegrationTests.Workflows; /// public partial class AgentRunExecutorTests { + [Fact] + public async Task A_cold_continuation_refused_for_spend_leaves_no_trace_of_a_conversation_set_aside() + { + // The cold degrade is decided before spend admission. A launch refused there never started a process, so the + // timeline must not say it "started a fresh conversation", and the Room's resumed mark keeps its envelope. + if (OperatingSystem.IsWindows()) return; + + var transcript = new string('x', NativeLaunchProtocol.MaximumFrameBytes + 1); + var teamId = await SeedTeamAsync(); + var workflowRunId = await SeedCappedWorkflowRunAsync(teamId, capUsd: TerminalClaimUsd); + Guid runId; + + using (var scope = await CodeSpace.IntegrationTests.Workflows.Infrastructure.WorkflowsTestSeed.BeginSeedOperatorScopeAsync(_fixture, teamId)) + { + var artifactId = await scope.Resolve().PutAsync(teamId, System.Text.Encoding.UTF8.GetBytes(transcript), "text/plain", CancellationToken.None); + var created = await scope.Resolve().CreateAsync( + new AgentTask { Goal = "resume the prior work", Harness = "scripted", Model = "claude-opus-4-8", TimeoutSeconds = 1800, MaxCostUsd = TerminalClaimUsd, ResumeFromSessionId = "session-too-large-to-restore", RestoredTranscriptArtifactId = artifactId }, + teamId, workflowRunId, null, iterationKey: "", cancellationToken: CancellationToken.None); + runId = created.Id; + } + + // A dead attempt's claim holds the whole cap, so this attempt's admission is refused. + await MintLaunchClaimAsync(workflowRunId, teamId, runId, epoch: 6); + await ArmNextAttemptAsync(runId, priorEpoch: 6); + + var harness = new ClaudeSpecScriptedHarness("cat >/dev/null; printf 'resumed\\n'"); + + await ExecuteAsync(runId, harness); + + var result = await PersistedResultAsync(runId); + using var verify = _fixture.BeginScope(); + var events = await verify.Resolve().GetEventsAsync(runId, teamId, 0, CancellationToken.None); + var run = await verify.Resolve().GetAsync(runId, CancellationToken.None); + + result.ExitReason.ShouldBe(FailureCodes.RunBudgetExhausted, "fixture check: the launch was refused for spend, before any process"); + harness.Specs.Count.ShouldBe(2, "fixture check: the cold degrade was decided"); + events.ShouldNotContain(e => e.Text == AgentRunExecutor.LaunchRanColdNote, "the trace must never say a conversation was set aside for an attempt that did not run"); + JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options)!.ResumeFromSessionId.ShouldBe("session-too-large-to-restore", "nothing ran, so nothing about its continuity changed"); + } + /// The cost a priced terminal leaves behind, and the reserve every claim below is minted for. private const decimal TerminalObservedUsd = 0.75m; private const decimal TerminalClaimUsd = 5m; From 582292b7fd8979deaa02ac3b397ca63936583f1c Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Fri, 25 Sep 2026 00:57:29 +0800 Subject: [PATCH 9/9] Pin the revise half of recording a cold round after admission Moving the revise round's cold note below its spend admission had no test: putting it back above admission left every suite green. A cold round refused for spend under a capped quick lane now asserts no "continued as a fresh conversation" note, and goes red with the note moved back. --- .../Workflows/AgentRunReviseLoopFlowTests.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs index 13f23666b..0bc4dbc43 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs @@ -95,6 +95,41 @@ public async Task A_revision_whose_session_overflows_the_launch_frame_goes_on_co (await scope.Resolve().GetEventsAsync(runId, run.TeamId, afterSequence: 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.ReviseRanColdNote); } + [Fact] + public async Task A_cold_revision_refused_for_spend_leaves_no_note_that_it_continued() + { + // The revise twin of the launch-path case: the round's cold decision comes before its spend admission, so a + // round refused there never ran and its note must not say it "continued as a fresh conversation". The quick + // lane's $5 cap is held whole by round 0's unpriced settle, so round 1's admission is refused. + if (OperatingSystem.IsWindows()) return; + + var (teamId, userId) = await SeedTeamAsync(); + using var remote = new BareRemote(); + await remote.SeedBaseAsync(CheckScript); + var repoId = await SeedBoundRepositoryAsync(teamId, remote.Url); + + Guid workflowId; + using (var seed = await WorkflowsTestSeed.BeginSeedOperatorScopeAsync(_fixture, teamId)) + workflowId = await seed.Resolve().Send(new CodeSpace.Messages.Commands.Workflows.CreateWorkflowCommand { Name = $"quick-lane-{Guid.NewGuid():N}", Definition = WorkflowsTestSeed.MinimalDefinition(), Activations = Array.Empty(), Enabled = true }); + var workflowRunId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, teamId, routePlanJson: WorkflowsTestSeed.RouteJsonWithCostCap(5m)); + + Guid runId; + using (var scope = _fixture.BeginScopeAs(userId, teamId)) + runId = (await scope.Resolve().CreateAsync(TaskWith(repoId) with { MaxReviseRounds = 1 }, teamId, workflowRunId, null, iterationKey: "", cancellationToken: CancellationToken.None)).Id; + + var harness = new OversizedSessionHarness(NativeLaunchProtocol.MaximumFrameBytes + 1); + + await ExecuteAsync(runId, harness); + + var (_, result) = await LoadAsync(runId); + var events = await LoadEventsAsync(runId); + + harness.Built.Count.ShouldBe(3, "fixture check: round 1 was built warm, then rebuilt cold"); + events.ShouldContain(t => t.StartsWith(AgentRunExecutor.ReviseBudgetStoppedPrefix, StringComparison.Ordinal), "fixture check: round 1 was refused for spend"); + result.ReviseRounds.ShouldBe(0, "fixture check: no revision ran"); + events.ShouldNotContain(AgentRunExecutor.ReviseRanColdNote, "no round ran, so no round continued as a fresh conversation"); + } + [Fact] public async Task A_revision_receives_the_real_oracle_diagnosis_instead_of_only_its_exit_code() {