fix(control): a turn without a runner reports ErrNoRunner, not a nil fault

runSettled and runComposedSyntheticTurn called c.runner.Run directly. On
a controller assembled without a runner, that was a nil dereference.
spawnGuardedTurn's recover turned it into "internal error: invalid
memory address or nil pointer dereference". On Windows the fault is a
hardware exception whether or not it is recovered, the class that
corrupts the heap on AMX runners (golang/go#81238, see 3d5b0b68d).

This was an audit of the recover() sites left after 3d5b0b68d:
- Three re-panic after recording: the CLI crash capture, the subagent
  progress tracker and an SDK path. Nothing to change.
- The others isolate faults: background task jobs, guarded turns, the
  rpcwire after-write callback, the jobs runner and the SDK handlers.
  Isolation is their job, so they stay.
- What mattered was whether anything routinely sent a real fault
  through them. Temporary instrumentation over control, serve, agent,
  extension, jobs, boot and sdk/go found two nil dereferences, both this
  runner call, plus six explicit panic("boom") values tests raise on
  purpose, which are not exceptions.

runWithRunner returns ErrNoRunner when the runner is nil. Both call
sites use it, and the identity is a sentinel a caller can test with
errors.Is. Re-running the instrumented control and serve suites records
no nil dereference, only the two intentional panics.
TestATurnWithoutARunnerReportsErrNoRunner holds the identity.

The sentinel lives in turn_projection.go beside its use; controller.go
is already past its size budget.
This commit is contained in:
esengine committed 2026-09-17 01:24:20 -07:00
1 parent 62a6870065
commit 2835cd5a58
3 files changed
+32 -1

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
package control
import (
"context"
"errors"
"testing"
)
// A controller assembled without a runner reports that by identity. Calling
// through the nil runner instead was a nil dereference the turn guard
// recovered, which on Windows is a hardware exception either way.
func TestATurnWithoutARunnerReportsErrNoRunner(t *testing.T) {
c := New(Options{})
defer c.Close()
if err := c.runSettled(context.Background(), "hello"); !errors.Is(err, ErrNoRunner) {
t.Fatalf("runSettled = %v, want ErrNoRunner", err)
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text st
ctx = c.continueAnnouncedTurn(ctx)
ctx = agent.WithRawUserInput(ctx, text)
ctx = c.withPlannerTurnMetadata(ctx, text, true)
return c.runner.Run(ctx, c.ComposeSynthetic(text))
return c.runWithRunner(ctx, c.ComposeSynthetic(text))
}
// runSubagentSkillGoalLoop executes a slash-invoked runAs=subagent skill as a
+13
View File
@@ -2,6 +2,7 @@ package control
import (
"context"
"errors"
"strings"
"reasonix/internal/ablation"
@@ -136,6 +137,18 @@ func (c *Controller) recallTail(source string, hasNotes bool) string {
// cleared by one of those leaves the model holding state nobody sent it.
func (c *Controller) runSettled(ctx context.Context, input string) error {
c.settleTurnProjections()
return c.runWithRunner(ctx, input)
}
// ErrNoRunner reports a turn started on a controller assembled without a
// runner. Returned instead of calling through the nil interface: that fault is
// a hardware exception on Windows, recovered or not.
var ErrNoRunner = errors.New("controller has no runner")
func (c *Controller) runWithRunner(ctx context.Context, input string) error {
if c.runner == nil {
return ErrNoRunner
}
return c.runner.Run(ctx, input)
}