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
16 changes: 16 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "if out=$(go build ./... 2>&1 && go test ./... 2>&1); then GOBIN=\"$HOME/.local/bin\" go install . >/dev/null 2>&1 && jq -n '{systemMessage:\"devdash: tests passed, deployed to ~/.local/bin\"}' || jq -n '{systemMessage:\"devdash: tests passed but go install failed\"}'; else jq -n --arg o \"$out\" '{systemMessage:(\"devdash: NOT deployed - build or tests failed:\\n\"+$o)}'; fi",
"timeout": 180,
"statusMessage": "Building, testing and deploying devdash..."
}
]
}
]
}
}
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ devdash -all-repos # ignore the current repository, show everything
| `p` | open the selected row's pull request |
| `c` | copy a shareable snippet: title, ticket link, every PR link |
| `s` | change the selected ticket's status |
| `S` | schedule the ticket for Symphony, or unschedule it if queued |
| `S` | schedule the ticket for Symphony, or take it back unless an agent is running |
| `r` | refresh now |
| `a` | pause or resume the automatic refresh |
| `?` | keys, icons, and the issue types currently on screen |
Expand Down Expand Up @@ -247,10 +247,18 @@ or has been given, are marked in a column at the right-hand edge:

| Marker | Meaning |
| --- | --- |
| `♪` grey | scheduled, waiting for Symphony to pick it up |
| `♪` | Symphony is working on the ticket |
| `!` | paused waiting for operator input or approval |
| `↻` | waiting for the next retry window |

`S` toggles. On a ticket Symphony would pick up it strips the required labels
again, which is enough to release the ticket: Symphony re-reads the labels before
it retries or reconsiders a blocked issue, so stuck work can be taken back, fixed,
and scheduled afresh. Only a ticket an agent is actively running is refused, since
removing a label cannot interrupt a turn already in progress — stop that session in
Symphony instead. Unrelated labels and the ticket's status are left alone.

The instance is located from the `server` block of `WORKFLOW.md`'s front matter,
searching upward from the working directory:

