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
49 changes: 49 additions & 0 deletions docs/changes/unreleased/1036-task-question-reask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
kind: fixed
title: a task-start question you answered is not asked again when you come back to the tab
pr: 1036
surface: [chat, engine]
invalidates:
- "A turn's backlog was replayed VERBATIM to whoever attached next
(session's [eventHub.attach]), cards included. A card is the one event in a
turn that is not a report of something that happened but a QUESTION about
something that has not, so a person who approved a task, looked at another
tab and came back was asked to approve it again — on every return, for as
long as that turn ran — and because a question takes the keyboard where it is
drawn (#919), the conversation underneath it could not be scrolled while it
stood. The replay now consults the answer: `eventHub.attach` is handed what
is still being asked ([Agent.stillAskedLocked], the banked words read under
a.mu at the moment of the attach) and leaves out a card whose question is no
longer one. The BACKLOG is unchanged and is still a faithful log of the turn;
what is filtered is the replay. `questionAsked` names the question a card
put in front of somebody, in the lanes' own keys, and it covers consent, a
task proposal, a standing card, a harness offer and a connect ask alike — so
a permission granted mid-turn is not re-asked on a re-attach either."
- "`TaskNotice.Withdrawn` was the ONLY outcome a proposal's card ever restated:
a proposal that was ANSWERED said nothing on the turn's stream, and what the
person's answer did lived only in the window that pressed the key.
`TaskNotice.Decided` is the other ending, sent by [taskWait.decided] the
moment anybody settles the proposal — the person, another window, or the
countdown — from the one place both roads pass through. A card that states
its own outcome is not asking anything, so the replay carries it in the open
card's place and a window arriving late draws the assignment with the answer
under it. The CLOCK's own wording (`approved · the clock`) belongs to the
window that ran the clock and is not restated; a replayed card says
`approved`, which is what is true afterwards."
- "tui3 spelled the settled proposal's verdict inside [app.taskAnswered] and
nowhere else. `taskVerdict` is that one reading of approved-and-redirect, and
both the window whose key answered and the window replaying a decision take
it; `taskAnswerWord` reads the answer's label off the proposal's own options
rather than spelling one a second time."
---

The two symptoms the owner met were one defect. A card that will not leave the
conversation is also a card that owns the viewport: the question was re-raised by
the replay, nothing would ever close it because the engine had already recorded
the answer, and every key aimed at the conversation went to it instead.

Leaving the settled card out of the replay altogether was the first attempt and
was not enough — the surface reads a `propose_task` result with no card as a call
that was REFUSED ([app.refuseFormingCard]), so coming back said
`not started · the call was refused` over work that was running. The engine
states the outcome instead, and the replay has something true to carry.
22 changes: 22 additions & 0 deletions internal/manual/chat/questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,28 @@ question is what is cut and they stay.
Widen the window, or open the conversation: the transcript and the record always
have the whole line.

## You answered it, then switched tab and came back — is it asked again?

**No.** An answer is the conversation's, not the screen's. Leave a tab while a
reply is still running, come back to it, and what you get is the conversation as
it stands: the work that has happened, and only the questions still waiting on
you. A decision you already made is not put to you a second time, and neither is
one somebody answered in another window or one the clock took.

What you will see of the answered one is nothing at all, once the receipt's half
minute has passed — that line is news, and coming back later is later. The
decision itself is in the transcript and in `decisions.jsonl` for good.

**A question you have NOT answered is still there when you come back**, wherever
you went — another tab, home, the tasks place. That is the point of leaving it:
nothing about the work moves while it waits, and the chip in the status line
counts it from every page.

This was wrong once and it was worth its own line here: a task-start question
approved mid-reply came back on every return to the tab, and because a question
takes the keyboard where it is drawn, the conversation underneath it could not be
scrolled while it stood. Both were the same defect, and both are gone.

## A question that stops needing you

Sometimes the thing a question was about goes away — the turn moved on, the plan
Expand Down
27 changes: 24 additions & 3 deletions internal/session/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1030,9 +1030,13 @@ func (a *Agent) Attach() (events <-chan Event, running bool, stop func()) {
return nil, false, func() {}
}
hub := a.hub
// AND WHAT IS STILL BEING ASKED, taken under this same lock rather than read
// through the map afterwards: the reply's cards are filtered against it
// ([eventHub.attach]), and a.questionWords belongs to a.mu.
asking := a.stillAskedLocked()
a.mu.Unlock()

stream, live := hub.attach()
stream, live := hub.attach(asking)
if !live {
// The turn ended between the two locks. Its channel is already closed,
// and saying "not running" is the honest answer to a question that was
Expand Down Expand Up @@ -1076,7 +1080,7 @@ func (a *Agent) AttachReplay() (entries []DisplayEntry, events <-chan Event, sto
// running under a.mu before it closes the hub, and we hold a.mu. The
// defensive arm keeps the honest answer anyway — a dead stream would hang
// the caller where the full record answers them.
stream, live := a.hub.attach()
stream, live := a.hub.attach(a.stillAskedLocked())
if !live {
return shapeEntries(a.messages, a.file), nil, func() {}
}
Expand Down Expand Up @@ -3512,11 +3516,25 @@ func (h *eventHub) subscribe() <-chan Event {
// [eventHub.send]'s own reason — an eventStream send is an append and a signal,
// never a wait.
//
// A CARD FOR A DECISION SOMEBODY HAS ALREADY MADE IS NOT REPLAYED, and asking
// is what says which those are — the questions still waiting on somebody, as
// [Agent.stillAskedLocked] reads them off the book at the moment of the attach.
// The backlog is a faithful log of the turn and stays one; what is filtered is the
// REPLAY, because a card is the one event in a turn that is not a report of
// something that happened but a question about something that has not. Without
// this, a person who approved a task and then looked at another tab was asked
// the same question again every time they came back, for as long as the turn ran
// — and the question, which takes the keyboard where it is drawn, made the
// conversation behind it unscrollable with it.
//
// A nil map filters nothing, which is the honest answer for a caller that cannot
// say what is open: every card replays, exactly as it did before this existed.
//
// false is a hub that has already closed, and the stream is closed with it: the
// turn whose events it would carry is over, and a caller is owed that answer
// rather than a channel that never ends. It is the same shape [taskRoom.join]
// answers a landed node with.
func (h *eventHub) attach() (*eventStream, bool) {
func (h *eventHub) attach(asking map[string]bool) (*eventStream, bool) {
stream := newEventStream()
if h == nil {
stream.close()
Expand All @@ -3530,6 +3548,9 @@ func (h *eventHub) attach() (*eventStream, bool) {
}
h.foldedLocked()
for _, event := range h.backlog {
if key, asks := questionAsked(event); asks && !asking[key] {
continue
}
stream.send(event)
}
h.subscribers = append(h.subscribers, stream)
Expand Down
120 changes: 118 additions & 2 deletions internal/session/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,14 +553,14 @@ func TestABacklogFoldedAcrossTwoAttachesStillSpellsTheWholeAnswer(t *testing.T)
}

send("the ", "answer ")
early, running := hub.attach()
early, running := hub.attach(nil)
if !running {
t.Fatal("attach to a live hub said nothing was running")
}
// The run CONTINUES past the attach, into the same backlog entry the first
// reader was just handed.
send("so ", "far", ", and ", "the rest")
late, running := hub.attach()
late, running := hub.attach(nil)
if !running {
t.Fatal("the second attach to a live hub said nothing was running")
}
Expand Down Expand Up @@ -636,3 +636,119 @@ func waitClosed(t *testing.T, events <-chan Event, what string) {
}
}
}

// ── a card for a decision already made ──────────────────────────────────────
//
// A turn's backlog is replayed to whoever attaches next, and a CARD in it is the
// one event that is not a report of something that happened but a question about
// something that has not. These two hold both halves of the one rule: a question
// somebody has answered is not asked again, and a question nobody has answered
// still is.

// attachedLate is one surface arriving at a turn already under way, reading the
// replay it is handed and nothing more. It is [Agent.AttachReplay]'s own two
// lines — the hub, and what is still being asked, read under one hold of a.mu —
// without the transcript, which these tests are not about.
func attachedLate(t *testing.T, agent *Agent) []Event {
t.Helper()
agent.mu.Lock()
hub := agent.hub
stream, live := hub.attach(agent.stillAskedLocked())
agent.mu.Unlock()
if !live {
t.Fatal("attaching to the turn said nothing was running")
}
// The hub is closed so the replay ends: what a late reader is handed is
// everything the backlog held, and nothing is left waiting on a channel.
hub.close()
return collect(t, stream.out)
}

// TestAnAnsweredProposalIsNotAskedAgainOfASurfaceThatArrivesLater is the defect
// the owner met: approve a task, look at another tab, come back, and be asked
// the same question again — every time, for as long as the turn ran. The answer
// was recorded and the engine knew it; the REPLAY did not consult it, so the
// card that raised the question went out again as though nobody had decided.
func TestAnAnsweredProposalIsNotAskedAgainOfASurfaceThatArrivesLater(t *testing.T) {
agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) {
config.AskConsent = true
config.TaskAutoApproveSeconds = 15
})
heldClock(agent)
ran := make(ranNodes, 2)
stubbedGraph(agent, func(node *TaskNode) {
ran <- node
node.graph.complete(node, TaskDone)
})
events := watched(agent)

held := holdProposal(t, agent, proposalArgs("Port the resume picker"))
card := nextTurnEvent(t, events, EventTaskProposal)

// THE PERSON SAYS YES, through the one door every answer on this surface
// goes through, and the reply that proposed the work then arrives whole.
if err := agent.ResolveQuestion(Answer{Kind: QuestionTask, ID: card.Task.ID, Key: "1"}); err != nil {
t.Fatalf("answering the proposal: %v", err)
}
held.hold.Release()
if result := held.await(t); result.isError {
t.Fatalf("the approved proposal answered %+v", result)
}
if open := agent.OpenQuestions(); len(open) != 0 {
t.Fatalf("OpenQuestions() = %+v after the answer, want none", open)
}

// THE REPLAY CARRIES THE ASSIGNMENT WITH THE ANSWER ON IT, ONCE. The open
// card — the one that asks — is left out; the restatement takes its place, so
// a surface arriving now draws what was decided and has nothing to answer.
cards := 0
for _, event := range attachedLate(t, agent) {
if event.Kind != EventTaskProposal {
continue
}
cards++
if event.Task == nil || event.Task.Decided == nil {
t.Fatalf("the replay asked about task %d again after it was approved: %+v", card.Task.ID, event.Task)
}
if !event.Task.Decided.Approved {
t.Fatalf("the replayed card says the proposal was declined: %+v", event.Task.Decided)
}
if !event.Task.Deadline.IsZero() {
t.Fatal("a decided card still carries a clock")
}
}
if cards != 1 {
t.Fatalf("the replay carried %d cards for one proposal, want the settled one alone", cards)
}
ran.await(t)
}

// TestAProposalNobodyAnsweredIsStillAskedOfASurfaceThatArrivesLater is the other
// half, and it is the one that keeps the fix from being a deletion: a card still
// waiting on somebody is exactly what a surface arriving mid-turn must be handed,
// or a person who opened a second tab while a question stood would come back to a
// conversation that had stopped and said nothing about why.
func TestAProposalNobodyAnsweredIsStillAskedOfASurfaceThatArrivesLater(t *testing.T) {
agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) {
config.AskConsent = true
config.TaskAutoApproveSeconds = 15
})
heldClock(agent)
stubbedGraph(agent, func(node *TaskNode) { node.graph.complete(node, TaskDone) })
events := watched(agent)

held := holdProposal(t, agent, proposalArgs("Port the resume picker"))
card := nextTurnEvent(t, events, EventTaskProposal)

asked := false
for _, event := range attachedLate(t, agent) {
if event.Kind == EventTaskProposal && event.Task != nil && event.Task.ID == card.Task.ID {
asked = true
}
}
if !asked {
t.Fatal("the replay left out a proposal nobody has answered")
}
held.hold.Withdraw()
held.await(t)
}
84 changes: 84 additions & 0 deletions internal/session/question.go
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,90 @@ func questionToken(kind QuestionKind, token string) string {
return string(kind) + ":" + token
}

// questionAsked names the question one event on a TURN'S STREAM put in front of
// somebody, and false for an event that asks nothing.
//
// IT EXISTS FOR THE REPLAY AND FOR NOTHING ELSE ([eventHub.attach]). A turn's
// backlog is every event that turn has sent, kept verbatim so a surface arriving
// mid-turn reads the work from its first line — and a card is the one kind of
// event in it that is not a report of something that happened but a QUESTION
// about something that has not. A decision somebody already made must not be
// replayed as one still waiting for them, so the replay asks this which question
// a card was, and [Agent.askingLocked] whether that question is still open.
//
// THE KEYS ARE THE LANES' OWN AND ARE NEVER GUESSED. Each case names the field
// its lane's question is keyed by — consent's id, the proposal's node id, the
// standing item's id inside its card, the harness offer's id, connect's string —
// and each is the same token the lane's builder further down this file gives the
// question it banks, so the two cannot mean different things. A kind missing
// from this list replays exactly as it always did, which is the safe half of
// being wrong about one.
func questionAsked(event Event) (string, bool) {
switch event.Kind {
case EventConsentRequest:
if event.ID == 0 {
return "", false
}
return questionToken(QuestionConsent, strconv.FormatUint(event.ID, 10)), true

case EventTaskProposal:
if event.Task == nil || event.Task.ID == 0 {
return "", false
}
if event.Task.Decided != nil || event.Task.Withdrawn != "" {
// A CARD THAT STATES ITS OWN OUTCOME IS NOT ASKING ANYTHING. The
// proposal is the one lane that restates its card when it is settled
// ([taskWait.decided], [taskWait.withdraw]), and that restatement is
// what the replay carries in the open card's place: the assignment,
// with the answer under it.
return "", false
}
return questionToken(QuestionTask, strconv.FormatUint(event.Task.ID, 10)), true

case EventStandingProposal:
if event.Standing == nil || event.Standing.ID == 0 {
return "", false
}
return questionToken(QuestionStanding, strconv.FormatUint(event.Standing.ID, 10)), true

case EventHarnessOffer:
if event.ID == 0 {
return "", false
}
return questionToken(QuestionHarness, strconv.FormatUint(event.ID, 10)), true

case EventConnectAsk:
id := strings.TrimSpace(event.ConnectID)
if id == "" {
return "", false
}
return questionToken(QuestionConnect, id), true
}
return "", false
}

// stillAskedLocked is every question this session is still waiting on somebody for,
// as the keys [questionAsked] names a card by. The caller holds a.mu.
//
// IT IS THE BANKED WORDS AND NOT A SECOND LIST, which is the whole reason it can
// be read here: [Agent.rememberQuestion] puts a question on that book as it is
// raised and [Agent.claimQuestionLocked] takes it off the moment it is answered
// or withdrawn, under this same lock — so "still on the book" is exactly "still
// a question", with no lock of anybody else's to take and no reconciliation to
// get wrong. [Agent.OpenQuestions] walks the lanes themselves and takes three
// locks doing it, which is the reading a surface asks for and not one an attach
// may make while it holds this one.
func (a *Agent) stillAskedLocked() map[string]bool {
if len(a.questionWords) == 0 {
return nil
}
asking := make(map[string]bool, len(a.questionWords))
for key := range a.questionWords {
asking[key] = true
}
return asking
}

// rememberQuestion banks the words of a question this session has just put, and
// answers the func that forgets them.
//
Expand Down
Loading
Loading