detached 6b2855e

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 {
59905990 return a.bridge != nil && a.bridge.Active()
59915991 }
59925992
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.
59955995 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
60006001 }
60016002
60026003 // TrackSwarmAgent is the exported entry point used by the cli to
@@ -6006,13 +6007,10 @@ func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
60066007 }
60076008
60086009 // 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.
60166014 //
60176015 // Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
60186016 // 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) {
60266024 i.swarmWatchMu.Unlock()
60276025
60286026 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"
60336030 }
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"
60476039 }
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 {
60486047 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
60516058 }
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 {
60526067 i.flushSwarmSummary(batch)
6053- })
6068+ }
60546069 }
60556070
60566071 // flushSwarmSummary composes a synthetic user turn describing every
@@ -6066,12 +6081,15 @@ func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
60666081 fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
60676082 for idx, e := range batch {
60686083 snap := e.agent.Snapshot()
6069- status := string(snap.Status)
6084+ status := e.outcome
6085+ if status == "" {
6086+ status = string(snap.Status)
6087+ }
60706088 task := snap.Task
60716089 if task == "" {
60726090 task = e.task
60736091 }
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)
60756093 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
60766094 if snap.Err != "" {
60776095 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 at5993+// 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.Agent5996+ agent *swarm.Agent
5997- task string5997+ task string
5998- done bool5998+ done bool
5999- err string5999+ outcome string
6000+ err string
6000 }6001 }
6001 6002
6002 // TrackSwarmAgent is the exported entry point used by the cli to6003 // 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 and6009 // trackSwarmAgent records a freshly-spawned auto-swarm agent and
6009-// subscribes to its prompt-level task completion events. Sub-agents6010+// subscribes to both ways its delegated task can finish. Successful
6010-// are long-lived daemons that keep running on the inbox after the6011+// 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 returns6012+// startup failures and unexpected daemon exits only unblock Agent.Wait.
6012-// until the whole daemon dies. Instead we mark each entry done when6013+// 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 = true6031+ i.completeSwarmWatchEntry(entry, outcome, errMsg)
6035- entry.err = errMsg6032+ })
6036- allDone := true6033+ go func() {
6037- for _, e := range i.swarmWatch {6034+ a.Wait()
6038- if !e.done {6035+ snap := a.Snapshot()
6039- allDone = false6036+ outcome := string(snap.Status)
6040- break6037+ 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- return6049+ }
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 every6071 // 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.Task6088 task := snap.Task
6071 if task == "" {6089 if task == "" {
6072 task = e.task6090 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) {
314314 }
315315 }
316316
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
320320 // continue a swarm session across zot restarts:
321321 //
322322 // swarmMgr.Reload()
@@ -339,15 +339,18 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
339339 }
340340
341341 // 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+ }
346349 m := agentMeta{
347350 ID: existing.ID, Task: existing.Task,
348351 Dir: existing.Dir, Started: existing.Started,
349352 Model: existing.Model, Provider: existing.Provider,
350- InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath,
353+ InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
351354 SessionPath: existing.SessionPath,
352355 }
353356
@@ -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. The317+// 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 to319+// 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 runner341 // Rebuild from the meta record so we don't carry stale runner
342- // state from a previous incarnation. We re-read meta.json rather342+ // state from a previous incarnation. The inbox is transient and
343- // than reusing the live struct's fields so callers that mutated343+ // may have been persisted under an incompatible filesystem by an
344- // (e.g. tests that hand-built an Agent) don't accidentally route344+ // 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 (
66 "errors"
77 "os"
88 "path/filepath"
9+ "strings"
910 "sync"
1011 "sync/atomic"
1112 "testing"
@@ -342,6 +343,35 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
342343 }
343344 }
344345
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+
345375 // TestResumeSetsResumingFlag pins the contract execRunner relies on:
346376 // after Resume, the new Agent has Resuming == true; after a fresh
347377 // 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 fresh376 // after Resume, the new Agent has Resuming == true; after a fresh
347 // Spawn it's false. The runner branches on this to decide whether377 // 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
33 import (
44 "crypto/sha1"
55 "encoding/hex"
6+ "errors"
67 "fmt"
8+ "net"
79 "os"
810 "path/filepath"
911 "runtime"
@@ -15,52 +17,98 @@ import (
1517 // tail stays under both caps with a safety margin.
1618 const maxUnixSocketPath = 100
1719
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.
2124 //
22-// Strategy:
25+// Candidate directories are tried in this order:
2326 //
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.
3534 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)
3942 }
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))
4266 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)
4468 }
69+
4570 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
48100 }
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
56105 }
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)
59107 }
60108
61109 // 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.
64112 func rootTag(root string) string { return shortHash(root) }
65113
66114 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 = 10018 const maxUnixSocketPath = 100
17 19
18-// inboxSocketPath returns a per-agent unix-socket path that's short20+// inboxSocketPath returns a per-agent unix-socket path in a transient
19-// enough to actually work (see maxUnixSocketPath) and unique per21+// 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 and27+// 1. $XDG_RUNTIME_DIR, when absolute;
25-// puts everything next to the durable state; on most setups it28+// 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 name31+// 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: collisions32+// The root hash keeps separate zot homes from colliding while preserving a
30-// only matter within a single user's tmp dir and we already33+// 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, nil37+ }
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, nil72+ 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 long101+ 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. Used109 // 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 different110+// 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 {
151151 //
152152 // <root>/agents/<id>/
153153 // events.jsonl durable event log (runner-owned)
154-// in.sock unix socket inbox (child-owned)
155154 // session.json persistent agent session (child-owned)
156155 // 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.
157159 func (f *Swarm) agentStateDir(id string) string {
158160 return filepath.Join(f.cfg.Root, "agents", id)
159161 }
@@ -200,12 +202,9 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
200202 }
201203 logPath := filepath.Join(stateDir, "events.jsonl")
202204 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.
209208 inboxPath, err := inboxSocketPath(f.cfg.Root, id)
210209 if err != nil {
211210 return nil, fmt.Errorf("swarm inbox path: %w", err)
@@ -400,8 +399,8 @@ func (f *Swarm) Remove(id string) error {
400399 return fmt.Errorf("agent %s still %s", a.ID, st)
401400 }
402401 // 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,
405404 // so swallow the error.
406405 _ = os.RemoveAll(f.agentStateDir(a.ID))
407406 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 108205+ // Keep the transient unix socket outside the durable state root.
204- // on linux. Long ZOT_HOME paths plus an agent slug blow that cap206+ // ZOT_HOME may live on a shared or network filesystem that supports
205- // quickly. Pick the shortest path that still keeps sockets207+ // 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 directory401 // Best-effort cleanup of the per-agent state directory
403- // (meta.json, events.jsonl, session.json, in.sock if it's402+ // (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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close