From bc3d9a078a5648140e11c0de483cd322522cacd5 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sat, 24 Oct 2026 18:17:08 +0000 Subject: [PATCH 1/3] test(team): pin whoami --check session state display (#815) Red first: live, lapsed, dormant, ended (person, reaper, expiry before the reap), old-server unknown, transport failure, null session, plain whoami makes no call, and per-candidate states on the server fallback. The fakes answer a deadline that disagrees with a client reading so a recompute fails. Worker: Joshua (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/cmd/team_cmd_test.go | 22 +- internal/cmd/team_whoami_state_815_test.go | 356 +++++++++++++++++++++ 2 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/team_whoami_state_815_test.go diff --git a/internal/cmd/team_cmd_test.go b/internal/cmd/team_cmd_test.go index 9768c32a..c0584b09 100644 --- a/internal/cmd/team_cmd_test.go +++ b/internal/cmd/team_cmd_test.go @@ -2327,6 +2327,16 @@ func bindWhoami(t *testing.T, responses map[string]string, args ...string) (*cob return root, out } +// whoamiStateJSON is the GetBoundSessionState answer (cli#815): the server's +// own state, never one the CLI works out. +func whoamiStateJSON(state string, live bool, lapsesAt string) string { + lapses := "null" + if lapsesAt != "" { + lapses = `"` + lapsesAt + `"` + } + return fmt.Sprintf(`{"data":{"session":{"id":"s-new","isLive":%v,"lapsesAt":%s,"state":%q}}}`, live, lapses, state) +} + // #484: --check asks the server whether the BOUND session is still open. // // The issue's original defect — a reaper killing a session under you — cannot @@ -2348,11 +2358,13 @@ func TestTeamSessionWhoamiCheck(t *testing.T) { // answer with a command's authority. #791 lets a plain start replace // an ended binding, so its remedy must not require --force. wantRemedy string + state string // the GetBoundSessionState answer (cli#815) }{ { name: "still open", session: whoamiCheckSession("", ""), wantActive: "true", wantEnded: "null", wantExpired: "null", - wantInLine: "still open", wantMissing: "ENDED", + wantInLine: "live, lapses at 2026-09-20T10:00:00Z", wantMissing: "ENDED", + state: whoamiStateJSON("LIVE", true, "2026-09-20T10:00:00Z"), }, { // A person ended it — from another worktree, or another machine. @@ -2360,6 +2372,7 @@ func TestTeamSessionWhoamiCheck(t *testing.T) { wantActive: "false", wantEnded: `"2026-09-19T11:00:00Z"`, wantExpired: "null", wantInLine: "ENDED", wantMissing: "auto-expired", wantRemedy: "session start --as Iris", + state: whoamiStateJSON("ENDED", false, ""), }, { // The SERVER reaped it. Same endedAt, different cause, and the @@ -2370,11 +2383,12 @@ func TestTeamSessionWhoamiCheck(t *testing.T) { wantActive: "false", wantEnded: `"2026-09-19T11:00:00Z"`, wantExpired: `"2026-09-19T11:00:00Z"`, wantInLine: "auto-expired", wantMissing: "", wantRemedy: "session start --as Iris", + state: whoamiStateJSON("ENDED", false, ""), }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - root, out := bindWhoami(t, map[string]string{"GetTeamSession": c.session}, + root, out := bindWhoami(t, map[string]string{"GetTeamSession": c.session, "GetBoundSessionState": c.state}, "team", "session", "whoami", "--check") if err := root.Execute(); err != nil { t.Fatalf("execute: %v", err) @@ -2396,7 +2410,7 @@ func TestTeamSessionWhoamiCheck(t *testing.T) { // implementation could return anything (@copilot on PR #626). Go // does not flag an unused struct field the way it flags a // variable, so nothing caught it but a reviewer. - jroot, jout := bindWhoami(t, map[string]string{"GetTeamSession": c.session}, + jroot, jout := bindWhoami(t, map[string]string{"GetTeamSession": c.session, "GetBoundSessionState": c.state}, "team", "session", "whoami", "--check", "--json") if err := jroot.Execute(); err != nil { t.Fatalf("execute --json: %v", err) @@ -2431,6 +2445,7 @@ func TestTeamSessionWhoamiCheckIsHonouredOnTheServerFallback(t *testing.T) { "AuthContext": whoamiAuthJSON, "TeamSessions": `{"data":{"sessions":[` + whoamiSession("s-mine", "wkr1", "u-me", "Jonas", false) + `]}}`, + "GetBoundSessionState": whoamiStateJSON("LIVE", true, "2026-09-20T10:00:00Z"), }) f, out := testFactory(t) root := NewRootCmd(f) @@ -2459,6 +2474,7 @@ func TestTeamSessionWhoamiCheckDoesNotAnswerForSeveral(t *testing.T) { "TeamSessions": `{"data":{"sessions":[` + whoamiSession("s-a", "wkr1", "u-me", "Jonas", false) + `,` + whoamiSession("s-b", "wkr2", "u-me", "Vera", false) + `]}}`, + "GetBoundSessionState": whoamiStateJSON("LIVE", true, "2026-09-20T10:00:00Z"), }) f, out := testFactory(t) root := NewRootCmd(f) diff --git a/internal/cmd/team_whoami_state_815_test.go b/internal/cmd/team_whoami_state_815_test.go new file mode 100644 index 00000000..9178ce4c --- /dev/null +++ b/internal/cmd/team_whoami_state_815_test.go @@ -0,0 +1,356 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// cli#815 (server #2307, cor:agt:020:13): `whoami --check` shows the server's +// OWN session state — live / lapsed / dormant / ended — and never works one out. +// The fakes answer the state and a deadline that deliberately DISAGREE with a +// client-side reading, so a CLI that recomputed from `lapsesAt` or a clock +// would fail here. + +const ( + // A deadline long past and one far future: neither may decide the display. + past815 = "2001-01-01T00:00:00Z" + future815 = "2999-01-01T00:00:00Z" +) + +// stateFake answers GetBoundSessionState per session id through `state`, the +// ordinary session read through `session`, and records every operation. +type stateFake struct { + mu sync.Mutex + ops []string + state func(id string) string + srv *httptest.Server +} + +func newStateFake(t *testing.T, session string, authAndList map[string]string, state func(id string) string) *stateFake { + t.Helper() + fk := &stateFake{state: state} + fk.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/capabilities" { + _, _ = w.Write([]byte(`{"capabilities":[]}`)) + return + } + var body struct { + Op string `json:"operationName"` + Vars map[string]any `json:"variables"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + fk.mu.Lock() + fk.ops = append(fk.ops, body.Op) + fk.mu.Unlock() + switch body.Op { + case "GetBoundSessionState": + id, _ := body.Vars["id"].(string) + if fk.state == nil { + _, _ = w.Write([]byte(`{"errors":[{"message":"not stubbed"}]}`)) + return + } + resp := fk.state(id) + if resp == "502" { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`bad gateway`)) + return + } + _, _ = w.Write([]byte(resp)) + case "GetTeamSession": + _, _ = w.Write([]byte(session)) + case "WorkerReporting": + _, _ = w.Write([]byte(`{"data":{"worker":null}}`)) + default: + if resp, ok := authAndList[body.Op]; ok { + _, _ = w.Write([]byte(resp)) + return + } + _, _ = w.Write([]byte(`{"errors":[{"message":"not stubbed"}]}`)) + } + })) + t.Cleanup(fk.srv.Close) + return fk +} + +func (fk *stateFake) count(op string) int { + fk.mu.Lock() + defer fk.mu.Unlock() + n := 0 + for _, o := range fk.ops { + if o == op { + n++ + } + } + return n +} + +func bindState815(t *testing.T) { + t.Helper() + dir := teamGitDir(t) + if err := os.WriteFile(filepath.Join(dir, "hadron-team-session.json"), []byte(bindingWithTeamFixture), 0o600); err != nil { + t.Fatal(err) + } +} + +func runState815(t *testing.T, fk *stateFake, args ...string) (string, error) { + t.Helper() + f, out := testFactory(t) + root := NewRootCmd(f) + root.SetArgs(append(args, "--server", fk.srv.URL)) + err := root.Execute() + return out.String(), err +} + +func rawJSON815(t *testing.T, s string) map[string]json.RawMessage { + t.Helper() + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(s), &raw); err != nil { + t.Fatalf("decode: %v — %s", err, s) + } + return raw +} + +// The four server states plus expiry-before-reap, bound worktree, text and JSON. +func TestWhoamiCheckShowsTheServersSessionState(t *testing.T) { + cases := []struct { + name string + session string // GetTeamSession (the open-row answer) + state string + wantLine string + notInLine []string + wantState string + wantLive string + wantLapse string + wantAct string + }{ + { + // The deadline is long past, yet the server says LIVE: displayed as + // the server says, never recomputed from lapsesAt. + name: "live", session: whoamiCheckSession("", ""), + state: whoamiStateJSON("LIVE", true, past815), + wantLine: "live, lapses at " + past815, notInLine: []string{"LAPSED", "DORMANT", "ENDED", "still open"}, + wantState: `"live"`, wantLive: "true", wantLapse: `"` + past815 + `"`, wantAct: "true", + }, + { + // The deadline is far future, yet the server says LAPSED. + name: "lapsed", session: whoamiCheckSession("", ""), + state: whoamiStateJSON("LAPSED", false, future815), + wantLine: "LAPSED at " + future815, notInLine: []string{"live,", "DORMANT", "still open"}, + wantState: `"lapsed"`, wantLive: "false", wantLapse: `"` + future815 + `"`, wantAct: "true", + }, + { + name: "dormant", session: whoamiCheckSession("", ""), + state: whoamiStateJSON("DORMANT", false, past815), + wantLine: "DORMANT — not resumable by any agent act", notInLine: []string{"live,", "LAPSED", "still open"}, + wantState: `"dormant"`, wantLive: "false", wantLapse: `"` + past815 + `"`, wantAct: "true", + }, + { + name: "ended by a person", session: whoamiCheckSession("2026-09-19T11:00:00Z", ""), + state: whoamiStateJSON("ENDED", false, ""), + wantLine: "ENDED 2026-09-19T11:00:00Z", notInLine: []string{"auto-expired", "live,", "DORMANT"}, + wantState: `"ended"`, wantLive: "false", wantLapse: "null", wantAct: "false", + }, + { + name: "ended by the reaper", session: whoamiCheckSession("2026-09-19T11:00:00Z", "2026-09-19T11:00:00Z"), + state: whoamiStateJSON("ENDED", false, ""), + wantLine: "ENDED 2026-09-19T11:00:00Z — auto-expired by the server", notInLine: []string{"live,", "DORMANT"}, + wantState: `"ended"`, wantLive: "false", wantLapse: "null", wantAct: "false", + }, + { + // Hard expiry passed, row not yet reaped: endedAt is null so + // `active` stays true (its stable meaning), but the state is ENDED + // and the line must not say the session is open. + name: "ended by expiry before the reap", session: whoamiCheckSession("", ""), + state: whoamiStateJSON("ENDED", false, ""), + wantLine: "ENDED — expired and not yet reaped", notInLine: []string{"still open", "live,"}, + wantState: `"ended"`, wantLive: "false", wantLapse: "null", wantAct: "true", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + bindState815(t) + fk := newStateFake(t, c.session, nil, func(string) string { return c.state }) + out, err := runState815(t, fk, "team", "session", "whoami", "--check") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "server: "+c.wantLine) { + t.Errorf("want server line %q in:\n%s", c.wantLine, out) + } + for _, bad := range c.notInLine { + if strings.Contains(out, "server: ") && strings.Contains(out[strings.Index(out, "server: "):], bad) { + t.Errorf("server line must not contain %q:\n%s", bad, out) + } + } + jout, err := runState815(t, fk, "team", "session", "whoami", "--check", "--json") + if err != nil { + t.Fatal(err) + } + raw := rawJSON815(t, jout) + for _, f := range []struct{ key, want string }{ + {"state", c.wantState}, {"live", c.wantLive}, {"lapsesAt", c.wantLapse}, + {"active", c.wantAct}, {"checked", "true"}, {"candidateStates", "[]"}, + } { + if got := string(raw[f.key]); got != f.want { + t.Errorf("%s = %s, want %s", f.key, got, f.want) + } + } + }) + } +} + +// An older server rejects the new field at validation: the state degrades to +// "unknown" (never guessed) and the open-row answer is unchanged. +func TestWhoamiCheckOldServerDegradesToUnknown(t *testing.T) { + bindState815(t) + old := `{"errors":[{"message":"Cannot query field \"state\" on type \"Session\".","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}` + fk := newStateFake(t, whoamiCheckSession("", ""), nil, func(string) string { return old }) + out, err := runState815(t, fk, "team", "session", "whoami", "--check") + if err != nil { + t.Fatalf("an old server must not fail the read: %v", err) + } + if !strings.Contains(out, "server: open (live state unknown") || strings.Contains(out, "live,") || strings.Contains(out, "DORMANT") { + t.Errorf("want an honest unknown, nothing guessed:\n%s", out) + } + jout, _ := runState815(t, fk, "team", "session", "whoami", "--check", "--json") + raw := rawJSON815(t, jout) + if string(raw["state"]) != `"unknown"` || string(raw["live"]) != "null" || string(raw["lapsesAt"]) != "null" || string(raw["active"]) != "true" { + t.Errorf("old-server JSON: %s", jout) + } +} + +// A transport failure on the state read is NOT "unknown": it keeps the +// command's existing error behaviour. +func TestWhoamiCheckStateTransportFailureIsAnError(t *testing.T) { + bindState815(t) + fk := newStateFake(t, whoamiCheckSession("", ""), nil, func(string) string { return "502" }) + _, err := runState815(t, fk, "team", "session", "whoami", "--check") + if err == nil { + t.Fatal("a failed read must not render as unknown") + } + if code := exitCodeFor(err); code == exitcode.OK { + t.Errorf("exit code = %d", code) + } +} + +// A null session answer on the state read (unreadable) is unknown, not ended. +func TestWhoamiCheckNullStateSessionIsUnknown(t *testing.T) { + bindState815(t) + fk := newStateFake(t, whoamiCheckSession("", ""), nil, func(string) string { return `{"data":{"session":null}}` }) + jout, err := runState815(t, fk, "team", "session", "whoami", "--check", "--json") + if err != nil { + t.Fatal(err) + } + if got := string(rawJSON815(t, jout)["state"]); got != `"unknown"` { + t.Errorf("state = %s", got) + } +} + +// Plain whoami stays local: no network at all, and the new keys are null / []. +func TestWhoamiPlainMakesNoCallAndStateIsNull(t *testing.T) { + bindState815(t) + fk := newStateFake(t, whoamiCheckSession("", ""), nil, func(string) string { return whoamiStateJSON("LIVE", true, "") }) + jout, err := runState815(t, fk, "team", "session", "whoami", "--json") + if err != nil { + t.Fatal(err) + } + if fk.count("GetBoundSessionState") != 0 || fk.count("GetTeamSession") != 0 { + t.Errorf("plain whoami must not check liveness: %v", fk.ops) + } + raw := rawJSON815(t, jout) + for _, k := range []string{"live", "lapsesAt", "state"} { + if string(raw[k]) != "null" { + t.Errorf("%s = %s, want null when --check did not run", k, raw[k]) + } + } + if string(raw["candidateStates"]) != "[]" { + t.Errorf("candidateStates = %s", raw["candidateStates"]) + } +} + +// No binding: --check answers per candidate. One candidate = a single subject +// (top-level keys); several = one row each, never a collapsed answer, and a +// DORMANT row is not shown as open. +func TestWhoamiCheckServerFallbackStatesPerCandidate(t *testing.T) { + list := func(rows ...string) map[string]string { + return map[string]string{ + "AuthContext": whoamiAuthJSON, + "TeamSessions": `{"data":{"sessions":[` + strings.Join(rows, ",") + `]}}`, + } + } + t.Run("one candidate", func(t *testing.T) { + teamGitDir(t) + fk := newStateFake(t, "", list(whoamiSession("s-a", "wkr1", "u-me", "Jonas", false)), + func(string) string { return whoamiStateJSON("LAPSED", false, future815) }) + out, err := runState815(t, fk, "team", "session", "whoami", "--check") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "server: LAPSED at "+future815) { + t.Errorf("single candidate lacks its state line:\n%s", out) + } + jout, _ := runState815(t, fk, "team", "session", "whoami", "--check", "--json") + raw := rawJSON815(t, jout) + if string(raw["state"]) != `"lapsed"` || string(raw["active"]) != "true" { + t.Errorf("single-candidate JSON: %s", jout) + } + }) + t.Run("several candidates", func(t *testing.T) { + teamGitDir(t) + answers := map[string]string{ + "s-a": whoamiStateJSON("LIVE", true, future815), + "s-b": whoamiStateJSON("DORMANT", false, past815), + "s-c": whoamiStateJSON("LAPSED", false, past815), + } + fk := newStateFake(t, "", list( + whoamiSession("s-a", "wkr1", "u-me", "Jonas", false), + whoamiSession("s-b", "wkr2", "u-me", "Vera", false), + whoamiSession("s-c", "wkr3", "u-me", "Iris", false)), + func(id string) string { return answers[id] }) + out, err := runState815(t, fk, "team", "session", "whoami", "--check") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"STATE", "live (lapses " + future815 + ")", "DORMANT", "LAPSED " + past815} { + if !strings.Contains(out, want) { + t.Errorf("table lacks %q:\n%s", want, out) + } + } + if fk.count("GetBoundSessionState") != 3 { + t.Errorf("one state read per candidate, got %d", fk.count("GetBoundSessionState")) + } + jout, _ := runState815(t, fk, "team", "session", "whoami", "--check", "--json") + raw := rawJSON815(t, jout) + if string(raw["state"]) != "null" || string(raw["active"]) != "null" { + t.Errorf("several candidates have no single answer: %s", jout) + } + var rows []struct { + SessionID string `json:"sessionId"` + State string `json:"state"` + } + if err := json.Unmarshal(raw["candidateStates"], &rows); err != nil || len(rows) != 3 || + rows[0].State != "live" || rows[1].State != "dormant" || rows[2].State != "lapsed" || + rows[1].SessionID != "s-b" { + t.Errorf("candidateStates = %s (%v)", raw["candidateStates"], err) + } + }) + t.Run("no --check reads nothing", func(t *testing.T) { + teamGitDir(t) + fk := newStateFake(t, "", list(whoamiSession("s-a", "wkr1", "u-me", "Jonas", false)), + func(string) string { return whoamiStateJSON("LIVE", true, "") }) + if _, err := runState815(t, fk, "team", "session", "whoami", "--json"); err != nil { + t.Fatal(err) + } + if fk.count("GetBoundSessionState") != 0 { + t.Errorf("the state read belongs to --check only: %v", fk.ops) + } + }) +} From 9192f445fbf105b9fbad5483443dc9fa793e80a0 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sat, 24 Oct 2026 18:17:08 +0000 Subject: [PATCH 2/3] feat(team): whoami --check shows the server's session state (#815) Reads Session.state/lapsesAt/isLive through a separate GetBoundSessionState operation (server #2307, cor:agt:020:13) and renders live / LAPSED / DORMANT / ENDED from the server's own answer, including ENDED before the reap. JSON gains live, lapsesAt, state and candidateStates additively; active keeps its meaning. An old server degrades to state unknown; transport failures stay errors. Plain whoami remains local and reads nothing. Refs server#1036. Worker: Joshua (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/api/gen/generated.go | 145 ++++++++++++++++++++++++++ internal/api/queries/team.graphql | 16 +++ internal/cmd/agentic/agentic-usage.md | 25 +++-- internal/cmd/team/session.go | 96 +++++++++++++---- internal/cmd/team/session_state.go | 137 ++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 27 deletions(-) create mode 100644 internal/cmd/team/session_state.go diff --git a/internal/api/gen/generated.go b/internal/api/gen/generated.go index f6c85b02..97a59a2a 100644 --- a/internal/api/gen/generated.go +++ b/internal/api/gen/generated.go @@ -22847,6 +22847,82 @@ func (v *GetBoundSessionLivenessSession) GetEndedAt() *string { return v.EndedAt // GetIsLive returns GetBoundSessionLivenessSession.IsLive, and is useful for accessing the field via an interface. func (v *GetBoundSessionLivenessSession) GetIsLive() bool { return v.IsLive } +// GetBoundSessionStateResponse is returned by GetBoundSessionState on success. +type GetBoundSessionStateResponse struct { + Session *GetBoundSessionStateSession `json:"session"` +} + +// GetSession returns GetBoundSessionStateResponse.Session, and is useful for accessing the field via an interface. +func (v *GetBoundSessionStateResponse) GetSession() *GetBoundSessionStateSession { return v.Session } + +// GetBoundSessionStateSession includes the requested fields of the GraphQL type Session. +// The GraphQL type's documentation follows. +// +// A unit of work recorded against a user or App: the general row, and what the +// bare word "session" means throughout this schema (#1034). `type` says which +// kind — DEVELOPER, CHATBOT, AUTOMATION, EDGE — and `workerId` is optional. +// +// A **worker session** is the subtype with `workerId` set: a Worker (named +// casting) bound to a driver, which is what makes work attributable to a +// teammate and what a merged PR traces back through. Binding is optional, so a +// list of these is not a staff view and may hold no worker sessions at all — +// read `workerId` per row rather than assuming either way. +// +// A **chat session** is NOT one of these and has no row here: it is the +// conversation a human is in — the Claude Desktop window, the Claude Code +// session, the IDE chat. The two are independent and **ending one does not end +// the other**: a chat session that closes leaves its worker session open +// until endSession. Since #1114 nothing ends it for inactivity — silence is not +// evidence of abandonment — but it stops reading as LIVE once nobody has driven +// it inside its idle window, so the stale session stops blocking another bind +// without being ended or losing its unwritten handoff. That changes no HOLD: a +// human-held name stays held until explicit release, so only its holder can bind +// it again. Do not use "chat session" for a Chat (the agent conversation entity) +// — that is an **agent chat**, a third concept. +type GetBoundSessionStateSession struct { + Id string `json:"id"` + // hadron-cli#791 / hrn:node:hadronmemory.com:specs:cor:agt:020:11: + // whether THIS session is live: not ended + // or hard-expired (hrn:node:hadronmemory.com:specs:cor:agt:020:03), + // AND driven inside its type's idle window (activity is the latest of + // startedAt, the heartbeat updatedAt, and the latest attributed usage + // event, capped at endedAt). The same derived predicate as startSession's + // WORKER_TAKEN gate and Worker.hasLiveSession, so the three always agree. + // An unexpired open session that is not live has LAPSED: nobody drove it inside the + // window, so another driver may bind its worker; it is not ended, and can + // still be ended with a handoff. False for an ended or deleted session. + IsLive bool `json:"isLive"` + // #1617 / #1036: the earlier of last activity plus the type's idle window + // and a promised expiresAt (hrn:node:hadronmemory.com:specs:cor:agt:020:03). + // The idle deadline is inclusive; a hard expiry is exclusive: the session + // is expired at now >= expiresAt, even before the reaper runs. Driving it + // can renew its idle deadline, never its promised expiry. Null for an + // ended or deleted session. + LapsesAt *string `json:"lapsesAt"` + // #2288 / hrn:node:hadronmemory.com:specs:cor:agt:020:13: + // this session's derived state, using its own App's dormancy period (platform + // default when App-less), never the viewer's App. ENDED means terminal: an + // explicit/reaped end, deletion, or now >= promised expiresAt before reap. + // It does not assert an explicit ending act. Otherwise DORMANT uses the same + // activity and strict older-than threshold as the resume refusal. At exact + // dormancy equality it is not dormant. LIVE includes idle deadline equality; + // hard expiry is exclusive even when the deadlines coincide. The remaining + // open sessions are LAPSED. Reading state never drives or resumes a session. + State SessionState `json:"state"` +} + +// GetId returns GetBoundSessionStateSession.Id, and is useful for accessing the field via an interface. +func (v *GetBoundSessionStateSession) GetId() string { return v.Id } + +// GetIsLive returns GetBoundSessionStateSession.IsLive, and is useful for accessing the field via an interface. +func (v *GetBoundSessionStateSession) GetIsLive() bool { return v.IsLive } + +// GetLapsesAt returns GetBoundSessionStateSession.LapsesAt, and is useful for accessing the field via an interface. +func (v *GetBoundSessionStateSession) GetLapsesAt() *string { return v.LapsesAt } + +// GetState returns GetBoundSessionStateSession.State, and is useful for accessing the field via an interface. +func (v *GetBoundSessionStateSession) GetState() SessionState { return v.State } + // GetChannelChannel includes the requested fields of the GraphQL type Channel. // The GraphQL type's documentation follows. // @@ -43023,6 +43099,23 @@ func (v *SessionInput) GetType() *string { return v.Type } // GetWorkerRef returns SessionInput.WorkerRef, and is useful for accessing the field via an interface. func (v *SessionInput) GetWorkerRef() *string { return v.WorkerRef } +// #2288: derived per-session state; ENDED means terminal, not necessarily an explicit end. +type SessionState string + +const ( + SessionStateDormant SessionState = "DORMANT" + SessionStateEnded SessionState = "ENDED" + SessionStateLapsed SessionState = "LAPSED" + SessionStateLive SessionState = "LIVE" +) + +var AllSessionState = []SessionState{ + SessionStateDormant, + SessionStateEnded, + SessionStateLapsed, + SessionStateLive, +} + // SetAgentBaseResponse is returned by SetAgentBase on success. type SetAgentBaseResponse struct { // #1885 (hrn:node:hadronmemory.com:specs:cor:dmo:050:14) - set, change or clear the one base Agent this @@ -62962,6 +63055,14 @@ type __GetBoundSessionLivenessInput struct { // GetId returns __GetBoundSessionLivenessInput.Id, and is useful for accessing the field via an interface. func (v *__GetBoundSessionLivenessInput) GetId() string { return v.Id } +// __GetBoundSessionStateInput is used internally by genqlient +type __GetBoundSessionStateInput struct { + Id string `json:"id"` +} + +// GetId returns __GetBoundSessionStateInput.Id, and is useful for accessing the field via an interface. +func (v *__GetBoundSessionStateInput) GetId() string { return v.Id } + // __GetChannelInput is used internally by genqlient type __GetChannelInput struct { Ref string `json:"ref"` @@ -74512,6 +74613,50 @@ func GetBoundSessionLiveness( return data_, err_ } +// The query executed by GetBoundSessionState. +const GetBoundSessionState_Operation = ` +query GetBoundSessionState ($id: ID!) { + session(id: $id) { + id + isLive + lapsesAt + state + } +} +` + +// cli#815 (server #2307, spec cor:agt:020:13): the canonical per-session state +// — LIVE | LAPSED | DORMANT | ENDED — and the lapse deadline, for +// `whoami --check`. A SEPARATE operation for the same reason as +// GetBoundSessionLiveness: an older server rejects the unknown field at +// validation, and the display then degrades to "unknown" while the ordinary +// GetTeamSession answer (active/endedAt) is unchanged. Reading it never +// drives or resumes a session. +func GetBoundSessionState( + ctx_ context.Context, + client_ graphql.Client, + id string, +) (data_ *GetBoundSessionStateResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetBoundSessionState", + Query: GetBoundSessionState_Operation, + Variables: &__GetBoundSessionStateInput{ + Id: id, + }, + } + + data_ = &GetBoundSessionStateResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetChannel. const GetChannel_Operation = ` query GetChannel ($ref: ID!) { diff --git a/internal/api/queries/team.graphql b/internal/api/queries/team.graphql index 37210820..9ef2c7c0 100644 --- a/internal/api/queries/team.graphql +++ b/internal/api/queries/team.graphql @@ -746,6 +746,22 @@ query GetBoundSessionLiveness($id: ID!) { } } +# cli#815 (server #2307, spec cor:agt:020:13): the canonical per-session state +# — LIVE | LAPSED | DORMANT | ENDED — and the lapse deadline, for +# `whoami --check`. A SEPARATE operation for the same reason as +# GetBoundSessionLiveness: an older server rejects the unknown field at +# validation, and the display then degrades to "unknown" while the ordinary +# GetTeamSession answer (active/endedAt) is unchanged. Reading it never +# drives or resumes a session. +query GetBoundSessionState($id: ID!) { + session(id: $id) { + id + isLive + lapsesAt + state + } +} + # hadron-cli#914 (Holger R1/R2 over server #1156, cor:dmo:070:05): the # ended-binding notice reads the bound session's end and its closed cause. # A SEPARATE operation: an older server rejects the unknown endReason field diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index b0f47be2..b3e8636d 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -2363,8 +2363,8 @@ Conventions: may merely SEE — and with several open it reports them all and leaves `sessionId` empty rather than guessing. **`git worktree remove` deletes the binding and does NOT end the session**; recover the id with `whoami` and end it - with `session end --session `. **`whoami --check` asks the server whether - the bound session is still open (#484)**. Binding identity stays available + with `session end --session `. **`whoami --check` asks the server about + the bound session (#484, state since cli#815)**. Binding identity stays available locally for compaction recovery. The default now also makes a best-effort worker read for its CURRENT reporting relation; a failed, masked or unavailable read reports `reportingKnown: false` / `? (unavailable)`, preserving recovery @@ -2379,11 +2379,22 @@ Conventions: `--check` is honoured on the server-fallback path too, where the openness is already the server's word. `endedAt` answers WHETHER it ended and `autoExpiredAt` answers HOW — non-null only when - the server reaped it rather than a person ending it. **`--check` says nothing - about idleness**: since hadron-server#1114 a developer session has no - inactivity deadline, so an open session undriven for months is correctly open, - and `active` keeps its open/ended meaning even though the server now exposes - per-session `isLive` for the `session start` binding guard. **Most of the + the server reaped it rather than a person ending it. **`--check` also shows + the server's own session state (cli#815, `Session.state`)**: text `server:` + reads `live, lapses at `, `LAPSED at ` (open but not live; + rebind), `DORMANT` (no agent act resumes it; its human holder must rebind) or + `ENDED` (also when a session hard-expired and is not yet reaped, where + `active` is still true). `--json` adds `live`, `lapsesAt`, `state` + (`live|lapsed|dormant|ended|unknown`) and `candidateStates` (`[]` unless the + server fallback ran under `--check`; one `{sessionId, live, lapsesAt, state}` + per candidate, also a STATE column in the table). `live`/`lapsesAt`/`state` + are **null** without `--check`; a server that predates `Session.state` + answers `state: "unknown"` (never guessed) and a transport/auth failure on + the state read is an error, not "unknown". The CLI derives none of it — no + clock, no idle or dormancy policy. Plain `whoami` stays local and reads + nothing. **`active` is unchanged and is not liveness**: since + hadron-server#1114 a developer session has no inactivity deadline, so use + `state` for "is it driven". **Most of the time, do not end a session at all**: they are meant to be long-lived (hadron-server#1114 removed the idle reaper), so ending is for when the WORK ends, not when a chat session, branch diff --git a/internal/cmd/team/session.go b/internal/cmd/team/session.go index 4a343001..be95b512 100644 --- a/internal/cmd/team/session.go +++ b/internal/cmd/team/session.go @@ -1424,6 +1424,26 @@ type whoamiDTO struct { // (#484). Both null on a live session and on an unchecked read. EndedAt *string `json:"endedAt"` AutoExpiredAt *string `json:"autoExpiredAt"` + // Live, LapsesAt and State are the server's own per-session state + // (Session.isLive / lapsesAt / state, cor:agt:020:13), additive beside the + // keys above and null unless --check ran (State is "unknown", never null, + // when --check ran against a server that predates the field). The CLI + // derives none of them: no clock, no idle or dormancy policy. Active keeps + // its stable meaning (open row), and is NOT replaced by these. + Live *bool `json:"live"` + LapsesAt *string `json:"lapsesAt"` + State *string `json:"state"` + // CandidateStates answers the same for every candidate of the server + // fallback under --check (several sessions have no single answer above). It + // is [] and never null everywhere else, so a consumer can iterate it blind. + CandidateStates []candidateStateDTO `json:"candidateStates"` +} + +// setState records the server's state answer on the DTO. +func (d *whoamiDTO) setState(st sessionStateDTO) { + d.Live, d.LapsesAt = st.Live, st.LapsesAt + state := st.State + d.State = &state } // livenessLine renders --check's answer, and distinguishes the two ways a @@ -1462,11 +1482,10 @@ type livenessResult struct { // clears only the binding of the worktree it ran in. Every other worktree's // file keeps describing a session that is gone. // -// It deliberately reports nothing about IDLENESS. An open session not driven -// for months is correctly open under #1114. Since #791 the server does expose -// Session.isLive, but this command's stable `active` answer means OPEN, not -// recently driven. `session start` reads the separate liveness field for its -// binding guard; `whoami --check` keeps its narrower ended-state contract. +// This answers only OPEN (endedAt IS NULL), the stable `active` meaning: an +// open session not driven for months is correctly open under #1114. Liveness is +// a separate question the server answers as Session.state, which the caller +// reads through fetchSessionState (cli#815); nothing here derives it. func checkSessionLiveness(cmd *cobra.Command, f *cmdutil.Factory, b *binding) (livenessResult, error) { // A binding from another deployment describes a session this server has // never heard of, and "not found" would read as "ended". Refuse with the @@ -1572,8 +1591,18 @@ func whoamiFromServer(cmd *cobra.Command, f *cmdutil.Factory, inWorktree, check "%s, and you have no open worker session on the server — `hadron team session start --as ` opens one", noBindingReason(inWorktree)) } - dto := whoamiDTO{binding: &binding{PRNumbers: []int{}}, Source: "server", Candidates: mine} + dto := whoamiDTO{binding: &binding{PRNumbers: []int{}}, Source: "server", Candidates: mine, CandidateStates: []candidateStateDTO{}} if check { + // One state read per candidate: --check is the one network path, and + // "every listed session" is the acceptance. Sequential and bounded by + // the number of open sessions the caller holds. + for _, s := range mine { + st, serr := fetchSessionState(cmd.Context(), client, s.ID) + if serr != nil { + return serr + } + dto.CandidateStates = append(dto.CandidateStates, candidateStateDTO{SessionID: s.ID, Live: st.Live, LapsesAt: st.LapsesAt, State: st.State}) + } // --check must not be SILENTLY downgraded here (@copilot on PR #626). // Its answer would otherwise depend on whether the local cache happens // to exist, which is the opposite of what the flag is for. @@ -1589,6 +1618,8 @@ func whoamiFromServer(cmd *cobra.Command, f *cmdutil.Factory, inWorktree, check // several leaves Active null exactly as it leaves sessionId empty. open := true dto.Active = &open + only := dto.CandidateStates[0] + dto.setState(sessionStateDTO{Live: only.Live, LapsesAt: only.LapsesAt, State: only.State}) } } if len(mine) == 1 { @@ -1622,15 +1653,26 @@ func whoamiFromServer(cmd *cobra.Command, f *cmdutil.Factory, inWorktree, check if r := strOrEmpty(s.Repo); r != "" { fmt.Fprintf(w, " repo: %s\n", r) } + if check { + fmt.Fprintf(w, " server: %s\n", stateLine(dto)) + } } else { // A TABLE, because several is the ordinary case rather than the // exception — one driver with a worker per worktree had 17 open at // once, measured. Seventeen detail blocks is not a list anyone can // pick from, and picking is the whole purpose here. fmt.Fprintf(w, "%d open worker sessions are yours; none is bound here, so pick one by SESSION.\n\n", len(mine)) - t := output.NewTable(w, "WORKER", "ROLE", "REPORTS TO", "REPO", "STARTED", "SESSION") - for _, s := range mine { - t.Row(dash(s.WorkerName), dash(s.WorkerRole), reportsToLabel(s.ReportsTo, s.ReportsToRetired, s.ReportingKnown), dash(s.Repo), s.StartedAt, s.ID) + header := []string{"WORKER", "ROLE", "REPORTS TO", "REPO", "STARTED", "SESSION"} + if check { + header = append(header, "STATE") + } + t := output.NewTable(w, header...) + for i, s := range mine { + row := []string{dash(s.WorkerName), dash(s.WorkerRole), reportsToLabel(s.ReportsTo, s.ReportsToRetired, s.ReportingKnown), dash(s.Repo), s.StartedAt, s.ID} + if check { + row = append(row, stateCell(dto.CandidateStates[i])) + } + t.Row(row...) } if err := t.Flush(); err != nil { return err @@ -1675,7 +1717,7 @@ left a session open with its only local handle gone. --json carries "source": "worktree" when the binding answered, "server" when the fallback did. ---check ASKS THE SERVER whether the bound session is still open (#484). The +--check ASKS THE SERVER about the bound session (#484). The binding identity remains available locally for compaction recovery. The default also reads the worker's CURRENT reports-to relation (best effort, at most five seconds); failed or masked reads show ? (unavailable), not nobody. It never @@ -1687,13 +1729,16 @@ a person ending it, says so: endedAt answers WHETHER, autoExpiredAt answers HOW. The --json answer is "active" (the endedAt-IS-NULL predicate, as everywhere else here), never "live" — an active session is not necessarily a driven one. ---check is not a health check for idleness. Since hadron-server#1114 a -developer session has no inactivity deadline, so an open session that has not -been driven in months is CORRECTLY open, and nothing here calls it stale. The -server exposes Session.isLive since hadron-server#1547, but this command deliberately -answers only whether the bound session is open. Its --json ` + "`active`" + ` key -keeps that stable meaning; use ` + "`session start`" + ` for the exact liveness -guard when rebinding. +--check asks the server about the session and shows its own state, never one +the CLI works out: "live, lapses at ", "LAPSED at " (open but +not live; rebind), "DORMANT" (no agent act resumes it; its human holder must +rebind) or "ENDED" (also shown when a session has hard-expired but is not yet +reaped). Since hadron-server#1114 a developer session has no inactivity +deadline, and an open one not driven for months is open, not stale. --json adds +` + "`live`, `lapsesAt`, `state` (live|lapsed|dormant|ended|unknown)" + ` and +` + "`candidateStates`" + `; ` + "`active`" + ` keeps its stable meaning (the row is +not ended) and is NOT liveness. A server that predates Session.state answers +state "unknown". Plain whoami (no --check) stays local and reads nothing. The fallback lists only sessions that are attributed to YOU, worker-bound, and still open. The server's session list is deliberately wider than that — it @@ -1730,7 +1775,7 @@ binds a worker holds its name until ` + "`worker release`" + ` (cor:agt:020:09). if b == nil { return whoamiFromServer(cmd, f, true, check) } - result := whoamiDTO{binding: b, BindingPath: path, Source: "worktree", Candidates: []sessionDTO{}} + result := whoamiDTO{binding: b, BindingPath: path, Source: "worktree", Candidates: []sessionDTO{}, CandidateStates: []candidateStateDTO{}} if check { live, cerr := checkSessionLiveness(cmd, f, b) if cerr != nil { @@ -1740,6 +1785,17 @@ binds a worker holds its name until ` + "`worker release`" + ` (cor:agt:020:09). result.Active = &live.active result.EndedAt = live.endedAt result.AutoExpiredAt = live.autoExpiredAt + // The state read is its own operation (an older server degrades + // to "unknown"); the open-row answer above is already in hand. + client, cerr := f.GraphQLClient() + if cerr != nil { + return cerr + } + st, cerr := fetchSessionState(cmd.Context(), client, b.SessionID) + if cerr != nil { + return cerr + } + result.setState(st) } loadReporting(cmd.Context(), f, b, &result) return output.Write(f.IOStreams, f.JSON, result, func(w io.Writer) error { @@ -1762,13 +1818,13 @@ binds a worker holds its name until ` + "`worker release`" + ` (cor:agt:020:09). fmt.Fprintf(w, " prs: %s\n", strings.Join(prs, " ")) } if result.Checked { - fmt.Fprintf(w, " server: %s\n", livenessLine(result)) + fmt.Fprintf(w, " server: %s\n", stateLine(result)) } return nil }) }, } - cmd.Flags().BoolVar(&check, "check", false, "ask the server whether the bound session is still open") + cmd.Flags().BoolVar(&check, "check", false, "ask the server about the bound session: live, lapsed, dormant or ended") return cmd } diff --git a/internal/cmd/team/session_state.go b/internal/cmd/team/session_state.go new file mode 100644 index 00000000..c687e6ba --- /dev/null +++ b/internal/cmd/team/session_state.go @@ -0,0 +1,137 @@ +package team + +import ( + "context" + "fmt" + + "github.com/Khan/genqlient/graphql" + + "github.com/hadron-memory/hadron-cli/internal/api" + "github.com/hadron-memory/hadron-cli/internal/api/gen" +) + +// The server's canonical per-session states (Session.state, cor:agt:020:13), +// lower-cased for --json, plus "unknown" for everything this CLI cannot say. +const ( + stateLive = "live" + stateLapsed = "lapsed" + stateDormant = "dormant" + stateEnded = "ended" + // stateUnknown: the server predates Session.state, or answered nothing for + // the session, or answered a state this build does not know. Never a guess. + stateUnknown = "unknown" +) + +// sessionStateDTO is what `whoami --check` learned from Session.state. It is +// the SERVER's answer verbatim: the CLI holds no expiry, idle or dormancy +// policy, and derives nothing from a clock or from `lapsesAt` (cli#815). +type sessionStateDTO struct { + Live *bool + LapsesAt *string + State string +} + +// candidateStateDTO is one row of whoami's --check answer when no binding picks +// a single session: the candidate's id plus the same three facts. +type candidateStateDTO struct { + SessionID string `json:"sessionId"` + Live *bool `json:"live"` + LapsesAt *string `json:"lapsesAt"` + State string `json:"state"` +} + +// fetchSessionState reads the server's state for one session through +// GetBoundSessionState, which is separate from GetTeamSession so an older +// server (a validation refusal naming one of the new fields) degrades to +// "unknown" instead of breaking the ordinary read. Any OTHER failure keeps the +// command's existing error mapping: a transport or auth problem is not "unknown". +func fetchSessionState(ctx context.Context, client graphql.Client, id string) (sessionStateDTO, error) { + resp, err := gen.GetBoundSessionState(ctx, client, id) + if err != nil { + for _, field := range []string{"state", "lapsesAt", "isLive"} { + if api.IsGraphQLValidationFor(err, field) { + return sessionStateDTO{State: stateUnknown}, nil + } + } + return sessionStateDTO{}, api.MapError(err) + } + if resp == nil || resp.Session == nil { + return sessionStateDTO{State: stateUnknown}, nil + } + live := resp.Session.IsLive + return sessionStateDTO{Live: &live, LapsesAt: resp.Session.LapsesAt, State: stateName(resp.Session.State)}, nil +} + +func stateName(s gen.SessionState) string { + switch s { + case gen.SessionStateLive: + return stateLive + case gen.SessionStateLapsed: + return stateLapsed + case gen.SessionStateDormant: + return stateDormant + case gen.SessionStateEnded: + return stateEnded + } + return stateUnknown +} + +// stateLine renders the `server:` line of `whoami --check` for a checked +// binding. The state decides, not `active` (which is only `endedAt IS NULL`): +// a session can be ENDED by expiry before the server physically reaps it, and +// DORMANT is not "open" or merely resumable. +func stateLine(d whoamiDTO) string { + state := stateUnknown + if d.State != nil { + state = *d.State + } + at := func() string { + if d.LapsesAt != nil { + return *d.LapsesAt + } + return "(instant unknown)" + } + switch state { + case stateLive: + if d.LapsesAt != nil { + return "live, lapses at " + *d.LapsesAt + } + return "live" + case stateLapsed: + return fmt.Sprintf("LAPSED at %s — open but not live; rebind with `hadron team session start --as %s`", at(), d.WorkerName) + case stateDormant: + return "DORMANT — not resumable by any agent act; its human holder must rebind" + case stateEnded: + if d.Active != nil && *d.Active { + // Hard-expired before the reap: the row is still open, the session is not. + return fmt.Sprintf("ENDED — expired and not yet reaped by the server; rebind with `hadron team session start --as %s`", d.WorkerName) + } + return livenessLine(d) + } + // unknown: the previous answer, honestly labelled. + if d.Active != nil && *d.Active { + return "open (live state unknown — the server predates session state)" + } + return livenessLine(d) +} + +// stateCell is the STATE column of the several-candidates table. +func stateCell(c candidateStateDTO) string { + switch c.State { + case stateLive: + if c.LapsesAt != nil { + return "live (lapses " + *c.LapsesAt + ")" + } + return "live" + case stateLapsed: + if c.LapsesAt != nil { + return "LAPSED " + *c.LapsesAt + } + return "LAPSED" + case stateDormant: + return "DORMANT" + case stateEnded: + return "ENDED" + } + return "unknown" +} From cee9a1e66f45d78113d05b79dc2a0fbb46301a03 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sat, 24 Oct 2026 18:42:56 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(team):=20whoami=20--check=20=E2=80=94?= =?UTF-8?q?=20plain=20whoami=20makes=20no=20state=20read,=20not=20no=20net?= =?UTF-8?q?work=20(#815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain whoami still reads the worker reporting line; correct the help, agentic-usage and test claims. Reword the unknown-state line so an empty answer does not blame an old server. Worker: Joshua (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/cmd/agentic/agentic-usage.md | 4 ++-- internal/cmd/team/session.go | 3 ++- internal/cmd/team/session_state.go | 2 +- internal/cmd/team_whoami_state_815_test.go | 5 +++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index b3e8636d..caafab5a 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -2391,8 +2391,8 @@ Conventions: are **null** without `--check`; a server that predates `Session.state` answers `state: "unknown"` (never guessed) and a transport/auth failure on the state read is an error, not "unknown". The CLI derives none of it — no - clock, no idle or dormancy policy. Plain `whoami` stays local and reads - nothing. **`active` is unchanged and is not liveness**: since + clock, no idle or dormancy policy. Plain `whoami` makes no session-state + read (it still reads the worker's reporting line). **`active` is unchanged and is not liveness**: since hadron-server#1114 a developer session has no inactivity deadline, so use `state` for "is it driven". **Most of the time, do not end a session at all**: they are meant to be long-lived diff --git a/internal/cmd/team/session.go b/internal/cmd/team/session.go index be95b512..0a477a3e 100644 --- a/internal/cmd/team/session.go +++ b/internal/cmd/team/session.go @@ -1738,7 +1738,8 @@ deadline, and an open one not driven for months is open, not stale. --json adds ` + "`live`, `lapsesAt`, `state` (live|lapsed|dormant|ended|unknown)" + ` and ` + "`candidateStates`" + `; ` + "`active`" + ` keeps its stable meaning (the row is not ended) and is NOT liveness. A server that predates Session.state answers -state "unknown". Plain whoami (no --check) stays local and reads nothing. +state "unknown". Plain whoami (no --check) makes no session-state read (it still +reads the worker reporting line). The fallback lists only sessions that are attributed to YOU, worker-bound, and still open. The server's session list is deliberately wider than that — it diff --git a/internal/cmd/team/session_state.go b/internal/cmd/team/session_state.go index c687e6ba..d3728f4a 100644 --- a/internal/cmd/team/session_state.go +++ b/internal/cmd/team/session_state.go @@ -110,7 +110,7 @@ func stateLine(d whoamiDTO) string { } // unknown: the previous answer, honestly labelled. if d.Active != nil && *d.Active { - return "open (live state unknown — the server predates session state)" + return "open (live state unknown — the server did not report it; an older server predates session state)" } return livenessLine(d) } diff --git a/internal/cmd/team_whoami_state_815_test.go b/internal/cmd/team_whoami_state_815_test.go index 9178ce4c..b23c1162 100644 --- a/internal/cmd/team_whoami_state_815_test.go +++ b/internal/cmd/team_whoami_state_815_test.go @@ -254,7 +254,8 @@ func TestWhoamiCheckNullStateSessionIsUnknown(t *testing.T) { } } -// Plain whoami stays local: no network at all, and the new keys are null / []. +// Plain whoami makes no session-state read (the existing reporting read stays), +// and the new keys are null / []. func TestWhoamiPlainMakesNoCallAndStateIsNull(t *testing.T) { bindState815(t) fk := newStateFake(t, whoamiCheckSession("", ""), nil, func(string) string { return whoamiStateJSON("LIVE", true, "") }) @@ -342,7 +343,7 @@ func TestWhoamiCheckServerFallbackStatesPerCandidate(t *testing.T) { t.Errorf("candidateStates = %s (%v)", raw["candidateStates"], err) } }) - t.Run("no --check reads nothing", func(t *testing.T) { + t.Run("no --check makes no state read", func(t *testing.T) { teamGitDir(t) fk := newStateFake(t, "", list(whoamiSession("s-a", "wkr1", "u-me", "Jonas", false)), func(string) string { return whoamiStateJSON("LIVE", true, "") })