on main help sign in
fix(shell): skip persistence when session no longer exists
commita9e3a57f7a9f40cd411e5ac1b98156b8240ed1fb author石岳峰 <132282304+syf2211@users.noreply.github.com> dateTue 30 Jun 2026, 03:49 (3 months ago) parent188dea6
5 files changed+169−19
internal/backend/agent.go+1−10 internal/backend/shell_command_test.go+54 internal/shell/persist_message.go+44 internal/shell/persist_message_test.go+69 internal/workspace/app_workspace.go+1−9
internal/backend/agent.go+1−10
@@ -8,7 +8,6 @@ import (
88 "github.com/charmbracelet/crush/internal/agent"
99 "github.com/charmbracelet/crush/internal/agent/notify"
1010 "github.com/charmbracelet/crush/internal/config"
11− "github.com/charmbracelet/crush/internal/message"
1211 "github.com/charmbracelet/crush/internal/proto"
1312 "github.com/charmbracelet/crush/internal/pubsub"
1413 "github.com/charmbracelet/crush/internal/shell"
@@ -253,15 +252,7 @@ func (b *Backend) RunShellCommand(ctx context.Context, workspaceID string, req p
253252 var persist shell.PersistFunc
254253 if req.SessionID != "" {
255254 persist = func(cmd, output string, exitCode int) error {
256− _, err := ws.Messages.Create(ctx, req.SessionID, message.CreateMessageParams{
257− Role: message.User,
258− Parts: []message.ContentPart{message.ShellCommand{
259− Command: cmd,
260− Output: output,
261− ExitCode: exitCode,
262− }},
263− })
264− return err
255+ return shell.PersistOutput(ctx, ws.Messages, req.SessionID, cmd, output, exitCode)
265256 }
266257 }
267258
internal/backend/shell_command_test.gonew file+54
@@ -0,0 +1,54 @@
1+package backend
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/app"
8+ "github.com/charmbracelet/crush/internal/db"
9+ "github.com/charmbracelet/crush/internal/message"
10+ "github.com/charmbracelet/crush/internal/proto"
11+ "github.com/charmbracelet/crush/internal/session"
12+ "github.com/google/uuid"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestRunShellCommand_SkipsPersistenceForMissingSession(t *testing.T) {
17+ t.Parallel()
18+
19+ conn, err := db.Connect(t.Context(), t.TempDir())
20+ require.NoError(t, err)
21+ t.Cleanup(func() { conn.Close() })
22+
23+ q := db.New(conn)
24+ sessions := session.NewService(q, conn)
25+ messages := message.NewService(q)
26+
27+ b, _ := newTestBackend(t)
28+ ws := &Workspace{
29+ ID: uuid.New().String(),
30+ Path: t.TempDir(),
31+ resolvedPath: t.TempDir(),
32+ clients: make(map[string]*clientState),
33+ shutdownFn: func() {},
34+ }
35+ ws.App = &app.App{
36+ Sessions: sessions,
37+ Messages: messages,
38+ }
39+ ws.ctx, ws.cancel = context.WithCancel(b.ctx)
40+ InsertWorkspaceForTest(b, ws)
41+
42+ missingSessionID := uuid.New().String()
43+ resp, err := b.RunShellCommand(t.Context(), ws.ID, proto.ShellCommandRequest{
44+ SessionID: missingSessionID,
45+ Command: "echo hello",
46+ })
47+ require.NoError(t, err)
48+ require.Equal(t, "hello\n", resp.Output)
49+ require.Zero(t, resp.ExitCode)
50+
51+ stored, err := messages.List(t.Context(), missingSessionID)
52+ require.NoError(t, err)
53+ require.Empty(t, stored)
54+}
internal/shell/persist_message.gonew file+44
@@ -0,0 +1,44 @@
1+package shell
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "strings"
7+
8+ "github.com/charmbracelet/crush/internal/message"
9+)
10+
11+// PersistOutput stores a bang-mode shell command result as a user message.
12+// If the target session no longer exists (deleted before or during the
13+// command), persistence is skipped without surfacing an error.
14+func PersistOutput(
15+ ctx context.Context,
16+ messages message.Service,
17+ sessionID, command, output string,
18+ exitCode int,
19+) error {
20+ if sessionID == "" {
21+ return nil
22+ }
23+
24+ _, err := messages.Create(ctx, sessionID, message.CreateMessageParams{
25+ Role: message.User,
26+ Parts: []message.ContentPart{message.ShellCommand{
27+ Command: command,
28+ Output: output,
29+ ExitCode: exitCode,
30+ }},
31+ })
32+ // The messages table has a single foreign key (session_id), so an FK
33+ // failure here can only mean the session is gone. We match on the error
34+ // text because the codebase builds against two swappable SQLite drivers
35+ // (modernc and ncruces) and this is the one signal stable across both.
36+ if err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
37+ slog.Debug(
38+ "Skipping shell command persistence: session no longer exists",
39+ "session_id", sessionID,
40+ )
41+ return nil
42+ }
43+ return err
44+}
internal/shell/persist_message_test.gonew file+69
@@ -0,0 +1,69 @@
1+package shell
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/db"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/session"
9+ "github.com/google/uuid"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestPersistOutput_SkipsMissingSession(t *testing.T) {
14+ t.Parallel()
15+
16+ conn, err := db.Connect(t.Context(), t.TempDir())
17+ require.NoError(t, err)
18+ t.Cleanup(func() { conn.Close() })
19+
20+ messages := message.NewService(db.New(conn))
21+
22+ missingID := uuid.New().String()
23+ err = PersistOutput(t.Context(), messages, missingID, "cat file.txt", "hello", 0)
24+ require.NoError(t, err)
25+
26+ stored, err := messages.List(t.Context(), missingID)
27+ require.NoError(t, err)
28+ require.Empty(t, stored)
29+}
30+
31+func TestPersistOutput_NoOpForEmptySessionID(t *testing.T) {
32+ t.Parallel()
33+
34+ conn, err := db.Connect(t.Context(), t.TempDir())
35+ require.NoError(t, err)
36+ t.Cleanup(func() { conn.Close() })
37+
38+ messages := message.NewService(db.New(conn))
39+
40+ require.NoError(t, PersistOutput(t.Context(), messages, "", "echo hi", "hi", 0))
41+}
42+
43+func TestPersistOutput_PersistsForExistingSession(t *testing.T) {
44+ t.Parallel()
45+
46+ conn, err := db.Connect(t.Context(), t.TempDir())
47+ require.NoError(t, err)
48+ t.Cleanup(func() { conn.Close() })
49+
50+ q := db.New(conn)
51+ sessions := session.NewService(q, conn)
52+ messages := message.NewService(q)
53+
54+ sess, err := sessions.Create(t.Context(), "shell test")
55+ require.NoError(t, err)
56+
57+ err = PersistOutput(t.Context(), messages, sess.ID, "cat file.txt", "hello", 0)
58+ require.NoError(t, err)
59+
60+ stored, err := messages.List(t.Context(), sess.ID)
61+ require.NoError(t, err)
62+ require.Len(t, stored, 1)
63+ require.Equal(t, message.User, stored[0].Role)
64+ shellParts := stored[0].ShellCommands()
65+ require.Len(t, shellParts, 1)
66+ require.Equal(t, "cat file.txt", shellParts[0].Command)
67+ require.Equal(t, "hello", shellParts[0].Output)
68+ require.Zero(t, shellParts[0].ExitCode)
69+}
internal/workspace/app_workspace.go+1−9
@@ -114,15 +114,7 @@ func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, comm
114114 var persist shell.PersistFunc
115115 if sessionID != "" {
116116 persist = func(cmd, output string, exitCode int) error {
117− _, err := w.app.Messages.Create(ctx, sessionID, message.CreateMessageParams{
118− Role: message.User,
119− Parts: []message.ContentPart{message.ShellCommand{
120− Command: cmd,
121− Output: output,
122− ExitCode: exitCode,
123− }},
124− })
125− return err
117+ return shell.PersistOutput(ctx, w.app.Messages, sessionID, cmd, output, exitCode)
126118 }
127119 }
128120