Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session>`. 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 <session> -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 <session> -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 <session>` 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
Expand All @@ -199,7 +226,8 @@ offload config open interactive settings (terminal only)
offload ls [n] list the last n runs (default 10)
offload show <session> 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
Expand Down
5 changes: 5 additions & 0 deletions internal/agentinstall/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session> (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 <session>, 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 <long bound, e.g. 10m> 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
Expand Down
23 changes: 23 additions & 0 deletions internal/agentinstall/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/zhoushoujianwork/offload/internal/config"
"github.com/zhoushoujianwork/offload/skills"
)

Expand Down Expand Up @@ -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")
}
}
3 changes: 2 additions & 1 deletion internal/agentinstall/mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -31,7 +32,8 @@ usage:
offload ls [n] list the last n runs (default 10)
offload show <session> 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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 <profile> 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)
Expand All @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 <profile>", "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)
}
}
6 changes: 6 additions & 0 deletions internal/cli/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"time"
Expand Down Expand Up @@ -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):
}
Expand Down
Loading
Loading