perf(subagents): unblock interactive startup
- commit
- 92fc3ccac0c1c453be26e2dd0f63542824ce380a
- author
- brice · · 2026-08-06 15:23 UTC
- parent
- c35564a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 380 | 380 | ||
| 381 | 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 | 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 | ||
| @@ -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 { | |||
| 1371 | 1371 | 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 | 1374 | if args.PermissionSet != nil { | |
| 1378 | 1375 | // Packaged zutfile agents have an explicit capability ceiling. A | |
| 1379 | 1376 | // child process would otherwise rebuild a fresh unrestricted sandbox, | |
| 1380 | 1377 | // so do not expose either slash or tool-based delegation from this | |
| 1381 | 1378 | // restricted host until worker capability propagation exists. | |
| 1382 | 1379 | 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 | 1388 | // 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}, nil | 1371 | 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. A | 1375 | // 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 this | 1377 | // 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 = nil | 1379 | 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 tool | 1388 | // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool | ||
M packages/agent/subagents/event.go +5 -9
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | + | "reflect" | |
| 11 | 12 | "sync" | |
| 12 | 13 | "time" | |
| 13 | 14 | ) | |
| @@ -343,16 +344,11 @@ func isLikelyDoubleWrite(ev, previous Event) bool { | |||
| 343 | 344 | return sameEventData(previous.Data, ev.Data) | |
| 344 | 345 | } | |
| 345 | 346 | ||
| 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. | |
| 349 | 350 | 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) | |
| 356 | 352 | } | |
| 357 | 353 | ||
| 358 | 354 | // 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 the | 347 | + | // sameEventData deep-compares two event payloads without re-encoding them. |
| 347 | - | // payloads are small map[string]any trees built from JSON, and only | 348 | + | // 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 as | 354 | // 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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | + | "math" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "strings" | |
| @@ -247,6 +248,13 @@ func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) { | |||
| 247 | 248 | // by more than the dedup window (250ms) must both survive. The | |
| 248 | 249 | // agent legitimately can emit identical adjacent events (think: a | |
| 249 | 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 | 258 | func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) { | |
| 251 | 259 | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 252 | 260 | 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. The | 248 | // by more than the dedup window (250ms) must both survive. The | ||
| 248 | // agent legitimately can emit identical adjacent events (think: a | 249 | // 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) | ||