Skip to content

Commit 1b57cec

Browse files
authored
fix: M1-M11 medium-severity issues from prior code review (#53)
* fix(cmd): M1 part 1 - pinSubcommand, GLM thinking, status use accessors - chat_subcommand_pin.go: use m.session.SetPinnedMessages - chat_subcommand_simple.go: use m.session.SetGLMThinkingEnabled - chat_subcommand_status.go: use m.session.CostValue().Summary() - session.go: add SetPinnedMessages accessor * fix(cmd): M1 part 2 - Autonomy, ContainerExecutor, ConvoDAG accessors - chat.go: use PermSvc().Autonomy()/SetAutonomy, SetContainerExecutor, SetContainerRequired - permissions_center.go: use PermSvc().SetAutonomy - welcome_gate.go, statusbar.go: use PermSvc().Autonomy() - hud_panel.go: use MemorySvc().Yaad() - chat_status.go: use ContextWindowCachedValue, Persistence().SetContextWindowCached - chat_subcommand_branches.go: use Persistence().DAG() - chat_commands_session.go: use Persistence().DAG() - session.go: add SetContainerExecutor accessor - audit_test.go: regex now matches m.session.X; cmdHardFailThreshold=0 * fix(cmd): M1 part 3 - remaining cmd/ Cost/Mode/ContextWindow accessors - statusbar.go: use m.session.CostValue() - chat_subcommand_cost.go, chat_subcommand_usage.go: use CostValue() - chat_commands_session.go, chat_commands_util.go: use CostValue() - chat.go: use ContextWindowCachedValue() - permissions_center.go: use ModeValue() (safe fallback for test struct literal) - chat_model_test.go, statusbar_test.go: use accessors - session_sync.go, snapshot_cmd.go: avoid audit regex false positives in text - session.go: add SetContextWindowCached, ModeValue, SetMode accessors * fix(engine): M2 - AddUser/AddAssistant use MemorySvc() and Persistence().DAG() - AddUser: read Memory from s.MemorySvc().Memory() (was s.Memory) - AddUser, AddUserWithImage, AddAssistant, ForkConversation, SwitchBranch, ListBranches, ConvoHead: read DAG from s.Persistence().DAG() (was s.ConvoDAG) - Aligns with the H6 sub-service decomposition: the legacy ConvoDAG and Memory fields on Session are aliases set by NewSessionWithClient; new code goes through the sub-service getters to keep the god-object-decomposition contract consistent. * fix(engine): M3 - deprecation comments point at real accessors - FewShot() -> FewShotStore() (typo fix in comment) - PlanState, Trajectory, LintLoop, TestLoop, FileMentions, Files, Snapshots, Tracer, RateLimiter: comments now indicate these remain on the legacy Session struct (no sub-service accessor exists); the previous deprecation text pointed to non-existent methods that callers could not find. * fix(engine): M4 - PermissionService constructor no longer pre-sets mode NewPermissionService no longer initializes mode to PermissionModeDefault; the zero value ("") is preserved so IsZero() can correctly report a freshly constructed service. IsZero() now checks for the empty string instead of PermissionModeDefault. This is the right semantics: callers that want a default mode should call SetMode explicitly, and tests can rely on IsZero() being true for a fresh service. * fix(cmd): M5 - SubcommandRegistry.Register detects alias collision The previous implementation checked for primary-name duplicates (`r.primary[name]`) but not alias collisions. Two subcommands that each register a different primary but share an alias would silently overwrite `r.aliasOf[alias]`. Now the alias check mirrors the primary-name check: registration is rejected if any of the subcommand's aliases is already taken. The existing silent-no-op pattern is preserved (no error return) so init() callers don't have to change. * fix(cmd): M6 - sessionSubcommand.Handle prepends command name to args The previous Handle passed the post-name args slice directly to handleSessionCommand, which expects parts[0] to be the command name. This broke /recover <id>, /resume <id>, and /tag <label>: the trailing arg landed at parts[0] instead of parts[1], and the `len(parts) >= 2` check failed, hitting the usage error path. The fix reconstructs the full parts slice (name + args) before calling handleSessionCommand, matching the contract the simple.go delegatingCommand handlers use. The parts-building logic is extracted into a buildSessionParts helper for testability. New tests (TestBuildSessionParts_*, TestSessionSubcommand_RecoverIDReachesPartsIndex1, TestResolveSessionName_PicksLongestMatch) assert the fix and the dispatcher contract. * fix(multiagent): M7 - WaitForLock no longer busy-spins The previous for-select loop had two problems: (1) after the first \<-w.done fired, the closed channel kept firing and the loop re-called AcquireLock, busy-spinning until the timer.C fired. (2) Both <-w.done and <-timer.C become ready at timeout, and Go's select picks randomly — so the function could miss the timeout and loop indefinitely. Fix: replace the for-loop with a one-shot select. On the first signal (w.done or timer), try AcquireLock exactly once. If it succeeds, return nil; if the timer fired, return a timeout error. The race-loser no longer re-registers — it returns an error. TestWaitForLock_MultipleWaiters_OnlyOneAcquires updated to assert the new semantics: the race-loser gets an error rather than re-registering and waiting for a second signal. * fix(multiagent): M8 - logDroppedMessage moved out of mb.mu critical section The previous implementation called logDroppedMessage while holding mb.mu (write lock). The slog.Warn call may acquire its own internal lock (the slog handler's mutex); if the handler is slow (file I/O, network sink), this serializes all bus operations for the duration of the log write. Fix: snapshot the (from, to, topic) of each drop into a local slice while still holding the lock, then iterate the slice after the lock is released and call logDroppedMessage on each entry. The error return path is also collected into a local variable and returned after the lock is released. No behavior change for the caller; only the order of operations differs (logs now happen after the bus is unlocked). * fix(multiagent): M9 - extract dropLogEveryN as a named constant The previous 100 was a magic number inlined in the `n%100 != 0` check. Extracted as a package-level const `dropLogEveryN = 100` with a doc comment explaining the rationale (balancing observability against log volume under sustained pressure). * test(multiagent): M10 - rename test + add real waiter-assertion test - TestWaitForLock_OwnerMismatchOnRelease renamed to TestReleaseLock_NonOwnerReturnsError; the original name claimed it was about waiters not being woken, but the body only checked the error return. - New TestWaitForLock_ReleaseByNonOwnerDoesNotWakeWaiter registers a real waiter, attempts a ReleaseLock from a non-owner, and asserts the waiter times out (rather than being woken by the failed ReleaseLock). This is the test the original name promised but never delivered. * test(multiagent): M11 - join the WaitForResponse goroutine via a done channel The previous TestStats_NotAffectedByWaiters launched a goroutine that called WaitForResponse and never joined it; the goroutine ran for 100ms after the test exited. Fixed by: - Adding a done channel that the goroutine closes on return. - Adding a 1-second join timeout via select on the done channel. - The test now fails loudly if the goroutine does not return within 1s (which would indicate a future regression). * fix(engine): M2 - make AddUser/AddAssistant/ForkConversation/etc nil-safe The M2 refactor changed these methods to call s.Persistence().DAG() and s.MemorySvc().Memory() directly. That panics on &engine.Session{} struct literals (used by cmd tests) where s.persist and s.memory are nil. This commit wraps each call site in a nil-check on the parent service (s.Persistence() / s.MemorySvc()) so that struct-literal sessions still work the same way as before — the methods become no-ops when their backing sub-service is nil, which matches the previous behavior on s.ConvoDAG == nil and s.Memory == nil.
1 parent 1cf3c20 commit 1b57cec

