From 4c510dfef6a96602faaee3e43889ada662b91ef3 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:47:00 +0800 Subject: [PATCH] fix(daemon): harden background-task-safety brief against background-and-yield (MUL-4140) (#4998) A Multica-managed run goes terminal the moment the top-level turn exits; there is no "background work finishes later and wakes you up" step. When an agent starts background work (a run_in_background shell, a Monitor, an async subagent) and ends its turn to "wait for a completion notification", the work is orphaned and the result comment it meant to post is never sent (MUL-4091 / PR #4970). The existing claude-only protocol guard forces run_in_background tool inputs to foreground and fails loud on async_launched tool results, but it cannot catch the actual MUL-4091 mechanism: a turn that ends cleanly with a "Standing by, I'll report when CI finishes" message. That shape is only addressable behaviorally, and it is harness-agnostic. Harden the Background Task Safety brief (both the legacy/verbose production path and the slim staging path) with explicit hard pins: - never background-and-yield / expect a future wakeup that does not exist here; - do every wait synchronously in a single foreground call (e.g. gh run watch); - the standalone-harness "running in the background, keep working" hint does not apply in Multica-managed runs; - never end a turn with a "standing by" / "I'll report back" sign-off. Add verbose- and slim-path test coverage for the new pins so a future brief trim cannot silently drop them. Co-authored-by: J Co-authored-by: multica-agent --- .../internal/daemon/execenv/execenv_test.go | 9 ++++++ .../internal/daemon/execenv/runtime_config.go | 9 +++--- .../execenv/runtime_config_kind_test.go | 32 +++++++++++++++++++ .../daemon/execenv/runtime_config_sections.go | 18 ++++++----- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index 6af46a5ab..f5dfd495f 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -858,6 +858,15 @@ func TestInjectRuntimeConfigBackgroundTaskSafetyProviderAgnostic(t *testing.T) { "Do NOT end your turn while background tasks", "wait for a future notification/reminder", "run the work synchronously instead", + // Hardened pins (MUL-4140): the mechanism that slipped + // through in MUL-4091 was a turn ending cleanly with a + // "standing by, I'll report when CI finishes" message and + // no follow-up wakeup. These pins forbid that shape. + "Never background-and-yield", + "foreground tool call that blocks", + "gh run watch", + "running in the background so you can keep working", + "standing by", } { if !strings.Contains(s, want) { t.Errorf("%s missing background task safety text %q\n---\n%s", tc.file, want, s) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 68614080d..e17bb0594 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -840,10 +840,11 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { func writeBackgroundTaskSafetyInstructions(b *strings.Builder) { b.WriteString("## Background Task Safety\n\n") - b.WriteString("Multica marks this task terminal when your top-level agent process/turn exits. Any background work you started but did not collect before exiting can be orphaned: its result may be lost, and the user may see a completed/failed task even though the delegated work was never synthesized.\n\n") - b.WriteString("- Do NOT end your turn while background tasks, async subagents, background shell commands, or detached tool calls are still running.\n") - b.WriteString("- If a tool or runtime offers a background mode, use it only when you can explicitly wait for completion and collect the result before your final response.\n") - b.WriteString("- If a tool response says to wait for a future notification/reminder instead of collecting now, do not rely on that in Multica-managed runs. Block on the appropriate wait/output/collect operation before exiting.\n") + b.WriteString("Multica marks this task terminal the moment your top-level agent process/turn exits. A Multica-managed run has NO \"background work finishes later and wakes you up\" step: whatever you leave running in the background is orphaned, its result is lost, and the final comment you meant to post once it finished never sends. The user sees a task that ended with no conclusion and has to re-trigger you.\n\n") + b.WriteString("- Do NOT end your turn while background tasks, async subagents, background shell commands, or detached tool calls are still running. Never background-and-yield: never end a turn expecting a future notification, reminder, or wakeup to let you resume — that wakeup does not exist here.\n") + b.WriteString("- Do every wait synchronously inside a single foreground tool call that blocks until the work is done — e.g. `gh run watch ` for CI, or a blocking test/build command. Never split \"start the wait\" into this turn and \"collect the result\" into a later turn.\n") + b.WriteString("- If a tool response says to wait for a future notification/reminder, or tells you it is now running in the background so you can keep working, do NOT rely on that in Multica-managed runs — that hint comes from the standalone harness and does not apply here. Block on the appropriate wait/output/collect operation before exiting.\n") b.WriteString("- If you cannot observe or collect a background task's result, do not spawn it in the background; run the work synchronously instead.\n") + b.WriteString("- Never end a turn with a \"standing by\" / \"I'll report back once X finishes\" / \"waiting for CI\" message. That message becomes your final output and the task ends immediately — either finish the wait synchronously now and report the real outcome, or post the result you already have.\n") b.WriteString("- Before posting your final result or exiting silently, account for every background task you started and incorporate its output or failure into your response.\n\n") } diff --git a/server/internal/daemon/execenv/runtime_config_kind_test.go b/server/internal/daemon/execenv/runtime_config_kind_test.go index 0a5f5c402..25f253cc0 100644 --- a/server/internal/daemon/execenv/runtime_config_kind_test.go +++ b/server/internal/daemon/execenv/runtime_config_kind_test.go @@ -281,3 +281,35 @@ func TestSlimBriefIsSubstantiallyShorter(t *testing.T) { } t.Logf("slim brief reduction: %.1f%% (legacy=%d, slim=%d, Δ=%d)", ratio*100, len(legacy), len(slim), len(legacy)-len(slim)) } + +// TestBackgroundTaskSafetySlimHardPins asserts the slim brief carries the +// same hardened Background Task Safety pins as the legacy brief (MUL-4140). +// The verbose path is covered by +// TestInjectRuntimeConfigBackgroundTaskSafetyProviderAgnostic; this locks +// the compressed slim path so a future slim-brief trim can't quietly drop +// the no-background-and-yield / no-"standing by" guardrails that address +// the MUL-4091 mechanism. +func TestBackgroundTaskSafetySlimHardPins(t *testing.T) { + withSlimBrief(t) + + out := buildMetaSkillContent("claude", TaskContextForEnv{ + IssueID: "i-1", TriggerCommentID: "tc-1", + AgentName: "Eve", AgentID: "eve-1", + }) + + for _, want := range []string{ + "## Background Task Safety", + "Do NOT end your turn while background tasks", + "wait for a future notification/reminder", + "run the work synchronously instead", + "Never background-and-yield", + "foreground tool call that blocks", + "gh run watch", + "running in the background so you can keep working", + "standing by", + } { + if !strings.Contains(out, want) { + t.Errorf("slim Background Task Safety missing hardened pin %q\n---\n%s", want, out) + } + } +} diff --git a/server/internal/daemon/execenv/runtime_config_sections.go b/server/internal/daemon/execenv/runtime_config_sections.go index 2d5529ee8..da82c004f 100644 --- a/server/internal/daemon/execenv/runtime_config_sections.go +++ b/server/internal/daemon/execenv/runtime_config_sections.go @@ -44,16 +44,18 @@ func writeHeader(b *strings.Builder) { // writeBackgroundTaskSafetySlim is the slim analogue of // writeBackgroundTaskSafetyInstructions (legacy). Drops the verbose -// preamble and keeps the three behaviour pins (the same ones tests -// assert): "Do NOT end your turn while background tasks", -// "wait for a future notification/reminder", "run the work synchronously -// instead". +// preamble but keeps the same hard behaviour pins the tests assert: +// "Do NOT end your turn while background tasks", "wait for a future +// notification/reminder", "run the work synchronously instead", the +// no-background-and-yield rule, and the no-"standing by" sign-off rule. func writeBackgroundTaskSafetySlim(b *strings.Builder) { b.WriteString("## Background Task Safety\n\n") - b.WriteString("Multica marks the task terminal when your top-level turn exits — any background work still running may be orphaned and its result lost.\n\n") - b.WriteString("- Do NOT end your turn while background tasks, async subagents, background shell commands, or detached tool calls are still running.\n") - b.WriteString("- If a tool response says to wait for a future notification/reminder, do not rely on that in Multica-managed runs — block on the appropriate wait / output / collect operation before exiting.\n") - b.WriteString("- If you can't observe a background task's result, run the work synchronously instead.\n\n") + b.WriteString("Multica marks the task terminal the moment your top-level turn exits — any background work still running is orphaned, its result lost, and the final comment you meant to post after it never sends. There is no background-completion wakeup here.\n\n") + b.WriteString("- Do NOT end your turn while background tasks, async subagents, background shell commands, or detached tool calls are still running. Never background-and-yield: never end a turn expecting a future notification or wakeup to resume — it will not arrive.\n") + b.WriteString("- Do every wait synchronously inside one foreground tool call that blocks to completion (e.g. `gh run watch`, a blocking test command); never split \"start the wait\" and \"collect the result\" across turns.\n") + b.WriteString("- If a tool response says to wait for a future notification/reminder, or that it is running in the background so you can keep working, do not rely on that in Multica-managed runs — block on the appropriate wait / output / collect operation before exiting.\n") + b.WriteString("- If you can't observe a background task's result, run the work synchronously instead.\n") + b.WriteString("- Never end a turn with a \"standing by\" / \"I'll report back when X finishes\" message — that becomes your final output and the task ends.\n\n") } // writeAgentIdentity emits the Agent Identity heading and (optionally) the