fix(runner): direct spawn via ServiceFor, only mark running after success

drainOne now spawns agents directly via ServiceFor[spawner] instead of
IPC SpawnQueued (which was never received by agentic). Workspace status
is only set to "running" AFTER successful spawn — no more PID=0 ghosts.

Also fixes workspace name resolution: uses relative path from workspace
root (core/go-ai/dev) instead of PathBase (dev).

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-26 14:24:05 +00:00
parent ac510fde19
commit 23f31953d4
2 changed files with 41 additions and 10 deletions

View file

@ -29,15 +29,16 @@ func (s *PrepSubsystem) HandleIPCEvents(c *core.Core, msg core.Message) core.Res
}
case messages.SpawnQueued:
// Runner asks agentic to spawn a queued workspace
// Runner asks agentic to spawn a queued workspace
wsDir := resolveWorkspace(ev.Workspace)
if wsDir == "" {
break
break
}
prompt := core.Concat("TASK: ", ev.Task, "\n\nResume from where you left off. Read CODEX.md for conventions. Commit when done.")
pid, outputFile, err := s.spawnAgent(ev.Agent, prompt, wsDir)
if err != nil {
break
break
}
// Update status with real PID
if st, serr := ReadStatus(wsDir); serr == nil {
@ -53,6 +54,15 @@ func (s *PrepSubsystem) HandleIPCEvents(c *core.Core, msg core.Message) core.Res
return core.Result{OK: true}
}
// SpawnFromQueue spawns an agent in a pre-prepped workspace.
// Called by runner.Service via ServiceFor interface matching.
//
// pid, err := prep.SpawnFromQueue("codex", prompt, wsDir)
func (s *PrepSubsystem) SpawnFromQueue(agent, prompt, wsDir string) (int, error) {
pid, _, err := s.spawnAgent(agent, prompt, wsDir)
return pid, err
}
// resolveWorkspace converts a workspace name back to the full path.
//
// resolveWorkspace("core/go-io/task-5") → "/Users/snider/Code/.core/workspace/core/go-io/task-5"

View file

@ -7,7 +7,6 @@ import (
"syscall"
"time"
"dappco.re/go/agent/pkg/messages"
core "dappco.re/go/core"
"gopkg.in/yaml.v3"
)
@ -261,18 +260,40 @@ func (s *Service) drainOne() bool {
// Ask agentic to spawn — runner doesn't own the spawn logic,
// just the gate. Send IPC to trigger the actual spawn.
if s.ServiceRuntime != nil {
s.Core().ACTION(messages.SpawnQueued{
Workspace: core.PathBase(wsDir),
Agent: st.Agent,
Task: st.Task,
})
// Workspace name is relative path from workspace root (e.g. "core/go-ai/dev")
wsRoot := WorkspaceRoot()
wsName := wsDir
if len(wsDir) > len(wsRoot)+1 {
wsName = wsDir[len(wsRoot)+1:]
}
core.Info("drainOne: found queued workspace", "workspace", wsName, "agent", st.Agent)
// Spawn directly — agentic is a Core service, use ServiceFor to get it
if s.ServiceRuntime == nil {
continue
}
type spawner interface {
SpawnFromQueue(agent, prompt, wsDir string) (int, error)
}
prep, ok := core.ServiceFor[spawner](s.Core(), "agentic")
if !ok {
core.Error("drainOne: agentic service not found")
continue
}
prompt := core.Concat("TASK: ", st.Task, "\n\nResume from where you left off. Read CODEX.md for conventions. Commit when done.")
pid, err := prep.SpawnFromQueue(st.Agent, prompt, wsDir)
if err != nil {
core.Error("drainOne: spawn failed", "err", err)
continue
}
// Only mark running AFTER successful spawn
st.Status = "running"
st.PID = pid
st.Runs++
WriteStatus(wsDir, st)
s.TrackWorkspace(core.PathBase(wsDir), st)
s.TrackWorkspace(wsName, st)
core.Info("drainOne: spawned", "pid", pid, "workspace", wsName)
return true
}