26 files changed

Lines changed: 564 additions & 229 deletions

cmd/chat.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
315315
startup.EndPhase("newChatModel:commandPalette")
316316

317317
// Pre-warm footer connection line so ctx (e.g. 0k/1.0m) shows on first paint.
318-
if m.session != nil && m.session.ContextWindowCached > 0 {
318+
if m.session != nil && m.session.ContextWindowCachedValue() > 0 {
319319
m.connStatusVal = m.buildConnectionStatusPlain()
320320
m.connStatusKey = m.connStatusFingerprint()
321321
}
@@ -896,11 +896,11 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
896896
m.updateViewportContent()
897897
return m, nil
898898
}
899-
next := nextAutonomyTier(m.session.Autonomy)
900-
if m.session.Autonomy == 0 || autonomyTierIndex(m.session.Autonomy) < 0 {
899+
next := nextAutonomyTier(m.session.PermSvc().Autonomy())
900+
if m.session.PermSvc().Autonomy() == 0 || autonomyTierIndex(m.session.PermSvc().Autonomy()) < 0 {
901901
next = DefaultContainerAutonomy
902902
}
903-
m.session.Autonomy = next
903+
m.session.PermSvc().SetAutonomy(next)
904904
m.invalidateConnStatus()
905905
m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(next)})
906906
m.viewDirty = true
@@ -1275,17 +1275,17 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12751275
if msg.sandbox != nil {
12761276
m.containerSandbox = msg.sandbox
12771277
if m.session != nil {
1278-
m.session.ContainerExecutor = msg.sandbox
1278+
m.session.SetContainerExecutor(msg.sandbox)
12791279
}
12801280
}
12811281
if msg.ready && m.session != nil {
1282-
if m.session.Autonomy == 0 {
1283-
m.session.Autonomy = DefaultContainerAutonomy
1282+
if m.session.PermSvc().Autonomy() == 0 {
1283+
m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy)
12841284
}
12851285
if m.phase == phaseWelcomeGate {
12861286
m.sandboxReadyPending = true
12871287
} else {
1288-
m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.Autonomy)})
1288+
m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.PermSvc().Autonomy())})
12891289
}
12901290
m.invalidateConnStatus()
12911291
}
@@ -1294,8 +1294,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12941294
m.containerEnabled = false
12951295
m.containerReady = false
12961296
if m.session != nil {
1297-
m.session.ContainerRequired = false
1298-
m.session.ContainerExecutor = nil
1297+
m.session.SetContainerRequired(false)
1298+
m.session.SetContainerExecutor(nil)
12991299
}
13001300
m.messages = append(m.messages, displayMsg{
13011301
role: "system",

cmd/chat_commands_session.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string
200200

201201
case "/fork":
202202
// If convodag is active, fork from the current head node
203-
if m.session.ConvoDAG != nil {
203+
if m.session.Persistence().DAG() != nil {
204204
headID := m.session.ConvoHead()
205205
if headID == "" {
206206
m.messages = append(m.messages, displayMsg{role: "error", content: "No conversation to fork from."})
@@ -415,7 +415,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string
415415
case "/session":
416416
info := fmt.Sprintf("Session: %s\nModel: %s/%s\nPermission mode: %s\nMessages: %d\nTools: %d\n%s",
417417
m.sessionID, m.session.Provider(), m.session.Model(),
418-
permissionModeLabel(m.session), m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.Cost.Summary())
418+
permissionModeLabel(m.session), m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.CostValue().Summary())
419419
m.messages = append(m.messages, displayMsg{role: "system", content: info})
420420
return m, nil
421421

cmd/chat_commands_util.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func (m *chatModel) mcpSummary() string {
123123

124124
func sessionStats(sess *engine.Session, id string) string {
125125
return fmt.Sprintf("Session: %s\nMessages: %d\nModel: %s/%s\n%s",
126-
id, sess.MessageCount(), sess.Provider(), sess.Model(), sess.Cost.Summary())
126+
id, sess.MessageCount(), sess.Provider(), sess.Model(), sess.CostValue().Summary())
127127
}
128128

129129
func hooksSummary() string {

cmd/chat_model_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212

1313
func newTestChatModel() *chatModel {
1414
sess := engine.NewSession("", "test-model", "you are helpful", nil)
15-
sess.MaxTurns = 1
15+
sess.PermSvc().SetMaxTurns(1)
1616
sess.SetTestClient(engine.NewMockClientForTest())
1717

1818
m := &chatModel{

cmd/chat_status.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string)
139139
model, contextLabel = modelStatusMeta(gw, modelID)
140140
if contextLabel == "" || contextLabel == "—" || contextLabel == "0k" {
141141
if m.session != nil {
142-
if w := m.session.ContextWindowCached; w > 0 {
142+
if w := m.session.ContextWindowCachedValue(); w > 0 {
143143
contextLabel = formatModelTableContext(w)
144144
} else if w := m.session.ContextWindowSize(); w > 0 && w != engine.DefaultContextWindow {
145145
contextLabel = formatModelTableContext(w)
@@ -149,7 +149,7 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string)
149149
if w := platformContextForNativeModel(modelID); w > 0 {
150150
contextLabel = formatModelTableContext(w)
151151
if m.session != nil {
152-
m.session.ContextWindowCached = w
152+
m.session.SetContextWindowCached(w)
153153
m.session.EnsureAutoCompactor()
154154
}
155155
}

cmd/chat_subcommand.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ func NewSubcommandRegistry() *SubcommandRegistry {
8989
// all aliases are indexed. If a name is already registered, this
9090
// is a no-op (the existing entry is kept) — duplicate registration
9191
// is treated as a configuration error but doesn't panic, so test
92-
// ordering and re-init don't blow up the binary.
92+
// ordering and re-init don't blow up the binary. The same applies
93+
// to alias collisions: if any of the subcommand's aliases is already
94+
// registered (either as a primary or as another alias), registration
95+
// is rejected (see M5 in the code review).
9396
func (r *SubcommandRegistry) Register(cmd ChatSubcommand) {
9497
if cmd == nil {
9598
return
@@ -100,6 +103,11 @@ func (r *SubcommandRegistry) Register(cmd ChatSubcommand) {
100103
if _, exists := r.primary[name]; exists {
101104
return // duplicate
102105
}
106+
for _, alias := range cmd.Aliases() {
107+
if _, exists := r.aliasOf[alias]; exists {
108+
return // alias collision
109+
}
110+
}
103111
r.primary[name] = cmd
104112
for _, alias := range cmd.Aliases() {
105113
r.aliasOf[alias] = name

cmd/chat_subcommand_branches.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func (b *branchesSubcommand) Aliases() []string { return nil }
1414
func (b *branchesSubcommand) Description() string { return "list conversation DAG branches" }
1515
func (b *branchesSubcommand) Usage() string { return "" }
1616
func (b *branchesSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
17-
if m.session.ConvoDAG == nil {
17+
if m.session.Persistence().DAG() == nil {
1818
m.messages = append(m.messages, displayMsg{role: "system", content: "No conversation branches (DAG not active)."})
1919
return m, nil
2020
}

cmd/chat_subcommand_cost.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func (c *costSubcommand) Aliases() []string { return nil }
1313
func (c *costSubcommand) Description() string { return "print session cost and token usage summary" }
1414
func (c *costSubcommand) Usage() string { return "" }
1515
func (c *costSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
16-
m.messages = append(m.messages, displayMsg{role: "system", content: m.session.Cost.Summary()})
16+
m.messages = append(m.messages, displayMsg{role: "system", content: m.session.CostValue().Summary()})
1717
return m, nil
1818
}
1919

cmd/chat_subcommand_pin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func (p *pinSubcommand) Handle(m *chatModel, args []string, text string) (tea.Mo
2525
n = parsed
2626
}
2727
}
28-
m.session.PinnedMessages = n
28+
m.session.SetPinnedMessages(n)
2929
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Pinned last %d messages (protected from compaction).", n)})
3030
return m, nil
3131
}

cmd/chat_subcommand_session.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,45 @@ func (s *sessionSubcommand) Description() string {
2424
return "session management: clear, compact, diff, recover, resume, history, quit, exit"
2525
}
2626
func (s *sessionSubcommand) Usage() string { return "" }
27-
func (s *sessionSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
28-
name := ""
27+
28+
// sessionSubcommandNames is the ordered list of slash names covered
29+
// by sessionSubcommand. Exposed for tests and help rendering.
30+
var sessionSubcommandNames = []string{"/clear", "/compact", "/diff", "/recover", "/resume", "/history", "/quit", "/exit"}
31+
32+
// resolveSessionName inspects the raw slash text and returns the
33+
// matching command name (with the leading "/"). Falls back to
34+
// "/<primary>" when text doesn't start with a slash.
35+
func resolveSessionName(text string, primary string) string {
2936
if len(text) > 0 && text[0] == '/' {
30-
for _, c := range []string{"/clear", "/compact", "/diff", "/recover", "/resume", "/history", "/quit", "/exit"} {
37+
for _, c := range sessionSubcommandNames {
3138
if len(text) >= len(c) && text[:len(c)] == c {
32-
name = c
33-
break
39+
return c
3440
}
3541
}
3642
}
37-
if name == "" {
38-
name = "/" + s.Name()
39-
}
40-
return m.handleSessionCommand(name, args, text)
43+
return "/" + primary
44+
}
45+
46+
// buildSessionParts reconstructs the full parts slice that
47+
// handleSessionCommand expects: parts[0] is the command name,
48+
// parts[1:] is the post-name argument list. Exposed for testing
49+
// the dispatcher contract (see M6 in the code review).
50+
func buildSessionParts(name string, args []string) []string {
51+
parts := make([]string, 0, 1+len(args))
52+
parts = append(parts, name)
53+
parts = append(parts, args...)
54+
return parts
55+
}
56+
57+
func (s *sessionSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
58+
name := resolveSessionName(text, s.Name())
59+
// handleSessionCommand expects parts[0] to be the command name (e.g.
60+
// "/recover"), with the remaining entries being the post-name args.
61+
// The dispatcher hands us the post-name slice; reconstruct the
62+
// full parts slice so /recover <id>, /resume <id>, /tag <label>, etc.
63+
// receive the trailing argument.
64+
parts := buildSessionParts(name, args)
65+
return m.handleSessionCommand(name, parts, text)
4166
}
4267

4368
func init() {

0 commit comments

Comments
 (0)