fix(session-manager): terminate sessions even when handle is missing (#360)

* fix(session-manager): terminate sessions even when handle is missing

Kill hard-failed with ErrIncompleteHandle before recording terminal
intent, so a session that lost its runtime/workspace handle (crash,
partial spawn) was un-killable and stuck forever on the dashboard.

MarkTerminated now runs unconditionally; each destroy step is gated
individually on whether that handle exists. If nothing is present to
tear down, the session still terminates — freed=false just signals
nothing was freed. ErrIncompleteHandle is still returned by Restore
and runtimeMessenger.Send, where a missing handle is genuinely fatal.

Updates TestKill_RefusesIncompleteHandle → TestKill_TerminatesIncompleteHandle.

* style: gofmt blank line between test functions

---------

Co-authored-by: AO Bot <ao-bot@composio.dev>
This commit is contained in:
i-trytoohard 2026-06-21 14:30:00 +05:30 committed by GitHub
parent 708ec5db56
commit 78cdc6d5ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 40 additions and 16 deletions

View File

@ -361,8 +361,6 @@ func (m *Manager) markSpawnFailedTerminated(ctx context.Context, id domain.Sessi
// don't accumulate terminated rows in session lists. DeleteSession only removes
// rows still in seed state; if the row has progressed or the delete itself
// fails, fall back to parking it terminated so a phantom row never looks live.
// (Kill is not a usable fallback here: it refuses seed rows with
// ErrIncompleteHandle before recording terminal intent.)
func (m *Manager) rollbackSpawnSeedRow(ctx context.Context, id domain.SessionID) {
if deleted, err := m.store.DeleteSession(ctx, id); err == nil && deleted {
return
@ -405,6 +403,11 @@ func (m *Manager) RollbackSpawn(ctx context.Context, id domain.SessionID) (delet
// workspace. A workspace teardown refused by the worktree-remove safety
// (uncommitted work) is never forced: the session still terminates and Kill
// succeeds with freed=false, signalling the workspace was preserved.
//
// A session whose runtime handle or workspace path is missing (e.g. spawn
// failed partway, handle lost after a crash) is still terminated — the destroy
// steps are skipped for whatever is absent, but the session record always
// moves to terminal state so it can be cleaned up from the dashboard.
func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
@ -415,22 +418,31 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
}
handle := runtimeHandle(rec.Metadata)
ws := workspaceInfo(rec)
if handle.ID == "" || ws.Path == "" {
return false, fmt.Errorf("kill %s: %w", id, ErrIncompleteHandle)
}
// Always record terminal intent so the session is marked terminated even
// when the runtime/workspace handle is missing.
if err := m.lcm.MarkTerminated(ctx, id); err != nil {
return false, fmt.Errorf("kill %s: %w", id, err)
}
if err := m.runtime.Destroy(ctx, handle); err != nil {
return false, fmt.Errorf("kill %s: runtime: %w", id, err)
}
if err := m.workspace.Destroy(ctx, ws); err != nil {
if errors.Is(err, ports.ErrWorkspaceDirty) {
return false, nil
// Only tear down what exists. A session may have lost its handle after a
// crash or never acquired one if spawn failed partway.
if handle.ID != "" {
if err := m.runtime.Destroy(ctx, handle); err != nil {
return false, fmt.Errorf("kill %s: runtime: %w", id, err)
}
return false, fmt.Errorf("kill %s: workspace: %w", id, err)
}
return true, nil
freed := false
if ws.Path != "" {
if err := m.workspace.Destroy(ctx, ws); err != nil {
if errors.Is(err, ports.ErrWorkspaceDirty) {
return false, nil
}
return false, fmt.Errorf("kill %s: workspace: %w", id, err)
}
freed = true
}
return freed, nil
}
// Restore relaunches a torn-down session in its workspace. The fallible I/O runs

View File

@ -425,11 +425,23 @@ func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) {
t.Fatal("kill should destroy runtime and workspace")
}
}
func TestKill_RefusesIncompleteHandle(t *testing.T) {
// TestKill_TerminatesIncompleteHandle: a session whose runtime handle or
// workspace path is missing is still terminated — the destroy steps are
// skipped, but the session moves to terminal state so it can be cleaned up
// from the dashboard.
func TestKill_TerminatesIncompleteHandle(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
if _, err := m.Kill(ctx, "mer-1"); !errors.Is(err, ErrIncompleteHandle) {
t.Fatalf("want ErrIncompleteHandle, got %v", err)
freed, err := m.Kill(ctx, "mer-1")
if err != nil {
t.Fatalf("want nil error, got %v", err)
}
if freed {
t.Fatal("freed = true, want false for session with no workspace")
}
if !st.sessions["mer-1"].IsTerminated {
t.Fatal("session should be terminated even without a handle")
}
}