After Cmd+Q quit + reopen, sessions the user killed reappeared as alive. The session_worktrees "shutdown-saved" restore marker was write-only: RestoreAll auto-relaunches any terminated session that still has a marker, but Kill never deleted it and RestoreAll never consumed it. A session that survived one reopen cycle (gaining a marker via reconcileLive) and was then killed still carried the marker, so the next boot resurrected it. Two-layer fix: - Kill now deletes the marker (best-effort) after MarkTerminated: a user kill is explicit terminal intent and must not leave a resurrection marker. - RestoreAll deletes the marker after consuming preserveRef and attempting relaunch, making it one-shot so it never outlives a single restart. A still-live session re-acquires a fresh marker at the next quit. Manual Restore stays marker-independent, so killed sessions remain restorable on demand, just not auto-resurrected on boot. Adds DeleteSessionWorktrees to the Manager Store interface (the concrete store already implements it) and tests covering all three behaviors. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
010c7e4c1a
commit
60b651f20e
|
|
@ -96,6 +96,8 @@ type Store interface {
|
||||||
// presence of any row is the marker; preserved_ref may be empty for clean
|
// presence of any row is the marker; preserved_ref may be empty for clean
|
||||||
// worktrees.
|
// worktrees.
|
||||||
ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error)
|
ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error)
|
||||||
|
// DeleteSessionWorktrees clears the "shutdown-saved" restore marker.
|
||||||
|
DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager coordinates internal session spawn, restore, kill, and cleanup over
|
// Manager coordinates internal session spawn, restore, kill, and cleanup over
|
||||||
|
|
@ -448,6 +450,12 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
|
||||||
return false, fmt.Errorf("kill %s: %w", id, err)
|
return false, fmt.Errorf("kill %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear the restore marker so the next boot's RestoreAll cannot resurrect a
|
||||||
|
// killed session (#2319). Best-effort: teardown below still matters.
|
||||||
|
if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil {
|
||||||
|
m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Only tear down what exists. A session may have lost its handle after a
|
// Only tear down what exists. A session may have lost its handle after a
|
||||||
// crash or never acquired one if spawn failed partway.
|
// crash or never acquired one if spawn failed partway.
|
||||||
if handle.ID != "" {
|
if handle.ID != "" {
|
||||||
|
|
@ -841,6 +849,12 @@ func (m *Manager) RestoreAll(ctx context.Context) error {
|
||||||
m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err)
|
m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot: drop the consumed marker so it never outlives one restart
|
||||||
|
// (#2319). A still-live session re-acquires it at the next quit.
|
||||||
|
if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil {
|
||||||
|
m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,10 @@ func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionW
|
||||||
func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) {
|
func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) {
|
||||||
return f.worktrees[id], nil
|
return f.worktrees[id], nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error {
|
||||||
|
delete(f.worktrees, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type fakeLCM struct {
|
type fakeLCM struct {
|
||||||
store *fakeStore
|
store *fakeStore
|
||||||
|
|
@ -570,6 +574,29 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
|
||||||
t.Fatalf("kill err = %v, want workspace error surfaced", err)
|
t.Fatalf("kill err = %v, want workspace error surfaced", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit
|
||||||
|
// terminal intent and must delete the session_worktrees "shutdown-saved" marker.
|
||||||
|
// A session that carried a marker (e.g. it survived a prior reopen cycle) and is
|
||||||
|
// then killed must not keep that marker, or the next boot's RestoreAll would
|
||||||
|
// resurrect it.
|
||||||
|
func TestKill_DeletesRestoreMarker(t *testing.T) {
|
||||||
|
m, st, _, _ := newManager()
|
||||||
|
st.sessions["mer-1"] = mkLive("mer-1")
|
||||||
|
// The session carries a leftover shutdown-saved marker from a prior cycle.
|
||||||
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
|
||||||
|
|
||||||
|
if _, err := m.Kill(ctx, "mer-1"); err != nil {
|
||||||
|
t.Fatalf("kill err = %v", err)
|
||||||
|
}
|
||||||
|
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("kill must delete the restore marker, got %d rows", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestRestore_ReopensTerminal(t *testing.T) {
|
func TestRestore_ReopensTerminal(t *testing.T) {
|
||||||
m, st, rt, _ := newManager()
|
m, st, rt, _ := newManager()
|
||||||
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
||||||
|
|
@ -1578,6 +1605,81 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the
|
||||||
|
// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its
|
||||||
|
// session_worktrees marker is deleted, so a second RestoreAll (with no fresh
|
||||||
|
// marker) does NOT relaunch it again.
|
||||||
|
func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) {
|
||||||
|
m, st, rt, _ := newLifecycleManager()
|
||||||
|
|
||||||
|
st.sessions["mer-1"] = domain.SessionRecord{
|
||||||
|
ID: "mer-1",
|
||||||
|
ProjectID: "mer",
|
||||||
|
Kind: domain.KindWorker,
|
||||||
|
Harness: domain.HarnessClaudeCode,
|
||||||
|
IsTerminated: true,
|
||||||
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
||||||
|
Activity: domain.Activity{State: domain.ActivityExited},
|
||||||
|
}
|
||||||
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
|
||||||
|
|
||||||
|
if err := m.RestoreAll(ctx); err != nil {
|
||||||
|
t.Fatalf("RestoreAll err = %v", err)
|
||||||
|
}
|
||||||
|
if rt.created != 1 {
|
||||||
|
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
|
||||||
|
}
|
||||||
|
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c),
|
||||||
|
// the killed-session-resurrection scenario. A terminated session WITH a marker
|
||||||
|
// is relaunched exactly once; on a second RestoreAll (no new marker) it stays
|
||||||
|
// terminated and is not relaunched again.
|
||||||
|
func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) {
|
||||||
|
m, st, rt, _ := newLifecycleManager()
|
||||||
|
|
||||||
|
st.sessions["mer-1"] = domain.SessionRecord{
|
||||||
|
ID: "mer-1",
|
||||||
|
ProjectID: "mer",
|
||||||
|
Kind: domain.KindWorker,
|
||||||
|
Harness: domain.HarnessClaudeCode,
|
||||||
|
IsTerminated: true,
|
||||||
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
||||||
|
Activity: domain.Activity{State: domain.ActivityExited},
|
||||||
|
}
|
||||||
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
|
||||||
|
|
||||||
|
// First boot: marker present, session relaunches once.
|
||||||
|
if err := m.RestoreAll(ctx); err != nil {
|
||||||
|
t.Fatalf("first RestoreAll err = %v", err)
|
||||||
|
}
|
||||||
|
if rt.created != 1 {
|
||||||
|
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the user killing the relaunched session before the next quit, so
|
||||||
|
// it has no fresh marker, then a second boot.
|
||||||
|
if _, err := m.Kill(ctx, "mer-1"); err != nil {
|
||||||
|
t.Fatalf("kill err = %v", err)
|
||||||
|
}
|
||||||
|
if err := m.RestoreAll(ctx); err != nil {
|
||||||
|
t.Fatalf("second RestoreAll err = %v", err)
|
||||||
|
}
|
||||||
|
if rt.created != 1 {
|
||||||
|
t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created)
|
||||||
|
}
|
||||||
|
if !st.sessions["mer-1"].IsTerminated {
|
||||||
|
t.Error("killed session must remain terminated after second RestoreAll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a
|
// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a
|
||||||
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
|
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
|
||||||
// restore but before relaunching.
|
// restore but before relaunching.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue