diff --git a/README.md b/README.md index 4da6faf..51ae9e1 100644 --- a/README.md +++ b/README.md @@ -404,8 +404,10 @@ shared grammar: the delegating transcript step, `⏎` the full registry record — trust, budget, cost, and artifact lines included. - **Jobs** — the session's background commands (odek ≥ v1.38), live-polled - every 3s; a background watcher surfaces starts and exits as transcript - notes even with the tab closed. `⏎` opens the job's output viewer (`f` + every 3s; a background watcher surfaces starts as transcript notes and + exits as **alert-tier notes naming the command** — with a bell / desktop + notification (same gates as turns) so a finished job is never missed, + even with the tab closed. `⏎` opens the job's output viewer (`f` pages further output), `s` stops a running job (two-step, same gate as `/stop`). Jobs are session-scoped: other sessions' jobs never appear. - **Events** — the `odek.event/v1` ring: `f` filter to this session, `x` diff --git a/internal/tui/attention.go b/internal/tui/attention.go index 3d88445..2178f50 100644 --- a/internal/tui/attention.go +++ b/internal/tui/attention.go @@ -11,8 +11,10 @@ import ( type attentionKind int const ( - attentionDone attentionKind = iota // a turn finished (done event) - attentionApproval // an approval is waiting (approval_request) + attentionDone attentionKind = iota // a turn finished (done event) + attentionApproval // an approval is waiting (approval_request) + attentionJobDone // a background job exited cleanly (jobs watcher) + attentionJobFailed // a background job failed / timed out / was killed ) // attention is the plan of terminal-attention effects for one state change. @@ -58,6 +60,10 @@ func (m *Model) attentionFor(kind attentionKind) attention { prefix, note = "⚠ approval needed", "bodek: approval needed" case attentionDone: prefix, note = "✓ done", "bodek: turn complete" + case attentionJobDone: + prefix, note = "✓ bg job done", "bodek: background job finished" + case attentionJobFailed: + prefix, note = "✗ bg job failed", "bodek: background job failed" default: return attention{} } diff --git a/internal/tui/jobs_notify_test.go b/internal/tui/jobs_notify_test.go new file mode 100644 index 0000000..44ed95a --- /dev/null +++ b/internal/tui/jobs_notify_test.go @@ -0,0 +1,105 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ── bg job completion visibility (J1 + J2) ────────────────────────────────── +// +// A finished background job used to surface as a 3-second transient note — +// on a 10-second poll cadence, with no command context and no attention +// trigger. Practically invisible. The contract now: terminal transitions +// post alert-tier notes that name the command, and fire the attention +// layer (bell / OSC 9) so the operator is actually told. + +// exitedJob is a finished job snapshot with a recognizable command head. +func exitedJob(status string, code int) client.Job { + c := code + return client.Job{ID: "bg_0000abcd", Command: "gh pr checks 63 --watch", + Status: status, RuntimeS: 104, ExitCode: &c} +} + +// seedRunning primes the diff map so the next applyJobs sees a transition. +func seedRunning(t *testing.T) *Model { + t.Helper() + m := newTestModel() + m.applyJobs([]client.Job{{ID: "bg_0000abcd", Command: "gh pr checks 63 --watch", + Status: "running", RuntimeS: 3}}, nil) + return m +} + +// TestJobExitAlertTier pins J1: a terminal transition posts an alert-tier +// note (dwell in (noticeTTL, alertTTL]), not a 3s transient. +func TestJobExitAlertTier(t *testing.T) { + m := seedRunning(t) + m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil) + + note, exp := lastNoteMatching(m, "bg_0000abcd") + if note == "" { + t.Fatal("exit transition posted no note") + } + if dwell := time.Until(exp); dwell <= noticeTTL || dwell > alertTTL { + t.Errorf("exit note dwell = %v, want alert tier (%v, %v]", dwell, noticeTTL, alertTTL) + } +} + +// TestJobExitNoteNamesCommand pins the command context: the exit note +// carries the sanitized command head so the operator knows WHICH job. +func TestJobExitNoteNamesCommand(t *testing.T) { + m := seedRunning(t) + m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil) + + note, _ := lastNoteMatching(m, "bg_0000abcd") + for _, want := range []string{"gh pr checks 63 --watch", "exited 0", "1m44s"} { + if !strings.Contains(note, want) { + t.Errorf("exit note missing %q: %q", want, note) + } + } +} + +// TestJobExitAttentionCmd pins J2: a transition fires the attention layer; +// a steady-state re-apply fires nothing. +func TestJobExitAttentionCmd(t *testing.T) { + m := seedRunning(t) + var attn tea.Cmd + attn = m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil) + if attn == nil { + t.Fatal("terminal transition fired no attention") + } + + if attn = m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil); attn != nil { + t.Error("steady-state re-apply must not re-fire attention") + } +} + +// TestJobAttentionKinds covers the new kinds' rendering plan: ✓ for clean +// exits, ✗ for failures; notification and bell stay user-gated. +func TestJobAttentionKinds(t *testing.T) { + if jobAttentionKind(exitedJob("exited", 0)) != attentionJobDone { + t.Error("clean exit should map to attentionJobDone") + } + for _, s := range []string{"failed", "timeout", "killed"} { + if jobAttentionKind(exitedJob(s, 1)) != attentionJobFailed { + t.Errorf("%s should map to attentionJobFailed", s) + } + } + + m := newTestModel() + m.notify = true + if a := m.attentionFor(attentionJobDone); !strings.Contains(a.sequence(), "bg job done") { + t.Errorf("done attention missing title/notify: %q", a.sequence()) + } + if a := m.attentionFor(attentionJobFailed); !strings.Contains(a.sequence(), "bg job failed") { + t.Errorf("failed attention missing title/notify: %q", a.sequence()) + } + m.notify = false + if a := m.attentionFor(attentionJobDone); strings.Contains(a.sequence(), "\x1b]9;") { + t.Errorf("notify disabled but OSC 9 emitted: %q", a.sequence()) + } +} diff --git a/internal/tui/jobs_tab.go b/internal/tui/jobs_tab.go index bb03450..dac3a1c 100644 --- a/internal/tui/jobs_tab.go +++ b/internal/tui/jobs_tab.go @@ -18,8 +18,9 @@ import ( // agent-side completion notice never leaves the LLM payload. bodek therefore // watches GET /api/jobs itself: a 10s cadence whenever a session is live — // the canonical case is a job finishing while the operator reads output — -// stepping up to 3s while the tab is visible. Diffs surface as transient -// notes; the tab is the full surface (rows, output detail, stop). +// stepping up to 3s while the tab is visible. Terminal transitions surface +// as alert-tier notes naming the command and fire the attention layer; the +// tab is the full surface (rows, output detail, stop). const ( jobsPollEvery = 3 * time.Second // tab visible: live view @@ -91,10 +92,12 @@ func (m *Model) fetchJobs() tea.Cmd { } } -// applyJobs stores a snapshot; watcher diffs become transient notes. The -// first successful snapshot baselines silently — jobs that predate the -// attach belong to the tab, not the transcript. -func (m *Model) applyJobs(jobs []client.Job, err error) { +// applyJobs stores a snapshot; watcher diffs become alert-tier notes — a +// finished job is actionable, not housekeeping — and terminal transitions +// fire the attention layer (bell / OSC 9). The first successful snapshot +// baselines silently — jobs that predate the attach belong to the tab, not +// the transcript. Returns the attention cmd (nil without a transition). +func (m *Model) applyJobs(jobs []client.Job, err error) tea.Cmd { if err != nil { if errors.Is(err, client.ErrJobsUnavailable) { m.jobsOff = true @@ -103,7 +106,7 @@ func (m *Model) applyJobs(jobs []client.Job, err error) { m.panelMsg = "background commands need odek ≥ v1.38.0" m.refresh() } - return + return nil } if m.panel == panelJobs { // Transient network blips surface here only when the operator @@ -111,9 +114,10 @@ func (m *Model) applyJobs(jobs []client.Job, err error) { m.panelMsg = "error: " + err.Error() m.refresh() } - return + return nil } m.jobs = jobs + var attn tea.Cmd if m.jobsPrev == nil { m.jobsPrev = make(map[string]string, len(jobs)) for _, j := range jobs { @@ -133,7 +137,10 @@ func (m *Model) applyJobs(jobs []client.Job, err error) { case !seen: m.addTransientNote("bg · " + jobStatusGlyph(j.Status) + " " + sanitize(j.ID) + " · " + sanitize(j.Command)) case old == "running" && j.Status != "running": - m.addTransientNote(jobExitNote(j)) + // Alert tier + attention: a finished job must survive a + // glance-away, and the operator opted into knowing. + m.addNote(jobExitNote(j)) + attn = m.attentionCmd(m.attentionFor(jobAttentionKind(j))) } m.jobsPrev[j.ID] = j.Status } @@ -147,6 +154,7 @@ func (m *Model) applyJobs(jobs []client.Job, err error) { if m.panelSel >= m.panelLen() { m.panelSel = max(m.panelLen()-1, 0) } + return attn } // rearmJobs schedules the next fetch: the fast chain while the tab is @@ -339,16 +347,29 @@ func jobStatusGlyph(status string) string { return "·" } -// jobExitNote is the watcher's terminal-transition note: glyph, id, terminal -// status, exit code when the server reported one, humanized runtime. +// jobExitNote is the watcher's terminal-transition note: glyph, id, command +// head, terminal status, exit code when the server reported one, humanized +// runtime. func jobExitNote(j client.Job) string { - s := jobStatusGlyph(j.Status) + " " + sanitize(j.ID) + " " + j.Status + s := jobStatusGlyph(j.Status) + " " + sanitize(j.ID) + " · " + truncate(sanitize(j.Command), 48) if j.ExitCode != nil { - s += fmt.Sprintf(" %d", *j.ExitCode) + s += " — " + j.Status + fmt.Sprintf(" %d", *j.ExitCode) + } else { + s += " — " + j.Status } return s + " · " + fmtRuntime(j.RuntimeS) } +// jobAttentionKind maps a terminal job status to its attention kind. +func jobAttentionKind(j client.Job) attentionKind { + switch j.Status { + case "failed", "timeout", "killed": + return attentionJobFailed + default: + return attentionJobDone + } +} + // fmtRuntime humanizes seconds the way the transcript does durations. func fmtRuntime(s float64) string { if s < 0 { diff --git a/internal/tui/model.go b/internal/tui/model.go index 76d8101..f91455c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -577,9 +577,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.handleJobsTick(msg) case jobsFetchedMsg: - m.applyJobs(msg.jobs, msg.err) + attn := m.applyJobs(msg.jobs, msg.err) m.refresh() - return m, tea.Batch(m.rearmJobs(), m.noticeSweep()) + return m, tea.Batch(m.rearmJobs(), m.noticeSweep(), attn) case jobOutputMsg: return m, m.handleJobOutput(msg)