From 66b953b5a5304a48ec1444412a0a810c4b0c48e1 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 09:08:52 +0800 Subject: [PATCH 1/8] fix(agent): match JS/TS extends/implements class forms in duplicate decl check (#2734) jsClassRe required the class name to be followed immediately by '{' or '<', so 'class Foo extends Bar {' / 'implements' forms -- the dominant class shape in real JS/TS -- were never counted. Old/new counts both 0 meant the duplicate-class check was silent dead code for those forms. Add an optional extends/implements heritage list before the terminator; 4 new tests pin match forms, non-class exclusions, duplicate detection, and the legitimate heritage-change no-warning case. --- internal/agent/duplicate_decl_check.go | 10 +++- internal/agent/zz_issue2734_test.go | 77 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 internal/agent/zz_issue2734_test.go diff --git a/internal/agent/duplicate_decl_check.go b/internal/agent/duplicate_decl_check.go index c4d3d8ffe..011d016c7 100644 --- a/internal/agent/duplicate_decl_check.go +++ b/internal/agent/duplicate_decl_check.go @@ -300,8 +300,14 @@ func collectPythonDecls(src string) map[regexDeclKey]int { // idiom inside any function body) and is excluded (#2703 scenario 2). var jsFuncRe = regexp.MustCompile(`(?m)^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(\w+)\s*\(`) -// jsClassRe matches top-level class declarations. -var jsClassRe = regexp.MustCompile(`(?m)^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)\s*[\{<]`) +// jsClassRe matches top-level class declarations. #2734: inheritance is the +// dominant class form in real JS/TS (React components extend Component, +// services extend Base) -- the old `[\{<]` only matched bare classes and +// silently excluded `class Foo extends Bar {` / `implements`, making the +// duplicate-class check dead code for those forms (old/new counts both 0, +// no failure signal). Match an optional extends/implements heritage list +// before the `{`/`<` terminator. +var jsClassRe = regexp.MustCompile(`(?m)^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)(?:\s+extends\s+[\w.<>[\]]+)?(?:\s+implements\s+[\w.<>[\],\s]+)?\s*[\{<]`) // jsConstFuncRe matches top-level "const foo = (" arrow function declarations. // Indented const is a block-scoped local (the most common false-positive diff --git a/internal/agent/zz_issue2734_test.go b/internal/agent/zz_issue2734_test.go new file mode 100644 index 000000000..5fff441f4 --- /dev/null +++ b/internal/agent/zz_issue2734_test.go @@ -0,0 +1,77 @@ +package agent + +import ( + "strings" + "testing" +) + +// zz_issue2734_test.go — jsClassRe must match inheritance forms +// (extends/implements), the dominant class shape in real JS/TS. The old +// `[\{<]`-only pattern silently skipped them, making the duplicate-class +// check dead code for those forms. + +func TestIssue2734JsClassReMatchesInheritanceForms(t *testing.T) { + cases := []string{ + "class Foo {", + "class Foo extends Bar {", + "class Foo extends Bar implements Baz {", + "export default class Foo extends React.Component {", + "export abstract class Foo extends Base {", + "export class Foo extends Bar {", + "class Foo extends NS.Base {", + "class Foo {", + } + for _, src := range cases { + if !jsClassRe.MatchString(src) { + t.Errorf("jsClassRe should match %q", src) + } + m := jsClassRe.FindStringSubmatch(src) + if len(m) <= 1 || m[1] != "Foo" { + t.Errorf("jsClassRe captured wrong name for %q: %v", src, m) + } + } +} + +func TestIssue2734JsClassReStillExcludesNonClasses(t *testing.T) { + nonClasses := []string{ + " class Foo {", // indented: nested/block-scoped, not top-level + "const classy = {", // not a class decl + "// class Foo extends Bar {", + "let x = MyClass.class Foo", // not line-start class keyword + } + for _, src := range nonClasses { + if jsClassRe.MatchString(src) { + t.Errorf("jsClassRe should NOT match %q", src) + } + } +} + +func TestIssue2734DuplicateClassWithExtendsDetected(t *testing.T) { + // The exact #2734 scenario: agent pastes a second copy of an + // existing extends-form class -- must now be flagged at write time. + old := `export class Foo extends Base { + run() {} +} +` + new := old + ` +export class Foo extends Base { + run() {} +} +` + warn := checkDuplicateDeclarations("svc.ts", old, new) + if warn == "" { + t.Fatal("duplicate extends-form class not detected") + } + if !strings.Contains(warn, `class "Foo"`) { + t.Errorf("warning should name the duplicated class, got: %s", warn) + } +} + +func TestIssue2734SingleClassWithExtendsNoWarning(t *testing.T) { + // Legitimate refactor that switches heritage must not warn (count 1 → 1). + old := "export class Foo extends Base {\n}\n" + new := "export class Foo extends Other implements Iface {\n}\n" + if warn := checkDuplicateDeclarations("svc.ts", old, new); warn != "" { + t.Errorf("no duplicate expected on heritage change, got: %s", warn) + } +} From 80591949f934740de1554cf04a6b34639b3522bb Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 10:10:37 +0800 Subject: [PATCH 2/8] fix(agent): scope declarations supersede instead of accumulating (#2733) Multiple self-declared scope constraints formed an implicit AND: after two different scope declarations every edit violated one of them, burning the cvMaxWarnings quota with false positives while real violations went silent. Scope declarations now REPLACE prior scope constraints; avoid constraints stay additive. 4 regression tests in zz_issue2733_test.go. Co-Authored-By: ggcode --- internal/agent/constraint_violation.go | 29 +++++- internal/agent/zz_issue2733_test.go | 117 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 internal/agent/zz_issue2733_test.go diff --git a/internal/agent/constraint_violation.go b/internal/agent/constraint_violation.go index 342386d7c..3642a0d34 100644 --- a/internal/agent/constraint_violation.go +++ b/internal/agent/constraint_violation.go @@ -126,12 +126,39 @@ func (s *constraintViolationState) recordReasoning(text string, iter int) { } s.currentIter = iter extracted := cvExtractConstraints(text, iter) + + // #2733: scope declarations have REPLACE semantics, not accumulate. + // Scope constraints are task-local commitments about where changes will + // land; a later declaration ("I'll limit changes to docs/") supersedes an + // earlier one ("I'll only modify auth/"). Accumulating them forms an + // implicit global AND -- after two different scope declarations, EVERY edit + // violates at least one of them, burning the cvMaxWarnings quota with + // false positives and silencing real violations. Avoid constraints are + // naturally additive and keep accumulating. + var newScopes []cvConstraint + for _, c := range extracted { + if c.constraintT == "scope" { + newScopes = append(newScopes, c) + } + } + if len(newScopes) > 0 { + kept := s.constraints[:0] + for _, existing := range s.constraints { + if existing.constraintT == "scope" { + continue // superseded by this turn's scope declaration(s) + } + kept = append(kept, existing) + } + s.constraints = kept + } + for _, c := range extracted { if len(s.constraints) >= cvMaxTracked { break } // Deduplicate: skip if we already track a constraint with the same - // pattern and type. + // pattern and type (covers re-declaring the same scope this turn -- + // a re-declaration must not re-arm an identical superseded scope). dup := false for _, existing := range s.constraints { if existing.constraintT == c.constraintT && existing.pattern == c.pattern { diff --git a/internal/agent/zz_issue2733_test.go b/internal/agent/zz_issue2733_test.go new file mode 100644 index 000000000..499a22467 --- /dev/null +++ b/internal/agent/zz_issue2733_test.go @@ -0,0 +1,117 @@ +package agent + +import ( + "strings" + "testing" +) + +// zz_issue2733_test.go: regression tests for scope-constraint supersede +// semantics (#2733). Scope declarations used to accumulate with no +// replacement, forming an implicit AND: after declaring "only modify auth/" +// and later "limit changes to docs/", any edit violated at least one of the +// two constraints, burning the cvMaxWarnings=2 quota with false positives +// while real violations went silent. + +func issue2733ScopeConstraints(s *constraintViolationState) []cvConstraint { + var out []cvConstraint + for _, c := range s.constraints { + if c.constraintT == "scope" { + out = append(out, c) + } + } + return out +} + +// Test 1: the issue's exact reproduction -- a second, different scope +// declaration must supersede the first, so an edit inside the CURRENT scope +// (docs/) is no longer falsely flagged by the stale auth/ constraint (the +// AND-ization that burned the whole warning quota with noise). +func TestIssue2733LaterScopeSupersedesEarlier(t *testing.T) { + s := newConstraintViolationState() + s.recordReasoning("I'll only modify files in the auth/ directory.", 1) + s.recordReasoning("I'll limit changes to the docs/ folder.", 5) + + scopes := issue2733ScopeConstraints(s) + if len(scopes) != 1 { + t.Fatalf("expected exactly 1 scope constraint after supersede, got %d (%+v)", len(scopes), scopes) + } + if scopes[0].pattern != "docs/" { + t.Fatalf("expected surviving scope pattern 'docs/', got %q", scopes[0].pattern) + } + + // Edit inside the CURRENT (latest) scope: under the old accumulate + // semantics this violated the stale auth/ constraint -- a false positive + // that burned the quota and let real violations through silently. + if msg := s.checkToolCall("edit_file", map[string]any{"file_path": "docs/readme.md"}, 6); msg != "" { + t.Fatalf("edit inside current scope must not warn, got: %s", msg) + } + // Quota untouched: 0 warnings spent. + if s.warnings != 0 { + t.Fatalf("quota must be intact after in-scope edit, warnings=%d", s.warnings) + } + + // A genuinely out-of-scope edit still fires exactly once. + msg := s.checkToolCall("edit_file", map[string]any{"file_path": "cmd/main.go"}, 7) + if msg == "" { + t.Fatal("real out-of-scope edit (cmd/main.go vs docs/) must warn") + } + if want := "docs/"; !strings.Contains(msg, want) { + t.Fatalf("warning should cite the current scope %q, got: %s", want, msg) + } +} + +// Test 2: avoid constraints remain additive alongside the supersede rule -- +// an avoid declaration must survive a later scope declaration. +func TestIssue2733AvoidStaysAdditive(t *testing.T) { + s := newConstraintViolationState() + s.recordReasoning("I won't modify the config/ directory.", 1) + s.recordReasoning("I'll limit changes to the docs/ folder.", 3) + + var avoids []cvConstraint + for _, c := range s.constraints { + if c.constraintT == "avoid" { + avoids = append(avoids, c) + } + } + if len(avoids) != 1 { + t.Fatalf("avoid constraint must survive scope supersede, got %d", len(avoids)) + } + // config/ is both avoided and outside docs/ -- the avoid arm must fire. + msg := s.checkToolCall("edit_file", map[string]any{"file_path": "config/app.yaml"}, 4) + if msg == "" || !strings.Contains(msg, "avoid") { + t.Fatalf("edit into avoided config/ must warn via avoid arm, got: %s", msg) + } +} + +// Test 3: re-declaring the SAME scope must not duplicate the constraint +// (dedup path still applies after supersede logic). +func TestIssue2733SameScopeRedeclareNoDuplicate(t *testing.T) { + s := newConstraintViolationState() + s.recordReasoning("I'll only modify files in the auth/ directory.", 1) + s.recordReasoning("Staying within auth/ as planned.", 4) + + scopes := issue2733ScopeConstraints(s) + if len(scopes) != 1 { + t.Fatalf("re-declared same scope must dedup, got %d scope constraints", len(scopes)) + } + if scopes[0].iter != 4 { + t.Fatalf("surviving entry should be the latest declaration (iter=4), got iter=%d", scopes[0].iter) + } +} + +// Test 4: supersede only drops older scopes when the new turn actually +// declares a scope -- reasoning with no scope declarations (e.g. only an +// avoid) must leave existing scope constraints intact. +func TestIssue2733NoScopeDeclarationKeepsExisting(t *testing.T) { + s := newConstraintViolationState() + s.recordReasoning("I'll only modify files in the auth/ directory.", 1) + s.recordReasoning("I won't touch the vendor/ directory.", 2) + + scopes := issue2733ScopeConstraints(s) + if len(scopes) != 1 || scopes[0].pattern != "auth/" { + t.Fatalf("scope without a competing declaration must survive, got %+v", scopes) + } + if msg := s.checkToolCall("edit_file", map[string]any{"file_path": "docs/readme.md"}, 3); msg == "" { + t.Fatal("out-of-scope edit must still warn when scope not superseded") + } +} From 958f9db4fe773d1765a5a009bb7817f1ba3d3e2b Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 11:09:00 +0800 Subject: [PATCH 3/8] fix(agent): detect double-lock re-acquire in lock_without_unlock check (#2740) --- internal/agent/lock_without_unlock_check.go | 24 +++- internal/agent/zz_issue2740_test.go | 128 ++++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 internal/agent/zz_issue2740_test.go diff --git a/internal/agent/lock_without_unlock_check.go b/internal/agent/lock_without_unlock_check.go index 4ff4723d5..ced489921 100644 --- a/internal/agent/lock_without_unlock_check.go +++ b/internal/agent/lock_without_unlock_check.go @@ -258,9 +258,29 @@ func simulateHeldLocks(fn *ast.FuncDecl, fset *token.FileSet) []lockWithoutUnloc return } if _, isLock := lockMethodNames[sel.Sel.Name]; isLock { - if _, exists := held[recv]; !exists { - held[recv] = &simHeldEntry{lock: lockCall{receiver: recv, method: sel.Sel.Name, pos: call.Pos()}} + if prev, exists := held[recv]; exists { + // #2740: re-acquiring an already-held lock on the same + // receiver is a guaranteed self-deadlock (Go mutexes are not + // reentrant) even when the function is syntactically + // balanced. The header's failure mode #3 promises this + // detection; the old code silently returned. Only Lock/ + // TryLock warn - RLock re-entry is legal (sync.RWMutex + // reader reentrancy) and stays silent per the issue's + // conservative guidance. Mark the held entry reported so the + // function-end check does not emit a second, misleading + // missing-unlock warning for the same anchor (#1099). + if !prev.reported && (sel.Sel.Name == "Lock" || sel.Sel.Name == "TryLock") { + prev.reported = true + instances = append(instances, lockWithoutUnlockInstance{ + receiver: recv, + method: sel.Sel.Name, + funcName: fn.Name.Name, + posStr: fset.Position(call.Pos()).String() + " (double lock / non-reentrant re-acquire)", + }) + } + return } + held[recv] = &simHeldEntry{lock: lockCall{receiver: recv, method: sel.Sel.Name, pos: call.Pos()}} return } if sel.Sel.Name == "Unlock" || sel.Sel.Name == "RUnlock" { diff --git a/internal/agent/zz_issue2740_test.go b/internal/agent/zz_issue2740_test.go new file mode 100644 index 000000000..83c0a36ba --- /dev/null +++ b/internal/agent/zz_issue2740_test.go @@ -0,0 +1,128 @@ +package agent + +import ( + "strings" + "testing" +) + +// Issue #2740: balanced double-lock (mu.Lock(); mu.Lock(); mu.Unlock(); +// mu.Unlock()) must warn - Go mutexes are not reentrant, the second Lock +// deadlocks at runtime, and go vet/staticcheck do not cover it (the exact +// gap the checker's header failure mode #3 promises to catch). +func TestIssue2740BalancedDoubleLockWarns(t *testing.T) { + src := `package x +import "sync" +func f(mu *sync.Mutex) { + mu.Lock() + doSomething() + mu.Lock() + doMore() + mu.Unlock() + mu.Unlock() +} +` + warnings := checkLockWithoutUnlock("test.go", "", src) + if len(warnings) != 1 { + t.Fatalf("expected exactly 1 warning for balanced double-lock, got %d: %v", len(warnings), warnings) + } + if !strings.Contains(warnings[0], "double lock") { + t.Errorf("warning should mention double lock, got: %s", warnings[0]) + } + if !strings.Contains(warnings[0], "mu.Lock") { + t.Errorf("warning should name mu.Lock, got: %s", warnings[0]) + } +} + +// RLock re-entry is legal (sync.RWMutex reader reentrancy): no warning. +func TestIssue2740RLockReentrySilent(t *testing.T) { + src := `package x +import "sync" +func f(rw *sync.RWMutex) { + rw.RLock() + rw.RLock() + rw.RUnlock() + rw.RUnlock() +} +` + warnings := checkLockWithoutUnlock("test.go", "", src) + if len(warnings) != 0 { + t.Fatalf("RLock re-entry is legal, expected 0 warnings, got %d: %v", len(warnings), warnings) + } +} + +// Lock while already holding the same receiver via RLock is also a real +// deadlock (writer blocks on the reader held by self): must warn. +func TestIssue2740LockOverRLockWarns(t *testing.T) { + src := `package x +import "sync" +func f(rw *sync.RWMutex) { + rw.RLock() + rw.Lock() + rw.Unlock() + rw.RUnlock() +} +` + warnings := checkLockWithoutUnlock("test.go", "", src) + if len(warnings) != 1 { + t.Fatalf("expected exactly 1 warning for Lock over held RLock, got %d: %v", len(warnings), warnings) + } + if !strings.Contains(warnings[0], "double lock") { + t.Errorf("warning should mention double lock, got: %s", warnings[0]) + } +} + +// Lock in a sequential branch after an outer Lock on the same receiver +// (nested branch copy) still warns: the branch inherits the outer held set. +func TestIssue2740DoubleLockInBranchWarns(t *testing.T) { + src := `package x +import "sync" +func f(mu *sync.Mutex, cond bool) { + mu.Lock() + if cond { + mu.Lock() + mu.Unlock() + } + mu.Unlock() +} +` + warnings := checkLockWithoutUnlock("test.go", "", src) + if len(warnings) != 1 { + t.Fatalf("expected exactly 1 warning for branch double-lock, got %d: %v", len(warnings), warnings) + } + if !strings.Contains(warnings[0], "double lock") { + t.Errorf("warning should mention double lock, got: %s", warnings[0]) + } +} + +// Delta path: pre-existing double-lock in old content is not re-flagged. +func TestIssue2740DeltaSuppressesPreexisting(t *testing.T) { + src := `package x +import "sync" +func f(mu *sync.Mutex) { + mu.Lock() + mu.Lock() + mu.Unlock() + mu.Unlock() +} +` + warnings := checkLockWithoutUnlock("test.go", src, src) + if len(warnings) != 0 { + t.Fatalf("delta check should suppress pre-existing double-lock, got %d: %v", len(warnings), warnings) + } +} + +// Sanity: ordinary balanced Lock/Unlock still silent (no regression). +func TestIssue2740BalancedSingleLockSilent(t *testing.T) { + src := `package x +import "sync" +func f(mu *sync.Mutex) { + mu.Lock() + doSomething() + mu.Unlock() +} +` + warnings := checkLockWithoutUnlock("test.go", "", src) + if len(warnings) != 0 { + t.Fatalf("balanced single Lock/Unlock should be silent, got %d: %v", len(warnings), warnings) + } +} From b416ccc691bf8fd3879056cbae64326c9fe61b32 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 12:09:27 +0800 Subject: [PATCH 4/8] fix(im): twitch sendRaw write deadline via net.Conn interface, not *net.TCPConn assertion (#2747) Production conn is always *tls.Conn (defaultDialIRC wraps tls.Client), so the type assertion never matched and #2113 F2 anti-wedge protection was dead code. Call SetWriteDeadline directly through the interface (tls.Conn propagates to the wrapped TCP conn) and clear it after the write. Co-Authored-By: ggcode --- internal/im/twitch_adapter.go | 14 ++++++-- internal/im/zz_issue2747_test.go | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 internal/im/zz_issue2747_test.go diff --git a/internal/im/twitch_adapter.go b/internal/im/twitch_adapter.go index 06b971391..6e3a3e69b 100644 --- a/internal/im/twitch_adapter.go +++ b/internal/im/twitch_adapter.go @@ -987,10 +987,18 @@ func (a *twitchAdapter) sendRaw(line string) error { // queued behind it, and the conn.Close() that would unblock everything // sat behind the QUIT: StopAdapter then held the IM Manager's m.mu for // the TCP retransmit timeout (15-30min) - a global manager stall. - if tc, ok := c.(*net.TCPConn); ok { - _ = tc.SetWriteDeadline(time.Now().Add(10 * time.Second)) - } + // #2747: call SetWriteDeadline through the net.Conn interface - the + // production conn is ALWAYS a *tls.Conn (defaultDialIRC wraps in + // tls.Client), so the old *net.TCPConn type assertion never matched and + // the deadline was never set: the whole #2113 F2 anti-wedge protection + // was dead code on every real connection. tls.Conn propagates the + // deadline to the wrapped TCP conn, and the proxy path (proxyDial) + // returns plain conns where the interface call is equally valid. Clear + // the deadline after the write so later reads on this conn are not + // affected by a lingering write deadline. + _ = c.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err := fmt.Fprintf(c, "%s\r\n", line) + _ = c.SetWriteDeadline(time.Time{}) return err } diff --git a/internal/im/zz_issue2747_test.go b/internal/im/zz_issue2747_test.go new file mode 100644 index 000000000..cd4aa78f4 --- /dev/null +++ b/internal/im/zz_issue2747_test.go @@ -0,0 +1,59 @@ +package im + +import ( + "io" + "net" + "testing" + "time" +) + +// issue2747RecordingConn records SetWriteDeadline calls made through the +// net.Conn interface. +type issue2747RecordingConn struct { + net.Conn + deadlineSet bool + deadlineCleared bool +} + +func (c *issue2747RecordingConn) SetWriteDeadline(t time.Time) error { + if t.IsZero() { + c.deadlineCleared = true + } else { + c.deadlineSet = true + } + return nil +} + +// TestIssue2747SendRawSetsWriteDeadlineViaInterface pins the #2747 fix: +// sendRaw must set the write deadline through the net.Conn INTERFACE, not +// via a *net.TCPConn type assertion. The production conn is always *tls.Conn +// (defaultDialIRC wraps in tls.Client), so the old assertion never matched +// on any real connection and #2113's F2 anti-wedge protection was dead +// code. A non-*net.TCPConn conn (here: net.Pipe, same interface-only +// situation as *tls.Conn) reproduces the miss: the old code skipped the +// deadline entirely. +func TestIssue2747SendRawSetsWriteDeadlineViaInterface(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + // Drain the pipe so sendRaw's write completes. + go func() { + _, _ = io.Copy(io.Discard, server) + }() + + rec := &issue2747RecordingConn{Conn: client} + adapter := newTestTwitchAdapter(nil) + adapter.mu.Lock() + adapter.conn = rec + adapter.mu.Unlock() + + if err := adapter.sendRaw("PING :probe"); err != nil { + t.Fatalf("sendRaw: %v", err) + } + if !rec.deadlineSet { + t.Fatalf("write deadline NOT set via net.Conn interface — #2747 regression: sendRaw still relies on a *net.TCPConn type assertion that never matches the production *tls.Conn") + } + if !rec.deadlineCleared { + t.Fatalf("write deadline NOT cleared after write — later I/O on this conn would inherit the stale deadline") + } +} From 16b73f6d29ecf8035c21c13523a03c1b2b70cec7 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 13:10:45 +0800 Subject: [PATCH 5/8] fix(agent): bare shell 'test' builtin no longer counts as test verification (#2754) case "test", "pytest" flipped testsRan for any run_command whose first token was 'test' - but bare 'test' is the POSIX shell conditional builtin ('test -f x', 'test -d dist && rm -rf dist'), not a test run. This silently disarmed the git_commit/git push reversibility gate, same false-verification family as #2255 and #2552. Narrowed to bare test runners (pytest/py.test/vitest/jest); #1194 semantics preserved. Co-Authored-By: ggcode --- internal/agent/reversibility_check.go | 10 ++++- internal/agent/zz_issue2754_test.go | 59 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 internal/agent/zz_issue2754_test.go diff --git a/internal/agent/reversibility_check.go b/internal/agent/reversibility_check.go index 01d5ba130..1dae86c14 100644 --- a/internal/agent/reversibility_check.go +++ b/internal/agent/reversibility_check.go @@ -91,9 +91,15 @@ func (r *reversibilityState) recordSafetySignal(toolName, args string) { if hasCommandToken(tokens[1:], "test", "check") { r.testsRan = true } - case "test", "pytest": - // pytest as the command's first token IS the test command + case "pytest", "py.test", "vitest", "jest": + // Bare test-runner commands as the first token ARE test runs // (#1194: `pytest -q scripts/` has no `test` token following). + // #2754: bare `test` is NOT in this list - it is the POSIX + // shell builtin (`test -f x`, `test -d dist && rm -rf dist`), + // a conditional, not a test run. Counting it flipped testsRan + // and silently disarmed the commit/push gate - the same + // false-verification family as #2255 ("build:" in a commit + // message) and #2552 (`make clean` counted as build). r.testsRan = true } case "git_add", "git_commit": diff --git a/internal/agent/zz_issue2754_test.go b/internal/agent/zz_issue2754_test.go new file mode 100644 index 000000000..73a4a55f5 --- /dev/null +++ b/internal/agent/zz_issue2754_test.go @@ -0,0 +1,59 @@ +package agent + +// Issue #2754 regression tests: bare `test` (the POSIX shell builtin for +// conditionals like `test -f x`) must NOT count as a test run. It used to +// flip testsRan via `case "test", "pytest"` and silently disarm the +// git_commit/git push reversibility gate - the same false-verification +// family as #2255 ("build:" in a commit message) and #2552 (`make clean` +// counted as build). + +import "testing" + +func TestIssue2754TestBuiltinDoesNotDisarmGate(t *testing.T) { + r := newReversibilityState() + + // A conditional check on a build artifact - not a test run. + r.recordSafetySignal("run_command", "# check build artifact exists\ntest -f /tmp/ggcode || echo missing") + if r.testsRan { + t.Fatal("shell builtin `test -f` must NOT set testsRan (#2754)") + } + + // The commit gate must still be armed. + if got := r.checkPreAction("git_commit", `{"message":"wip"}`); got == "" { + t.Fatal("git_commit warning must still fire after a bare `test` conditional (#2754)") + } + // The push gate must still be armed. + if got := r.checkPreAction("run_command", "git push origin main"); got == "" { + t.Fatal("git push warning must still fire after a bare `test` conditional (#2754)") + } +} + +func TestIssue2754DestructivePrecheckDoesNotDisarmGate(t *testing.T) { + // `test -d dist && rm -rf dist` is a destructive pre-cleanup guarded by + // a conditional - counting it as "verified" is the worst-case miss. + r := newReversibilityState() + r.recordSafetySignal("run_command", `{"command":"test -d dist && rm -rf dist"}`) + if r.testsRan { + t.Fatal("JSON-enveloped `test -d && rm -rf` must NOT set testsRan (#2754)") + } + if got := r.checkPreAction("run_command", "git push origin main"); got == "" { + t.Fatal("git push warning must still fire after `test -d && rm -rf` (#2754)") + } +} + +func TestIssue2754BareTestRunnersStillCount(t *testing.T) { + // The #1194 motivation - bare pytest with no `test` token following - + // plus the common bare runners must keep counting. + for _, args := range []string{ + "pytest -q scripts/", + "py.test tests/", + "vitest run", + "jest --ci", + } { + r := newReversibilityState() + r.recordSafetySignal("run_command", args) + if !r.testsRan { + t.Errorf("bare test runner %q must still set testsRan (#2754/#1194)", args) + } + } +} From 56aba7cf710e51b38c34245ed9b2d0d22296ca6a Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 14:09:30 +0800 Subject: [PATCH 6/8] fix(tui): statusline failed/timed-out refresh keeps last good output (#2748) runStatuslineCommand now returns (text, ok); handleStatuslineMsg only replaces the cached text on success, honoring the documented contract. Failed refresh still clears running/dirty so refresh cadence is unchanged. --- internal/tui/statusline_script.go | 25 ++++++---- internal/tui/statusline_script_test.go | 18 +++---- internal/tui/zz_issue2748_test.go | 57 ++++++++++++++++++++++ internal/tui/zz_statusline_timeout_test.go | 8 +-- 4 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 internal/tui/zz_issue2748_test.go diff --git a/internal/tui/statusline_script.go b/internal/tui/statusline_script.go index 9ff773d97..b82382147 100644 --- a/internal/tui/statusline_script.go +++ b/internal/tui/statusline_script.go @@ -29,9 +29,12 @@ import ( const statuslineDefaultTimeout = 2 * time.Second // statuslineMsg carries a finished external refresh back into the TUI. +// ok=false marks a failed/timed-out invocation: the cached last good output +// must be kept (#2748). type statuslineMsg struct { seq int text string + ok bool } // statuslineState caches the rendered external status line and serializes @@ -112,13 +115,13 @@ func (m *Model) buildStatuslinePayload() statuslinePayload { } // runStatuslineCommand executes one external refresh synchronously and -// returns the first stdout line. On timeout or non-zero exit it returns "" -// (caller keeps the cached output). -func runStatuslineCommand(command string, payload statuslinePayload, timeout time.Duration) string { +// returns the first stdout line. On timeout or non-zero exit it returns +// ("", false) so the caller keeps the cached last good output (#2748). +func runStatuslineCommand(command string, payload statuslinePayload, timeout time.Duration) (string, bool) { input, err := json.Marshal(payload) if err != nil { debug.Log("tui", "statusline: payload marshal: %v", err) - return "" + return "", false } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -145,13 +148,13 @@ func runStatuslineCommand(command string, payload statuslinePayload, timeout tim } else { debug.Log("tui", "statusline: command failed: %v", err) } - return "" + return "", false } first := out if i := bytes.IndexByte(out, '\n'); i >= 0 { first = out[:i] } - return strings.TrimSpace(strings.TrimRight(string(first), "\r")) + return strings.TrimSpace(strings.TrimRight(string(first), "\r")), true } // refreshStatusline fires an async external refresh unless one is already in @@ -184,9 +187,9 @@ func (m *Model) refreshStatusline() { payload := m.buildStatuslinePayload() prog := m.program safego.Go("tui.statusline", func() { - text := runStatuslineCommand(command, payload, timeout) + text, ok := runStatuslineCommand(command, payload, timeout) if prog != nil { - prog.Send(statuslineMsg{seq: seq, text: text}) + prog.Send(statuslineMsg{seq: seq, text: text, ok: ok}) } }) } @@ -201,7 +204,11 @@ func (m Model) handleStatuslineMsg(msg statuslineMsg) (Model, tea.Cmd) { requeue := false sl.mu.Lock() if msg.seq >= sl.seq { - sl.text = msg.text + // A failed or timed-out invocation keeps the last good output - + // only a successful refresh may replace the cached text (#2748). + if msg.ok { + sl.text = msg.text + } sl.running = false if sl.dirty { sl.dirty = false diff --git a/internal/tui/statusline_script_test.go b/internal/tui/statusline_script_test.go index c9bfadcd5..b29739dca 100644 --- a/internal/tui/statusline_script_test.go +++ b/internal/tui/statusline_script_test.go @@ -28,8 +28,8 @@ func TestRunStatuslineFirstLineAndStdin(t *testing.T) { cmd := statuslineTestCommand(t, `grep -q '"model"' && printf 'AAA\nBBB\n'`) payload := statuslinePayload{Version: "ggcode"} payload.Model.ID = "test-model" - got := runStatuslineCommand(cmd, payload, 2*time.Second) - if got != "AAA" { + got, ok := runStatuslineCommand(cmd, payload, 2*time.Second) + if !ok || got != "AAA" { t.Fatalf("runStatuslineCommand = %q, want AAA (first line only)", got) } } @@ -37,9 +37,9 @@ func TestRunStatuslineFirstLineAndStdin(t *testing.T) { func TestRunStatuslineTimeout(t *testing.T) { cmd := statuslineTestCommand(t, `sleep 5`) start := time.Now() - got := runStatuslineCommand(cmd, statuslinePayload{}, 80*time.Millisecond) - if got != "" { - t.Fatalf("timeout run = %q, want empty", got) + got, ok := runStatuslineCommand(cmd, statuslinePayload{}, 80*time.Millisecond) + if ok || got != "" { + t.Fatalf("timeout run = (%q, %v), want (empty, false)", got, ok) } if time.Since(start) > 2*time.Second { t.Fatalf("timeout not enforced, took %s", time.Since(start)) @@ -48,9 +48,9 @@ func TestRunStatuslineTimeout(t *testing.T) { func TestRunStatuslineErrorReturnsEmpty(t *testing.T) { cmd := statuslineTestCommand(t, `exit 3`) - got := runStatuslineCommand(cmd, statuslinePayload{}, time.Second) - if got != "" { - t.Fatalf("failing run = %q, want empty", got) + got, ok := runStatuslineCommand(cmd, statuslinePayload{}, time.Second) + if ok || got != "" { + t.Fatalf("failing run = (%q, %v), want (empty, false)", got, ok) } } @@ -87,7 +87,7 @@ func TestHandleStatuslineMsgCacheAndStale(t *testing.T) { m := Model{} m.config = statuslineTestConfig() m.statusline = &statuslineState{seq: 2} - updated, _ := m.handleStatuslineMsg(statuslineMsg{seq: 2, text: "hello"}) + updated, _ := m.handleStatuslineMsg(statuslineMsg{seq: 2, text: "hello", ok: true}) if got := updated.statusline.text; got != "hello" { t.Fatalf("cache = %q, want hello", got) } diff --git a/internal/tui/zz_issue2748_test.go b/internal/tui/zz_issue2748_test.go new file mode 100644 index 000000000..6e52fb7bd --- /dev/null +++ b/internal/tui/zz_issue2748_test.go @@ -0,0 +1,57 @@ +package tui + +import ( + "runtime" + "testing" + "time" +) + +// #2748: a failed or timed-out statusline invocation must keep the last good +// cached output instead of blanking the bar. +func TestIssue2748FailedRefreshKeepsLastGoodOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sh-based statusline script test is unix-only") + } + m := Model{} + m.config = statuslineTestConfig() + m.statusline = &statuslineState{seq: 1, text: "last-good", running: true} + + // A failing refresh (non-zero exit) arrives with the current seq. + updated, _ := m.handleStatuslineMsg(statuslineMsg{seq: 2, text: "", ok: false}) + if got := updated.statusline.text; got != "last-good" { + t.Fatalf("failed refresh clobbered cache: got %q, want last-good", got) + } + if updated.statusline.running { + t.Fatal("running flag must be cleared even on failed refresh") + } + + // A timed-out refresh is the same ok=false path. + updated, _ = m.handleStatuslineMsg(statuslineMsg{seq: 3, text: "", ok: false}) + if got := updated.statusline.text; got != "last-good" { + t.Fatalf("timeout refresh clobbered cache: got %q, want last-good", got) + } + + // A successful refresh still replaces the cache. + updated, _ = m.handleStatuslineMsg(statuslineMsg{seq: 4, text: "fresh", ok: true}) + if got := updated.statusline.text; got != "fresh" { + t.Fatalf("successful refresh did not update cache: got %q, want fresh", got) + } +} + +// End-to-end variant: runStatuslineCommand's failure return must carry ok=false +// so the goroutine in refreshStatusline can distinguish failure from a +// legitimately empty successful line. +func TestIssue2748RunCommandFailureSignalsNotOK(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sh-based statusline script test is unix-only") + } + if _, ok := runStatuslineCommand(`exit 1`, statuslinePayload{}, time.Second); ok { + t.Fatal("non-zero exit must return ok=false") + } + if _, ok := runStatuslineCommand(`sleep 5`, statuslinePayload{}, 80*time.Millisecond); ok { + t.Fatal("timeout must return ok=false") + } + if text, ok := runStatuslineCommand(`printf 'line'`, statuslinePayload{}, time.Second); !ok || text != "line" { + t.Fatalf("success = (%q, %v), want (line, true)", text, ok) + } +} diff --git a/internal/tui/zz_statusline_timeout_test.go b/internal/tui/zz_statusline_timeout_test.go index 70f16af4c..948d5fff9 100644 --- a/internal/tui/zz_statusline_timeout_test.go +++ b/internal/tui/zz_statusline_timeout_test.go @@ -14,9 +14,9 @@ func TestStatuslineGrandchildPipeRelease(t *testing.T) { t.Skip("unix-only") } start := time.Now() - got := runStatuslineCommand(`sleep 5 & sleep 5`, statuslinePayload{}, 80*time.Millisecond) + got, ok := runStatuslineCommand(`sleep 5 & sleep 5`, statuslinePayload{}, 80*time.Millisecond) elapsed := time.Since(start) - if got != "" { + if ok || got != "" { t.Fatalf("got %q, want empty", got) } if elapsed > 1500*time.Millisecond { @@ -39,9 +39,9 @@ func TestStatuslineWaitDelayBackstop(t *testing.T) { } escape := `python3 -c 'import os,time; os.setsid(); time.sleep(5)' & sleep 5` start := time.Now() - got := runStatuslineCommand(escape, statuslinePayload{}, 80*time.Millisecond) + got, ok := runStatuslineCommand(escape, statuslinePayload{}, 80*time.Millisecond) elapsed := time.Since(start) - if got != "" { + if ok || got != "" { t.Fatalf("got %q, want empty", got) } // WaitDelay is 1s: expect ~1.1s total, well under the 2s tolerance the From 1db483823c0d9310a8cc3a9d110f7a7b79ec87ee Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 15:10:03 +0800 Subject: [PATCH 7/8] fix(agent): reproducer rerun detection validates command content (#2752) Phase 3 of the reproducer lifecycle tracker only checked the tool name (run_command/start_command), so any intermediate command like 'git diff' or 'ls' discharged the re-run obligation. Now the command must match the reproducer script shape (reproducerCommandRe) or share a distinctive token with the recorded reproducer snippet. Also replaces the unused reproducerFertilityWindow constant with reproducerRerunGraceIterations and fixes the stale comment in checkIncomplete. Co-Authored-By: ggcode --- internal/agent/reproducer_lifecycle.go | 86 ++++++++++++++++++-- internal/agent/zz_issue2752_test.go | 104 +++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 internal/agent/zz_issue2752_test.go diff --git a/internal/agent/reproducer_lifecycle.go b/internal/agent/reproducer_lifecycle.go index 56a75be01..f13b1dd16 100644 --- a/internal/agent/reproducer_lifecycle.go +++ b/internal/agent/reproducer_lifecycle.go @@ -57,9 +57,11 @@ import ( const ( reproLifecycleMaxWarnings = 1 // max warnings per run - // reproducerFertilityWindow: how many iterations after a reproducer run - // we consider the agent "in the edit phase" and expect a re-run. - reproducerFertilityWindow = 8 + reproducerRerunGraceIterations = 2 // iterations to wait after edit before warning + + // commandTokenMinLen: minimum length of a command token to count for + // overlap matching (filters out short generic words). + commandTokenMinLen = 3 ) // reproducerLifecycleState tracks the reproduce->edit->rerun lifecycle. @@ -126,6 +128,70 @@ var reproducerRunToolNames = map[string]bool{ "start_command": true, } +// reproducerRerunMatches reports whether a run tool input qualifies as a +// re-run of the reproducer itself (#2752). It qualifies if it matches the +// reproducer script shape (e.g. `python3 repro.py`), or if it shares a +// meaningful token overlap with the recorded reproducer snippet (covers +// text-established reproducers whose snippet may be prose-like). +func reproducerRerunMatches(inp, snippet string) bool { + if inp == "" { + return false + } + if reproducerCommandRe.MatchString(inp) { + return true + } + if snippet == "" { + return false + } + return reproCommandTokenOverlap(inp, snippet) +} + +// reproCommandTokenOverlap checks whether the two command strings share a +// distinctive script/path token (e.g. both reference `repro.py`). +func reproCommandTokenOverlap(a, b string) bool { + tokensA := reproCommandTokens(a) + tokensB := reproCommandTokens(b) + if len(tokensA) == 0 || len(tokensB) == 0 { + return false + } + for ta := range tokensA { + if tokensB[ta] { + return true + } + } + return false +} + +// reproCommandTokens splits a command string into lowercase tokens suitable +// for overlap matching. Fields are additionally split on path separators so +// `./cmd/reprogo/main.go` and `go run ./cmd/reprogo` share `reprogo`. +// Generic shell verbs, flags, and common directory names are dropped so +// overlap means script/argument identity rather than generic words. +func reproCommandTokens(s string) map[string]bool { + generic := map[string]bool{ + "and": true, "the": true, "run": true, "bash": true, "sh": true, + "python": true, "python3": true, "node": true, "ruby": true, + "cargo": true, "go": true, "test": true, "tests": true, "cd": true, + "echo": true, "make": true, "cmd": true, "src": true, "pkg": true, + "internal": true, "desktop": true, "main": true, "github.com": true, + "github": true, "www": true, "head": true, "git": true, "diff": true, + } + tokens := make(map[string]bool) + for _, field := range strings.Fields(strings.ToLower(s)) { + for _, comp := range strings.Split(field, "/") { + comp = strings.Trim(comp, "\"'`$();|&~.:") + if len(comp) < commandTokenMinLen || strings.HasPrefix(comp, "-") { + continue + } + if generic[comp] { + continue + } + tokens[comp] = true + } + } + return tokens +} + // observeToolCalls updates the lifecycle state based on the tools the agent // invoked this iteration. func (s *reproducerLifecycleState) observeToolCalls(iteration int, toolNames []string, toolInputs []string) { @@ -157,9 +223,12 @@ func (s *reproducerLifecycleState) observeToolCalls(iteration int, toolNames []s } } - // Phase 3: detect re-run after edit. + // Phase 3: detect re-run of the reproducer itself after edit (#2752). + // A bare run_command (e.g. `git diff`, `ls`) must NOT discharge the + // re-run obligation: the command must either match the reproducer + // script shape or resemble the recorded reproducer snippet. if s.editedAfterReproducer && !s.reranAfterEdit { - if reproducerRunToolNames[tn] { + if reproducerRunToolNames[tn] && reproducerRerunMatches(inp, s.reproducerSnippet) { s.reranAfterEdit = true debug.Log("agent", "reproducer-lifecycle: re-run after edit at iter %d", iteration) } @@ -189,12 +258,13 @@ func (s *reproducerLifecycleState) checkIncomplete(iteration int) string { if s.warned { return "" } - // Only warn if: reproducer established, code edited after, NOT re-run, - // and we're past the fertility window from the edit. + // Only warn if: reproducer established, code edited after, and the + // reproducer itself has NOT been re-run. Wait a grace period after the + // edit so the agent has a chance to re-run it. if !s.hasReproducer || !s.editedAfterReproducer || s.reranAfterEdit { return "" } - if iteration-s.editIteration < 2 { + if iteration-s.editIteration < reproducerRerunGraceIterations { return "" // give the agent a chance to re-run } diff --git a/internal/agent/zz_issue2752_test.go b/internal/agent/zz_issue2752_test.go new file mode 100644 index 000000000..6ea5a5778 --- /dev/null +++ b/internal/agent/zz_issue2752_test.go @@ -0,0 +1,104 @@ +package agent + +import ( + "strings" + "testing" +) + +// Regression tests for #2752: Phase 3 re-run detection must verify the +// command content, not just the tool name. A bare `run_command "git diff"` +// between edit and completion must NOT discharge the reproducer re-run +// obligation. + +func TestIssue2752BareGitDiffDoesNotDischarge(t *testing.T) { + s := newReproducerLifecycleState() + // Iter 1: establish reproducer. + s.observeToolCalls(1, []string{"run_command"}, []string{"python3 repro.py"}) + if !s.hasReproducer { + t.Fatal("reproducer should be established") + } + // Iter 2: edit source. + s.observeToolCalls(2, []string{"edit_file"}, []string{"src/main.go"}) + if !s.editedAfterReproducer { + t.Fatal("edit after reproducer should be recorded") + } + // Iter 3: run an unrelated command (git diff). + s.observeToolCalls(3, []string{"run_command"}, []string{"git diff HEAD~1"}) + if s.reranAfterEdit { + t.Fatal("bare 'git diff' must NOT count as reproducer re-run (#2752)") + } + // checkIncomplete past grace period must fire. + hint := s.checkIncomplete(6) + if !strings.Contains(hint, "reproducer-lifecycle") { + t.Fatalf("expected lifecycle warning, got: %q", hint) + } +} + +func TestIssue2752LsEchoDoNotDischarge(t *testing.T) { + s := newReproducerLifecycleState() + s.observeToolCalls(1, []string{"run_command"}, []string{"node crash.js"}) + s.observeToolCalls(2, []string{"write_file"}, []string{"internal/foo.go"}) + for _, cmd := range []string{"ls -la", "echo done", "cat internal/foo.go"} { + s.observeToolCalls(3, []string{"run_command"}, []string{cmd}) + if s.reranAfterEdit { + t.Fatalf("%q must NOT count as reproducer re-run", cmd) + } + } +} + +func TestIssue2752ActualRerunDischarges(t *testing.T) { + s := newReproducerLifecycleState() + s.observeToolCalls(1, []string{"run_command"}, []string{"python3 repro.py"}) + s.observeToolCalls(2, []string{"edit_file"}, []string{"src/main.go"}) + // Re-run the same reproducer script (matches reproducerCommandRe). + s.observeToolCalls(3, []string{"run_command"}, []string{"python3 repro.py"}) + if !s.reranAfterEdit { + t.Fatal("re-running the reproducer script must discharge the obligation") + } + if hint := s.checkIncomplete(6); hint != "" { + t.Fatalf("no warning expected after genuine re-run, got: %q", hint) + } +} + +func TestIssue2752StartCommandValidatedToo(t *testing.T) { + s := newReproducerLifecycleState() + s.observeToolCalls(1, []string{"run_command"}, []string{"python3 repro.py"}) + s.observeToolCalls(2, []string{"edit_file"}, []string{"src/main.go"}) + // start_command with unrelated content must not discharge. + s.observeToolCalls(3, []string{"start_command"}, []string{"watch ls"}) + if s.reranAfterEdit { + t.Fatal("unrelated start_command must NOT count as re-run") + } + // start_command re-running the script must discharge. + s.observeToolCalls(4, []string{"start_command"}, []string{"python3 repro.py"}) + if !s.reranAfterEdit { + t.Fatal("start_command re-running the reproducer must discharge") + } +} + +func TestIssue2752SnippetOverlapFallback(t *testing.T) { + s := newReproducerLifecycleState() + // Reproducer established via script shape. + s.observeToolCalls(1, []string{"run_command"}, []string{"go run ./cmd/reprogo/main.go"}) + s.observeToolCalls(2, []string{"edit_file"}, []string{"main.go"}) + // Re-run that doesn't match reproducerCommandRe exactly (no file ext) + // but shares the distinctive token with the snippet. + s.observeToolCalls(3, []string{"run_command"}, []string{"go run ./cmd/reprogo"}) + if !s.reranAfterEdit { + t.Fatal("snippet token overlap should discharge the obligation") + } +} + +func TestIssue2752GracePeriodUnchanged(t *testing.T) { + s := newReproducerLifecycleState() + s.observeToolCalls(1, []string{"run_command"}, []string{"python3 repro.py"}) + s.observeToolCalls(2, []string{"edit_file"}, []string{"src/main.go"}) + // iteration - editIteration == 1 < grace(2): no warning yet. + if hint := s.checkIncomplete(3); hint != "" { + t.Fatalf("grace period should suppress warning, got: %q", hint) + } + // Exactly at grace boundary: warning fires (2 >= 2). + if hint := s.checkIncomplete(4); !strings.Contains(hint, "reproducer-lifecycle") { + t.Fatalf("expected warning at grace boundary, got: %q", hint) + } +} From eb1e3cd3d0f61e92cf8c63a2237837f544704637 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 25 Sep 2026 16:12:19 +0800 Subject: [PATCH 8/8] feat(tool): govern background command job lifecycle (cap, memory floor, shutdown reaping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_command background jobs were the only background work class that survived session exit as orphan processes: TUI shutdownAll cancelled sub-agents, swarm teammates, panes and knight tasks, but never the CommandJobManager — detach=true dev servers and long builds kept running after quit//restart, holding ports and memory. There was also no ceiling on concurrently running jobs or on process memory, the documented OOM vector on shared machines (exit-137 builds). Implements the harness-engineering "cheap caps" floor for the command loop, aligned with Hermes-style safety-gated parallel batches: - CommandJobManager.ShutdownAll(wait): cancel all running jobs, wait up to `wait`, return the reaped count. Cancelled jobs stay readable as terminal entries so late read_command_output still explains the stop. - Admission gate at spawn: refuse new jobs when running >= cap (default 8, GGCODE_MAX_RUNNING_JOBS) or when process memory (MemStats.Sys) exceeds the ceiling (GGCODE_JOB_MEM_LIMIT > GOMEMLIMIT*1.5 > 3GiB). Refusal messages are actionable (stop_command / poll / override env), sampled at spawn time with no new goroutines. - Registry.JobManager() accessor; manager wired in RegisterBuiltinTools. - Reaping wired at every exit: TUI shutdownAll (quit/ctrl+d//restart exec handoff), RunPipe, and ACP server exit. Co-Authored-By: ggcode Co-Authored-By: ggcode --- cmd/ggcode/acp.go | 9 +- cmd/ggcode/pipe.go | 5 + cmd/ggcode/root.go | 1 + internal/tool/builtin.go | 1 + internal/tool/command_jobs.go | 167 ++++++++++++++++++- internal/tool/command_jobs_lifecycle_test.go | 120 +++++++++++++ internal/tool/tool.go | 16 +- internal/tui/model.go | 8 + internal/tui/model_pending.go | 9 + internal/tui/repl.go | 9 + 10 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 internal/tool/command_jobs_lifecycle_test.go diff --git a/cmd/ggcode/acp.go b/cmd/ggcode/acp.go index 6cc700a98..24c28c03c 100644 --- a/cmd/ggcode/acp.go +++ b/cmd/ggcode/acp.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/spf13/cobra" "github.com/topcheer/ggcode/internal/acp" @@ -92,7 +93,13 @@ func newACPCommand(cfgFile *string) *cobra.Command { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - return handler.Run(ctx) + // r71: reap managed background jobs when the ACP server exits so + // start_command children (detach=true included) do not orphan. + runErr := handler.Run(ctx) + if jm := registry.JobManager(); jm != nil { + jm.ShutdownAll(2 * time.Second) + } + return runErr }, } diff --git a/cmd/ggcode/pipe.go b/cmd/ggcode/pipe.go index 30ef4487e..e9aaa437b 100644 --- a/cmd/ggcode/pipe.go +++ b/cmd/ggcode/pipe.go @@ -77,6 +77,11 @@ func RunPipe(cfg *config.Config, cfgPath, prompt string, allowedTools, allowedDi } registry := core.Registry core.StartBackgroundServices() + // r71: reap managed background jobs (detach=true included) when the pipe + // run ends so its children do not outlive the process as orphans. + if jm := registry.JobManager(); jm != nil { + defer jm.ShutdownAll(2 * time.Second) + } defer core.Close() // Load project memory file list (for path-triggered dynamic loading). diff --git a/cmd/ggcode/root.go b/cmd/ggcode/root.go index 87e4db402..160439324 100644 --- a/cmd/ggcode/root.go +++ b/cmd/ggcode/root.go @@ -955,6 +955,7 @@ func run(cfg *config.Config, cfgFile, resumeID string, bypass bool) error { RemoteAgentsInfo: func() string { return remoteAgentsInfo }, }, task, agentType) }) + repl.SetJobManager(registry.JobManager()) repl.SetSubAgentManager(subMgr, prov, registry) repl.SetAskUserTool(registry) repl.SetCommandPane(registry, workingDir) diff --git a/internal/tool/builtin.go b/internal/tool/builtin.go index c090b58c4..f5752d910 100644 --- a/internal/tool/builtin.go +++ b/internal/tool/builtin.go @@ -44,6 +44,7 @@ func RegisterBuiltinTools(registry *Registry, policy permission.PermissionPolicy } jobManager := NewCommandJobManager(workingDir) jobManager.SetSandboxPolicy(sandbox) + registry.jobManager = jobManager codeIndex := NewCodeIndexManager(workingDir) registry.codeIndex = codeIndex tools := []Tool{ diff --git a/internal/tool/command_jobs.go b/internal/tool/command_jobs.go index 6e1f1f9af..400644fcb 100644 --- a/internal/tool/command_jobs.go +++ b/internal/tool/command_jobs.go @@ -5,8 +5,12 @@ import ( "errors" "fmt" "io" + "os" "os/exec" + "runtime" + "runtime/debug" "sort" + "strconv" "strings" "sync" "time" @@ -96,13 +100,159 @@ type CommandJobManager struct { // sandbox, when non-nil and Enabled, wraps managed job spawns in the // OS-level containment sandbox (same policy as run_command). sandbox *SandboxPolicy + + // r71 lifecycle governance: concurrently RUNNING jobs are capped and new + // spawns are refused while the ggcode process holds more than + // maxJobMemBytes of OS-obtained memory. Both are sampled at spawn time + // (no background goroutines); thresholds resolve from env with safe + // defaults. Unbounded parallel background jobs were the documented OOM + // vector on memory-constrained machines (build/test jobs die with exit + // 137), matching the harness-engineering guidance that the cheap caps + // (a bounded running set + a resource floor) are the floor for + // production loops. + maxRunningJobs int + maxJobMemBytes uint64 } func NewCommandJobManager(workingDir string) *CommandJobManager { return &CommandJobManager{ - workingDir: workingDir, - jobs: make(map[string]*CommandJob), + workingDir: workingDir, + jobs: make(map[string]*CommandJob), + maxRunningJobs: jobEnvPositiveInt("GGCODE_MAX_RUNNING_JOBS", maxRunningJobsDefault), + maxJobMemBytes: resolveJobMemLimit(), + } +} + +const ( + // maxRunningJobsDefault caps concurrently RUNNING managed jobs. It is a + // safety floor, not a throughput limit: finished jobs never count, and + // the cap is per-manager (the agent's long-lived manager). 8 covers the + // practical parallel-build/test workload while preventing the runaway + // pattern where every retry piles another build onto an + // already-swapping machine. + maxRunningJobsDefault = 8 + // jobMemLimitDefault is the process memory ceiling (bytes obtained from + // the OS, runtime.MemStats.Sys) beyond which new background jobs are + // refused. Overridden by GGCODE_JOB_MEM_LIMIT, or derived from + // GOMEMLIMIT (x1.5) when that env is set. + jobMemLimitDefault = uint64(3) << 30 +) + +// jobEnvPositiveInt resolves an env override to a positive int, falling back +// to def on missing/invalid values (0 disables the cap). +func jobEnvPositiveInt(name string, def int) int { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return def + } + if n, err := strconv.Atoi(v); err == nil { + return n + } + return def +} + +// resolveJobMemLimit picks the memory-pressure ceiling for new background +// jobs. Precedence: GGCODE_JOB_MEM_LIMIT (bytes) > GOMEMLIMIT*1.5 (the Go +// runtime reports the env-configured soft limit via SetMemoryLimit(-1)) > +// jobMemLimitDefault. Invalid values fall through to the next tier. +func resolveJobMemLimit() uint64 { + if n, err := strconv.ParseUint(strings.TrimSpace(os.Getenv("GGCODE_JOB_MEM_LIMIT")), 10, 64); err == nil && n > 0 { + return n } + if lim := debug.SetMemoryLimit(-1); lim > 0 && lim < 1<<62 { + return uint64(lim) + uint64(lim)/2 + } + return jobMemLimitDefault +} + +// jobMemSampleFn samples the process's OS-obtained memory in bytes. A +// package-level seam so tests can inject a value without allocating. +var jobMemSampleFn = func() uint64 { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + return ms.Sys +} + +// admissionBlockReason returns a non-empty actionable message when the +// manager must refuse a new background job right now: either the concurrent +// running-job cap is reached, or the process is above its memory ceiling. +// Read-only; safe to call before the manager takes ownership of the spawn. +func (m *CommandJobManager) admissionBlockReason() string { + m.mu.Lock() + running := 0 + for _, j := range m.jobs { + if !j.isTerminal() { + running++ + } + } + maxRunning := m.maxRunningJobs + memLimit := m.maxJobMemBytes + m.mu.Unlock() + + var reasons []string + if maxRunning > 0 && running >= maxRunning { + reasons = append(reasons, fmt.Sprintf( + "concurrent running background jobs at cap (%d/%d, GGCODE_MAX_RUNNING_JOBS) — poll or stop_command an older job before starting another", + running, maxRunning)) + } + if memLimit > 0 { + if sys := jobMemSampleFn(); sys > memLimit { + reasons = append(reasons, fmt.Sprintf( + "process memory pressure: %d MiB obtained from OS exceeds the %d MiB ceiling — finish or stop_command running jobs before starting new ones (tune GGCODE_JOB_MEM_LIMIT to override)", + sys>>20, memLimit>>20)) + } + } + return strings.Join(reasons, "; ") +} + +// ShutdownAll cancels every running job and waits up to wait for each to +// terminate, returning the number of running jobs reaped. Called at session +// and process shutdown (TUI quit, /restart exec handoff, pipe/ACP exit) so +// managed children — especially detach=true services — do not outlive the +// harness as orphan processes. Cancelled jobs stay in the map as terminal +// entries so a late read_command_output still shows why they stopped; normal +// finished-job eviction reclaims them. +func (m *CommandJobManager) ShutdownAll(wait time.Duration) int { + m.mu.Lock() + var running []*CommandJob + for _, j := range m.jobs { + if !j.isTerminal() { + running = append(running, j) + } + } + // Collect cancels, then cancel OUTSIDE m.mu: cancel wakes the job's + // waiter goroutine, whose finish path re-enters m.mu (recordFinish) — + // cancelling under the lock would serially block on it. + var cancels []context.CancelFunc + for _, j := range running { + j.mu.Lock() + if j.cancel != nil { + cancels = append(cancels, j.cancel) + } + j.mu.Unlock() + } + m.mu.Unlock() + for _, cancel := range cancels { + cancel() + } + if len(running) == 0 { + return 0 + } + + deadline := time.Now().Add(wait) + reaped := 0 + for _, j := range running { + d := time.Until(deadline) + if d <= 0 { + d = time.Millisecond + } + select { + case <-j.done: + reaped++ + case <-time.After(d): + } + } + return reaped } // SetOutputTee sets an optional writer that receives a copy of stdout/stderr. @@ -185,7 +335,12 @@ func (m *CommandJobManager) Start(ctx context.Context, command string, detach bo } _, snapshot, err := m.startExisting(jobCtx, command, timeout, cancel, cmd) - return snapshot, err + if err != nil { + // Refusal/ownership never happened: release the ctx/timer tree. + cancel() + return nil, err + } + return snapshot, nil } // StartExisting starts an already-configured command as a managed background job. @@ -213,6 +368,12 @@ func (m *CommandJobManager) StartExisting(ctx context.Context, cmd *exec.Cmd, co } func (m *CommandJobManager) startExisting(ctx context.Context, command string, timeout time.Duration, cancel context.CancelFunc, cmd *exec.Cmd) (*CommandJob, *CommandJobSnapshot, error) { + // r71 admission gate: refuse BEFORE the manager takes ownership of the + // spawn. The caller keeps cancel ownership on error and must cancel it + // (Start's wrapper and executeWithAutoBackground both do). + if reason := m.admissionBlockReason(); reason != "" { + return nil, nil, fmt.Errorf("background job refused: %s", reason) + } job := m.newJob(command, timeout, cancel) writer := &commandJobWriter{job: job} diff --git a/internal/tool/command_jobs_lifecycle_test.go b/internal/tool/command_jobs_lifecycle_test.go new file mode 100644 index 000000000..a8126d0cd --- /dev/null +++ b/internal/tool/command_jobs_lifecycle_test.go @@ -0,0 +1,120 @@ +package tool + +import ( + "context" + "strings" + "testing" + "time" +) + +// r71 lifecycle governance tests: concurrent running-job cap, memory-pressure +// admission refusal, and ShutdownAll reaping at session/process exit. + +func TestShutdownAllReapsRunningJob(t *testing.T) { + mgr := NewCommandJobManager(t.TempDir()) + started, err := mgr.Start(context.Background(), "sleep 5", false, 30*time.Second) + if err != nil { + t.Fatalf("start: %v", err) + } + if !started.Running { + t.Fatalf("job should be running right after start, got %s", started.Status) + } + + reaped := mgr.ShutdownAll(2 * time.Second) + if reaped != 1 { + t.Fatalf("ShutdownAll reaped %d jobs, want 1", reaped) + } + + snap, err := mgr.Read(started.ID, 5, 0) + if err != nil { + t.Fatalf("read after shutdown: %v", err) + } + // The cancelled job stays as a terminal entry so late polling still + // explains why it stopped. + if snap.Running { + t.Fatalf("job still running after ShutdownAll") + } + if snap.Status != CommandJobCancelled { + t.Fatalf("status after ShutdownAll = %s, want %s", snap.Status, CommandJobCancelled) + } +} + +func TestShutdownAllWithNoJobsReturnsZero(t *testing.T) { + mgr := NewCommandJobManager(t.TempDir()) + if got := mgr.ShutdownAll(time.Second); got != 0 { + t.Fatalf("ShutdownAll on empty manager = %d, want 0", got) + } +} + +func TestRunningJobCapRefusesNewJob(t *testing.T) { + mgr := NewCommandJobManager(t.TempDir()) + mgr.maxRunningJobs = 1 // test-scoped cap; production default is maxRunningJobsDefault + + first, err := mgr.Start(context.Background(), "sleep 5", false, 30*time.Second) + if err != nil { + t.Fatalf("first start: %v", err) + } + + _, err = mgr.Start(context.Background(), "sleep 5", false, 30*time.Second) + if err == nil { + t.Fatalf("second start should be refused at cap 1") + } + if !strings.Contains(err.Error(), "at cap") || !strings.Contains(err.Error(), "stop_command") { + t.Fatalf("refusal message not actionable: %v", err) + } + + // A refused start must NOT leave a job entry behind (ownership never + // transferred), otherwise the cap would count phantom jobs. + if n := mgr.countRunningForTest(); n != 1 { + t.Fatalf("running jobs after refusal = %d, want 1 (no phantom entries)", n) + } + + if _, err := mgr.Stop(first.ID); err != nil { + t.Fatalf("stop first: %v", err) + } + if _, err := mgr.Start(context.Background(), "printf 'ok\\n'", false, 5*time.Second); err != nil { + t.Fatalf("start after freeing a slot: %v", err) + } + mgr.ShutdownAll(2 * time.Second) +} + +func TestMemoryPressureRefusesNewJob(t *testing.T) { + orig := jobMemSampleFn + defer func() { jobMemSampleFn = orig }() + jobMemSampleFn = func() uint64 { return 10 << 30 } // 10 GiB + + mgr := NewCommandJobManager(t.TempDir()) + mgr.maxJobMemBytes = 3 << 30 + + _, err := mgr.Start(context.Background(), "printf 'ok\\n'", false, 5*time.Second) + if err == nil { + t.Fatalf("start should be refused under memory pressure") + } + if !strings.Contains(err.Error(), "memory pressure") || !strings.Contains(err.Error(), "GGCODE_JOB_MEM_LIMIT") { + t.Fatalf("pressure message not actionable: %v", err) + } +} + +func TestResolveJobMemLimitEnvOverride(t *testing.T) { + t.Setenv("GGCODE_JOB_MEM_LIMIT", "1073741824") // 1 GiB + if got := resolveJobMemLimit(); got != 1<<30 { + t.Fatalf("resolveJobMemLimit = %d, want %d", got, uint64(1)<<30) + } + + t.Setenv("GGCODE_JOB_MEM_LIMIT", "not-a-number") + if got := resolveJobMemLimit(); got != jobMemLimitDefault { + t.Fatalf("invalid env should fall back to default %d, got %d", jobMemLimitDefault, got) + } +} + +func (m *CommandJobManager) countRunningForTest() int { + m.mu.Lock() + defer m.mu.Unlock() + n := 0 + for _, j := range m.jobs { + if !j.isTerminal() { + n++ + } + } + return n +} diff --git a/internal/tool/tool.go b/internal/tool/tool.go index f2aabf751..c219bbe25 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -110,9 +110,10 @@ type SandboxSafe interface { // Registry manages the set of available tools. type Registry struct { - tools map[string]Tool - codeIndex *CodeIndexManager // optional: shared code index for @ fuzzy search - mu sync.RWMutex + tools map[string]Tool + codeIndex *CodeIndexManager // optional: shared code index for @ fuzzy search + jobManager *CommandJobManager // r71: shared command job manager for shutdown reaping + mu sync.RWMutex } // NewRegistry creates an empty tool registry. @@ -127,6 +128,15 @@ func (r *Registry) CodeIndex() *CodeIndexManager { return r.codeIndex } +// JobManager returns the built-in command job manager, if one was registered +// by RegisterBuiltinTools. Callers use it to reap running background jobs at +// session/process shutdown (ShutdownAll). +func (r *Registry) JobManager() *CommandJobManager { + r.mu.RLock() + defer r.mu.RUnlock() + return r.jobManager +} + // Register adds a tool to the registry. Returns error if name is already taken. func (r *Registry) Register(t Tool) error { r.mu.Lock() diff --git a/internal/tui/model.go b/internal/tui/model.go index 7d50cf313..92064c9ef 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -169,6 +169,7 @@ type Model struct { autoMemFiles []string pluginMgr *plugin.Manager subAgentMgr *subagent.Manager + jobManager *toolpkg.CommandJobManager // r71: reaped by shutdownAll subAgentFollow subAgentFollowState usageTurnIndex int lastMetricDigestTurn int @@ -1623,6 +1624,13 @@ func (m *Model) SetSubAgentManager(mgr *subagent.Manager) { m.subAgentMgr = mgr } +// SetJobManager wires the shared background-command manager so shutdownAll +// can reap running start_command jobs (r71: they were the only background +// work class not cancelled at exit, orphaning detach=true children). +func (m *Model) SetJobManager(jm *toolpkg.CommandJobManager) { + m.jobManager = jm +} + func (m *Model) SetKnight(k *knight.Knight) { m.knight = k // Wire Knight task events into the TUI chat area via program.Send. diff --git a/internal/tui/model_pending.go b/internal/tui/model_pending.go index daa87e9a6..3fb76d4c2 100644 --- a/internal/tui/model_pending.go +++ b/internal/tui/model_pending.go @@ -4,6 +4,7 @@ import ( "context" "strings" "sync" + "time" tea "charm.land/bubbletea/v2" "github.com/topcheer/ggcode/internal/agentruntime" @@ -230,6 +231,14 @@ func (m *Model) shutdownAll() { if m.cmdPaneMgr != nil { m.cmdPaneMgr.Close() } + // r71: reap managed background command jobs too. start_command children + // (especially detach=true dev servers/builds) were the only background + // work class surviving exit as orphan processes. Same fire-and-forget + // discipline as the sub-agent cancel above: the process is quitting. + if m.jobManager != nil { + jm := m.jobManager + safego.Go("tui.shutdownAll.commandJobs", func() { jm.ShutdownAll(2 * time.Second) }) + } // #1364: knight adhoc tasks (LLM-backed, formerly context.Background) // must die with the session - otherwise they keep billing and running // tool side effects invisibly after exit. diff --git a/internal/tui/repl.go b/internal/tui/repl.go index e387b267a..e1326078c 100644 --- a/internal/tui/repl.go +++ b/internal/tui/repl.go @@ -784,6 +784,15 @@ func (r *REPL) SetSystemPromptBuilder(fn func(task, agentType string) string) { } // SetSubAgentManager wires the sub-agent manager and registers sub-agent tools. +// SetJobManager wires the shared background-command manager to the TUI model +// so exit-time shutdownAll can reap running start_command jobs (r71). +func (r *REPL) SetJobManager(jm *tool.CommandJobManager) { + if jm == nil { + return + } + r.model.SetJobManager(jm) +} + func (r *REPL) SetSubAgentManager(mgr *subagent.Manager, prov provider.Provider, tools *tool.Registry) { r.model.SetSubAgentManager(mgr)