detached 92fc3cc

perf(subagents): unblock interactive startup

commit
92fc3ccac0c1c453be26e2dd0f63542824ce380a
author
brice · · 2026-08-06 15:23 UTC
parent
c35564a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +20 -13
M README.md +1 -1
@@ -380,7 +380,7 @@ Background subagents that run alongside your main session. Each one is a separat
380380
381381 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
382382
383-**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
383+**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they reappear as **detached** while history restores in the background; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
384384
385385 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
386386
@@ -380,7 +380,7 @@ Background subagents that run alongside your main session. Each one is a separat
380 380
381 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.381 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
382 382
383-**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.383+**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they reappear as **detached** while history restores in the background; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
384 384
385 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.385 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
386 386
M packages/agent/cli.go +6 -3
@@ -1371,15 +1371,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13711371 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
13721372 },
13731373 })
1374- // Pull any previously-spawned agents off disk so the dashboard
1375- // shows them as detached and the user can resume / remove them.
1376- _, _ = subagentsMgr.Reload()
13771374 if args.PermissionSet != nil {
13781375 // Packaged zutfile agents have an explicit capability ceiling. A
13791376 // child process would otherwise rebuild a fresh unrestricted sandbox,
13801377 // so do not expose either slash or tool-based delegation from this
13811378 // restricted host until worker capability propagation exists.
13821379 subagentsMgr = nil
1380+ } else {
1381+ // Replaying every historical event log can take seconds for users with
1382+ // many completed subagents. The manager is safe for concurrent snapshots,
1383+ // so populate the detached-agent dashboard in the background instead of
1384+ // blocking the first interactive paint.
1385+ go func() { _, _ = subagentsMgr.Reload() }()
13831386 }
13841387
13851388 // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
@@ -1371,15 +1371,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1371 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil1371 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1372 },1372 },
1373 })1373 })
1374- // Pull any previously-spawned agents off disk so the dashboard
1375- // shows them as detached and the user can resume / remove them.
1376- _, _ = subagentsMgr.Reload()
1377 if args.PermissionSet != nil {1374 if args.PermissionSet != nil {
1378 // Packaged zutfile agents have an explicit capability ceiling. A1375 // Packaged zutfile agents have an explicit capability ceiling. A
1379 // child process would otherwise rebuild a fresh unrestricted sandbox,1376 // child process would otherwise rebuild a fresh unrestricted sandbox,
1380 // so do not expose either slash or tool-based delegation from this1377 // so do not expose either slash or tool-based delegation from this
1381 // restricted host until worker capability propagation exists.1378 // restricted host until worker capability propagation exists.
1382 subagentsMgr = nil1379 subagentsMgr = nil
1380+ } else {
1381+ // Replaying every historical event log can take seconds for users with
1382+ // many completed subagents. The manager is safe for concurrent snapshots,
1383+ // so populate the detached-agent dashboard in the background instead of
1384+ // blocking the first interactive paint.
1385+ go func() { _, _ = subagentsMgr.Reload() }()
1383 }1386 }
1384 1387
1385 // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool1388 // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
M packages/agent/subagents/event.go +5 -9
@@ -8,6 +8,7 @@ import (
88 "io"
99 "os"
1010 "path/filepath"
11+ "reflect"
1112 "sync"
1213 "time"
1314 )
@@ -343,16 +344,11 @@ func isLikelyDoubleWrite(ev, previous Event) bool {
343344 return sameEventData(previous.Data, ev.Data)
344345 }
345346
346-// sameEventData deep-compares two event payloads. Cheap because the
347-// payloads are small map[string]any trees built from JSON, and only
348-// called for adjacent same-type pairs.
347+// sameEventData deep-compares two event payloads without re-encoding them.
348+// Reload invokes this for adjacent same-type events across every persisted
349+// subagent log, so serializing large streamed payloads here delays startup.
349350 func sameEventData(a, b map[string]any) bool {
350- if len(a) != len(b) {
351- return false
352- }
353- ab, _ := json.Marshal(a)
354- bb, _ := json.Marshal(b)
355- return string(ab) == string(bb)
351+ return len(a) == len(b) && reflect.DeepEqual(a, b)
356352 }
357353
358354 // EventFollower polls an events.jsonl file and emits new events as
@@ -8,6 +8,7 @@ import (
8 "io"8 "io"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11+ "reflect"
11 "sync"12 "sync"
12 "time"13 "time"
13 )14 )
@@ -343,16 +344,11 @@ func isLikelyDoubleWrite(ev, previous Event) bool {
343 return sameEventData(previous.Data, ev.Data)344 return sameEventData(previous.Data, ev.Data)
344 }345 }
345 346
346-// sameEventData deep-compares two event payloads. Cheap because the347+// sameEventData deep-compares two event payloads without re-encoding them.
347-// payloads are small map[string]any trees built from JSON, and only348+// Reload invokes this for adjacent same-type events across every persisted
348-// called for adjacent same-type pairs.349+// subagent log, so serializing large streamed payloads here delays startup.
349 func sameEventData(a, b map[string]any) bool {350 func sameEventData(a, b map[string]any) bool {
350- if len(a) != len(b) {351+ return len(a) == len(b) && reflect.DeepEqual(a, b)
351- return false
352- }
353- ab, _ := json.Marshal(a)
354- bb, _ := json.Marshal(b)
355- return string(ab) == string(bb)
356 }352 }
357 353
358 // EventFollower polls an events.jsonl file and emits new events as354 // EventFollower polls an events.jsonl file and emits new events as
M packages/agent/subagents/event_test.go +8 -0
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "encoding/json"
66 "fmt"
7+ "math"
78 "os"
89 "path/filepath"
910 "strings"
@@ -247,6 +248,13 @@ func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) {
247248 // by more than the dedup window (250ms) must both survive. The
248249 // agent legitimately can emit identical adjacent events (think: a
249250 // retry running the same tool seconds apart).
251+func TestSameEventDataDoesNotConflateFailedEncodings(t *testing.T) {
252+ payload := math.NaN()
253+ if sameEventData(map[string]any{"value": payload}, map[string]any{"value": payload}) {
254+ t.Fatal("payloads with non-equal values must not be treated as equal when encoding fails")
255+ }
256+}
257+
250258 func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {
251259 path := filepath.Join(t.TempDir(), "events.jsonl")
252260 log, err := OpenEventLog(path)
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7+ "math"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "strings"10 "strings"
@@ -247,6 +248,13 @@ func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) {
247 // by more than the dedup window (250ms) must both survive. The248 // by more than the dedup window (250ms) must both survive. The
248 // agent legitimately can emit identical adjacent events (think: a249 // agent legitimately can emit identical adjacent events (think: a
249 // retry running the same tool seconds apart).250 // retry running the same tool seconds apart).
251+func TestSameEventDataDoesNotConflateFailedEncodings(t *testing.T) {
252+ payload := math.NaN()
253+ if sameEventData(map[string]any{"value": payload}, map[string]any{"value": payload}) {
254+ t.Fatal("payloads with non-equal values must not be treated as equal when encoding fails")
255+ }
256+}
257+
250 func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {258 func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {
251 path := filepath.Join(t.TempDir(), "events.jsonl")259 path := filepath.Join(t.TempDir(), "events.jsonl")
252 log, err := OpenEventLog(path)260 log, err := OpenEventLog(path)

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