mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* Wire LSP diagnostics into headless --self-correct newExecSelfCorrector now backs the --self-correct loop with both halves: the workspace test plan AND an lsp.Manager-based diagnostics checker (IncludeLSP=true). The manager is lazy (Manager.Check degrades a missing language server to nil,nil), and a deferred cleanup shuts it down at run end, terminating any spawned sessions. This revives the internal/lsp client (44 -> 3 unreachable funcs); it was dead because the only consumer passed a nil checker with IncludeLSP=false. Add TestManagerCheckRealGopls: drives NewManager -> Check against a real gopls and asserts a real diagnostic; skips when gopls is absent, so it stays CI-safe. * Wire self-correct into the interactive TUI (on by default) runAgentWithOptions now builds a SelfCorrector for every turn, so post-edit verification (workspace test plan + LSP diagnostics) runs by default in the TUI. The spec-draft planning path is excluded, matching exec. A per-turn lsp.Manager is created and torn down when the run returns. Auto-fix vs report-only follows the active permission mode (unsafe->high, auto->medium, ask/other->low) via selfCorrectAutonomyForMode. * Keep TUI self-correct fast by default; gate the test half behind /selfcorrect The TUI default now runs only the LSP-diagnostics half of self-correct — cheap and scoped to the changed files — so it never adds the whole-repo test plan's latency to a turn (and a pre-existing failure can't hijack the agent). The project test plan is opt-in per session via a new /selfcorrect [on|off] command (alias /sc); IncludeTests reads m.selfCorrectTests, default false. exec keeps both halves. * Remove dead session.didClose from lsp session.didClose sent a textDocument/didClose notification but was never called (the manager only closes documents via Shutdown), so it was unreachable even from tests and flagged by staticcheck U1000. Removing it takes internal/lsp to 0 truly-dead funcs and 0 U1000 findings; the 2 remaining unreachable-from-main funcs (Available, URIToPath) are public, test-covered API. * Align /selfcorrect help with accepted arguments The usage/description advertised only [on|off] but the handler also accepts status, list, tests, full, and lsp. Surface the primary verbs (on|off|status) and clarify the on/off behavior so command discovery matches the handler (CodeRabbit). * Make /selfcorrect usage strings match the handler exactly Both the command definition and the invalid-argument error message now list the full accepted set [status|on|off|tests|full|lsp]; the redundant 'list' alias is dropped so docs and handler agree exactly. Addresses Vasanth's review (the error-message usage at session_controls.go was still [on|off]) and CodeRabbit's help-text finding. * Clarify /selfcorrect off semantics in help text Reword the description so 'off/lsp' reads as 'disable tests, LSP-only' rather than the ambiguous 'return to LSP-only', which could be misread as disabling the feature entirely (CodeRabbit).
205 lines
6.2 KiB
Go
205 lines
6.2 KiB
Go
package lsp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// lspServer is the subset of *Server the session needs, so a session can be
|
|
// driven by a stub over an in-memory pipe in tests.
|
|
type lspServer interface {
|
|
Client() *Client
|
|
Shutdown(ctx context.Context) error
|
|
}
|
|
|
|
// session wraps one language-server connection with per-document version
|
|
// tracking and a diagnostics store fed by textDocument/publishDiagnostics
|
|
// notifications. Safe for concurrent use.
|
|
type session struct {
|
|
server lspServer
|
|
client *Client
|
|
|
|
mu sync.Mutex
|
|
open map[string]bool // uri -> didOpen sent
|
|
versions map[string]int // uri -> current (committed) version
|
|
diagnostics map[string][]Diagnostic // uri -> latest published diagnostics
|
|
lastPublish map[string]time.Time // uri -> time of last publish
|
|
publishCount map[string]int // uri -> monotonic publish count
|
|
waiters map[string][]chan struct{} // uri -> goroutines awaiting the next publish
|
|
fileLocks map[string]*sync.Mutex // uri -> per-document sync serializer
|
|
}
|
|
|
|
func newSession(server lspServer) *session {
|
|
s := &session{
|
|
server: server,
|
|
client: server.Client(),
|
|
open: map[string]bool{},
|
|
versions: map[string]int{},
|
|
diagnostics: map[string][]Diagnostic{},
|
|
lastPublish: map[string]time.Time{},
|
|
publishCount: map[string]int{},
|
|
waiters: map[string][]chan struct{}{},
|
|
fileLocks: map[string]*sync.Mutex{},
|
|
}
|
|
s.client.SetNotificationHandler(s.handleNotification)
|
|
return s
|
|
}
|
|
|
|
func (s *session) handleNotification(method string, params json.RawMessage) {
|
|
if method != "textDocument/publishDiagnostics" {
|
|
return
|
|
}
|
|
var payload PublishDiagnosticsParams
|
|
if err := json.Unmarshal(params, &payload); err != nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
// Drop a delayed publish for a superseded version: if we've already synced a
|
|
// newer document version, an older version's diagnostics are stale and must
|
|
// not move the cache backward. Many servers omit the version (0) — only skip
|
|
// when the payload carries a strictly-older positive version.
|
|
if payload.Version > 0 && s.versions[payload.URI] > payload.Version {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.diagnostics[payload.URI] = payload.Diagnostics
|
|
s.lastPublish[payload.URI] = time.Now()
|
|
s.publishCount[payload.URI]++
|
|
waiters := s.waiters[payload.URI]
|
|
delete(s.waiters, payload.URI)
|
|
s.mu.Unlock()
|
|
for _, ch := range waiters {
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
// sync opens the document on first sight, otherwise sends a full-text change.
|
|
// It holds a per-URI lock across the whole compute+notify so two concurrent
|
|
// syncs of the same document can't race their didOpen/didChange writes onto the
|
|
// wire out of order, and only commits the new version/open state after the
|
|
// notification succeeds (a failed Notify leaves tracking unchanged).
|
|
func (s *session) sync(ctx context.Context, absPath, languageID, text string) error {
|
|
uri := PathToURI(absPath)
|
|
lock := s.fileLock(uri)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
s.mu.Lock()
|
|
first := !s.open[uri]
|
|
nextVersion := 1
|
|
if !first {
|
|
nextVersion = s.versions[uri] + 1
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
var err error
|
|
if first {
|
|
err = s.client.Notify(ctx, "textDocument/didOpen", map[string]any{
|
|
"textDocument": TextDocumentItem{URI: uri, LanguageID: languageID, Version: nextVersion, Text: text},
|
|
})
|
|
} else {
|
|
err = s.client.Notify(ctx, "textDocument/didChange", map[string]any{
|
|
"textDocument": map[string]any{"uri": uri, "version": nextVersion},
|
|
"contentChanges": []any{map[string]any{"text": text}}, // full-document sync
|
|
})
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.open[uri] = true
|
|
s.versions[uri] = nextVersion
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *session) fileLock(uri string) *sync.Mutex {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
lock, ok := s.fileLocks[uri]
|
|
if !ok {
|
|
lock = &sync.Mutex{}
|
|
s.fileLocks[uri] = lock
|
|
}
|
|
return lock
|
|
}
|
|
|
|
func (s *session) diagnosticsFor(uri string) []Diagnostic {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]Diagnostic(nil), s.diagnostics[uri]...)
|
|
}
|
|
|
|
// publishBaseline snapshots the current publish count for a URI, captured before
|
|
// a sync so waitForDiagnostics can wait specifically for the publish that sync
|
|
// triggers (not be satisfied by a stale earlier publish).
|
|
func (s *session) publishBaseline(uri string) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.publishCount[uri]
|
|
}
|
|
|
|
// waitForDiagnostics blocks until a publish newer than baseline arrives for the
|
|
// URI and the server then goes quiet for the debounce window, or until ctx is
|
|
// done. It returns true when a fresh publish was observed and false when ctx
|
|
// expired before any did — so a caller can avoid returning a stale prior result
|
|
// for newer text. Servers don't signal "analysis complete", so the debounce
|
|
// approximates it: once a fresh publish lands, wait debounce for a follow-up,
|
|
// resetting on each new publish.
|
|
func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce time.Duration, baseline int) bool {
|
|
for {
|
|
s.mu.Lock()
|
|
ch := make(chan struct{})
|
|
s.waiters[uri] = append(s.waiters[uri], ch)
|
|
count := s.publishCount[uri]
|
|
last := s.lastPublish[uri]
|
|
s.mu.Unlock()
|
|
|
|
if count <= baseline {
|
|
select {
|
|
case <-ctx.Done():
|
|
s.cancelWaiter(uri, ch)
|
|
return false // no fresh publish for this sync arrived in time
|
|
case <-ch:
|
|
continue // a fresh publish arrived; loop into the debounce check
|
|
}
|
|
}
|
|
|
|
remaining := debounce - time.Since(last)
|
|
if remaining <= 0 {
|
|
s.cancelWaiter(uri, ch)
|
|
return true
|
|
}
|
|
timer := time.NewTimer(remaining)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
s.cancelWaiter(uri, ch)
|
|
return true // a fresh publish did arrive; ctx merely cut the debounce short
|
|
case <-ch:
|
|
timer.Stop()
|
|
continue // a newer publish arrived; re-arm the debounce
|
|
case <-timer.C:
|
|
s.cancelWaiter(uri, ch)
|
|
return true // quiet for the full window
|
|
}
|
|
}
|
|
}
|
|
|
|
// cancelWaiter removes a still-open waiter (one a publish has not already closed
|
|
// and cleared) so it can't leak or be closed twice.
|
|
func (s *session) cancelWaiter(uri string, target chan struct{}) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
list := s.waiters[uri]
|
|
for i, ch := range list {
|
|
if ch == target {
|
|
s.waiters[uri] = append(list[:i], list[i+1:]...)
|
|
return
|
|
}
|
|
}
|
|
}
|