From 3da38e884f357d3fa95b0dbab300b9894c11670b Mon Sep 17 00:00:00 2001 From: San Lee <295248956+sanlee-ys@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:37:24 -0400 Subject: [PATCH] council: /retry sends the last brief again, to the seats that owe an answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn where four seats replied and one failed, or was cut with `x`, left the operator retyping the brief and the mentions to finish it. That is arithmetic at the keyboard on the one line where getting it wrong bills seats that already answered -- the complaint `-@` and `/unseat` were built for, one turn later. `/retry` puts the last dispatched brief back in the composer, addressed to the seats that did not answer it: `@codex @agy `, a draft the operator could have typed, in the grammar that already exists. The brief comes from the columns' own per-turn record and goes out unchanged. It arms; it does not dispatch. setDraft re-derives the route, and the footer prices it through the same State.SeatsIn intersection dispatch gates on, so the operator reads the bill before paying it and can edit the draft first. What counts as an answer is defined against the four endings. PhaseDone answered, including the seat whose body is the "0 text chunks streamed" placeholder -- that is a measured zero, not a missing reply. PhaseFailed and PhaseCancelled did not, and cancelled covers ctrl+c and the per-seat give-up together. A seat that sat the turn out is not a candidate at all, or a /retry after an @codex turn would widen the bill to seats nobody addressed. Bare-only, on /read and /write's rule: "/retry the failing test" is a brief, and a verb that took it as an argument would run a re-send and discard the sentence. Three refusals, three sentences -- a turn in flight (which keeps the draft), no brief on record, and every seat answered. The verb sits in roomVerbs, so §9.31's walked refusal teaches it with no second copy of the vocabulary; the notice still fits the reference width. `--brief` stays unfiled. This verb re-sends text and never reads Model.brief, so §9.17's re-briefing question is still open and still to be decided on its own. Tests: roomcmd_test.go covers the four endings, the measured zero, the three refusals, the bare-word rule and the walked-table appearance, plus a dispatch-level test (countSpawns stubs) that the re-send spawns one process per seat owing an answer and none for a seat that replied. The two slash-refusal goldens carry the new word. No test spawns a vendor. Verified offline on the Intel Mac: `go vet ./...` and the full `go test ./...` are green. No live room was driven; nothing here is a claim about vendor behaviour. Co-Authored-By: Claude Fable 5 --- docs/council.md | 11 + docs/design.md | 70 +++++ internal/council/roomcmd.go | 136 +++++++++ internal/council/roomcmd_test.go | 273 ++++++++++++++++++ .../testdata/golden/slash-refusal-ascii.txt | 2 +- .../council/testdata/golden/slash-refusal.txt | 2 +- 6 files changed, 492 insertions(+), 2 deletions(-) diff --git a/docs/council.md b/docs/council.md index ab3843f..1ad7271 100644 --- a/docs/council.md +++ b/docs/council.md @@ -353,6 +353,17 @@ and command authority to every seat. Both refuse while a turn is in flight, and kills anything: seats move on their next turn. Only the bare word is a command, so `/write a test for this` never changes the posture. +**`/retry` sends the last brief again, to the seats that owe an answer.** A turn where four +seats replied and one failed, was cut with `x`, or fell over leaves you retyping the brief and +the mentions to finish it. Type `/retry` instead: the brief comes back into the composer +addressed to exactly the seats that did not answer — `@codex @agy ` — and **enter is +still what sends it**, so the footer prices the re-send before you pay for it and you can edit +the draft first. A seat that answered is never re-sent to, and neither is one that sat the turn +out, so the bill never grows past the turn you are finishing. A seat that finished and streamed +nothing counts as having answered: that is a measured zero, not a missing reply. It refuses +while a turn is in flight, when nothing has been dispatched yet, and when every seat answered. +The text goes out unchanged — `/retry` re-sends, it does not re-brief. + **A slash the room does not know is refused, not sent.** A draft that opens with `/` and names no room command used to go to the vendors as a brief, so a mistyped verb cost every seated seat a turn. Now nothing spawns, the draft stays in the composer, and the notice names diff --git a/docs/design.md b/docs/design.md index a35ef99..1559984 100644 --- a/docs/design.md +++ b/docs/design.md @@ -10139,6 +10139,76 @@ the honest shape rather than only a saving: the two take one argument in one voc only in direction, so a reader who finds either has found both. "times" paid for the width — the row is a list of controls, and `/trace ` is unambiguous without the verb. +#### Amendment, 2026-08-17: `/retry` sends the last brief again, to the seats that owe an answer + +**The gap.** §9.37's 2026-08-17 amendment gave the operator a way to stop ONE seat of an ordinary +turn, and it argued from the room's most probable live failure: five seats on an `@all` turn, four +answers, one vendor that fails or stalls. The room can now end that turn. It has no way to finish +the brief. The only act left is to retype the brief and retype the mentions — arithmetic at the +keyboard, on the one line where getting it wrong bills seats that already answered. That is the +same complaint `-@` and `/unseat` were built for, one turn later. + +**`/retry` puts the last dispatched brief back in the composer, addressed to the seats that did not +answer it.** The brief comes from the columns' own per-turn record (`Column.Prompt`), unchanged. The +mentions are the seats that owe an answer, so the draft reads `@codex @agy ` — a draft the +operator could have typed, in the grammar that already exists. + +**It arms; it does not dispatch, and enter is still what spends the money.** The verb writes a +draft, `setDraft` re-derives the route from it, and the footer prices that route through the same +`State.SeatsIn` intersection dispatch gates on (§9.21). So the operator reads the bill before paying +it, and can edit the draft — drop a seat off the front, fix a word — because it is an ordinary +draft. A verb that spawned on the spot would spend up to five quotas on a keystroke that named none +of them, and the room would have no surface left on which to say which ones. + +**What counts as an ANSWER is defined against the four endings**, and it is narrower than "the +column looks empty": + +- **`PhaseDone` answered**, including the seat whose body reads `[Turn completed with 0 text chunks + streamed]`. That is a measured zero, not a missing reply (§4a.1), and re-sending on it would be + the room overruling a vendor's honest empty answer — and billing a seat that did the work. +- **`PhaseFailed` and `PhaseCancelled` did not.** Cancelled covers `ctrl+c` and the per-seat + give-up together, deliberately: a seat the operator cut is the case this verb exists for, and + after the turn the room holds one cancelled phase for both. +- **A seat that SAT THE TURN OUT is not a candidate at all.** `dispatch` never calls `startTurn` on + it, so its `Column.TurnN` still names the last turn it took and the scan skips it. That is + load-bearing rather than incidental: without it, a `/retry` after an `@codex` turn would widen the + bill to four seats the operator deliberately did not address. + +**Bare-only, on `/read` and `/write`'s rule.** The verb takes no argument, and "/retry the failing +test" is a sentence someone types. A verb that swallowed that argument would run a re-send and +discard the brief — §9.17's vanishing-brief failure. The bare draft is the command; anything longer +is refused with the space escape named, which costs nothing. + +**Three refusals, three sentences, and only the first keeps the draft.** *A turn in flight*: the +phases this verb reads are not settled, so any list it produced would be a claim about a turn that +has not ended — and the operator still wants the verb one turn later, which is `postureCommand`'s +own reason for holding the draft. *No brief on record*: turn 0 and the degenerate turn whose brief +sanitized away to nothing are one sentence, because they are one fact. *Every seat answered*: there +is nothing to re-send, and saying so beats a composer the operator has to clear by hand. The verb +sits in `roomVerbs` like every other, so §9.31's walked refusal teaches it for free — no second copy +of the vocabulary was added, and the notice fits the reference width with one cell to spare. + +**`--brief` stays unfiled, and this verb is careful not to file it.** It re-sends the brief +UNCHANGED: no re-briefing, no edit, no automatic second attempt, and it never reads `Model.brief`. +§9.17's sweep left `--brief` out on the ruling that first-turn context is a different feature from +re-briefing; that question is still open and still to be decided on its own. + +**After a race it re-sends the race's brief as an ORDINARY turn.** `Column.Prompt` holds the brief +the racers were given and never the `/arena` draft that wrapped it, and this verb invents no grammar +to put the wrapper back. The composer shows exactly what will be sent before enter, which is where +the operator reads that the worktrees are not part of it; `/arena ` races again. + +**The help panel does not name it**, on `/adopt` and `/arena drop`'s precedent: the room-controls +row is at its budget (§9.20), and the verb is taught by the slash refusal and by this block. + +Verified offline only. `roomcmd_test.go` pins the four endings producing the right seat list, the +measured zero counting as an answer, the three refusals, the bare-word rule, the verb's appearance +in the walked refusal table, and — at the dispatch level, with `countSpawns` — that the re-send +spawns one process per seat owing an answer and none for a seat that already replied. The +`slash-refusal` goldens and their `--ascii` twins carry the new word. No test here spawns a vendor. +A live `/retry` on the Windows reference box is not owed as a separate payment: nothing here is a +claim about vendor behaviour, and every process the verb can cause is an ordinary turn's spawn. + ### 9.32 the room remembered where it was and forgot who was in it diff --git a/internal/council/roomcmd.go b/internal/council/roomcmd.go index eeff080..997ffb8 100644 --- a/internal/council/roomcmd.go +++ b/internal/council/roomcmd.go @@ -86,6 +86,13 @@ func roomVerbs() []roomVerb { {verb: "/cd", run: (*Model).cdCommand}, {verb: "/flow"}, {verb: "/read", bare: true, run: func(m *Model, _ string) bool { return m.postureCommand(false) }}, + // Bare-only, for /read and /write's reason rather than for symmetry: it + // takes no argument, and "/retry the failing test" is a sentence someone + // types. A verb that swallowed that argument would run a re-send and + // discard the brief the user meant — §9.17's vanishing-brief failure. The + // bare form is the command; anything after it is refused with the space + // escape named, which costs nothing. + {verb: "/retry", bare: true, run: (*Model).retryCommand}, {verb: "/seat", run: (*Model).seatCommand}, {verb: "/trace", run: (*Model).traceCommand}, {verb: "/unseat", run: (*Model).unseatCommand}, @@ -880,6 +887,135 @@ func (m *Model) applyPosture(write bool) { } } +// retryCommand puts the last dispatched brief back in the composer, addressed +// to the seats that did not answer it. +// +// IT DOES NOT DISPATCH, and that is the shape of the control rather than a +// caution. The seats a re-send would bill are read off a turn that is already +// over, so the operator can see the bill before paying it: setDraft re-derives +// the route from the draft this builds, and the footer prices it through the +// same State.SeatsIn arithmetic dispatch itself gates on (§9.21). Enter sends +// it, the way enter sends any other brief. A verb that spawned on the spot +// would spend up to five quotas on a keystroke that named none of them, and the +// room would have no surface on which to say which ones. +// +// THE DRAFT IS A MENTION LIST AND A BRIEF, which is why this adds no grammar. +// "@codex @agy " is what a user types by hand, so ParseRoute reads it, +// the footer renders it, and dispatch intersects it with the roster exactly as +// it does for typed text. The draft is also editable, because it is an ordinary +// draft — the operator can drop a seat off the front before pressing enter. +// +// WHAT COUNTS AS AN ANSWER is defined against the four endings §9.37's +// 2026-08-17 amendment names, and it is narrower than "the column looks empty": +// +// - PhaseDone ANSWERED. That includes the seat whose body reads "[Turn +// completed with 0 text chunks streamed]": it is a measured zero, not a +// missing reply (§4a.1), and re-sending on it would be the room overruling a +// vendor's honest empty answer. +// - PhaseFailed and PhaseCancelled did not answer. Cancelled covers ctrl+c and +// the per-seat give-up (`x`) together, deliberately: a seat the operator cut +// is the case a re-send exists for, and after the turn the room holds one +// cancelled phase for both. +// - A seat that SAT THE TURN OUT is not a candidate at all. dispatch never +// calls startTurn on it, so its Column.TurnN still names the last turn it +// took and the scan below skips it. That is load-bearing: without it, a +// /retry after an "@codex" turn would widen the bill to four seats the +// operator deliberately did not address. +// +// It re-sends the brief UNCHANGED and does nothing else. No re-briefing, no +// edit, no automatic second attempt. That is what keeps §9.17's `--brief` +// question unfiled: `--brief` is first-turn context by definition, re-briefing +// is a separate feature to be decided on its own, and this verb never reads +// Model.brief. +// +// AFTER A RACE it re-sends the race's brief as an ORDINARY turn. Column.Prompt +// holds the brief the racers were given and never the "/arena" draft that +// wrapped it, and this verb invents no grammar to put the wrapper back. The +// composer shows exactly what will be sent before enter, which is where the +// operator reads that the worktrees are not part of it; "/arena " races +// again. +// +// THREE REFUSALS, THREE SENTENCES, and only the first keeps the draft. A turn in +// flight: the phases this verb reads are not settled, so any list it produced +// would be a claim about a turn that has not ended — and the operator still +// wants the verb one turn later, which is postureCommand's own reason for +// holding the draft. No brief on record: turn 0 and the degenerate turn whose +// brief sanitized away to nothing are one sentence, because they are one fact. +// Every seat answered: there is nothing to re-send, and saying so is better than +// a composer the operator has to clear by hand. +func (m *Model) retryCommand(_ string) bool { + if m.turn != nil { + m.st.Notice = "a turn is in flight — /retry re-sends between turns" + return true + } + + brief, seats := m.lastTurnUnanswered() + if brief == "" { + m.st.Notice = "no brief to re-send — /retry sends the last one again to the seats that did not answer it" + m.setDraft("") + return true + } + if len(seats) == 0 { + m.st.Notice = "every seat answered turn " + itoa(m.st.Turn) + " — /retry has nothing to re-send" + m.setDraft("") + return true + } + + var draft strings.Builder + names := make([]string, 0, len(seats)) + for _, v := range seats { + draft.WriteString("@" + string(v) + " ") + names = append(names, string(v)) + } + draft.WriteString(brief) + m.setDraft(draft.String()) + // ", " joins the names, because Route.label joins them that way and the + // footer is about to print the same set one line below this notice. Two + // spellings of one list would read as two lists. + m.st.Notice = strings.Join(names, ", ") + " did not answer turn " + itoa(m.st.Turn) + + " — enter re-sends that brief to them, and to no other seat" + return true +} + +// lastTurnUnanswered reads the previous turn off the columns: the brief it +// carried, and the seats it left without an answer. +// +// THE COLUMNS ARE THE RECORD HERE, not Column.History, and that is not a +// shortcut. startTurn files a turn into History when the NEXT one is dispatched, +// so between turns the finished turn is still the live one on the column; +// reading History would answer about the turn before last. +// +// The brief is taken from the first column that took the turn, and any of them +// would do — dispatch sanitizes ONE echo for the whole turn and hands that same +// string to every seat it starts. It is read WITHOUT the seating filter, so a +// seat unseated since the turn can still supply the text it was asked. The seat +// list applies the filter, because a mention of an unseated seat prices nothing: +// State.SeatsIn intersects the route with the roster, and dispatch drops the +// same seat for the same reason. +// +// An empty brief with seats behind it is possible and is left to the caller: a +// draft of nothing but control characters passes dispatch's own empty check and +// sanitizes to "", so the turn is on record with no text to re-send. +func (m *Model) lastTurnUnanswered() (brief string, seats []model.VendorID) { + last := m.st.Turn + if last == 0 { + return "", nil + } + for _, c := range m.st.Columns { + if c.TurnN != last { + continue + } + if brief == "" { + brief = c.Prompt + } + if c.Phase == PhaseDone || !m.st.seats(c) { + continue + } + seats = append(seats, c.Vendor) + } + return brief, seats +} + // plural is the one-word difference between "1 turn" and "2 turns". func plural(n int, word string) string { if n == 1 { diff --git a/internal/council/roomcmd_test.go b/internal/council/roomcmd_test.go index acd302a..b762cc6 100644 --- a/internal/council/roomcmd_test.go +++ b/internal/council/roomcmd_test.go @@ -399,6 +399,279 @@ func TestRunRejectsAMissingCdDirectory(t *testing.T) { } } +// --- /retry: the last brief, sent again to the seats that owe an answer ----- +// +// Every assertion below is about a COST: what the composer is about to bill, +// and what actually spawned. The verb's whole shape is that it arms and does +// not dispatch, so "nothing spawned" is the claim on the arming half and "only +// these seats spawned" is the claim on the sending half. + +// retryRoom is a four-seat room whose last turn is OVER, carrying one column +// for each of the four ways a seat ends a turn (§9.37's 2026-08-17 amendment): +// answered, failed, given up, and sat out. +// +// The sat-out seat keeps the turn number of the last turn it TOOK, because that +// is what dispatch leaves behind for a seat it never started — startTurn is not +// called on it. A fixture that stamped turn 3 on it would be testing a state +// the room cannot produce. +func retryRoom(t *testing.T) *Model { + t.Helper() + m := flowRoom(t, true) + m.st.Turn = 3 + const brief = "the brief that half landed" + for i := range m.st.Columns { + c := &m.st.Columns[i] + switch c.Vendor { + case model.VendorClaude: + c.TurnN, c.Phase, c.Prompt, c.Body = 3, PhaseDone, brief, "an answer" + case model.VendorCodex: + c.TurnN, c.Phase, c.Prompt = 3, PhaseFailed, brief + c.Note = "exit status 1" + case model.VendorAntigravity: + c.TurnN, c.Phase, c.Prompt = 3, PhaseCancelled, brief + c.Note = "given up after 4m12s — nothing had arrived, and its process is dead" + case model.VendorCursor: + c.TurnN, c.Phase, c.Prompt, c.Skipped = 2, PhaseDone, "an earlier brief", true + c.Note = "not addressed in turn 3" + } + } + return m +} + +// TestRetryAddressesOnlyTheSeatsThatDidNotAnswer is the verb's contract in one +// test: the brief comes back unchanged, the mentions name the failed and the +// given-up seat, and the two that have nothing to re-send — the one that +// answered and the one that sat the turn out — are not billed. +func TestRetryAddressesOnlyTheSeatsThatDidNotAnswer(t *testing.T) { + log := countSpawns(t) + m := retryRoom(t) + m.setDraft("/retry") + + if !m.roomCommand() { + t.Fatal("/retry was not recognised as a room command") + } + if log.n() != 0 { + t.Fatalf("/retry spawned %d process(es) — it arms the composer, it does not dispatch", log.n()) + } + if m.st.Turn != 3 { + t.Errorf("/retry counted itself as a turn: %d", m.st.Turn) + } + if want := "@codex @agy the brief that half landed"; m.st.Draft != want { + t.Errorf("draft = %q, want %q", m.st.Draft, want) + } + // The bill the footer is about to print, read through the arithmetic + // dispatch itself gates on rather than by counting the words in the draft. + if n := m.st.SeatsIn(m.st.Route); n != 2 { + t.Errorf("the route prices %d seats, want the 2 that did not answer", n) + } + for _, v := range []model.VendorID{model.VendorClaude, model.VendorCursor} { + if m.st.Route.addresses(v) { + t.Errorf("%s has no answer owing and the re-send addresses it anyway", v) + } + } + if !strings.Contains(m.st.Notice, "codex, agy") || !strings.Contains(m.st.Notice, "turn 3") { + t.Errorf("the notice does not say who owes an answer, and for which turn: %q", m.st.Notice) + } +} + +// TestAMeasuredZeroCountsAsAnAnswer. A seat that finished and streamed nothing +// says so — "[Turn completed with 0 text chunks streamed]" under `done` — and +// that is a MEASURED zero, not a missing reply (§4a.1). Re-sending on it would +// be the room overruling a vendor's honest empty answer, and it would bill a +// seat that already did the work. +func TestAMeasuredZeroCountsAsAnAnswer(t *testing.T) { + m := retryRoom(t) + for i := range m.st.Columns { + if m.st.Columns[i].Vendor == model.VendorClaude { + m.st.Columns[i].Body = "[Turn completed with 0 text chunks streamed]" + } + } + m.setDraft("/retry") + m.roomCommand() + + if m.st.Route.addresses(model.VendorClaude) { + t.Errorf("a measured empty answer was re-sent as though the seat had not answered: %q", m.st.Draft) + } +} + +// TestRetryRefusesWithNoBriefOnRecord: turn 0. Nothing has been dispatched, so +// there is nothing to send again, and the refusal says which of the two it is +// rather than arming an empty composer. +func TestRetryRefusesWithNoBriefOnRecord(t *testing.T) { + m := flowRoom(t, true) + m.setDraft("/retry") + m.roomCommand() + + if !strings.Contains(m.st.Notice, "no brief to re-send") { + t.Errorf("the refusal does not say what is missing: %q", m.st.Notice) + } + if m.st.Draft != "" { + t.Errorf("a refusal that only reports left the verb in the composer: %q", m.st.Draft) + } + if m.st.Route.addresses(model.VendorCodex) { + t.Error("a refused /retry routed a draft anyway") + } +} + +// TestRetryRefusesWhenEverySeatAnswered. The other end of the same question: +// the turn is on record and every seat in it replied, so a re-send would bill +// the room for answers it already has. +func TestRetryRefusesWhenEverySeatAnswered(t *testing.T) { + m := retryRoom(t) + for i := range m.st.Columns { + if m.st.Columns[i].TurnN == 3 { + m.st.Columns[i].Phase = PhaseDone + } + } + m.setDraft("/retry") + m.roomCommand() + + if !strings.Contains(m.st.Notice, "every seat answered turn 3") { + t.Errorf("the refusal does not say why there is nothing to send: %q", m.st.Notice) + } + if m.st.Draft != "" { + t.Errorf("a refusal that only reports left the verb in the composer: %q", m.st.Draft) + } +} + +// TestRetryRefusedMidTurnKeepsTheDraft. The phases this verb reads are not +// settled while a turn runs, so it refuses — and it KEEPS the draft, which is +// postureCommand's rule: the command is still what the operator wants, one turn +// later. +func TestRetryRefusedMidTurnKeepsTheDraft(t *testing.T) { + m := retryRoom(t) + m.turn = &turnState{cancel: func() {}, live: map[model.VendorID]bool{}} + m.setDraft("/retry") + m.roomCommand() + + if !strings.Contains(m.st.Notice, "in flight") { + t.Errorf("the refusal does not say why: %q", m.st.Notice) + } + if m.st.Draft != "/retry" { + t.Errorf("the draft was thrown away on a refusal the next turn undoes: %q", m.st.Draft) + } +} + +// TestRetryIsOnlyABareCommand is §9.17's bare-word rule reaching this verb: +// "/retry the failing test" is a brief, and a verb that took it as an argument +// would run a re-send and discard the sentence the operator typed. It is +// refused instead, which costs nothing and names the escape. +func TestRetryIsOnlyABareCommand(t *testing.T) { + log := countSpawns(t) + m := retryRoom(t) + m.setDraft("/retry the failing test") + + if !m.roomCommand() { + t.Fatal("a slash-leading draft was neither run nor refused") + } + if !strings.Contains(m.st.Notice, "no room command") { + t.Errorf("/retry took an argument it does not have: %q", m.st.Notice) + } + if m.st.Draft != "/retry the failing test" { + t.Errorf("the refused draft was rewritten: %q", m.st.Draft) + } + if log.n() != 0 { + t.Errorf("a refused draft spawned %d process(es)", log.n()) + } +} + +// TestRetryIsNamedInTheWalkedCommandTable. The refusal reads roomVerbs and +// §9.31's rule is that the table is walked rather than copied into a string — +// so the claim here is registration: a verb missing from that table is a verb +// the room refuses, and one present in it is taught by the refusal for free. +func TestRetryIsNamedInTheWalkedCommandTable(t *testing.T) { + registered := false + for _, rc := range roomVerbs() { + if rc.verb == "/retry" { + registered = true + } + } + if !registered { + t.Fatal("/retry is not in roomVerbs, so the room refuses its own word") + } + + m := flowRoom(t, true) + m.setDraft("/nosuchverb") + m.roomCommand() + if !strings.Contains(m.st.Notice, "/retry") { + t.Errorf("the refusal does not teach /retry: %q", m.st.Notice) + } +} + +// TestTheReSendSpawnsOnlyForTheSeatsThatOweAnAnswer is the dispatch-level half, +// asserted on what SPAWNED rather than on the draft the verb built. +// +// The fixture derives the two sets rather than naming vendors: which seats the +// registry drives as one-shot processes and which as long-lived ones is not this +// test's claim, and hardcoding it would make a registry change look like a +// /retry bug. One batch seat is cut with `x`, the rest fail, and every +// persistent seat answers — so the seats owing an answer are exactly the batch +// seats, and exactly they should spawn. +// +// No vendor is started: countSpawns stubs all three spawn vars, per CLAUDE.md's +// council-test rule. +func TestTheReSendSpawnsOnlyForTheSeatsThatOweAnAnswer(t *testing.T) { + m, oneShots, live := ordinaryTurn(t) + + cut := oneOf(t, oneShots, "batch seat") + focusSeatOn(t, m, cut) + m.key(key("x")) + m.key(key("y")) + for v := range oneShots { + if v == cut { + continue + } + m.applyEvents([]runner.Event{{ + Vendor: v, Kind: runner.KindError, Note: "exit status 1", ExitCode: 1, + }}) + } + for v := range live { + m.applyEvents([]runner.Event{{ + Vendor: v, Kind: runner.KindMeta, EndsTurn: true, Text: "an answer", + }}) + } + if m.turn != nil { + t.Fatal("fixture: the turn never ended, so there is no finished turn to re-send") + } + + // Counted from here, so the first turn's own spawns are not in the number. + log := countSpawns(t) + m.setDraft("/retry") + enter(m) + if log.n() != 0 { + t.Fatalf("/retry spawned %d process(es) before enter was pressed", log.n()) + } + enter(m) + + if log.n() != len(oneShots) { + t.Fatalf("the re-send spawned %d process(es), want %d — one per seat owing an answer: %+v", + log.n(), len(oneShots), log.specs) + } + for _, spec := range log.specs { + if _, owed := oneShots[spec.Vendor]; !owed { + t.Errorf("the re-send spawned %s, which already answered turn 1", spec.Vendor) + } + } + for v := range live { + c := m.column(v) + if c.TurnN != 1 || !c.Skipped { + t.Errorf("%s answered turn 1 and was billed again: turn %d, skipped %v", v, c.TurnN, c.Skipped) + } + } + for v := range oneShots { + if c := m.column(v); c.TurnN != 2 { + t.Errorf("%s owed an answer and the re-send missed it: turn %d", v, c.TurnN) + } + } + // The text is the same brief, unchanged — the property that keeps this verb + // clear of §9.17's unfiled re-briefing question. + for v := range oneShots { + if got := m.column(v).Prompt; got != "an ordinary brief" { + t.Errorf("%s was re-sent %q, not the brief it was given", v, got) + } + } +} + // TestAnUnmovedRoomKeepsItsProcess is the other side: seatProcess must not // respawn a seat whose directory still matches — that would pay a session // init per turn and undo the sixth amendment. diff --git a/internal/council/testdata/golden/slash-refusal-ascii.txt b/internal/council/testdata/golden/slash-refusal-ascii.txt index 846ae83..0839552 100644 --- a/internal/council/testdata/golden/slash-refusal-ascii.txt +++ b/internal/council/testdata/golden/slash-refusal-ascii.txt @@ -21,4 +21,4 @@ +------------------------------------------------------------------------------------------------------------------+ | : /unseet codex_ | +------------------------------------------------------------------------------------------------------ COMPOSE -+ - ! no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /seat /trace /unseat /write \ No newline at end of file + ! no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /retry /seat /trace /unseat /write \ No newline at end of file diff --git a/internal/council/testdata/golden/slash-refusal.txt b/internal/council/testdata/golden/slash-refusal.txt index 66c07d4..c37f52a 100644 --- a/internal/council/testdata/golden/slash-refusal.txt +++ b/internal/council/testdata/golden/slash-refusal.txt @@ -21,4 +21,4 @@ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › /unseet codex_ │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────── COMPOSE ─╯ - ⚠ no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /seat /trace /unseat /write \ No newline at end of file + ⚠ no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /retry /seat /trace /unseat /write \ No newline at end of file