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 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Bohan Jiang
2026-07-06 19:47:00 +08:00
committed by GitHub
co-authored by J multica-agent
parent 39ccb7d342
commit 4c510dfef6
4 changed files with 56 additions and 12 deletions
@@ -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)
@@ -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 <run-id>` 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")
}
@@ -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)
}
}
}
@@ -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