fix(daemon): surface failed process auto-resume
When a session is restored at startup and the pty write for the resume command fails, the user only saw an idle shell. Raise a NoticeAutoResume warning (queued pre-attach, drained to a toast at first attach) alongside the existing log line so the user knows why their agent isn't running.
- commit
- 1f71d39b8626de2111d354485e93233ff6ebcb6b
- author
- brice · · 2026-07-21 05:13 UTC
- parent
- 1408e43
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +43 -0
M internal/usecase/daemon/snapshot.go +2 -0
| @@ -228,6 +228,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 228 | 228 | if restoreCommand != "" { | |
| 229 | 229 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |
| 230 | 230 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |
| 231 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, | |
| 232 | + | "couldn't restore the running program in session "+snap.Name, err) | |
| 231 | 233 | } | |
| 232 | 234 | } | |
| 233 | 235 | tb.panes[paneSnap.ID] = p | |
| @@ -228,6 +228,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 228 | if restoreCommand != "" { | 228 | if restoreCommand != "" { | ||
| 229 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | 229 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | ||
| 230 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | 230 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | ||
| 231 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, | |||
| 232 | + | "couldn't restore the running program in session "+snap.Name, err) | |||
| 231 | } | 233 | } | ||
| 232 | } | 234 | } | ||
| 233 | tb.panes[paneSnap.ID] = p | 235 | tb.panes[paneSnap.ID] = p | ||
M internal/usecase/daemon/snapshot_restore_test.go +41 -0
| @@ -546,6 +546,47 @@ func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) { | |||
| 546 | 546 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 547 | 547 | } | |
| 548 | 548 | ||
| 549 | + | func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) { | |
| 550 | + | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 551 | + | store := processRestoreTestStore(t, "agent-session", proc) | |
| 552 | + | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |
| 553 | + | d := newTestDaemon(t, factory, newNoticeClock()) | |
| 554 | + | WithSnapshotStore(store)(d) | |
| 555 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |
| 556 | + | ||
| 557 | + | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |
| 558 | + | // caller goroutine and signals completion by closing d.restoreDone. | |
| 559 | + | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |
| 560 | + | ||
| 561 | + | select { | |
| 562 | + | case <-d.restoreDone: | |
| 563 | + | case <-time.After(2 * time.Second): | |
| 564 | + | t.Fatal("restoreSnapshots did not close restoreDone in time") | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails") | |
| 568 | + | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |
| 569 | + | ||
| 570 | + | history := d.notices.history() | |
| 571 | + | require.Len(t, history, 1) | |
| 572 | + | require.Equal(t, domain.NoticeAutoResume, history[0].Code) | |
| 573 | + | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |
| 574 | + | require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message) | |
| 575 | + | ||
| 576 | + | tr, _ := newCapturingTransport(t) | |
| 577 | + | sess, ac, err := d.route(ports.Hello{ | |
| 578 | + | Version: ports.ProtocolVersion, | |
| 579 | + | Intent: ports.IntentEphemeral, | |
| 580 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 581 | + | }, tr) | |
| 582 | + | require.NoError(t, err) | |
| 583 | + | d.firstPaint(sess, ac, ac.size) | |
| 584 | + | ||
| 585 | + | toasts := awaitToastCount(t, ac, 1) | |
| 586 | + | require.Equal(t, domain.NoticeAutoResume, toasts[0].Code) | |
| 587 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 588 | + | } | |
| 589 | + | ||
| 549 | 590 | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |
| 550 | 591 | releaseLoad := make(chan struct{}) | |
| 551 | 592 | loaded := make(chan struct{}) | |
| @@ -546,6 +546,47 @@ func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) { | |||||
| 546 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | 546 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | ||
| 547 | } | 547 | } | ||
| 548 | 548 | ||||
| 549 | + | func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) { | |||
| 550 | + | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 551 | + | store := processRestoreTestStore(t, "agent-session", proc) | |||
| 552 | + | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |||
| 553 | + | d := newTestDaemon(t, factory, newNoticeClock()) | |||
| 554 | + | WithSnapshotStore(store)(d) | |||
| 555 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |||
| 556 | + | ||||
| 557 | + | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |||
| 558 | + | // caller goroutine and signals completion by closing d.restoreDone. | |||
| 559 | + | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |||
| 560 | + | ||||
| 561 | + | select { | |||
| 562 | + | case <-d.restoreDone: | |||
| 563 | + | case <-time.After(2 * time.Second): | |||
| 564 | + | t.Fatal("restoreSnapshots did not close restoreDone in time") | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails") | |||
| 568 | + | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |||
| 569 | + | ||||
| 570 | + | history := d.notices.history() | |||
| 571 | + | require.Len(t, history, 1) | |||
| 572 | + | require.Equal(t, domain.NoticeAutoResume, history[0].Code) | |||
| 573 | + | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |||
| 574 | + | require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message) | |||
| 575 | + | ||||
| 576 | + | tr, _ := newCapturingTransport(t) | |||
| 577 | + | sess, ac, err := d.route(ports.Hello{ | |||
| 578 | + | Version: ports.ProtocolVersion, | |||
| 579 | + | Intent: ports.IntentEphemeral, | |||
| 580 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 581 | + | }, tr) | |||
| 582 | + | require.NoError(t, err) | |||
| 583 | + | d.firstPaint(sess, ac, ac.size) | |||
| 584 | + | ||||
| 585 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 586 | + | require.Equal(t, domain.NoticeAutoResume, toasts[0].Code) | |||
| 587 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 588 | + | } | |||
| 589 | + | ||||
| 549 | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | 590 | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | ||
| 550 | releaseLoad := make(chan struct{}) | 591 | releaseLoad := make(chan struct{}) | ||
| 551 | loaded := make(chan struct{}) | 592 | loaded := make(chan struct{}) | ||