mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* fix(local): defeat the macOS stale-pooled-connection hang (transport + stall-retry)
Root cause (you confirmed it: both chatgpt AND ollama hung, only on macOS): providers
fell back to http.DefaultClient, whose pooled keep-alive connections get reused on a
later turn after the server/NAT silently dropped them. The model call is a POST
(non-idempotent), so Go won't auto-retry it on a fresh conn — it blocks forever.
macOS keeps dead pooled conns around longer than Linux, so it reproduced on macOS only.
1. providerio.HTTPClient now returns a shared, tuned transport (ResponseHeaderTimeout
60s bounds a hung POST that gets no response; IdleConnTimeout 30s stops dead conns
from lingering to be reused). Routed openai/codex through it too (anthropic/gemini
already used HTTPClient). Slow first tokens / long reasoning are unaffected — the
header timeout only bounds time-to-first-response-header, not the stream body.
2. Agent loop now retries a stream idle/stall timeout that produced NO output yet, on
a fresh connection (capped + backoff). A partial-output turn is NOT retried (would
duplicate). Recovers any stall that slips past the transport timeout.
Local only — not pushed.
* fix(local): tolerate concatenated tool-arg JSON + widen response-header timeout
1) Weak models (minimax-m3, glm) pack MULTIPLE concatenated top-level JSON objects
into one tool-call arguments slot ({A}{B}), which json.Unmarshal rejects with
'invalid character {' after top-level value' (~9 failed tool calls/session).
New recoverableToolArguments returns the first JSON value ONLY when the payload is
one value optionally followed by more WHOLE JSON values; it rejects a bad/truncated
first value OR trailing NON-JSON garbage ({"x":1}xyz) so genuine corruption still
errors (adversarial-review fix). decodeToolArguments uses it at the central dispatch
(executeToolCall) so {A}{B} runs the first object; historySafeToolCalls uses the
SAME helper so the replayed transcript records the recovered first object (not {}),
keeping dispatch and history consistent. Truly-malformed JSON still hits the
existing sanitize-to-{} + retry path. Trailing objects are dropped (the model
re-issues on a later turn).
2) Raise the shared transport ResponseHeaderTimeout 60s -> 120s so a slow cloud proxy
(ollama *:cloud) that withholds its 200 header until first token isn't wrongly
aborted; still bounds a truly dead reused connection.
Local only — not pushed.
* feat(local): add /turns command + raise default maxTurns 30->50
Agentic multi-step runs (build + delegate + swarm + MCP + self-verify) routinely hit
the 30-turn ceiling mid-task and never reached the later steps; removing /mode earlier
also left NO interactive way to raise the budget.
- /turns [n]: per-session tool-turn budget setter (commandGroupSession), mirroring
/selfcorrect. Empty/status shows the current budget; a positive int sets
agentOptions.MaxTurns (read by the run at model.go); clamped to maxTurnsCeiling=500
to guard against typos; invalid/non-positive shows usage without changing it.
- defaultMaxTurns 30 -> 50 (matches the old 'deep' preset). Raise per-run with /turns
for long tasks (e.g. /turns 150 for a full acceptance run).
Local only — not pushed.
* fix(local): swarm reports failed members as failed + sub-agents inherit /turns budget
Issue B — a swarm member whose child exited non-zero (e.g. exit 4 / max-turns) was
recorded [done] (swarm_status '0 failed'), so the orchestrator — which can't see the
AGENTS panel — trusted incomplete work. The specialist launcher ignored
res.Result.Status; it now surfaces a StatusError result as a member FAILURE
(errors.New(report)) so the swarm marks it [failed]. Added Coordinator.FailWithSession
and switched the member watch path to it so a failed member keeps its session id
(still drillable) and its report rides along as the failure message.
Issue A — a member hit its OWN turn limit because /turns raised only the parent's
budget; children re-resolved config.json's default. New config.SetMaxTurnsEnv exports
ZERO_MAX_TURNS, read in applyEnv -> cfg.MaxTurns; /turns now exports it so spawned
sub-agents / swarm members (which inherit the env) run with the same budget. A
non-numeric/zero env value never clobbers the configured budget.
Tests: launcher non-zero-exit -> failed (+ session kept); SetMaxTurnsEnv round-trip
+ garbage-env guard; /turns exports + clamps the env. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): corrective error for unknown specialist name + clamp ZERO_MAX_TURNS
1) A model that invents a specialist name (validator-runner, file-creator,
file-writer — none registered) got an opaque 'specialist not found' and kept
spawning doomed sub-agents. loadManifest now lists the available specialists and
notes that 'worker' has shell/edit while explorer/code-review are read-only, so the
model self-corrects to a capable specialist. New availableSpecialistList helper.
2) Defense-in-depth (review nit): applyEnv now clamps an inherited ZERO_MAX_TURNS to
MaxTurnsCeiling, so a raw shell 'export ZERO_MAX_TURNS=999999' can't bypass the
/turns ceiling. Ceiling promoted to config.MaxTurnsCeiling (single source of truth,
referenced by the TUI /turns clamp).
Tests: unknown-name error lists worker/explorer/code-review; applyEnv clamps an
over-ceiling env value. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): surface sub-agent kill signal instead of opaque exit -1
When a sub-agent child is terminated by a signal, command.Wait returns an ExitError
whose ExitCode() is -1, losing the cause. Capture ProcessState.String() (portable,
e.g. "signal: killed") into ChildRunResult.Signal and have BuildFinalResult render
it — "Subagent terminated by a signal (signal: killed) — killed before it finished.
Likely causes: the OS out-of-memory killer ... a timeout, or cancellation." — instead
of an opaque "Subagent failed (exit -1)". The message lists causes rather than
asserting OOM, since timeout/cancellation also SIGKILL the child. A captured signal
always surfaces even if a late run_end reported a clean exit. The background task
path (which can't carry the signal name) emits the same hint for a negative exit code.
* address CodeRabbit review on #349
- loop.go: gate stall-retry on a turn-level forwardedAnything flag (wrapped
callbacks), so a retry never re-streams on top of output already forwarded earlier
in the turn (e.g. across the reactive-compaction retry). New test covers a
reasoning-then-stall (collected.Text empty but output forwarded).
- loop.go: route a reissued stream's non-stall error through the SAME recovery as
the initial stream (image-rejection wrapping / context-limit compaction) via a
shared recoverStreamError closure, instead of returning it raw.
- exec.go: drop the hardcoded worker/explorer/code-review guidance from the
unknown-specialist error; rely on the dynamically-rendered available list (a
custom registry may not have those names).
- streamer.go: kill-signal message lists OOM/timeout/cancellation evenhandedly
instead of asserting OOM (the branch also covers timeouts/cancellations).
- launcher_specialist.go + exec.go: preserve the child SessionID on a post-start
failure too, so FailWithSession keeps those failed members drillable.
- model.go: block /turns while a run is active (mid-run change would make the
inherited ZERO_MAX_TURNS budget inconsistent for later-spawned sub-agents).
189 lines
6.5 KiB
Go
189 lines
6.5 KiB
Go
package swarm
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
// Spawn registers a task and launches a member of agentType to run it under the
|
|
// given team, inheriting the orchestrator's policy. It returns the task id
|
|
// immediately; the member runs concurrently (under the Swarm's base context, so
|
|
// it outlives this call) and reports its result through the coordinator. If the
|
|
// team is at its slot cap the member is queued and launches when a slot frees
|
|
// (it stays pending in the coordinator until then).
|
|
func (s *Swarm) Spawn(pol Policy, teamName, agentType, task, cwd string) (string, error) {
|
|
def, err := s.registry.Lookup(agentType)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
team := sanitizeName(teamName)
|
|
id := s.nextID(agentType)
|
|
if _, err := s.coord.Register(id, id, team, task); err != nil {
|
|
return "", err
|
|
}
|
|
s.rememberCwd(id, cwd)
|
|
spec := s.buildSpec(pol, id, id, team, def, task, cwd)
|
|
s.dispatch(spec)
|
|
return id, nil
|
|
}
|
|
|
|
// dispatch admits a spec to its team (launching now or queuing for a slot).
|
|
func (s *Swarm) dispatch(spec MemberSpec) {
|
|
t := s.team(spec.Team)
|
|
if t.admit(spec) {
|
|
s.launch(t, spec)
|
|
}
|
|
// Otherwise the spec is queued; the coordinator task stays pending until a
|
|
// slot frees and afterExit launches it.
|
|
}
|
|
|
|
// launch starts a member for spec and supervises it. A synchronous launch
|
|
// failure fails the task and frees the slot.
|
|
func (s *Swarm) launch(t *Team, spec MemberSpec) {
|
|
handle, err := s.launcher.Launch(s.baseCtx, spec)
|
|
if err != nil {
|
|
_ = s.coord.Fail(spec.TaskID, "launch: "+err.Error())
|
|
s.afterExit(t)
|
|
return
|
|
}
|
|
m := &Member{ID: spec.ID, AgentType: spec.AgentType, TaskID: spec.TaskID, handle: handle}
|
|
t.addMember(m)
|
|
_ = s.coord.SetStatus(spec.TaskID, StatusRunning)
|
|
go s.watch(t, m, spec)
|
|
}
|
|
|
|
// watch awaits a member, applies bounded relaunch on temporary failures, records
|
|
// the terminal outcome, then frees the slot and drains the queue.
|
|
//
|
|
// The relaunch counter is per-member (m.restarts) and the same spec is reused on
|
|
// retry. This is sound because a Member is bound 1:1 to its spec for its whole
|
|
// life (Member.ID == MemberSpec.ID); a retry never reuses the struct for a
|
|
// different spec.
|
|
func (s *Swarm) watch(t *Team, m *Member, spec MemberSpec) {
|
|
res, err := m.handle.Wait()
|
|
if err != nil {
|
|
if isRetryable(err) && m.restarts < maxMemberRestarts {
|
|
if nh, relErr := s.launcher.Launch(s.baseCtx, spec); relErr == nil {
|
|
m.restarts++
|
|
m.handle = nh
|
|
go s.watch(t, m, spec)
|
|
return
|
|
}
|
|
// fall through to record the original error if relaunch fails
|
|
}
|
|
// res.SessionID is preserved by the handle even on error, so a member that
|
|
// ran then failed stays drillable; a pure launch error carries an empty id.
|
|
_ = s.coord.FailWithSession(m.TaskID, memberError(err), res.SessionID)
|
|
} else {
|
|
_ = s.coord.CompleteWithSession(m.TaskID, res.Result, res.SessionID)
|
|
}
|
|
t.removeMember(m.ID)
|
|
s.afterExit(t)
|
|
}
|
|
|
|
// afterExit releases the just-vacated slot and launches the next queued spec, if
|
|
// any. Each exit drains at most one queued member; that member's own exit drains
|
|
// the next, so the queue empties one-per-slot without unbounded recursion.
|
|
func (s *Swarm) afterExit(t *Team) {
|
|
next, ok := t.onExit()
|
|
if !ok {
|
|
return
|
|
}
|
|
s.launch(t, next)
|
|
}
|
|
|
|
// Handoff transfers a task to a fresh member of toAgentType, delivering a note to
|
|
// the new member's inbox and marking the original task handed-off. It returns the
|
|
// new task id. A handoff of an already-terminal task is rejected (fail closed).
|
|
func (s *Swarm) Handoff(pol Policy, teamName, taskID, toAgentType, note string) (string, error) {
|
|
task, ok := s.coord.Get(taskID)
|
|
if !ok {
|
|
return "", fmt.Errorf("%w: %s", ErrUnknownTask, taskID)
|
|
}
|
|
if task.Status.terminal() {
|
|
return "", fmt.Errorf("swarm: task %s already %s; cannot hand off", taskID, task.Status)
|
|
}
|
|
def, err := s.registry.Lookup(toAgentType)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
team := sanitizeName(teamName)
|
|
newID := s.nextID(toAgentType)
|
|
handoffTask := task.Description
|
|
if note != "" {
|
|
handoffTask += "\n\nHandoff note: " + note
|
|
}
|
|
// Deliver the handoff note to the new member's inbox BEFORE registering the new
|
|
// task, so a mailbox failure can't leave a phantom pending task in the
|
|
// coordinator (it returns the error having registered nothing) (M5).
|
|
if note != "" {
|
|
if mbErr := s.mailbox.Send(team, newID, Message{
|
|
From: task.AgentID, Subject: "handoff", Body: note, Type: "handoff", Time: nowRFC3339(),
|
|
}); mbErr != nil {
|
|
return "", fmt.Errorf("swarm: deliver handoff note: %w", mbErr)
|
|
}
|
|
}
|
|
if _, err := s.coord.Register(newID, newID, team, handoffTask); err != nil {
|
|
return "", err
|
|
}
|
|
cwd := s.cwdFor(taskID)
|
|
s.rememberCwd(newID, cwd)
|
|
// Retire the original task (it has been re-delegated).
|
|
_ = s.coord.SetStatus(taskID, StatusHandedOff)
|
|
spec := s.buildSpec(pol, newID, newID, team, def, handoffTask, cwd)
|
|
s.dispatch(spec)
|
|
return newID, nil
|
|
}
|
|
|
|
// AdoptOrphans re-parents tasks in a team whose owning member is no longer live
|
|
// (e.g. a crashed worker) onto fresh members of toAgentType, returning the
|
|
// adopted task ids. Terminal tasks and tasks with a live owner are left alone.
|
|
func (s *Swarm) AdoptOrphans(pol Policy, teamName, toAgentType string) ([]string, error) {
|
|
def, err := s.registry.Lookup(toAgentType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
team := sanitizeName(teamName)
|
|
t := s.team(team)
|
|
live := t.liveAgents()
|
|
var adopted []string
|
|
for _, task := range s.coord.List() {
|
|
if task.Team != team || task.Status.terminal() {
|
|
continue
|
|
}
|
|
if _, ok := live[task.AgentID]; ok {
|
|
continue // still has a live member
|
|
}
|
|
newAgent := s.nextID(toAgentType)
|
|
if err := s.coord.Reassign(task.ID, newAgent); err != nil {
|
|
continue // raced to terminal; skip
|
|
}
|
|
cwd := s.cwdFor(task.ID)
|
|
s.rememberCwd(task.ID, cwd)
|
|
spec := s.buildSpec(pol, newAgent, task.ID, team, def, task.Description, cwd)
|
|
s.dispatch(spec)
|
|
adopted = append(adopted, task.ID)
|
|
}
|
|
return adopted, nil
|
|
}
|
|
|
|
// Collect returns snapshots of every task in a team, for swarm_collect/status.
|
|
func (s *Swarm) Collect(teamName string) []Task {
|
|
team := sanitizeName(teamName)
|
|
var out []Task
|
|
for _, task := range s.coord.List() {
|
|
if task.Team == team {
|
|
out = append(out, task)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CollectWait blocks until the team's tasks all reach a terminal state (or ctx is
|
|
// done), then returns their snapshots. This is what swarm_collect uses so the
|
|
// orchestrator gets final results in a single call instead of polling
|
|
// swarm_status repeatedly while members are still running.
|
|
func (s *Swarm) CollectWait(ctx context.Context, teamName string) []Task {
|
|
return s.coord.WaitSettled(ctx, sanitizeName(teamName))
|
|
}
|