Expand Down
10 changes: 7 additions & 3 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func writeHelp(w io.Writer) {
{"p", "open the selected row's pull request"},
{"c", "copy a shareable snippet: title, ticket link, every PR link"},
{"s", "change the selected ticket's status"},
{"S", "schedule the ticket for Symphony, or unschedule it if queued"},
{"S", "schedule the ticket for Symphony, or take it back unless an agent is running"},
{"r", "refresh now"},
{"a", "pause or resume the automatic refresh"},
{"?", "keys, columns and the issue types currently on screen"},
Expand Down Expand Up @@ -152,9 +152,13 @@ func writeHelp(w io.Writer) {
prose(w, "On a ticket Symphony would not pick up, S adds whatever is missing. On one it")
prose(w, "already would, S removes the required labels again, leaving the status alone.")
prose(w, "")
prose(w, "Stuck work can be taken back: a blocked or retrying ticket comes out of the")
prose(w, "queue so it can be fixed and scheduled afresh, since Symphony re-reads the")
prose(w, "labels before it acts on either and drops its claim when they are gone.")
prose(w, "")
prose(w, "Refused rather than done: a ticket in a terminal_state, one from another")
prose(w, "project, and unscheduling one Symphony already holds a session for — the")
prose(w, "label would come off without the work stopping.")
prose(w, "project, and unscheduling one an agent is actively running — no label change")
prose(w, "interrupts a turn already in progress, so stop that session in Symphony.")
prose(w, "")
prose(w, "The instance is found from the server block of WORKFLOW.md's front matter,")
prose(w, "rediscovered and queried on every refresh, since Symphony starts and stops")
Expand Down
29 changes: 18 additions & 11 deletions schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,18 @@ func scheduled(cfg symphonyConfig, t Ticket) bool {
return containsFold(cfg.Tracker.ActiveStates, t.Status)
}

// symphonyHasIt reports whether Symphony holds a live session for the ticket.
// Running, blocked and retrying all mean it is in hand; only the scheduled marker
// means nothing is in flight.
func symphonyHasIt(t Ticket) bool {
return t.Symphony != "" && t.Symphony != SymphonyScheduled
// symphonyIsWorkingOnIt reports whether Symphony has an agent actually working the
// ticket, which is the one state a keystroke must not interrupt.
//
// Blocked and retrying are deliberately not included. Both mean Symphony is
// holding the ticket without making progress — waiting for an operator, or failing
// to start and backing off — and that is exactly when taking it back to fix it is
// the point. Symphony re-reads the required labels before it acts on either: a
// retry that fires re-checks routability and drops the claim when the label is
// gone, and blocked issues are reconciled the same way. So removing the label does
// stop the work, rather than merely hiding it from the dashboard.
func symphonyIsWorkingOnIt(t Ticket) bool {
return t.Symphony == SymphonyRunning
}

// planToggle decides what the key does: a ticket Symphony would already pick up
Expand All @@ -67,9 +74,10 @@ func symphonyHasIt(t Ticket) bool {
// would mean guessing where the ticket came from, and a ticket sitting in To Do
// without the label is simply not Symphony's business.
//
// A ticket Symphony already has a session for cannot be unscheduled: removing the
// label would not stop the work, it would only make the dashboard disagree with
// what is happening.
// A ticket an agent is actively running cannot be unscheduled, since removing the
// label would not interrupt the turn in progress. A ticket that is blocked or
// retrying can: Symphony re-checks the labels before it acts again, so the claim is
// dropped and the ticket is free to be fixed and scheduled afresh.
func planToggle(cfg symphonyConfig, t Ticket) schedulePlan {
if !scheduled(cfg, t) {
return planSchedule(cfg, t)
Expand All @@ -83,9 +91,8 @@ func planToggle(cfg symphonyConfig, t Ticket) schedulePlan {
return plan
}
}
if symphonyHasIt(t) {
plan.refusal = fmt.Sprintf("Symphony is already %s %s; stop the session first",
t.Symphony, t.Key)
if symphonyIsWorkingOnIt(t) {
plan.refusal = fmt.Sprintf("an agent is running %s; stop the session first", t.Key)
return plan
}
for _, label := range cfg.Tracker.RequiredLabels {
Expand Down
90 changes: 50 additions & 40 deletions schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,65 +365,75 @@ func TestScheduledPredicate(t *testing.T) {
}
}

// A ticket Symphony already holds cannot be taken back out: removing the label
// would not stop the work, it would only make the dashboard disagree with it.
func TestCannotUnscheduleWhileSymphonyHasIt(t *testing.T) {
// Only an agent actually running the ticket blocks the toggle, since a keystroke
// cannot interrupt a turn in progress.
func TestCannotUnscheduleWhileAnAgentIsRunning(t *testing.T) {
cfg := testConfig()
base := Ticket{Key: "PROJ-1", Status: "In Progress", Labels: []string{"symphony-ready"}}

for _, live := range []string{SymphonyRunning, SymphonyBlocked, SymphonyRetrying} {
t.Run(live, func(t *testing.T) {
ticket := base
ticket.Symphony = SymphonyRunning

plan := planToggle(cfg, ticket)
if plan.refusal == "" {
t.Fatalf("expected a refusal while running, got %+v", plan)
}
if !strings.Contains(plan.refusal, ticket.Key) {
t.Errorf("refusal = %q, want it to name the ticket", plan.refusal)
}
// Nothing may be proposed alongside the refusal.
if len(plan.removeLabels) > 0 || len(plan.addLabels) > 0 || plan.toStatus != "" {
t.Errorf("refused plan still proposes changes: %+v", plan)
}
}

// Stuck work is the case the toggle exists for: a ticket that is blocked or
// failing to start has to come back out so it can be fixed and put back.
func TestCanUnscheduleStuckWork(t *testing.T) {
cfg := testConfig()
base := Ticket{Key: "PROJ-1", Status: "In Progress", Labels: []string{"symphony-ready"}}

for _, state := range []string{
SymphonyBlocked, SymphonyRetrying, SymphonyScheduled, "",
} {
name := state
if name == "" {
name = "no session"
}
t.Run(name, func(t *testing.T) {
ticket := base
ticket.Symphony = live
ticket.Symphony = state

plan := planToggle(cfg, ticket)
if plan.refusal == "" {
t.Fatalf("expected a refusal while %s, got %+v", live, plan)
if plan.refusal != "" {
t.Fatalf("unexpected refusal: %q", plan.refusal)
}
if !strings.Contains(plan.refusal, live) {
t.Errorf("refusal = %q, want it to name the state", plan.refusal)
if !plan.unschedule {
t.Fatalf("plan = %+v, want an unschedule", plan)
}
// Nothing may be proposed alongside the refusal.
if len(plan.removeLabels) > 0 || len(plan.addLabels) > 0 || plan.toStatus != "" {
t.Errorf("refused plan still proposes changes: %+v", plan)
if strings.Join(plan.removeLabels, ",") != "symphony-ready" {
t.Errorf("plan = %+v, want it to remove the label", plan)
}
// The status is left alone: Symphony drops the claim on the label
// going, and guessing where the ticket came from is not this key's job.
if plan.toStatus != "" {
t.Errorf("plan = %+v, want the status untouched", plan)
}
})
}

// Scheduled but not picked up: this is exactly the case that can be undone.
t.Run("scheduled is still reversible", func(t *testing.T) {
ticket := base
ticket.Symphony = SymphonyScheduled

plan := planToggle(cfg, ticket)
if plan.refusal != "" {
t.Fatalf("unexpected refusal: %q", plan.refusal)
}
if !plan.unschedule || strings.Join(plan.removeLabels, ",") != "symphony-ready" {
t.Errorf("plan = %+v, want it to remove the label", plan)
}
})

// No marker at all, e.g. Symphony is not running: still reversible.
t.Run("no session is reversible", func(t *testing.T) {
plan := planToggle(cfg, base)
if plan.refusal != "" || !plan.unschedule {
t.Errorf("plan = %+v, want an unschedule", plan)
}
})
}

func TestSymphonyHasIt(t *testing.T) {
func TestSymphonyIsWorkingOnIt(t *testing.T) {
tests := map[string]bool{
SymphonyRunning: true,
SymphonyBlocked: true,
SymphonyRetrying: true,
SymphonyBlocked: false,
SymphonyRetrying: false,
SymphonyScheduled: false,
"": false,
}
for state, want := range tests {
if got := symphonyHasIt(Ticket{Symphony: state}); got != want {
t.Errorf("symphonyHasIt(%q) = %v, want %v", state, got, want)
if got := symphonyIsWorkingOnIt(Ticket{Symphony: state}); got != want {
t.Errorf("symphonyIsWorkingOnIt(%q) = %v, want %v", state, got, want)
}
}
}
2 changes: 1 addition & 1 deletion view.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ func (a *app) helpView(lay layout) []string {
{"p", "open the selected row's pull request"},
{"c", "copy a shareable snippet of the row to the clipboard"},
{"s", "change the selected ticket's status"},
{"S", "schedule the ticket for Symphony, or unschedule it if queued"},
{"S", "schedule the ticket for Symphony, or take it back unless an agent is running"},
{"r", "refresh now"},
{"a", "toggle automatic refresh"},
{"?", "toggle this help"},
Expand Down
Loading