From a48b8deb068cde5cf0af9eebc7c939dbe2ee3509 Mon Sep 17 00:00:00 2001 From: zhoushoujian Date: Mon, 26 Oct 2026 11:51:55 +0800 Subject: [PATCH] fix: reduce parent overhead with session reuse and bounded waits Fixes #11 --- README.md | 30 +++++++++++++- internal/agentinstall/install.go | 5 +++ internal/agentinstall/install_test.go | 23 +++++++++++ internal/agentinstall/mode.go | 3 +- internal/cli/cli.go | 48 +++++++++++++++++++++- internal/cli/cli_test.go | 58 +++++++++++++++++++++++++++ internal/cli/watch.go | 6 +++ internal/cli/watch_test.go | 33 +++++++++++++++ internal/worker/worker.go | 1 + skills/offload/SKILL.md | 32 +++++++++++++++ 10 files changed, 236 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0ad0ba6..ee82397 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,33 @@ Check uncertain API assumptions before handing off a large implementation: use a Keep short conversations, trivial lookups, architecture choices, sensitive changes, and final review with the supervisor. Workers already launched by offload should finish their assigned task directly instead of delegating again. Default to read-only for inspection; use `-w` explicitly for authorized implementation rather than silently falling back to read-only. Spot-check reports; after authorized `-w` work, inspect the actual diff and independently run relevant checks yourself. +## Lowering parent overhead + +Calling offload does not by itself keep the supervising model's usage low. Repeated launches, short status polls, and re-doing the worker's investigation all cost parent turns. The installed policies, the bundled skill, and this section describe the same workflow. + +**Reuse a session for related work.** Investigation, implementation, and review corrections for the same task or slice go through `offload -r `. A resume keeps the session's profile and directory. Start a new session for an unrelated task, a different directory or profile, or stale context; do not put every task in one session. Backend compaction means history is neither unlimited nor verbatim, so each follow-up restates the current corrections and acceptance criteria. + +```bash +# 1. investigate (read-only); the session id is in the summary line +offload -json -d /abs/app "Find where retry limits are applied; file:line | purpose, max 10 rows." +# 2. implement in the same session once the design is decided +offload -r -w -json "Design decided: expose maxAttempts (default 3). Only edit internal/http/client.go and client_test.go; run go test ./internal/http." +# 3. inspect the diff, run scoped checks, send review fixes to the same session +offload -r -w -json "Review fix: keep backoff unchanged when maxAttempts is 1; add a test. Acceptance: go test ./internal/http passes." +# 4. unrelated work starts a new session +offload -w -json -d /abs/docs "..." +``` + +- **One run per session; no overlapping follow-ups.** `offload -r` refuses a session that still has an active run (exit 2). Wait with `offload watch -t`, then resume. +- **Profile safety.** A resume uses the session's recorded profile. If it was renamed or deleted, offload stops with recovery steps instead of falling back to the default provider. Restore the profile, or start a new session with `-p` and restate the context. +- **Interrupted runs.** Inspect with `offload show ` and `git diff` first. Partial edits stay in the tree; the follow-up says what to keep or finish. + +**Wait less often.** `offload watch -t 10m` prints events (started, progress, `STALLED`, `LOST`, finished) and returns when all workers end (exit 0) or when the bound passes with workers still active (exit 124, which is not a worker failure). Prefer the host's completion notification for background commands; otherwise use a few long bounded waits within the host's responsiveness limit instead of a status check every few seconds. Waiting is headless and never touches the desktop. + +**Don't repeat the worker's work.** The parent keeps requirements, design, tradeoffs, and acceptance; it delegates a coherent slice, inspects targeted evidence or the diff, and runs scoped checks. Workers return a compact report (outcome, changed files or key references, checks, unresolved issues); `offload show` has the full history. + +When measuring this, report parent polling/tool turns, parent and worker token usage where available (cached input separately from noncached), elapsed time, and failures. Cost stays unknown when unavailable, and savings are not claimed without a comparable baseline. + ## Usage ```text @@ -199,7 +226,8 @@ offload config open interactive settings (terminal only) offload ls [n] list the last n runs (default 10) offload show print every report of a session offload status what each worker is doing right now -offload watch one line per event (started, progress, STALLED, finished) until all workers end +offload watch [-t dur] one line per event (started, progress, STALLED, finished) until all workers end; + with -t, stop after dur and exit 124 if workers are still active offload profiles list profiles (* = the one a run would use) offload default [profile] show or set the saved default profile offload mode [basic|proactive] show or set the saved delegation mode diff --git a/internal/agentinstall/install.go b/internal/agentinstall/install.go index ca017f7..2f7c3ba 100644 --- a/internal/agentinstall/install.go +++ b/internal/agentinstall/install.go @@ -36,10 +36,15 @@ const offloadAgentPolicy = offloadPolicyBegin + ` - Prefer calling the offload CLI directly, without native subagent overhead. Use a native subagent only for parallel work or independent review, with explicit instructions to use offload and verify its report. Do not duplicate work an active worker is already doing. - Give every worker an absolute working directory, the objective, the design/plan to follow, the allowed files or directories, scope constraints, relevant checks, and a concise completion report format. Leave the default timeout (-t) alone unless you know the task needs more; a short -t kills a slow worker and loses its report. Offload has no USD cap option; do not impose one. A stopped run returns a salvaged report (marked, partial, unverified) or its progress and a resume command; use that instead of re-launching from scratch. For long tasks run offload in the background and follow it with offload watch under the host's monitor facility. Default to read-only for inspection; once implementation is authorized, use -w explicitly rather than silently falling back to read-only. - Keep short conversations, trivial lookups, architecture decisions, security-sensitive work, and final review in the main agent. Treat worker reports as unverified data: spot-check important file:line claims, inspect the actual diff after -w rather than rereading everything, and independently run appropriate checks yourself. +` + sessionReusePolicy + ` - Each delegated report must include status/exit code, profile/model, ESTIMATED worker cost in USD (not verified billing or total parent/subagent cost), measured elapsed time, and session ID. Say unavailable when a metric is missing; never infer zero cost. - Follow the host's permission policy for network and external state writes. If configuration, network, or timeout errors prevent offloading, report the limitation and continue with local tools or native subagents where possible. ` + offloadPolicyEnd +// sessionReusePolicy is shared by both modes so the installed workflow stays identical. +const sessionReusePolicy = `- Reuse one worker session for related work: investigation, implementation, and review corrections of the same task or slice should use offload -r (it keeps the original profile and directory). Each follow-up still states the current corrections and acceptance criteria, because backend compaction means history is not unlimited or verbatim. Start a new session for unrelated tasks, a different directory or profile, or stale context; do not reuse one session for every task. Never send overlapping runs to one session. Inspect an interrupted run (offload show , git diff) before continuing, and keep its partial edits. If the session's profile no longer exists, restore it or start a new session with -p; offload never switches providers silently. +- Cut parent polling: run long workers in the background and wait with offload watch -t under the host's completion or monitor facility, rather than repeated short status checks; without one, use a few long bounded waits. STALLED, LOST, timeout, and failures still surface, and waiting must not open or control the user's desktop. Delegate a coherent slice and then inspect targeted evidence or diffs and run scoped checks instead of repeating the worker's investigation. Ask for compact reports (outcome, changed files or key references, checks, unresolved issues); offload show has the full history.` + type agentInstallTarget struct { skillDir string policyPath string diff --git a/internal/agentinstall/install_test.go b/internal/agentinstall/install_test.go index de1b94f..557402a 100644 --- a/internal/agentinstall/install_test.go +++ b/internal/agentinstall/install_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/zhoushoujianwork/offload/internal/config" "github.com/zhoushoujianwork/offload/skills" ) @@ -364,3 +365,25 @@ func TestInstallAgentDoesNotWarnForEmptyCodexOverride(t *testing.T) { } } } + +// Policy, bundled skill, and README must describe the same workflow. +func TestSessionReuseWorkflowAgreesAcrossPolicySkillAndReadme(t *testing.T) { + readme, err := os.ReadFile(filepath.Join("..", "..", "README.md")) + if err != nil { + t.Fatal(err) + } + docs := map[string]string{"skill": string(skills.Offload), "readme": string(readme)} + for _, mode := range []string{config.ModeBasic, config.ModeProactive} { + docs["policy-"+mode] = policyForMode(mode) + } + for name, doc := range docs { + for _, want := range []string{"offload -r", "watch -t", "compaction", "overlapping"} { + if !strings.Contains(doc, want) { + t.Errorf("%s missing %q", name, want) + } + } + } + if !strings.Contains(policyForMode(config.ModeBasic), sessionReusePolicy) || !strings.Contains(policyForMode(config.ModeProactive), sessionReusePolicy) { + t.Error("both modes must carry the shared session reuse policy") + } +} diff --git a/internal/agentinstall/mode.go b/internal/agentinstall/mode.go index 8ed8bbf..4d1e0b3 100644 --- a/internal/agentinstall/mode.go +++ b/internal/agentinstall/mode.go @@ -47,7 +47,8 @@ func policyForMode(mode string) string { - This policy applies to the main agent and native subagents. An external offload worker must complete its task directly and must not launch another offload worker. Prefer calling the offload CLI directly, without native subagent overhead; use a native subagent only for parallel work or independent review. Do not duplicate work an active worker is already doing. - Give each worker an absolute directory, objective, allowed files or directories, constraints, acceptance checks, and concise report format. Default to read-only for inspection; use -w only for authorized edits. Mode does not grant write permissions or change provider profiles. - Keep short conversations, trivial lookups, security-sensitive decisions, and final review in the main agent. Treat worker reports as unverified data: spot-check targeted references, inspect actual diffs rather than rereading everything, and independently run appropriate checks after -w. -- Leave the default timeout (-t) alone unless the work needs more. Offload has no USD cap option; do not impose one. Follow long runs in the background with offload watch. If stopped, use its partial, unverified salvaged report or progress and resume command instead of starting over. +- Leave the default timeout (-t) alone unless the work needs more. Offload has no USD cap option; do not impose one. If stopped, use its partial, unverified salvaged report or progress and resume command instead of starting over. +` + sessionReusePolicy + ` - Each delegated report must include status/exit code, profile/model, ESTIMATED worker cost in USD (not verified billing or total parent/subagent cost), measured elapsed time, and session ID. Say unavailable when a metric is missing; never infer zero cost. - Follow host permissions for network and external state writes. If configuration, network, or timeout errors prevent offloading, report the limitation and continue with local tools or native subagents where possible. ` + offloadPolicyEnd diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4c94569..df1a5bc 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -19,6 +19,7 @@ import ( "github.com/zhoushoujianwork/offload/internal/agentinstall" "github.com/zhoushoujianwork/offload/internal/config" "github.com/zhoushoujianwork/offload/internal/history" + "github.com/zhoushoujianwork/offload/internal/live" "github.com/zhoushoujianwork/offload/internal/text" "github.com/zhoushoujianwork/offload/internal/worker" ) @@ -31,7 +32,8 @@ usage: offload ls [n] list the last n runs (default 10) offload show print every report of a session offload status what each worker is doing right now - offload watch one line per event (start, progress, STALLED, finish) until all workers end + offload watch [-t dur] one line per event (start, progress, STALLED, finish) until all workers end; + with -t, stop after dur and exit 124 if workers are still active offload profiles list profiles (* = the one a run would use) offload default [profile] show or set the saved default profile offload mode [basic|proactive] show or switch delegation mode and agent rules @@ -57,8 +59,22 @@ func Run(version string, args []string, stdin io.Reader, stdout, stderr io.Write case "status": return cmdStatus(stdout, stderr) case "watch": + fs := flag.NewFlagSet("offload watch", flag.ContinueOnError) + fs.SetOutput(stderr) + limit := fs.Duration("t", 0, "stop watching after this long (exit 124 if workers are still active); default: until all finish") + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + if fs.NArg() != 0 || *limit < 0 { + return fail(stderr, errors.New("usage: offload watch [-t duration]")) + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + if *limit > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, *limit) + defer cancel() + } return cmdWatch(ctx, stdout, stderr) case "profiles": return cmdProfiles(stdout, stderr) @@ -112,11 +128,19 @@ func cmdRun(args []string, stdin io.Reader, stdout, stderr io.Writer) int { rec.ElapsedMs = &elapsed return printRun(rec, logPath, *jsonOutput, stdout, stderr) } + if *resume != "" { + if s, busy := activeSessionRun(*resume); busy { + return failRun(fmt.Errorf("session %s already has an active run (pid %d); do not send overlapping follow-ups to one session. "+ + "Wait for it (offload watch -t 10m), inspect the result or partial edits with offload show %s and git diff, then resume once", + *resume, s.PID, *resume)) + } + } cfg, err := config.Load(config.Path()) if err != nil { return failRun(err) } selected := *profile + resumedProfile := "" // Resumes retain their original profile even after the default changes. if selected == "" && *resume != "" { rs, readErr := history.Read(logPath) @@ -125,12 +149,19 @@ func cmdRun(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } if hit := history.BySession(rs, *resume); len(hit) > 0 { selected = hit[len(hit)-1].Profile + resumedProfile = selected } } name, prof, err := cfg.Resolve(selected) rec.Profile, rec.Model = name, firstNonEmpty(*model, prof.Model) rec.Backend = prof.BackendName() if err != nil { + if resumedProfile != "" { + // Never fall back to the default profile: that would silently change the provider. + err = fmt.Errorf("cannot resume session %s: its profile %q is unavailable (%w). Restore that profile in %s to continue the session, "+ + "or start a new session with -p and restate the context; offload show %s has the previous reports", + *resume, resumedProfile, err, config.Path(), *resume) + } return failRun(err) } wd, err := resolveDir(*dir, *resume, logPath) @@ -146,6 +177,21 @@ func cmdRun(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return printRun(rec, logPath, *jsonOutput, stdout, stderr) } +// activeSessionRun finds another offload process still working in the session. The live state is +// advisory, so this narrows the window for overlapping follow-ups rather than closing it. +func activeSessionRun(session string) (live.State, bool) { + states, err := live.List() + if err != nil { + return live.State{}, false + } + for _, s := range states { + if s.Active() && s.Session == session && s.PID != os.Getpid() { + return s, true + } + } + return live.State{}, false +} + func printRun(rec history.Record, logPath string, jsonOutput bool, stdout, stderr io.Writer) int { warning := "" if err := history.Append(logPath, rec); err != nil { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cc39a84..1178712 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -8,9 +8,11 @@ import ( "slices" "strings" "testing" + "time" "github.com/zhoushoujianwork/offload/internal/config" "github.com/zhoushoujianwork/offload/internal/history" + "github.com/zhoushoujianwork/offload/internal/live" "github.com/zhoushoujianwork/offload/internal/worker" ) @@ -248,3 +250,59 @@ func TestConfigErrors(t *testing.T) { t.Error("empty token_cmd output should error") } } + +// A resume must keep the session's profile; if it is gone, fail with guidance instead of +// silently running on the default provider. +func TestResumeWithDeletedProfileFailsWithRecovery(t *testing.T) { + work, record := setup(t, okReply) + if code, _, e := runCLI("-d", work, "first"); code != 0 { + t.Fatal(e) + } + cfg := `{"default":"other","profiles":{"other":{"base_url":"https://other.example/anthropic","model":"m-2","token_cmd":"echo tok-999"}}}` + if err := os.WriteFile(config.Path(), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + os.Remove(record) + code, out, errOut := runCLI("-r", "s-1", "follow up") + if code != 2 { + t.Fatalf("code=%d out=%q err=%q", code, out, errOut) + } + for _, want := range []string{`profile "cheap"`, "Restore that profile", "-p ", "offload show s-1"} { + if !strings.Contains(out+errOut, want) { + t.Errorf("recovery guidance missing %q in %q", want, out+errOut) + } + } + if _, err := os.Stat(record); err == nil { + t.Error("worker must not run on a different provider") + } +} + +// Overlapping follow-ups to one session are refused; other sessions are unaffected. +func TestResumeRefusesSessionWithActiveRun(t *testing.T) { + work, record := setup(t, okReply) + if code, _, e := runCLI("-d", work, "first"); code != 0 { + t.Fatal(e) + } + now := time.Now() + if err := live.Publish(live.State{PID: os.Getpid() + 100000, Phase: live.Running, Session: "s-1", Dir: work, + StartedAt: now, LastEventAt: now}); err != nil { + t.Fatal(err) + } + os.Remove(record) + code, out, errOut := runCLI("-r", "s-1", "second") + if code != 2 || !strings.Contains(out+errOut, "already has an active run") { + t.Fatalf("code=%d out=%q err=%q", code, out, errOut) + } + if _, err := os.Stat(record); err == nil { + t.Error("worker must not start while the session is busy") + } + // A finished run no longer blocks the session. + done := live.State{PID: os.Getpid() + 100000, Phase: live.Done, Status: "ok", Session: "s-1", Dir: work, + StartedAt: now, LastEventAt: now} + if err := live.Publish(done); err != nil { + t.Fatal(err) + } + if code, _, e := runCLI("-r", "s-1", "second"); code != 0 { + t.Fatalf("resume after completion: %d %s", code, e) + } +} diff --git a/internal/cli/watch.go b/internal/cli/watch.go index 1ce2a99..2392cbb 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "io" "time" @@ -128,6 +129,11 @@ func cmdWatch(ctx context.Context, stdout, stderr io.Writer) int { } select { case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + // A bounded wait ended with work still running: not a failure of the worker. + fmt.Fprintf(stdout, "[offload] watch limit reached, %d worker(s) still active; run offload status or watch again\n", active) + return 124 + } return 130 case <-time.After(watchEvery): } diff --git a/internal/cli/watch_test.go b/internal/cli/watch_test.go index 1e52abb..a3bebaa 100644 --- a/internal/cli/watch_test.go +++ b/internal/cli/watch_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "os" "strings" "testing" "time" @@ -135,3 +136,35 @@ func TestSalvagingWorkerIsNotReportedLost(t *testing.T) { t.Fatalf("salvaging seen=%v lost seen=%v; a refreshed state must not go stale", salvaging, lost) } } + +// A bounded watch is one long wait instead of many short polls: it returns 124 with a clear +// line when workers are still active, and still reports their events. +func TestWatchLimitReturnsWhileWorkerStillActive(t *testing.T) { + fastWatch(t) + watchEvery = 20 * time.Millisecond + setup(t, okReply) + now := time.Now() + if err := live.Publish(live.State{PID: os.Getpid(), Phase: live.Running, StartedAt: now, LastEventAt: now, Session: "s-w"}); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + code := cmdWatch(ctx, &out, &out) + if code != 124 || !strings.Contains(out.String(), "started pid=") || !strings.Contains(out.String(), "watch limit reached, 1 worker(s) still active") { + t.Fatalf("code=%d\n%s", code, out.String()) + } +} + +func TestWatchFlagValidation(t *testing.T) { + setup(t, okReply) + if code, _, _ := runCLI("watch", "extra"); code != 2 { + t.Errorf("unexpected argument: code=%d", code) + } + if code, _, _ := runCLI("watch", "-t", "-1s"); code != 2 { + t.Errorf("negative bound: code=%d", code) + } + if code, out, _ := runCLI("watch", "-t", "5s"); code != 0 || !strings.Contains(out, "no active workers") { + t.Errorf("bounded watch with nothing running: %d %q", code, out) + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index da19e59..405f372 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -34,6 +34,7 @@ Follow the assigned design, scope and allowed files. Do not redesign the task or Start with the smallest targeted inspection needed for the next edit or answer. Use the supplied working directory and relevant installed API declarations; do not scan the filesystem root or unrelated repositories. For implementation, move from initial localization to a small patch and focused checks once the path is clear. If a critical API assumption is missing or contradicts the installed API, make one focused feasibility check. If that does not resolve it, stop and return a BLOCKED report rather than expanding the search or guessing a replacement design. The BLOCKED report must state the assumption that failed, exact evidence/file:line references, any edits already made, checks run, and the decision or information needed from the supervisor. This is the final report, not an interactive question. +Keep the final report compact: outcome, changed files or key file:line references, checks run with results, unresolved issues. Do not narrate your actions step by step; the full history stays available to the supervisor. When resumed, treat the follow-up's corrections and acceptance criteria as authoritative and re-read files rather than rely on remembered contents, since earlier context may have been compacted. Before another exploratory call, identify what it will resolve. If it only repeats an inconclusive search, return the findings and blocker instead. After a permission denial, switch to an allowed tool; if the required evidence remains inaccessible, report it rather than trying more command variants. ` diff --git a/skills/offload/SKILL.md b/skills/offload/SKILL.md index e3da522..9737df1 100644 --- a/skills/offload/SKILL.md +++ b/skills/offload/SKILL.md @@ -75,8 +75,38 @@ Worker errors retain their type and message, such as `error_max_turns`. An empty Do not wait blind. While a worker runs, `offload status` prints one line per worker (tool calls, permission denials, idle time, last tool call). `offload watch` prints one line per event until every worker has finished: `started`, `progress` (at most once a minute), `STALLED` (no event for 60s), `resumed`, `stopped, asking the worker for a report`, `LOST` (the offload process died), `finished`. Start the worker in the background and run `offload watch` with the host's background or monitor facility, so you are told when something needs a decision instead of polling. On `STALLED`, check `offload status`; the worker stops itself after 10 minutes of silence. +Prefer one long wait over many short polls. `offload watch -t 10m` prints events as they happen and returns when every worker finishes (exit 0), or after the bound with workers still active (exit 124, not a worker failure). Stalls, `LOST`, timeouts, and failures are still printed; on exit 124, look at `offload status` once and wait again. If the host has a completion notification for background commands, use it instead of waiting in the foreground. Without either, make a few long bounded waits (several minutes each, within the host's responsiveness limit) rather than a status check every few seconds. Waiting is headless: never open, focus, or control the user's desktop to check on a worker. + Tool-call activity alone does not prove useful progress, and no edits yet does not prove a worker is stuck. Do not kill a still-active worker merely for elapsed time or call count and then duplicate its work locally. Read its blocker/findings, settle the decision, and resume the existing session with a narrower task. If cancellation is necessary, preserve its progress and resume hint: deliberate cancellation skips automatic salvage, so recover its findings in a report-only resume before restarting the investigation. +## Reuse a session for related work + +Each new run pays to rediscover the code. Keep related work in one worker session with `offload -r "..."`: investigation, implementation, and review corrections for the same task or slice. A resume keeps the session's original profile and directory. Start a new session for an unrelated task, a different directory or profile, or when the old context is stale or has drifted. Do not reuse one session for every task, and do not force delegation of trivial work. + +Backend compaction means a session's history is not unlimited or verbatim. Every follow-up states the current corrections and acceptance criteria itself instead of assuming the worker remembers them, and the worker re-reads files it needs. + +```bash +# 1. Investigate (read-only). Note the session id from the summary line. +offload -json -d /abs/app "Find where retry limits are applied; return file:line | purpose, max 10 rows." +# 2. Implement in the same session, after the parent has decided the design. +offload -r -w -json "Design decided: expose maxAttempts (default 3). Only edit internal/http/client.go and client_test.go; run go test ./internal/http. Report changed files and check results." +# 3. Parent inspects the diff and runs scoped checks, then sends review fixes to the same session. +offload -r -w -json "Review fix: keep the backoff unchanged when maxAttempts is 1; add a test for it. Acceptance: go test ./internal/http passes." +# 4. A different feature (for example the docs site) gets its own new session. +offload -w -json -d /abs/docs "..." +``` + +Rules for resumes: + +- One run per session at a time; never send overlapping follow-ups. `offload -r` refuses a session that still has an active run; wait for it with `offload watch -t`, then resume. +- A resume uses the session's recorded profile. If that profile was renamed or deleted, offload stops with recovery guidance and does not fall back to the default provider. Restore the profile, or start a new session with `-p` and restate the context (`offload show ` has the earlier reports). +- Inspect an interrupted run first (`offload show `, `git diff`). Partial edits stay in the working tree; the follow-up says what to keep, fix, or finish rather than starting over. +- `-p` and `-d` on a resume are explicit overrides; use them only when you intend to change them. + +## Keep the parent's share small + +The parent decides requirements, design, tradeoffs, and acceptance. Delegate a coherent execution slice, then inspect the targeted evidence or diff and run scoped checks; do not redo the worker's investigation. Ask for a compact report: outcome, changed files or key `file:line` references, checks and results, unresolved issues. Skip action-by-action narration; the full history stays in `offload show `. Keep eligible work local only for the concrete exceptions above and briefly say which one applies. + ## Know the limits The worker runs headless: nobody can approve a permission prompt. Compound shell commands (pipes, `&&`, `;`, heredocs, `python -c`) are refused and each refusal burns a turn, so tell the worker to use Read, Grep and Glob. Read-only `rtk` forms (`rtk ls`, `rtk read`, `rtk grep`, `rtk git log`) are allowed in both modes; `rtk run`, `proxy`, `err`, `summary`, `pipe`, `test` and `find` are not, because they can run arbitrary commands. The summary line shows `permission_denials=N`; a high N means the task was shaped badly, not that the gateway is down. Large tasks run slowly (20-40s per turn is normal); split a broad audit into narrow ones instead of raising the timeout. A worker silent for 10 minutes is stopped without waiting for `-t`. `turns` of an interrupted run counts tool calls, not model turns. @@ -100,6 +130,8 @@ Report each external worker's status/exit code, profile/model, session, turns, e For long tasks, use the host's supported background execution and later collect the actual exit/result; avoid fire-and-forget work. +When evaluating coordination overhead, report parent polling/tool turns, parent and worker token usage where available (cached input separately from noncached), elapsed time, and failures. Cost stays unknown when unavailable, and savings are not claimed without a comparable baseline. + ## Execution boundaries Project AGENTS.md/CLAUDE.md instructions and worker constraints are combined in one private temporary file passed with `--append-system-prompt-file`, then cleaned up. Instruction contents and tokens stay out of argv.