Fix swarm inbox portability and failure updates
- commit
- 6b2855e8486cc919450f4b50e97338a8c243dac3
- author
- patriceckhart · · 2026-07-28 11:12 UTC
- parent
- 9eb66e1
- signature
- unsigned
7 files · +362 -86
A packages/agent/modes/auto_swarm_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 11 | + | "github.com/patriceckhart/zot/packages/core" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) { | |
| 15 | + | mgr := swarm.New(swarm.Config{ | |
| 16 | + | Root: t.TempDir(), | |
| 17 | + | RepoRoot: t.TempDir(), | |
| 18 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |
| 19 | + | return swarm.RunnerFunc(func(context.Context, swarm.Sink) error { | |
| 20 | + | return errors.New("listener startup failed") | |
| 21 | + | }) | |
| 22 | + | }, | |
| 23 | + | }) | |
| 24 | + | a, err := mgr.Spawn(context.Background(), "report the date") | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | iv := newQueuedAutoSwarmInteractive() | |
| 30 | + | iv.TrackSwarmAgent(a, a.Task) | |
| 31 | + | ||
| 32 | + | update := waitForQueuedPrompt(t, iv) | |
| 33 | + | if !strings.Contains(update, "status: failed") { | |
| 34 | + | t.Fatalf("update missing failed status: %q", update) | |
| 35 | + | } | |
| 36 | + | if !strings.Contains(update, "listener startup failed") { | |
| 37 | + | t.Fatalf("update missing startup error: %q", update) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) { | |
| 42 | + | started := make(chan struct{}) | |
| 43 | + | mgr := swarm.New(swarm.Config{ | |
| 44 | + | Root: t.TempDir(), | |
| 45 | + | RepoRoot: t.TempDir(), | |
| 46 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |
| 47 | + | return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error { | |
| 48 | + | close(started) | |
| 49 | + | <-ctx.Done() | |
| 50 | + | return ctx.Err() | |
| 51 | + | }) | |
| 52 | + | }, | |
| 53 | + | }) | |
| 54 | + | a, err := mgr.Spawn(context.Background(), "report the date") | |
| 55 | + | if err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | <-started | |
| 59 | + | t.Cleanup(func() { | |
| 60 | + | _ = mgr.Stop(a.ID) | |
| 61 | + | a.Wait() | |
| 62 | + | }) | |
| 63 | + | ||
| 64 | + | iv := newQueuedAutoSwarmInteractive() | |
| 65 | + | entry := &swarmWatchEntry{agent: a, task: a.Task} | |
| 66 | + | iv.swarmWatch = []*swarmWatchEntry{entry} | |
| 67 | + | iv.completeSwarmWatchEntry(entry, "completed", "") | |
| 68 | + | iv.completeSwarmWatchEntry(entry, "failed", "late error") | |
| 69 | + | ||
| 70 | + | update := waitForQueuedPrompt(t, iv) | |
| 71 | + | if !strings.Contains(update, "status: completed") { | |
| 72 | + | t.Fatalf("update uses daemon status instead of turn outcome: %q", update) | |
| 73 | + | } | |
| 74 | + | iv.mu.Lock() | |
| 75 | + | queued := len(iv.queued) | |
| 76 | + | iv.mu.Unlock() | |
| 77 | + | if queued != 1 { | |
| 78 | + | t.Fatalf("queued updates = %d; want exactly one", queued) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func newQueuedAutoSwarmInteractive() *Interactive { | |
| 83 | + | return &Interactive{ | |
| 84 | + | agent: &core.Agent{}, | |
| 85 | + | busy: true, | |
| 86 | + | compacting: true, | |
| 87 | + | dirty: make(chan struct{}, 1), | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func waitForQueuedPrompt(t *testing.T, iv *Interactive) string { | |
| 92 | + | t.Helper() | |
| 93 | + | deadline := time.NewTimer(time.Second) | |
| 94 | + | defer deadline.Stop() | |
| 95 | + | ticker := time.NewTicker(time.Millisecond) | |
| 96 | + | defer ticker.Stop() | |
| 97 | + | for { | |
| 98 | + | iv.mu.Lock() | |
| 99 | + | if len(iv.queued) > 0 { | |
| 100 | + | prompt := iv.queued[0] | |
| 101 | + | iv.mu.Unlock() | |
| 102 | + | return prompt | |
| 103 | + | } | |
| 104 | + | iv.mu.Unlock() | |
| 105 | + | select { | |
| 106 | + | case <-deadline.C: | |
| 107 | + | t.Fatal("timed out waiting for auto-swarm update") | |
| 108 | + | case <-ticker.C: | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) { | |||
| 15 | + | mgr := swarm.New(swarm.Config{ | |||
| 16 | + | Root: t.TempDir(), | |||
| 17 | + | RepoRoot: t.TempDir(), | |||
| 18 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |||
| 19 | + | return swarm.RunnerFunc(func(context.Context, swarm.Sink) error { | |||
| 20 | + | return errors.New("listener startup failed") | |||
| 21 | + | }) | |||
| 22 | + | }, | |||
| 23 | + | }) | |||
| 24 | + | a, err := mgr.Spawn(context.Background(), "report the date") | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | iv := newQueuedAutoSwarmInteractive() | |||
| 30 | + | iv.TrackSwarmAgent(a, a.Task) | |||
| 31 | + | ||||
| 32 | + | update := waitForQueuedPrompt(t, iv) | |||
| 33 | + | if !strings.Contains(update, "status: failed") { | |||
| 34 | + | t.Fatalf("update missing failed status: %q", update) | |||
| 35 | + | } | |||
| 36 | + | if !strings.Contains(update, "listener startup failed") { | |||
| 37 | + | t.Fatalf("update missing startup error: %q", update) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) { | |||
| 42 | + | started := make(chan struct{}) | |||
| 43 | + | mgr := swarm.New(swarm.Config{ | |||
| 44 | + | Root: t.TempDir(), | |||
| 45 | + | RepoRoot: t.TempDir(), | |||
| 46 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |||
| 47 | + | return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error { | |||
| 48 | + | close(started) | |||
| 49 | + | <-ctx.Done() | |||
| 50 | + | return ctx.Err() | |||
| 51 | + | }) | |||
| 52 | + | }, | |||
| 53 | + | }) | |||
| 54 | + | a, err := mgr.Spawn(context.Background(), "report the date") | |||
| 55 | + | if err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | <-started | |||
| 59 | + | t.Cleanup(func() { | |||
| 60 | + | _ = mgr.Stop(a.ID) | |||
| 61 | + | a.Wait() | |||
| 62 | + | }) | |||
| 63 | + | ||||
| 64 | + | iv := newQueuedAutoSwarmInteractive() | |||
| 65 | + | entry := &swarmWatchEntry{agent: a, task: a.Task} | |||
| 66 | + | iv.swarmWatch = []*swarmWatchEntry{entry} | |||
| 67 | + | iv.completeSwarmWatchEntry(entry, "completed", "") | |||
| 68 | + | iv.completeSwarmWatchEntry(entry, "failed", "late error") | |||
| 69 | + | ||||
| 70 | + | update := waitForQueuedPrompt(t, iv) | |||
| 71 | + | if !strings.Contains(update, "status: completed") { | |||
| 72 | + | t.Fatalf("update uses daemon status instead of turn outcome: %q", update) | |||
| 73 | + | } | |||
| 74 | + | iv.mu.Lock() | |||
| 75 | + | queued := len(iv.queued) | |||
| 76 | + | iv.mu.Unlock() | |||
| 77 | + | if queued != 1 { | |||
| 78 | + | t.Fatalf("queued updates = %d; want exactly one", queued) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func newQueuedAutoSwarmInteractive() *Interactive { | |||
| 83 | + | return &Interactive{ | |||
| 84 | + | agent: &core.Agent{}, | |||
| 85 | + | busy: true, | |||
| 86 | + | compacting: true, | |||
| 87 | + | dirty: make(chan struct{}, 1), | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func waitForQueuedPrompt(t *testing.T, iv *Interactive) string { | |||
| 92 | + | t.Helper() | |||
| 93 | + | deadline := time.NewTimer(time.Second) | |||
| 94 | + | defer deadline.Stop() | |||
| 95 | + | ticker := time.NewTicker(time.Millisecond) | |||
| 96 | + | defer ticker.Stop() | |||
| 97 | + | for { | |||
| 98 | + | iv.mu.Lock() | |||
| 99 | + | if len(iv.queued) > 0 { | |||
| 100 | + | prompt := iv.queued[0] | |||
| 101 | + | iv.mu.Unlock() | |||
| 102 | + | return prompt | |||
| 103 | + | } | |||
| 104 | + | iv.mu.Unlock() | |||
| 105 | + | select { | |||
| 106 | + | case <-deadline.C: | |||
| 107 | + | t.Fatal("timed out waiting for auto-swarm update") | |||
| 108 | + | case <-ticker.C: | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
M packages/agent/modes/interactive.go +53 -35
| @@ -5990,13 +5990,14 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 5990 | 5990 | return a.bridge != nil && a.bridge.Active() | |
| 5991 | 5991 | } | |
| 5992 | 5992 | ||
| 5993 | - | // swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at | |
| 5994 | - | // spawn time and finalised in trackSwarmAgent's waiter goroutine. | |
| 5993 | + | // swarmWatchEntry is one tracked auto-swarm sub-agent. It records the | |
| 5994 | + | // delegated turn's outcome separately from the long-lived daemon status. | |
| 5995 | 5995 | type swarmWatchEntry struct { | |
| 5996 | - | agent *swarm.Agent | |
| 5997 | - | task string | |
| 5998 | - | done bool | |
| 5999 | - | err string | |
| 5996 | + | agent *swarm.Agent | |
| 5997 | + | task string | |
| 5998 | + | done bool | |
| 5999 | + | outcome string | |
| 6000 | + | err string | |
| 6000 | 6001 | } | |
| 6001 | 6002 | ||
| 6002 | 6003 | // TrackSwarmAgent is the exported entry point used by the cli to | |
| @@ -6006,13 +6007,10 @@ func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) { | |||
| 6006 | 6007 | } | |
| 6007 | 6008 | ||
| 6008 | 6009 | // trackSwarmAgent records a freshly-spawned auto-swarm agent and | |
| 6009 | - | // subscribes to its prompt-level task completion events. Sub-agents | |
| 6010 | - | // are long-lived daemons that keep running on the inbox after the | |
| 6011 | - | // initial task, so we can't wait on agent.Wait() — it never returns | |
| 6012 | - | // until the whole daemon dies. Instead we mark each entry done when | |
| 6013 | - | // the swarm daemon reports the initial prompt has finished, and when | |
| 6014 | - | // every tracked entry has reported in, flush a single summary into | |
| 6015 | - | // the main chat. | |
| 6010 | + | // subscribes to both ways its delegated task can finish. Successful | |
| 6011 | + | // long-lived agents report a prompt-level turn_end and keep listening; | |
| 6012 | + | // startup failures and unexpected daemon exits only unblock Agent.Wait. | |
| 6013 | + | // Whichever signal arrives first completes the entry exactly once. | |
| 6016 | 6014 | // | |
| 6017 | 6015 | // Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto- | |
| 6018 | 6016 | // swarm is enabled, so this is a no-op when the feature is off. | |
| @@ -6026,31 +6024,48 @@ func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) { | |||
| 6026 | 6024 | i.swarmWatchMu.Unlock() | |
| 6027 | 6025 | ||
| 6028 | 6026 | a.SetOnTurnEnd(func(step int, errMsg string) { | |
| 6029 | - | i.swarmWatchMu.Lock() | |
| 6030 | - | if entry.done { | |
| 6031 | - | i.swarmWatchMu.Unlock() | |
| 6032 | - | return | |
| 6027 | + | outcome := "completed" | |
| 6028 | + | if errMsg != "" { | |
| 6029 | + | outcome = "failed" | |
| 6033 | 6030 | } | |
| 6034 | - | entry.done = true | |
| 6035 | - | entry.err = errMsg | |
| 6036 | - | allDone := true | |
| 6037 | - | for _, e := range i.swarmWatch { | |
| 6038 | - | if !e.done { | |
| 6039 | - | allDone = false | |
| 6040 | - | break | |
| 6041 | - | } | |
| 6042 | - | } | |
| 6043 | - | var batch []*swarmWatchEntry | |
| 6044 | - | if allDone { | |
| 6045 | - | batch = i.swarmWatch | |
| 6046 | - | i.swarmWatch = nil | |
| 6031 | + | i.completeSwarmWatchEntry(entry, outcome, errMsg) | |
| 6032 | + | }) | |
| 6033 | + | go func() { | |
| 6034 | + | a.Wait() | |
| 6035 | + | snap := a.Snapshot() | |
| 6036 | + | outcome := string(snap.Status) | |
| 6037 | + | if snap.Status == swarm.StatusDone { | |
| 6038 | + | outcome = "completed" | |
| 6047 | 6039 | } | |
| 6040 | + | i.completeSwarmWatchEntry(entry, outcome, snap.Err) | |
| 6041 | + | }() | |
| 6042 | + | } | |
| 6043 | + | ||
| 6044 | + | func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) { | |
| 6045 | + | i.swarmWatchMu.Lock() | |
| 6046 | + | if entry.done { | |
| 6048 | 6047 | i.swarmWatchMu.Unlock() | |
| 6049 | - | if len(batch) == 0 { | |
| 6050 | - | return | |
| 6048 | + | return | |
| 6049 | + | } | |
| 6050 | + | entry.done = true | |
| 6051 | + | entry.outcome = outcome | |
| 6052 | + | entry.err = errMsg | |
| 6053 | + | allDone := true | |
| 6054 | + | for _, e := range i.swarmWatch { | |
| 6055 | + | if !e.done { | |
| 6056 | + | allDone = false | |
| 6057 | + | break | |
| 6051 | 6058 | } | |
| 6059 | + | } | |
| 6060 | + | var batch []*swarmWatchEntry | |
| 6061 | + | if allDone { | |
| 6062 | + | batch = i.swarmWatch | |
| 6063 | + | i.swarmWatch = nil | |
| 6064 | + | } | |
| 6065 | + | i.swarmWatchMu.Unlock() | |
| 6066 | + | if len(batch) != 0 { | |
| 6052 | 6067 | i.flushSwarmSummary(batch) | |
| 6053 | - | }) | |
| 6068 | + | } | |
| 6054 | 6069 | } | |
| 6055 | 6070 | ||
| 6056 | 6071 | // flushSwarmSummary composes a synthetic user turn describing every | |
| @@ -6066,12 +6081,15 @@ func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) { | |||
| 6066 | 6081 | fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch)) | |
| 6067 | 6082 | for idx, e := range batch { | |
| 6068 | 6083 | snap := e.agent.Snapshot() | |
| 6069 | - | status := string(snap.Status) | |
| 6084 | + | status := e.outcome | |
| 6085 | + | if status == "" { | |
| 6086 | + | status = string(snap.Status) | |
| 6087 | + | } | |
| 6070 | 6088 | task := snap.Task | |
| 6071 | 6089 | if task == "" { | |
| 6072 | 6090 | task = e.task | |
| 6073 | 6091 | } | |
| 6074 | - | fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status) | |
| 6092 | + | fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status) | |
| 6075 | 6093 | fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240)) | |
| 6076 | 6094 | if snap.Err != "" { | |
| 6077 | 6095 | fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240)) | |
| @@ -5990,13 +5990,14 @@ func (a telegramSenderAdapter) Active() bool { | |||||
| 5990 | return a.bridge != nil && a.bridge.Active() | 5990 | return a.bridge != nil && a.bridge.Active() | ||
| 5991 | } | 5991 | } | ||
| 5992 | 5992 | ||||
| 5993 | - | // swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at | 5993 | + | // swarmWatchEntry is one tracked auto-swarm sub-agent. It records the |
| 5994 | - | // spawn time and finalised in trackSwarmAgent's waiter goroutine. | 5994 | + | // delegated turn's outcome separately from the long-lived daemon status. |
| 5995 | type swarmWatchEntry struct { | 5995 | type swarmWatchEntry struct { | ||
| 5996 | - | agent *swarm.Agent | 5996 | + | agent *swarm.Agent |
| 5997 | - | task string | 5997 | + | task string |
| 5998 | - | done bool | 5998 | + | done bool |
| 5999 | - | err string | 5999 | + | outcome string |
| 6000 | + | err string | |||
| 6000 | } | 6001 | } | ||
| 6001 | 6002 | ||||
| 6002 | // TrackSwarmAgent is the exported entry point used by the cli to | 6003 | // TrackSwarmAgent is the exported entry point used by the cli to | ||
| @@ -6006,13 +6007,10 @@ func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) { | |||||
| 6006 | } | 6007 | } | ||
| 6007 | 6008 | ||||
| 6008 | // trackSwarmAgent records a freshly-spawned auto-swarm agent and | 6009 | // trackSwarmAgent records a freshly-spawned auto-swarm agent and | ||
| 6009 | - | // subscribes to its prompt-level task completion events. Sub-agents | 6010 | + | // subscribes to both ways its delegated task can finish. Successful |
| 6010 | - | // are long-lived daemons that keep running on the inbox after the | 6011 | + | // long-lived agents report a prompt-level turn_end and keep listening; |
| 6011 | - | // initial task, so we can't wait on agent.Wait() — it never returns | 6012 | + | // startup failures and unexpected daemon exits only unblock Agent.Wait. |
| 6012 | - | // until the whole daemon dies. Instead we mark each entry done when | 6013 | + | // Whichever signal arrives first completes the entry exactly once. |
| 6013 | - | // the swarm daemon reports the initial prompt has finished, and when | |||
| 6014 | - | // every tracked entry has reported in, flush a single summary into | |||
| 6015 | - | // the main chat. | |||
| 6016 | // | 6014 | // | ||
| 6017 | // Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto- | 6015 | // Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto- | ||
| 6018 | // swarm is enabled, so this is a no-op when the feature is off. | 6016 | // swarm is enabled, so this is a no-op when the feature is off. | ||
| @@ -6026,31 +6024,48 @@ func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) { | |||||
| 6026 | i.swarmWatchMu.Unlock() | 6024 | i.swarmWatchMu.Unlock() | ||
| 6027 | 6025 | ||||
| 6028 | a.SetOnTurnEnd(func(step int, errMsg string) { | 6026 | a.SetOnTurnEnd(func(step int, errMsg string) { | ||
| 6029 | - | i.swarmWatchMu.Lock() | 6027 | + | outcome := "completed" |
| 6030 | - | if entry.done { | 6028 | + | if errMsg != "" { |
| 6031 | - | i.swarmWatchMu.Unlock() | 6029 | + | outcome = "failed" |
| 6032 | - | return | |||
| 6033 | } | 6030 | } | ||
| 6034 | - | entry.done = true | 6031 | + | i.completeSwarmWatchEntry(entry, outcome, errMsg) |
| 6035 | - | entry.err = errMsg | 6032 | + | }) |
| 6036 | - | allDone := true | 6033 | + | go func() { |
| 6037 | - | for _, e := range i.swarmWatch { | 6034 | + | a.Wait() |
| 6038 | - | if !e.done { | 6035 | + | snap := a.Snapshot() |
| 6039 | - | allDone = false | 6036 | + | outcome := string(snap.Status) |
| 6040 | - | break | 6037 | + | if snap.Status == swarm.StatusDone { |
| 6041 | - | } | 6038 | + | outcome = "completed" |
| 6042 | - | } | |||
| 6043 | - | var batch []*swarmWatchEntry | |||
| 6044 | - | if allDone { | |||
| 6045 | - | batch = i.swarmWatch | |||
| 6046 | - | i.swarmWatch = nil | |||
| 6047 | } | 6039 | } | ||
| 6040 | + | i.completeSwarmWatchEntry(entry, outcome, snap.Err) | |||
| 6041 | + | }() | |||
| 6042 | + | } | |||
| 6043 | + | ||||
| 6044 | + | func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) { | |||
| 6045 | + | i.swarmWatchMu.Lock() | |||
| 6046 | + | if entry.done { | |||
| 6048 | i.swarmWatchMu.Unlock() | 6047 | i.swarmWatchMu.Unlock() | ||
| 6049 | - | if len(batch) == 0 { | 6048 | + | return |
| 6050 | - | return | 6049 | + | } |
| 6050 | + | entry.done = true | |||
| 6051 | + | entry.outcome = outcome | |||
| 6052 | + | entry.err = errMsg | |||
| 6053 | + | allDone := true | |||
| 6054 | + | for _, e := range i.swarmWatch { | |||
| 6055 | + | if !e.done { | |||
| 6056 | + | allDone = false | |||
| 6057 | + | break | |||
| 6051 | } | 6058 | } | ||
| 6059 | + | } | |||
| 6060 | + | var batch []*swarmWatchEntry | |||
| 6061 | + | if allDone { | |||
| 6062 | + | batch = i.swarmWatch | |||
| 6063 | + | i.swarmWatch = nil | |||
| 6064 | + | } | |||
| 6065 | + | i.swarmWatchMu.Unlock() | |||
| 6066 | + | if len(batch) != 0 { | |||
| 6052 | i.flushSwarmSummary(batch) | 6067 | i.flushSwarmSummary(batch) | ||
| 6053 | - | }) | 6068 | + | } |
| 6054 | } | 6069 | } | ||
| 6055 | 6070 | ||||
| 6056 | // flushSwarmSummary composes a synthetic user turn describing every | 6071 | // flushSwarmSummary composes a synthetic user turn describing every | ||
| @@ -6066,12 +6081,15 @@ func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) { | |||||
| 6066 | fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch)) | 6081 | fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch)) | ||
| 6067 | for idx, e := range batch { | 6082 | for idx, e := range batch { | ||
| 6068 | snap := e.agent.Snapshot() | 6083 | snap := e.agent.Snapshot() | ||
| 6069 | - | status := string(snap.Status) | 6084 | + | status := e.outcome |
| 6085 | + | if status == "" { | |||
| 6086 | + | status = string(snap.Status) | |||
| 6087 | + | } | |||
| 6070 | task := snap.Task | 6088 | task := snap.Task | ||
| 6071 | if task == "" { | 6089 | if task == "" { | ||
| 6072 | task = e.task | 6090 | task = e.task | ||
| 6073 | } | 6091 | } | ||
| 6074 | - | fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status) | 6092 | + | fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status) |
| 6075 | fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240)) | 6093 | fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240)) | ||
| 6076 | if snap.Err != "" { | 6094 | if snap.Err != "" { | ||
| 6077 | fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240)) | 6095 | fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240)) | ||
M packages/agent/swarm/persist.go +11 -8
| @@ -314,9 +314,9 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 314 | 314 | } | |
| 315 | 315 | } | |
| 316 | 316 | ||
| 317 | - | // Resume re-attaches a Runner to a previously-spawned agent. The | |
| 318 | - | // existing worktree, session file, branch, and inbox path are kept; | |
| 319 | - | // only the in-memory Agent and its runner are replaced. Use this to | |
| 317 | + | // Resume re-attaches a Runner to a previously-spawned agent. Durable | |
| 318 | + | // session and event files are kept, while the transient inbox path is | |
| 319 | + | // recalculated for the current runtime environment. Use this to | |
| 320 | 320 | // continue a swarm session across zot restarts: | |
| 321 | 321 | // | |
| 322 | 322 | // swarmMgr.Reload() | |
| @@ -339,15 +339,18 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | 341 | // Rebuild from the meta record so we don't carry stale runner | |
| 342 | - | // state from a previous incarnation. We re-read meta.json rather | |
| 343 | - | // than reusing the live struct's fields so callers that mutated | |
| 344 | - | // (e.g. tests that hand-built an Agent) don't accidentally route | |
| 345 | - | // the new runner at the wrong paths. | |
| 342 | + | // state from a previous incarnation. The inbox is transient and | |
| 343 | + | // may have been persisted under an incompatible filesystem by an | |
| 344 | + | // older zot version, so always select it again on resume. | |
| 345 | + | inboxPath, err := inboxSocketPath(f.cfg.Root, existing.ID) | |
| 346 | + | if err != nil { | |
| 347 | + | return nil, fmt.Errorf("swarm inbox path: %w", err) | |
| 348 | + | } | |
| 346 | 349 | m := agentMeta{ | |
| 347 | 350 | ID: existing.ID, Task: existing.Task, | |
| 348 | 351 | Dir: existing.Dir, Started: existing.Started, | |
| 349 | 352 | Model: existing.Model, Provider: existing.Provider, | |
| 350 | - | InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, | |
| 353 | + | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |
| 351 | 354 | SessionPath: existing.SessionPath, | |
| 352 | 355 | } | |
| 353 | 356 | ||
| @@ -314,9 +314,9 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||||
| 314 | } | 314 | } | ||
| 315 | } | 315 | } | ||
| 316 | 316 | ||||
| 317 | - | // Resume re-attaches a Runner to a previously-spawned agent. The | 317 | + | // Resume re-attaches a Runner to a previously-spawned agent. Durable |
| 318 | - | // existing worktree, session file, branch, and inbox path are kept; | 318 | + | // session and event files are kept, while the transient inbox path is |
| 319 | - | // only the in-memory Agent and its runner are replaced. Use this to | 319 | + | // recalculated for the current runtime environment. Use this to |
| 320 | // continue a swarm session across zot restarts: | 320 | // continue a swarm session across zot restarts: | ||
| 321 | // | 321 | // | ||
| 322 | // swarmMgr.Reload() | 322 | // swarmMgr.Reload() | ||
| @@ -339,15 +339,18 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 339 | } | 339 | } | ||
| 340 | 340 | ||||
| 341 | // Rebuild from the meta record so we don't carry stale runner | 341 | // Rebuild from the meta record so we don't carry stale runner | ||
| 342 | - | // state from a previous incarnation. We re-read meta.json rather | 342 | + | // state from a previous incarnation. The inbox is transient and |
| 343 | - | // than reusing the live struct's fields so callers that mutated | 343 | + | // may have been persisted under an incompatible filesystem by an |
| 344 | - | // (e.g. tests that hand-built an Agent) don't accidentally route | 344 | + | // older zot version, so always select it again on resume. |
| 345 | - | // the new runner at the wrong paths. | 345 | + | inboxPath, err := inboxSocketPath(f.cfg.Root, existing.ID) |
| 346 | + | if err != nil { | |||
| 347 | + | return nil, fmt.Errorf("swarm inbox path: %w", err) | |||
| 348 | + | } | |||
| 346 | m := agentMeta{ | 349 | m := agentMeta{ | ||
| 347 | ID: existing.ID, Task: existing.Task, | 350 | ID: existing.ID, Task: existing.Task, | ||
| 348 | Dir: existing.Dir, Started: existing.Started, | 351 | Dir: existing.Dir, Started: existing.Started, | ||
| 349 | Model: existing.Model, Provider: existing.Provider, | 352 | Model: existing.Model, Provider: existing.Provider, | ||
| 350 | - | InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, | 353 | + | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, |
| 351 | SessionPath: existing.SessionPath, | 354 | SessionPath: existing.SessionPath, | ||
| 352 | } | 355 | } | ||
| 353 | 356 | ||||
M packages/agent/swarm/persist_test.go +30 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "sync/atomic" | |
| 11 | 12 | "testing" | |
| @@ -342,6 +343,35 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||
| 342 | 343 | } | |
| 343 | 344 | } | |
| 344 | 345 | ||
| 346 | + | func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) { | |
| 347 | + | root := t.TempDir() | |
| 348 | + | f := New(Config{ | |
| 349 | + | Root: root, RepoRoot: root, | |
| 350 | + | NewRunner: func(*Agent) Runner { | |
| 351 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 352 | + | }, | |
| 353 | + | }) | |
| 354 | + | a, err := f.Spawn(context.Background(), "legacy inbox") | |
| 355 | + | if err != nil { | |
| 356 | + | t.Fatal(err) | |
| 357 | + | } | |
| 358 | + | a.Wait() | |
| 359 | + | ||
| 360 | + | legacyPath := filepath.Join(root, "agents", a.ID, "in.sock") | |
| 361 | + | a.InboxPath = legacyPath | |
| 362 | + | a2, err := f.Resume(context.Background(), a.ID) | |
| 363 | + | if err != nil { | |
| 364 | + | t.Fatalf("resume: %v", err) | |
| 365 | + | } | |
| 366 | + | a2.Wait() | |
| 367 | + | if a2.InboxPath == legacyPath { | |
| 368 | + | t.Fatalf("resume kept legacy state-directory inbox path %q", legacyPath) | |
| 369 | + | } | |
| 370 | + | if strings.HasPrefix(a2.InboxPath, root+string(filepath.Separator)) { | |
| 371 | + | t.Fatalf("resumed inbox %q remains below state root %q", a2.InboxPath, root) | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | ||
| 345 | 375 | // TestResumeSetsResumingFlag pins the contract execRunner relies on: | |
| 346 | 376 | // after Resume, the new Agent has Resuming == true; after a fresh | |
| 347 | 377 | // Spawn it's false. The runner branches on this to decide whether | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "sync/atomic" | 11 | "sync/atomic" | ||
| 11 | "testing" | 12 | "testing" | ||
| @@ -342,6 +343,35 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||||
| 342 | } | 343 | } | ||
| 343 | } | 344 | } | ||
| 344 | 345 | ||||
| 346 | + | func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) { | |||
| 347 | + | root := t.TempDir() | |||
| 348 | + | f := New(Config{ | |||
| 349 | + | Root: root, RepoRoot: root, | |||
| 350 | + | NewRunner: func(*Agent) Runner { | |||
| 351 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 352 | + | }, | |||
| 353 | + | }) | |||
| 354 | + | a, err := f.Spawn(context.Background(), "legacy inbox") | |||
| 355 | + | if err != nil { | |||
| 356 | + | t.Fatal(err) | |||
| 357 | + | } | |||
| 358 | + | a.Wait() | |||
| 359 | + | ||||
| 360 | + | legacyPath := filepath.Join(root, "agents", a.ID, "in.sock") | |||
| 361 | + | a.InboxPath = legacyPath | |||
| 362 | + | a2, err := f.Resume(context.Background(), a.ID) | |||
| 363 | + | if err != nil { | |||
| 364 | + | t.Fatalf("resume: %v", err) | |||
| 365 | + | } | |||
| 366 | + | a2.Wait() | |||
| 367 | + | if a2.InboxPath == legacyPath { | |||
| 368 | + | t.Fatalf("resume kept legacy state-directory inbox path %q", legacyPath) | |||
| 369 | + | } | |||
| 370 | + | if strings.HasPrefix(a2.InboxPath, root+string(filepath.Separator)) { | |||
| 371 | + | t.Fatalf("resumed inbox %q remains below state root %q", a2.InboxPath, root) | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | ||||
| 345 | // TestResumeSetsResumingFlag pins the contract execRunner relies on: | 375 | // TestResumeSetsResumingFlag pins the contract execRunner relies on: | ||
| 346 | // after Resume, the new Agent has Resuming == true; after a fresh | 376 | // after Resume, the new Agent has Resuming == true; after a fresh | ||
| 347 | // Spawn it's false. The runner branches on this to decide whether | 377 | // Spawn it's false. The runner branches on this to decide whether | ||
M packages/agent/swarm/socketpath.go +82 -34
| @@ -3,7 +3,9 @@ package swarm | |||
| 3 | 3 | import ( | |
| 4 | 4 | "crypto/sha1" | |
| 5 | 5 | "encoding/hex" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 8 | + | "net" | |
| 7 | 9 | "os" | |
| 8 | 10 | "path/filepath" | |
| 9 | 11 | "runtime" | |
| @@ -15,52 +17,98 @@ import ( | |||
| 15 | 17 | // tail stays under both caps with a safety margin. | |
| 16 | 18 | const maxUnixSocketPath = 100 | |
| 17 | 19 | ||
| 18 | - | // inboxSocketPath returns a per-agent unix-socket path that's short | |
| 19 | - | // enough to actually work (see maxUnixSocketPath) and unique per | |
| 20 | - | // swarm root so two zot instances on the same machine don't collide. | |
| 20 | + | // inboxSocketPath returns a per-agent unix-socket path in a transient | |
| 21 | + | // runtime directory. Durable swarm state can live on filesystems such as | |
| 22 | + | // NFS, 9p, or shared VM mounts that support regular files but reject unix | |
| 23 | + | // socket nodes, so the socket must not be placed below root. | |
| 21 | 24 | // | |
| 22 | - | // Strategy: | |
| 25 | + | // Candidate directories are tried in this order: | |
| 23 | 26 | // | |
| 24 | - | // 1. Try <root>/agents/<id>/in.sock. This is the obvious place and | |
| 25 | - | // puts everything next to the durable state; on most setups it | |
| 26 | - | // fits. | |
| 27 | - | // 2. If that's too long, fall back to <tmp>/zot-swarm-<roothash>/<id>.sock. | |
| 28 | - | // We hash root rather than embedding it so the tmp directory name | |
| 29 | - | // stays short. SHA-1's first 8 hex chars is plenty: collisions | |
| 30 | - | // only matter within a single user's tmp dir and we already | |
| 31 | - | // create a dedicated subdir. | |
| 32 | - | // 3. If even /tmp is somehow too long (chroots, containers), give | |
| 33 | - | // up with a clear error so the caller surfaces it instead of | |
| 34 | - | // leaving the user wondering why follow-ups don't work. | |
| 27 | + | // 1. $XDG_RUNTIME_DIR, when absolute; | |
| 28 | + | // 2. the platform temporary directory; and | |
| 29 | + | // 3. /tmp as a Unix fallback. | |
| 30 | + | // | |
| 31 | + | // Each candidate is probed with a real unix listener before it is selected. | |
| 32 | + | // The root hash keeps separate zot homes from colliding while preserving a | |
| 33 | + | // stable path across parent and child processes and across Resume calls. | |
| 35 | 34 | func inboxSocketPath(root, agentID string) (string, error) { | |
| 36 | - | primary := filepath.Join(root, "agents", agentID, "in.sock") | |
| 37 | - | if len(primary) <= maxUnixSocketPath { | |
| 38 | - | return primary, nil | |
| 35 | + | if runtime.GOOS == "windows" { | |
| 36 | + | return "", fmt.Errorf("swarm inbox: unix sockets are not supported on %s", runtime.GOOS) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | var bases []string | |
| 40 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtimeDir) { | |
| 41 | + | bases = append(bases, runtimeDir) | |
| 39 | 42 | } | |
| 40 | - | tmp := os.TempDir() | |
| 41 | - | dir := filepath.Join(tmp, "zot-swarm-"+rootTag(root)) | |
| 43 | + | bases = append(bases, os.TempDir(), "/tmp") | |
| 44 | + | ||
| 45 | + | seen := make(map[string]bool) | |
| 46 | + | var candidateErrs []error | |
| 47 | + | for _, base := range bases { | |
| 48 | + | base = filepath.Clean(base) | |
| 49 | + | if base == "." || seen[base] { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | seen[base] = true | |
| 53 | + | ||
| 54 | + | path, err := socketPathInBase(base, root, agentID) | |
| 55 | + | if err != nil { | |
| 56 | + | candidateErrs = append(candidateErrs, err) | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | return path, nil | |
| 60 | + | } | |
| 61 | + | return "", fmt.Errorf("no usable unix socket directory: %w", errors.Join(candidateErrs...)) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func socketPathInBase(base, root, agentID string) (string, error) { | |
| 65 | + | dir := filepath.Join(base, "zot-swarm-"+rootTag(root)) | |
| 42 | 66 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 43 | - | return "", fmt.Errorf("socket tmp dir: %w", err) | |
| 67 | + | return "", fmt.Errorf("socket dir %s: %w", dir, err) | |
| 44 | 68 | } | |
| 69 | + | ||
| 45 | 70 | candidate := filepath.Join(dir, agentID+".sock") | |
| 46 | - | if len(candidate) <= maxUnixSocketPath { | |
| 47 | - | return candidate, nil | |
| 71 | + | if len(candidate) > maxUnixSocketPath { | |
| 72 | + | candidate = filepath.Join(dir, shortHash(agentID)+".sock") | |
| 73 | + | } | |
| 74 | + | if len(candidate) > maxUnixSocketPath { | |
| 75 | + | return "", fmt.Errorf("unix socket path too long (%s, %d > %d)", candidate, len(candidate), maxUnixSocketPath) | |
| 76 | + | } | |
| 77 | + | if err := probeUnixSocket(dir); err != nil { | |
| 78 | + | return "", fmt.Errorf("unix sockets unavailable in %s: %w", dir, err) | |
| 79 | + | } | |
| 80 | + | return candidate, nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func probeUnixSocket(dir string) error { | |
| 84 | + | probe, err := os.CreateTemp(dir, ".socket-probe-") | |
| 85 | + | if err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | path := probe.Name() | |
| 89 | + | if err := probe.Close(); err != nil { | |
| 90 | + | _ = os.Remove(path) | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | if err := os.Remove(path); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | ln, err := net.Listen("unix", path) | |
| 98 | + | if err != nil { | |
| 99 | + | return err | |
| 48 | 100 | } | |
| 49 | - | // Last-resort: use just the short hash of the id so even very long | |
| 50 | - | // task slugs fit. We surface the original id in the meta.json / | |
| 51 | - | // events log; the socket path is purely transport. | |
| 52 | - | short := shortHash(agentID) | |
| 53 | - | candidate = filepath.Join(dir, short+".sock") | |
| 54 | - | if len(candidate) <= maxUnixSocketPath { | |
| 55 | - | return candidate, nil | |
| 101 | + | closeErr := ln.Close() | |
| 102 | + | removeErr := os.Remove(path) | |
| 103 | + | if os.IsNotExist(removeErr) { | |
| 104 | + | removeErr = nil | |
| 56 | 105 | } | |
| 57 | - | return "", fmt.Errorf("unix socket path too long even after shortening (%s, %d > %d, GOOS=%s)", | |
| 58 | - | candidate, len(candidate), maxUnixSocketPath, runtime.GOOS) | |
| 106 | + | return errors.Join(closeErr, removeErr) | |
| 59 | 107 | } | |
| 60 | 108 | ||
| 61 | 109 | // rootTag returns a stable 8-hex-char tag for the swarm root. Used | |
| 62 | - | // in the tmp-dir name so two parallel zot instances with different | |
| 63 | - | // roots don't share sockets. | |
| 110 | + | // in the runtime-directory name so two parallel zot instances with | |
| 111 | + | // different roots don't share sockets. | |
| 64 | 112 | func rootTag(root string) string { return shortHash(root) } | |
| 65 | 113 | ||
| 66 | 114 | func shortHash(s string) string { | |
| @@ -3,7 +3,9 @@ package swarm | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "crypto/sha1" | 4 | "crypto/sha1" | ||
| 5 | "encoding/hex" | 5 | "encoding/hex" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 8 | + | "net" | |||
| 7 | "os" | 9 | "os" | ||
| 8 | "path/filepath" | 10 | "path/filepath" | ||
| 9 | "runtime" | 11 | "runtime" | ||
| @@ -15,52 +17,98 @@ import ( | |||||
| 15 | // tail stays under both caps with a safety margin. | 17 | // tail stays under both caps with a safety margin. | ||
| 16 | const maxUnixSocketPath = 100 | 18 | const maxUnixSocketPath = 100 | ||
| 17 | 19 | ||||
| 18 | - | // inboxSocketPath returns a per-agent unix-socket path that's short | 20 | + | // inboxSocketPath returns a per-agent unix-socket path in a transient |
| 19 | - | // enough to actually work (see maxUnixSocketPath) and unique per | 21 | + | // runtime directory. Durable swarm state can live on filesystems such as |
| 20 | - | // swarm root so two zot instances on the same machine don't collide. | 22 | + | // NFS, 9p, or shared VM mounts that support regular files but reject unix |
| 23 | + | // socket nodes, so the socket must not be placed below root. | |||
| 21 | // | 24 | // | ||
| 22 | - | // Strategy: | 25 | + | // Candidate directories are tried in this order: |
| 23 | // | 26 | // | ||
| 24 | - | // 1. Try <root>/agents/<id>/in.sock. This is the obvious place and | 27 | + | // 1. $XDG_RUNTIME_DIR, when absolute; |
| 25 | - | // puts everything next to the durable state; on most setups it | 28 | + | // 2. the platform temporary directory; and |
| 26 | - | // fits. | 29 | + | // 3. /tmp as a Unix fallback. |
| 27 | - | // 2. If that's too long, fall back to <tmp>/zot-swarm-<roothash>/<id>.sock. | 30 | + | // |
| 28 | - | // We hash root rather than embedding it so the tmp directory name | 31 | + | // Each candidate is probed with a real unix listener before it is selected. |
| 29 | - | // stays short. SHA-1's first 8 hex chars is plenty: collisions | 32 | + | // The root hash keeps separate zot homes from colliding while preserving a |
| 30 | - | // only matter within a single user's tmp dir and we already | 33 | + | // stable path across parent and child processes and across Resume calls. |
| 31 | - | // create a dedicated subdir. | |||
| 32 | - | // 3. If even /tmp is somehow too long (chroots, containers), give | |||
| 33 | - | // up with a clear error so the caller surfaces it instead of | |||
| 34 | - | // leaving the user wondering why follow-ups don't work. | |||
| 35 | func inboxSocketPath(root, agentID string) (string, error) { | 34 | func inboxSocketPath(root, agentID string) (string, error) { | ||
| 36 | - | primary := filepath.Join(root, "agents", agentID, "in.sock") | 35 | + | if runtime.GOOS == "windows" { |
| 37 | - | if len(primary) <= maxUnixSocketPath { | 36 | + | return "", fmt.Errorf("swarm inbox: unix sockets are not supported on %s", runtime.GOOS) |
| 38 | - | return primary, nil | 37 | + | } |
| 38 | + | ||||
| 39 | + | var bases []string | |||
| 40 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtimeDir) { | |||
| 41 | + | bases = append(bases, runtimeDir) | |||
| 39 | } | 42 | } | ||
| 40 | - | tmp := os.TempDir() | 43 | + | bases = append(bases, os.TempDir(), "/tmp") |
| 41 | - | dir := filepath.Join(tmp, "zot-swarm-"+rootTag(root)) | 44 | + | |
| 45 | + | seen := make(map[string]bool) | |||
| 46 | + | var candidateErrs []error | |||
| 47 | + | for _, base := range bases { | |||
| 48 | + | base = filepath.Clean(base) | |||
| 49 | + | if base == "." || seen[base] { | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | seen[base] = true | |||
| 53 | + | ||||
| 54 | + | path, err := socketPathInBase(base, root, agentID) | |||
| 55 | + | if err != nil { | |||
| 56 | + | candidateErrs = append(candidateErrs, err) | |||
| 57 | + | continue | |||
| 58 | + | } | |||
| 59 | + | return path, nil | |||
| 60 | + | } | |||
| 61 | + | return "", fmt.Errorf("no usable unix socket directory: %w", errors.Join(candidateErrs...)) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func socketPathInBase(base, root, agentID string) (string, error) { | |||
| 65 | + | dir := filepath.Join(base, "zot-swarm-"+rootTag(root)) | |||
| 42 | if err := os.MkdirAll(dir, 0o700); err != nil { | 66 | if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| 43 | - | return "", fmt.Errorf("socket tmp dir: %w", err) | 67 | + | return "", fmt.Errorf("socket dir %s: %w", dir, err) |
| 44 | } | 68 | } | ||
| 69 | + | ||||
| 45 | candidate := filepath.Join(dir, agentID+".sock") | 70 | candidate := filepath.Join(dir, agentID+".sock") | ||
| 46 | - | if len(candidate) <= maxUnixSocketPath { | 71 | + | if len(candidate) > maxUnixSocketPath { |
| 47 | - | return candidate, nil | 72 | + | candidate = filepath.Join(dir, shortHash(agentID)+".sock") |
| 73 | + | } | |||
| 74 | + | if len(candidate) > maxUnixSocketPath { | |||
| 75 | + | return "", fmt.Errorf("unix socket path too long (%s, %d > %d)", candidate, len(candidate), maxUnixSocketPath) | |||
| 76 | + | } | |||
| 77 | + | if err := probeUnixSocket(dir); err != nil { | |||
| 78 | + | return "", fmt.Errorf("unix sockets unavailable in %s: %w", dir, err) | |||
| 79 | + | } | |||
| 80 | + | return candidate, nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func probeUnixSocket(dir string) error { | |||
| 84 | + | probe, err := os.CreateTemp(dir, ".socket-probe-") | |||
| 85 | + | if err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | path := probe.Name() | |||
| 89 | + | if err := probe.Close(); err != nil { | |||
| 90 | + | _ = os.Remove(path) | |||
| 91 | + | return err | |||
| 92 | + | } | |||
| 93 | + | if err := os.Remove(path); err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | ln, err := net.Listen("unix", path) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return err | |||
| 48 | } | 100 | } | ||
| 49 | - | // Last-resort: use just the short hash of the id so even very long | 101 | + | closeErr := ln.Close() |
| 50 | - | // task slugs fit. We surface the original id in the meta.json / | 102 | + | removeErr := os.Remove(path) |
| 51 | - | // events log; the socket path is purely transport. | 103 | + | if os.IsNotExist(removeErr) { |
| 52 | - | short := shortHash(agentID) | 104 | + | removeErr = nil |
| 53 | - | candidate = filepath.Join(dir, short+".sock") | |||
| 54 | - | if len(candidate) <= maxUnixSocketPath { | |||
| 55 | - | return candidate, nil | |||
| 56 | } | 105 | } | ||
| 57 | - | return "", fmt.Errorf("unix socket path too long even after shortening (%s, %d > %d, GOOS=%s)", | 106 | + | return errors.Join(closeErr, removeErr) |
| 58 | - | candidate, len(candidate), maxUnixSocketPath, runtime.GOOS) | |||
| 59 | } | 107 | } | ||
| 60 | 108 | ||||
| 61 | // rootTag returns a stable 8-hex-char tag for the swarm root. Used | 109 | // rootTag returns a stable 8-hex-char tag for the swarm root. Used | ||
| 62 | - | // in the tmp-dir name so two parallel zot instances with different | 110 | + | // in the runtime-directory name so two parallel zot instances with |
| 63 | - | // roots don't share sockets. | 111 | + | // different roots don't share sockets. |
| 64 | func rootTag(root string) string { return shortHash(root) } | 112 | func rootTag(root string) string { return shortHash(root) } | ||
| 65 | 113 | ||||
| 66 | func shortHash(s string) string { | 114 | func shortHash(s string) string { | ||
A packages/agent/swarm/socketpath_test.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "runtime" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) { | |
| 12 | + | if runtime.GOOS == "windows" { | |
| 13 | + | t.Skip("swarm inbox transport uses Unix-domain sockets") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | runtimeDir := shortSocketDir(t) | |
| 17 | + | stateRoot := filepath.Join(t.TempDir(), "state") | |
| 18 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | |
| 19 | + | ||
| 20 | + | path, err := inboxSocketPath(stateRoot, "agent-123") | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatalf("inboxSocketPath: %v", err) | |
| 23 | + | } | |
| 24 | + | if !strings.HasPrefix(path, runtimeDir+string(filepath.Separator)) { | |
| 25 | + | t.Fatalf("path = %q; want it below runtime dir %q", path, runtimeDir) | |
| 26 | + | } | |
| 27 | + | if strings.HasPrefix(path, stateRoot+string(filepath.Separator)) { | |
| 28 | + | t.Fatalf("path = %q; transient socket must not be below state root %q", path, stateRoot) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) { | |
| 33 | + | if runtime.GOOS == "windows" { | |
| 34 | + | t.Skip("swarm inbox transport uses Unix-domain sockets") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | badRuntime := filepath.Join(shortSocketDir(t), "not-a-directory") | |
| 38 | + | if err := os.WriteFile(badRuntime, []byte("x"), 0o600); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | tempDir := shortSocketDir(t) | |
| 42 | + | t.Setenv("XDG_RUNTIME_DIR", badRuntime) | |
| 43 | + | t.Setenv("TMPDIR", tempDir) | |
| 44 | + | ||
| 45 | + | path, err := inboxSocketPath(t.TempDir(), "agent-456") | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatalf("inboxSocketPath: %v", err) | |
| 48 | + | } | |
| 49 | + | if !strings.HasPrefix(path, tempDir+string(filepath.Separator)) { | |
| 50 | + | t.Fatalf("path = %q; want fallback below %q", path, tempDir) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestInboxSocketPathShortensLongAgentID(t *testing.T) { | |
| 55 | + | if runtime.GOOS == "windows" { | |
| 56 | + | t.Skip("swarm inbox transport uses Unix-domain sockets") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | t.Setenv("XDG_RUNTIME_DIR", shortSocketDir(t)) | |
| 60 | + | path, err := inboxSocketPath(t.TempDir(), strings.Repeat("agent", 100)) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatalf("inboxSocketPath: %v", err) | |
| 63 | + | } | |
| 64 | + | if len(path) > maxUnixSocketPath { | |
| 65 | + | t.Fatalf("socket path length = %d; want <= %d: %q", len(path), maxUnixSocketPath, path) | |
| 66 | + | } | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "runtime" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) { | |||
| 12 | + | if runtime.GOOS == "windows" { | |||
| 13 | + | t.Skip("swarm inbox transport uses Unix-domain sockets") | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | runtimeDir := shortSocketDir(t) | |||
| 17 | + | stateRoot := filepath.Join(t.TempDir(), "state") | |||
| 18 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | |||
| 19 | + | ||||
| 20 | + | path, err := inboxSocketPath(stateRoot, "agent-123") | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatalf("inboxSocketPath: %v", err) | |||
| 23 | + | } | |||
| 24 | + | if !strings.HasPrefix(path, runtimeDir+string(filepath.Separator)) { | |||
| 25 | + | t.Fatalf("path = %q; want it below runtime dir %q", path, runtimeDir) | |||
| 26 | + | } | |||
| 27 | + | if strings.HasPrefix(path, stateRoot+string(filepath.Separator)) { | |||
| 28 | + | t.Fatalf("path = %q; transient socket must not be below state root %q", path, stateRoot) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) { | |||
| 33 | + | if runtime.GOOS == "windows" { | |||
| 34 | + | t.Skip("swarm inbox transport uses Unix-domain sockets") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | badRuntime := filepath.Join(shortSocketDir(t), "not-a-directory") | |||
| 38 | + | if err := os.WriteFile(badRuntime, []byte("x"), 0o600); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | tempDir := shortSocketDir(t) | |||
| 42 | + | t.Setenv("XDG_RUNTIME_DIR", badRuntime) | |||
| 43 | + | t.Setenv("TMPDIR", tempDir) | |||
| 44 | + | ||||
| 45 | + | path, err := inboxSocketPath(t.TempDir(), "agent-456") | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatalf("inboxSocketPath: %v", err) | |||
| 48 | + | } | |||
| 49 | + | if !strings.HasPrefix(path, tempDir+string(filepath.Separator)) { | |||
| 50 | + | t.Fatalf("path = %q; want fallback below %q", path, tempDir) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestInboxSocketPathShortensLongAgentID(t *testing.T) { | |||
| 55 | + | if runtime.GOOS == "windows" { | |||
| 56 | + | t.Skip("swarm inbox transport uses Unix-domain sockets") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | t.Setenv("XDG_RUNTIME_DIR", shortSocketDir(t)) | |||
| 60 | + | path, err := inboxSocketPath(t.TempDir(), strings.Repeat("agent", 100)) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatalf("inboxSocketPath: %v", err) | |||
| 63 | + | } | |||
| 64 | + | if len(path) > maxUnixSocketPath { | |||
| 65 | + | t.Fatalf("socket path length = %d; want <= %d: %q", len(path), maxUnixSocketPath, path) | |||
| 66 | + | } | |||
| 67 | + | } | |||
M packages/agent/swarm/swarm.go +8 -9
| @@ -151,9 +151,11 @@ func (f *Swarm) ActiveSession() string { | |||
| 151 | 151 | // | |
| 152 | 152 | // <root>/agents/<id>/ | |
| 153 | 153 | // events.jsonl durable event log (runner-owned) | |
| 154 | - | // in.sock unix socket inbox (child-owned) | |
| 155 | 154 | // session.json persistent agent session (child-owned) | |
| 156 | 155 | // meta.json static metadata (id, task) | |
| 156 | + | // | |
| 157 | + | // The transient unix socket inbox lives in a probed runtime directory, | |
| 158 | + | // which may be on a different filesystem from this durable state. | |
| 157 | 159 | func (f *Swarm) agentStateDir(id string) string { | |
| 158 | 160 | return filepath.Join(f.cfg.Root, "agents", id) | |
| 159 | 161 | } | |
| @@ -200,12 +202,9 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 200 | 202 | } | |
| 201 | 203 | logPath := filepath.Join(stateDir, "events.jsonl") | |
| 202 | 204 | sessionPath := filepath.Join(stateDir, "session.json") | |
| 203 | - | // Unix sockets have a hard 104-byte path limit on darwin and 108 | |
| 204 | - | // on linux. Long ZOT_HOME paths plus an agent slug blow that cap | |
| 205 | - | // quickly. Pick the shortest path that still keeps sockets | |
| 206 | - | // per-swarm-root so two zot instances on the same machine don't | |
| 207 | - | // collide. inboxSocketPath falls back from $TMPDIR to /tmp if | |
| 208 | - | // neither is short enough. | |
| 205 | + | // Keep the transient unix socket outside the durable state root. | |
| 206 | + | // ZOT_HOME may live on a shared or network filesystem that supports | |
| 207 | + | // regular state files but cannot host unix socket nodes. | |
| 209 | 208 | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | |
| 210 | 209 | if err != nil { | |
| 211 | 210 | return nil, fmt.Errorf("swarm inbox path: %w", err) | |
| @@ -400,8 +399,8 @@ func (f *Swarm) Remove(id string) error { | |||
| 400 | 399 | return fmt.Errorf("agent %s still %s", a.ID, st) | |
| 401 | 400 | } | |
| 402 | 401 | // Best-effort cleanup of the per-agent state directory | |
| 403 | - | // (meta.json, events.jsonl, session.json, in.sock if it's | |
| 404 | - | // local). Failing here would leave the user with no recourse, | |
| 402 | + | // (meta.json, events.jsonl, session.json). Failing here would leave | |
| 403 | + | // the user with no recourse, | |
| 405 | 404 | // so swallow the error. | |
| 406 | 405 | _ = os.RemoveAll(f.agentStateDir(a.ID)) | |
| 407 | 406 | f.mu.Lock() | |
| @@ -151,9 +151,11 @@ func (f *Swarm) ActiveSession() string { | |||||
| 151 | // | 151 | // | ||
| 152 | // <root>/agents/<id>/ | 152 | // <root>/agents/<id>/ | ||
| 153 | // events.jsonl durable event log (runner-owned) | 153 | // events.jsonl durable event log (runner-owned) | ||
| 154 | - | // in.sock unix socket inbox (child-owned) | |||
| 155 | // session.json persistent agent session (child-owned) | 154 | // session.json persistent agent session (child-owned) | ||
| 156 | // meta.json static metadata (id, task) | 155 | // meta.json static metadata (id, task) | ||
| 156 | + | // | |||
| 157 | + | // The transient unix socket inbox lives in a probed runtime directory, | |||
| 158 | + | // which may be on a different filesystem from this durable state. | |||
| 157 | func (f *Swarm) agentStateDir(id string) string { | 159 | func (f *Swarm) agentStateDir(id string) string { | ||
| 158 | return filepath.Join(f.cfg.Root, "agents", id) | 160 | return filepath.Join(f.cfg.Root, "agents", id) | ||
| 159 | } | 161 | } | ||
| @@ -200,12 +202,9 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 200 | } | 202 | } | ||
| 201 | logPath := filepath.Join(stateDir, "events.jsonl") | 203 | logPath := filepath.Join(stateDir, "events.jsonl") | ||
| 202 | sessionPath := filepath.Join(stateDir, "session.json") | 204 | sessionPath := filepath.Join(stateDir, "session.json") | ||
| 203 | - | // Unix sockets have a hard 104-byte path limit on darwin and 108 | 205 | + | // Keep the transient unix socket outside the durable state root. |
| 204 | - | // on linux. Long ZOT_HOME paths plus an agent slug blow that cap | 206 | + | // ZOT_HOME may live on a shared or network filesystem that supports |
| 205 | - | // quickly. Pick the shortest path that still keeps sockets | 207 | + | // regular state files but cannot host unix socket nodes. |
| 206 | - | // per-swarm-root so two zot instances on the same machine don't | |||
| 207 | - | // collide. inboxSocketPath falls back from $TMPDIR to /tmp if | |||
| 208 | - | // neither is short enough. | |||
| 209 | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | 208 | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | ||
| 210 | if err != nil { | 209 | if err != nil { | ||
| 211 | return nil, fmt.Errorf("swarm inbox path: %w", err) | 210 | return nil, fmt.Errorf("swarm inbox path: %w", err) | ||
| @@ -400,8 +399,8 @@ func (f *Swarm) Remove(id string) error { | |||||
| 400 | return fmt.Errorf("agent %s still %s", a.ID, st) | 399 | return fmt.Errorf("agent %s still %s", a.ID, st) | ||
| 401 | } | 400 | } | ||
| 402 | // Best-effort cleanup of the per-agent state directory | 401 | // Best-effort cleanup of the per-agent state directory | ||
| 403 | - | // (meta.json, events.jsonl, session.json, in.sock if it's | 402 | + | // (meta.json, events.jsonl, session.json). Failing here would leave |
| 404 | - | // local). Failing here would leave the user with no recourse, | 403 | + | // the user with no recourse, |
| 405 | // so swallow the error. | 404 | // so swallow the error. | ||
| 406 | _ = os.RemoveAll(f.agentStateDir(a.ID)) | 405 | _ = os.RemoveAll(f.agentStateDir(a.ID)) | ||
| 407 | f.mu.Lock() | 406 | f.mu.Lock() | ||