Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
10 changes: 8 additions & 2 deletions internal/tui/attention.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{}
}
Expand Down
105 changes: 105 additions & 0 deletions internal/tui/jobs_notify_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
47 changes: 34 additions & 13 deletions internal/tui/jobs_tab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -103,17 +106,18 @@ 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
// is looking; the silent watcher keeps ticking.
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 {
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down