fix(subagents): skip incompatible journals during discovery (#180)
- commit
- d341fee54622c84d4cfcfc34a6cd96c10c77639f
- author
- bnema · · 2026-09-06 05:26 UTC
- committer
- GitHub · · 2026-09-06 05:26 UTC
- parent
- 4a12e83
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
3 files · +49 -8
M docs/subagents.md +4 -2
| @@ -90,8 +90,10 @@ prompt that has not received a slot and records its terminal failure durably. | |||
| 90 | 90 | a cumulative weighted-token budget equal to its resolved model's context window. | |
| 91 | 91 | Removed legacy settings, including `tui_subagent_position` and | |
| 92 | 92 | `subagents.budget_ratio`, are ignored. Resident journals created before this | |
| 93 | - | budget policy are incompatible: zut leaves them untouched, reports a recovery | |
| 94 | - | error, and requires a new child rather than resuming them. | |
| 93 | + | budget policy are incompatible: zut leaves them untouched on disk and excludes | |
| 94 | + | them from automatic discovery without reporting an error at every startup. | |
| 95 | + | They cannot be resumed; start a new child instead. Other journal recovery | |
| 96 | + | errors are still reported. | |
| 95 | 97 | ||
| 96 | 98 | Budget accounting charges uncached input, cache writes, and output fully; cache | |
| 97 | 99 | reads count at 25%. It is separate from active context-window usage and survives | |
| @@ -90,8 +90,10 @@ prompt that has not received a slot and records its terminal failure durably. | |||||
| 90 | a cumulative weighted-token budget equal to its resolved model's context window. | 90 | a cumulative weighted-token budget equal to its resolved model's context window. | ||
| 91 | Removed legacy settings, including `tui_subagent_position` and | 91 | Removed legacy settings, including `tui_subagent_position` and | ||
| 92 | `subagents.budget_ratio`, are ignored. Resident journals created before this | 92 | `subagents.budget_ratio`, are ignored. Resident journals created before this | ||
| 93 | - | budget policy are incompatible: zut leaves them untouched, reports a recovery | 93 | + | budget policy are incompatible: zut leaves them untouched on disk and excludes |
| 94 | - | error, and requires a new child rather than resuming them. | 94 | + | them from automatic discovery without reporting an error at every startup. |
| 95 | + | They cannot be resumed; start a new child instead. Other journal recovery | |||
| 96 | + | errors are still reported. | |||
| 95 | 97 | ||||
| 96 | Budget accounting charges uncached input, cache writes, and output fully; cache | 98 | Budget accounting charges uncached input, cache writes, and output fully; cache | ||
| 97 | reads count at 25%. It is separate from active context-window usage and survives | 99 | reads count at 25%. It is separate from active context-window usage and survives | ||
M packages/agent/subagents/resident_manager.go +9 -2
| @@ -809,8 +809,9 @@ func (m *ResidentManager) SnapshotFor(childID string) (ResidentSnapshot, bool) { | |||
| 809 | 809 | ||
| 810 | 810 | // Reconcile discovers journals left by an earlier host, repairs their bounded | |
| 811 | 811 | // projections, and marks queued/running work interrupted. It never creates a | |
| 812 | - | // live child or replays a prompt. Per-child errors are returned for callers to | |
| 813 | - | // surface without hiding valid sibling journals. | |
| 812 | + | // live child or replays a prompt. Incompatible budget journals are left untouched | |
| 813 | + | // and excluded from discovery. Other per-child errors are returned for callers | |
| 814 | + | // to surface without hiding valid sibling journals. | |
| 814 | 815 | func (m *ResidentManager) Reconcile() []error { | |
| 815 | 816 | if m == nil { | |
| 816 | 817 | return []error{errors.New("resident manager: unavailable")} | |
| @@ -850,6 +851,12 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 850 | 851 | } | |
| 851 | 852 | childDir := filepath.Join(m.root, childID) | |
| 852 | 853 | metadata, spec, reconcileErr := reconcileResidentJournalWithSpec(childDir) | |
| 854 | + | // Old budget journals cannot be resumed. Leave them on disk without | |
| 855 | + | // reporting the same incompatibility at every host startup. Match the | |
| 856 | + | // exact sentinel so a joined journal-close error is still reported. | |
| 857 | + | if reconcileErr == ErrIncompatibleResidentBudget { | |
| 858 | + | continue | |
| 859 | + | } | |
| 853 | 860 | if errors.Is(reconcileErr, ErrResidentLeaseBusy) { | |
| 854 | 861 | foreign, readErr := ReadResidentMetadata(filepath.Join(childDir, residentMetadataName)) | |
| 855 | 862 | if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { | |
| @@ -809,8 +809,9 @@ func (m *ResidentManager) SnapshotFor(childID string) (ResidentSnapshot, bool) { | |||||
| 809 | 809 | ||||
| 810 | // Reconcile discovers journals left by an earlier host, repairs their bounded | 810 | // Reconcile discovers journals left by an earlier host, repairs their bounded | ||
| 811 | // projections, and marks queued/running work interrupted. It never creates a | 811 | // projections, and marks queued/running work interrupted. It never creates a | ||
| 812 | - | // live child or replays a prompt. Per-child errors are returned for callers to | 812 | + | // live child or replays a prompt. Incompatible budget journals are left untouched |
| 813 | - | // surface without hiding valid sibling journals. | 813 | + | // and excluded from discovery. Other per-child errors are returned for callers |
| 814 | + | // to surface without hiding valid sibling journals. | |||
| 814 | func (m *ResidentManager) Reconcile() []error { | 815 | func (m *ResidentManager) Reconcile() []error { | ||
| 815 | if m == nil { | 816 | if m == nil { | ||
| 816 | return []error{errors.New("resident manager: unavailable")} | 817 | return []error{errors.New("resident manager: unavailable")} | ||
| @@ -850,6 +851,12 @@ func (m *ResidentManager) Reconcile() []error { | |||||
| 850 | } | 851 | } | ||
| 851 | childDir := filepath.Join(m.root, childID) | 852 | childDir := filepath.Join(m.root, childID) | ||
| 852 | metadata, spec, reconcileErr := reconcileResidentJournalWithSpec(childDir) | 853 | metadata, spec, reconcileErr := reconcileResidentJournalWithSpec(childDir) | ||
| 854 | + | // Old budget journals cannot be resumed. Leave them on disk without | |||
| 855 | + | // reporting the same incompatibility at every host startup. Match the | |||
| 856 | + | // exact sentinel so a joined journal-close error is still reported. | |||
| 857 | + | if reconcileErr == ErrIncompatibleResidentBudget { | |||
| 858 | + | continue | |||
| 859 | + | } | |||
| 853 | if errors.Is(reconcileErr, ErrResidentLeaseBusy) { | 860 | if errors.Is(reconcileErr, ErrResidentLeaseBusy) { | ||
| 854 | foreign, readErr := ReadResidentMetadata(filepath.Join(childDir, residentMetadataName)) | 861 | foreign, readErr := ReadResidentMetadata(filepath.Join(childDir, residentMetadataName)) | ||
| 855 | if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { | 862 | if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { | ||
M packages/agent/subagents/resident_manager_test.go +36 -4
| @@ -584,7 +584,7 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing | |||
| 584 | 584 | } | |
| 585 | 585 | } | |
| 586 | 586 | ||
| 587 | - | func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) { | |
| 587 | + | func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) { | |
| 588 | 588 | root := t.TempDir() | |
| 589 | 589 | journal, err := OpenResidentJournal(root, "legacy-budget") | |
| 590 | 590 | if err != nil { | |
| @@ -611,9 +611,13 @@ func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) | |||
| 611 | 611 | } | |
| 612 | 612 | manager := NewResidentManager(root, nil) | |
| 613 | 613 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 614 | - | errs := manager.Reconcile() | |
| 615 | - | if len(errs) != 1 || !errors.Is(errs[0], ErrIncompatibleResidentBudget) { | |
| 616 | - | t.Fatalf("Reconcile errors = %v", errs) | |
| 614 | + | for range 2 { | |
| 615 | + | if errs := manager.Reconcile(); len(errs) != 0 { | |
| 616 | + | t.Fatalf("Reconcile errors = %v", errs) | |
| 617 | + | } | |
| 618 | + | } | |
| 619 | + | if _, err := ReconcileResidentJournal(filepath.Dir(transcript)); !errors.Is(err, ErrIncompatibleResidentBudget) { | |
| 620 | + | t.Fatalf("explicit reconciliation error = %v, want incompatible budget", err) | |
| 617 | 621 | } | |
| 618 | 622 | if snapshots := manager.Snapshot(); len(snapshots) != 0 { | |
| 619 | 623 | t.Fatalf("snapshots = %#v", snapshots) | |
| @@ -625,6 +629,34 @@ func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) | |||
| 625 | 629 | if string(unchanged) != legacy { | |
| 626 | 630 | t.Fatal("Reconcile modified incompatible journal") | |
| 627 | 631 | } | |
| 632 | + | ||
| 633 | + | valid, err := OpenResidentJournal(root, "valid") | |
| 634 | + | if err != nil { | |
| 635 | + | t.Fatal(err) | |
| 636 | + | } | |
| 637 | + | spec.ID = "valid" | |
| 638 | + | if err := valid.Accept(spec, "keep discoverable"); err != nil { | |
| 639 | + | t.Fatal(err) | |
| 640 | + | } | |
| 641 | + | if err := valid.Close(); err != nil { | |
| 642 | + | t.Fatal(err) | |
| 643 | + | } | |
| 644 | + | corrupt, err := OpenResidentJournal(root, "corrupt") | |
| 645 | + | if err != nil { | |
| 646 | + | t.Fatal(err) | |
| 647 | + | } | |
| 648 | + | if err := corrupt.Close(); err != nil { | |
| 649 | + | t.Fatal(err) | |
| 650 | + | } | |
| 651 | + | for range 2 { | |
| 652 | + | errs := manager.Reconcile() | |
| 653 | + | if len(errs) != 1 || !strings.Contains(errs[0].Error(), "resident child corrupt:") { | |
| 654 | + | t.Fatalf("Reconcile errors = %v, want corrupt child error only", errs) | |
| 655 | + | } | |
| 656 | + | if snapshots := manager.Snapshot(); len(snapshots) != 1 || snapshots[0].ID != "valid" { | |
| 657 | + | t.Fatalf("snapshots = %#v, want valid sibling only", snapshots) | |
| 658 | + | } | |
| 659 | + | } | |
| 628 | 660 | } | |
| 629 | 661 | ||
| 630 | 662 | func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) { | |
| @@ -584,7 +584,7 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing | |||||
| 584 | } | 584 | } | ||
| 585 | } | 585 | } | ||
| 586 | 586 | ||||
| 587 | - | func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) { | 587 | + | func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) { |
| 588 | root := t.TempDir() | 588 | root := t.TempDir() | ||
| 589 | journal, err := OpenResidentJournal(root, "legacy-budget") | 589 | journal, err := OpenResidentJournal(root, "legacy-budget") | ||
| 590 | if err != nil { | 590 | if err != nil { | ||
| @@ -611,9 +611,13 @@ func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) | |||||
| 611 | } | 611 | } | ||
| 612 | manager := NewResidentManager(root, nil) | 612 | manager := NewResidentManager(root, nil) | ||
| 613 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 613 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 614 | - | errs := manager.Reconcile() | 614 | + | for range 2 { |
| 615 | - | if len(errs) != 1 || !errors.Is(errs[0], ErrIncompatibleResidentBudget) { | 615 | + | if errs := manager.Reconcile(); len(errs) != 0 { |
| 616 | - | t.Fatalf("Reconcile errors = %v", errs) | 616 | + | t.Fatalf("Reconcile errors = %v", errs) |
| 617 | + | } | |||
| 618 | + | } | |||
| 619 | + | if _, err := ReconcileResidentJournal(filepath.Dir(transcript)); !errors.Is(err, ErrIncompatibleResidentBudget) { | |||
| 620 | + | t.Fatalf("explicit reconciliation error = %v, want incompatible budget", err) | |||
| 617 | } | 621 | } | ||
| 618 | if snapshots := manager.Snapshot(); len(snapshots) != 0 { | 622 | if snapshots := manager.Snapshot(); len(snapshots) != 0 { | ||
| 619 | t.Fatalf("snapshots = %#v", snapshots) | 623 | t.Fatalf("snapshots = %#v", snapshots) | ||
| @@ -625,6 +629,34 @@ func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) | |||||
| 625 | if string(unchanged) != legacy { | 629 | if string(unchanged) != legacy { | ||
| 626 | t.Fatal("Reconcile modified incompatible journal") | 630 | t.Fatal("Reconcile modified incompatible journal") | ||
| 627 | } | 631 | } | ||
| 632 | + | ||||
| 633 | + | valid, err := OpenResidentJournal(root, "valid") | |||
| 634 | + | if err != nil { | |||
| 635 | + | t.Fatal(err) | |||
| 636 | + | } | |||
| 637 | + | spec.ID = "valid" | |||
| 638 | + | if err := valid.Accept(spec, "keep discoverable"); err != nil { | |||
| 639 | + | t.Fatal(err) | |||
| 640 | + | } | |||
| 641 | + | if err := valid.Close(); err != nil { | |||
| 642 | + | t.Fatal(err) | |||
| 643 | + | } | |||
| 644 | + | corrupt, err := OpenResidentJournal(root, "corrupt") | |||
| 645 | + | if err != nil { | |||
| 646 | + | t.Fatal(err) | |||
| 647 | + | } | |||
| 648 | + | if err := corrupt.Close(); err != nil { | |||
| 649 | + | t.Fatal(err) | |||
| 650 | + | } | |||
| 651 | + | for range 2 { | |||
| 652 | + | errs := manager.Reconcile() | |||
| 653 | + | if len(errs) != 1 || !strings.Contains(errs[0].Error(), "resident child corrupt:") { | |||
| 654 | + | t.Fatalf("Reconcile errors = %v, want corrupt child error only", errs) | |||
| 655 | + | } | |||
| 656 | + | if snapshots := manager.Snapshot(); len(snapshots) != 1 || snapshots[0].ID != "valid" { | |||
| 657 | + | t.Fatalf("snapshots = %#v, want valid sibling only", snapshots) | |||
| 658 | + | } | |||
| 659 | + | } | |||
| 628 | } | 660 | } | ||
| 629 | 661 | ||||
| 630 | func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) { | 662 | func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) { | ||