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..40070f5f7 100755 --- a/backend/deploy/e2e/run.sh +++ b/backend/deploy/e2e/run.sh @@ -102,9 +102,18 @@ 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. +# 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..4772b8860 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs @@ -57,6 +57,19 @@ 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. 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 — 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}"; + 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..012c98bb3 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -438,17 +438,39 @@ 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). + // + // 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 = 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. @@ -503,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); @@ -513,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; @@ -574,7 +601,18 @@ 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. + 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); + } var priorUsage = result.TokenUsage; @@ -593,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 }; @@ -601,7 +642,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; } @@ -1605,6 +1646,46 @@ 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 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) }; + + /// + /// 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. + /// + 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); + + if (envelope is not null) await PersistResolvedModelAsync(owner, envelope, cancellationToken).ConfigureAwait(false); + + try + { + 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) + { + _logger.LogWarning(ex, "Agent run {RunId}: could not record the cold-start note", owner.RunId); + } + } + + /// 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 /// COLD start instead of failing the launch. @@ -2563,9 +2644,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) { - var warm = result is { SessionId.Length: > 0, SessionTranscript.Length: > 0 }; + // 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 ef10e9f7e..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 @@ -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,26 +84,39 @@ 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) { 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 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 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) + { + var stdin = spec.StandardInput is null ? 0 : Encoding.UTF8.GetByteCount(spec.StandardInput); + 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}; 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) { var spool = Path.GetDirectoryName(directory)!; @@ -164,9 +178,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.Messages/Agents/NativeLaunchProtocol.cs b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs index 61200a6b6..074013830 100644 --- a/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs +++ b/backend/src/CodeSpace.Messages/Agents/NativeLaunchProtocol.cs @@ -8,6 +8,25 @@ public static class NativeLaunchProtocol { public const int Version = 1; public const int MaximumFrameBytes = 16 * 1024 * 1024; + + /// 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 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; public const string DirectoryName = "launch-v1"; public const string RequestFile = "request.json"; public const string CommitmentFile = "commitment.json"; 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.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; diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs index bd38f11d7..63aeee616 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs @@ -174,6 +174,100 @@ 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.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}"); + 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"); + + 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.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] public async Task A_resume_transcript_that_lives_at_a_provider_reaches_the_harness_byte_for_byte() { @@ -1947,6 +2041,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. records the real spec, argv included, before the swap. + 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 real = _real.BuildInvocation(task); + Specs.Add(real); + return real with { Command = "/bin/sh", Args = new[] { "-c", _script } }; + } + + 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..0bc4dbc43 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,75 @@ 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.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() { @@ -689,6 +759,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.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 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); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorReviseTests.cs index 8e250a1a0..a51c8320f 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; @@ -150,6 +151,88 @@ 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_revision_the_launch_pipe_cannot_carry_warm_is_built_cold_with_the_whole_contract() + { + // 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", mayResume: false); + + 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_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() + { + var task = TaskWith() with + { + ResumeFromSessionId = "sess-1", RestoredTranscript = "{\"line\":1}", RestoredTranscriptIsCheckpoint = true, ResumedFromCheckpointAt = DateTimeOffset.UnixEpoch, + ResumedFromAgentRunId = Guid.NewGuid(), Goal = AgentRetryContinuity.WithHonestNoContinuityHint("fix the flaky test"), + }; + + 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 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() + { + var task = TaskWith() with { Goal = new string('x', NativeLaunchProtocol.MaximumFrameBytes) }; + + 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] public void An_ancestor_continue_resume_is_superseded_by_this_runs_own_session() { 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 } } }; +} 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() {