From be3e68ec0f26dd58f9a8f9a585b5119aeb12ec38 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 16:24:06 +0000 Subject: [PATCH] fix(ax): make status projections explicit Co-Authored-By: Virgil --- docs/RFC.md | 1 + pkg/agentic/status.go | 8 ++-- pkg/agentic/status_example_test.go | 21 +++++++++++ pkg/agentic/status_test.go | 50 +++++++++++++++++++++++++ pkg/runner/paths.go | 60 +++++++++++++++++++++++------- pkg/runner/paths_example_test.go | 19 ++++++++++ pkg/runner/paths_test.go | 53 ++++++++++++++++++++++++++ pkg/runner/runner.go | 31 ++++++++++----- pkg/runner/runner_test.go | 10 +++++ 9 files changed, 225 insertions(+), 28 deletions(-) diff --git a/docs/RFC.md b/docs/RFC.md index 1e9323c..05be4e8 100644 --- a/docs/RFC.md +++ b/docs/RFC.md @@ -423,6 +423,7 @@ Every exported function MUST have a usage-example comment: ## Changelog +- 2026-03-30: runner workspace status projections now use explicit typed copies, and `ReadStatusResult` gained direct AX-7 coverage in both runner and agentic packages. - 2026-03-30: transport helpers preserve request and read causes, brain direct API calls surface upstream bodies, and review queue retry parsing no longer uses `MustCompile`. - 2026-03-30: direct Core process calls replaced the `proc.go` wrapper layer; PID helpers now live in `pid.go` and the workspace template documents `c.Process()` directly. - 2026-03-30: main now logs startup failures with structured context, and the workspace contract reference restored usage-example comments for the Action lifecycle messages. diff --git a/pkg/agentic/status.go b/pkg/agentic/status.go index 3fca547..df8ac7b 100644 --- a/pkg/agentic/status.go +++ b/pkg/agentic/status.go @@ -117,17 +117,17 @@ func ReadStatusResult(wsDir string) core.Result { if !r.OK { err, _ := r.Value.(error) if err == nil { - return core.Result{Value: core.E("ReadStatus", "status not found", nil), OK: false} + return core.Result{Value: core.E("ReadStatusResult", "status not found", nil), OK: false} } - return core.Result{Value: core.E("ReadStatus", core.Concat("status not found for ", wsDir), err), OK: false} + return core.Result{Value: core.E("ReadStatusResult", core.Concat("status not found for ", wsDir), err), OK: false} } var s WorkspaceStatus if ur := core.JSONUnmarshalString(r.Value.(string), &s); !ur.OK { err, _ := ur.Value.(error) if err == nil { - return core.Result{Value: core.E("ReadStatus", "invalid status json", nil), OK: false} + return core.Result{Value: core.E("ReadStatusResult", "invalid status json", nil), OK: false} } - return core.Result{Value: core.E("ReadStatus", "invalid status json", err), OK: false} + return core.Result{Value: core.E("ReadStatusResult", "invalid status json", err), OK: false} } return core.Result{Value: &s, OK: true} } diff --git a/pkg/agentic/status_example_test.go b/pkg/agentic/status_example_test.go index 56789bb..ee36a36 100644 --- a/pkg/agentic/status_example_test.go +++ b/pkg/agentic/status_example_test.go @@ -43,3 +43,24 @@ func ExampleReadStatus() { // completed // claude } + +func ExampleReadStatusResult() { + fsys := (&core.Fs{}).NewUnrestricted() + dir := fsys.TempDir("agentic-status-result") + defer fsys.DeleteAll(dir) + + status := &WorkspaceStatus{ + Status: "completed", + Agent: "codex", + Repo: "go-io", + } + core.Println(fs.Write(core.JoinPath(dir, "status.json"), core.JSONMarshalString(status)).OK) + + result := ReadStatusResult(dir) + core.Println(result.OK) + core.Println(result.Value.(*WorkspaceStatus).Repo) + // Output: + // true + // true + // go-io +} diff --git a/pkg/agentic/status_test.go b/pkg/agentic/status_test.go index a6ec969..c5d1155 100644 --- a/pkg/agentic/status_test.go +++ b/pkg/agentic/status_test.go @@ -88,6 +88,56 @@ func TestStatus_ReadStatus_Good(t *testing.T) { assert.Equal(t, "https://forge.lthn.ai/core/go-log/pulls/5", read.PRURL) } +func TestStatus_ReadStatusResult_Good(t *testing.T) { + dir := t.TempDir() + + status := &WorkspaceStatus{ + Status: "completed", + Agent: "codex", + Repo: "go-log", + Task: "add logging", + Branch: "agent/add-logging", + StartedAt: time.Now().Truncate(time.Second), + UpdatedAt: time.Now().Truncate(time.Second), + Runs: 2, + PRURL: "https://forge.lthn.ai/core/go-log/pulls/5", + } + + require.True(t, fs.Write(core.JoinPath(dir, "status.json"), core.JSONMarshalString(status)).OK) + + result := ReadStatusResult(dir) + require.True(t, result.OK) + + read, ok := result.Value.(*WorkspaceStatus) + require.True(t, ok) + assert.Equal(t, "completed", read.Status) + assert.Equal(t, "codex", read.Agent) + assert.Equal(t, "go-log", read.Repo) + assert.Equal(t, "add logging", read.Task) + assert.Equal(t, "agent/add-logging", read.Branch) + assert.Equal(t, 2, read.Runs) + assert.Equal(t, "https://forge.lthn.ai/core/go-log/pulls/5", read.PRURL) +} + +func TestStatus_ReadStatusResult_Bad_NoFile(t *testing.T) { + result := ReadStatusResult(t.TempDir()) + assert.False(t, result.OK) + err, ok := result.Value.(error) + require.True(t, ok) + assert.Error(t, err) +} + +func TestStatus_ReadStatusResult_Ugly_InvalidJSON(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.Write(core.JoinPath(dir, "status.json"), "{not-json").OK) + + result := ReadStatusResult(dir) + assert.False(t, result.OK) + err, ok := result.Value.(error) + require.True(t, ok) + assert.Error(t, err) +} + func TestStatus_ReadStatus_Bad_NoFile(t *testing.T) { dir := t.TempDir() _, err := ReadStatus(dir) diff --git a/pkg/runner/paths.go b/pkg/runner/paths.go index 257ae06..95935b5 100644 --- a/pkg/runner/paths.go +++ b/pkg/runner/paths.go @@ -12,6 +12,44 @@ import ( // fs reuses the shared unrestricted filesystem used by agentic. var fs = agentic.LocalFs() +func runnerWorkspaceStatusFromAgentic(status *agentic.WorkspaceStatus) *WorkspaceStatus { + if status == nil { + return nil + } + return &WorkspaceStatus{ + Status: status.Status, + Agent: status.Agent, + Repo: status.Repo, + Org: status.Org, + Task: status.Task, + Branch: status.Branch, + PID: status.PID, + Question: status.Question, + PRURL: status.PRURL, + StartedAt: status.StartedAt, + Runs: status.Runs, + } +} + +func agenticWorkspaceStatusFromRunner(status *WorkspaceStatus) *agentic.WorkspaceStatus { + if status == nil { + return nil + } + return &agentic.WorkspaceStatus{ + Status: status.Status, + Agent: status.Agent, + Repo: status.Repo, + Org: status.Org, + Task: status.Task, + Branch: status.Branch, + PID: status.PID, + Question: status.Question, + PRURL: status.PRURL, + StartedAt: status.StartedAt, + Runs: status.Runs, + } +} + // WorkspaceRoot returns the root directory for agent workspaces. // // root := runner.WorkspaceRoot() // ~/Code/.core/workspace @@ -52,19 +90,14 @@ func ReadStatus(wsDir string) (*WorkspaceStatus, error) { func ReadStatusResult(wsDir string) core.Result { status, err := agentic.ReadStatus(wsDir) if err != nil { - return core.Result{Value: core.E("runner.ReadStatus", "failed to read status", err), OK: false} + return core.Result{Value: core.E("runner.ReadStatusResult", "failed to read status", err), OK: false} } - json := core.JSONMarshalString(status) - var st WorkspaceStatus - if result := core.JSONUnmarshalString(json, &st); !result.OK { - parseErr, _ := result.Value.(error) - if parseErr == nil { - parseErr = core.E("runner.ReadStatus", "failed to parse status", nil) - } - return core.Result{Value: parseErr, OK: false} + st := runnerWorkspaceStatusFromAgentic(status) + if st == nil { + return core.Result{Value: core.E("runner.ReadStatusResult", "invalid status payload", nil), OK: false} } - return core.Result{Value: &st, OK: true} + return core.Result{Value: st, OK: true} } // WriteStatus writes `status.json` for one workspace directory. @@ -75,11 +108,10 @@ func WriteStatus(wsDir string, st *WorkspaceStatus) { return } - json := core.JSONMarshalString(st) - var status agentic.WorkspaceStatus - if result := core.JSONUnmarshalString(json, &status); !result.OK { + status := agenticWorkspaceStatusFromRunner(st) + if status == nil { return } status.UpdatedAt = time.Now() - fs.WriteAtomic(agentic.WorkspaceStatusPath(wsDir), core.JSONMarshalString(&status)) + fs.WriteAtomic(agentic.WorkspaceStatusPath(wsDir), core.JSONMarshalString(status)) } diff --git a/pkg/runner/paths_example_test.go b/pkg/runner/paths_example_test.go index 7bc4ab5..d2f146a 100644 --- a/pkg/runner/paths_example_test.go +++ b/pkg/runner/paths_example_test.go @@ -37,6 +37,25 @@ func ExampleReadStatus() { // go-io } +func ExampleReadStatusResult() { + fsys := (&core.Fs{}).NewUnrestricted() + dir := fsys.TempDir("runner-paths-result") + defer fsys.DeleteAll(dir) + + WriteStatus(dir, &WorkspaceStatus{ + Status: "completed", + Agent: "codex", + Repo: "go-io", + }) + + result := ReadStatusResult(dir) + core.Println(result.OK) + core.Println(result.Value.(*WorkspaceStatus).Repo) + // Output: + // true + // go-io +} + func ExampleWriteStatus() { fsys := (&core.Fs{}).NewUnrestricted() dir := fsys.TempDir("runner-paths") diff --git a/pkg/runner/paths_test.go b/pkg/runner/paths_test.go index 9534092..7aa1ea2 100644 --- a/pkg/runner/paths_test.go +++ b/pkg/runner/paths_test.go @@ -71,6 +71,59 @@ func TestPaths_ReadStatus_Good(t *testing.T) { assert.Equal(t, 2, st.Runs) } +func TestPaths_ReadStatusResult_Good(t *testing.T) { + wsDir := t.TempDir() + status := &agentic.WorkspaceStatus{ + Status: "completed", + Agent: "claude", + Repo: "go-log", + Org: "core", + Task: "finish AX cleanup", + Branch: "agent/ax-cleanup", + PID: 21, + Question: "Ready?", + PRURL: "https://forge.test/core/go-log/pulls/12", + StartedAt: time.Now(), + Runs: 4, + } + require.True(t, agentic.LocalFs().WriteAtomic(agentic.WorkspaceStatusPath(wsDir), core.JSONMarshalString(status)).OK) + + result := ReadStatusResult(wsDir) + require.True(t, result.OK) + + st, ok := result.Value.(*WorkspaceStatus) + require.True(t, ok) + assert.Equal(t, "completed", st.Status) + assert.Equal(t, "claude", st.Agent) + assert.Equal(t, "go-log", st.Repo) + assert.Equal(t, "core", st.Org) + assert.Equal(t, "finish AX cleanup", st.Task) + assert.Equal(t, "agent/ax-cleanup", st.Branch) + assert.Equal(t, 21, st.PID) + assert.Equal(t, "Ready?", st.Question) + assert.Equal(t, "https://forge.test/core/go-log/pulls/12", st.PRURL) + assert.Equal(t, 4, st.Runs) +} + +func TestPaths_ReadStatusResult_Bad_NoFile(t *testing.T) { + result := ReadStatusResult(t.TempDir()) + assert.False(t, result.OK) + err, ok := result.Value.(error) + require.True(t, ok) + assert.Error(t, err) +} + +func TestPaths_ReadStatusResult_Ugly_InvalidJSON(t *testing.T) { + wsDir := t.TempDir() + require.True(t, agentic.LocalFs().WriteAtomic(agentic.WorkspaceStatusPath(wsDir), "{not-json").OK) + + result := ReadStatusResult(wsDir) + assert.False(t, result.OK) + err, ok := result.Value.(error) + require.True(t, ok) + assert.Error(t, err) +} + func TestPaths_ReadStatus_Bad(t *testing.T) { wsDir := t.TempDir() require.True(t, agentic.LocalFs().WriteAtomic(agentic.WorkspaceStatusPath(wsDir), "{not-json").OK) diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 689741d..8220ff4 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -218,22 +218,33 @@ func (s *Service) Poke() { } // TrackWorkspace registers or updates a workspace in the in-memory Registry. -// Accepts any status type — agentic passes *agentic.WorkspaceStatus, -// runner stores its own *WorkspaceStatus copy. +// Accepts the runner projection directly and the agentic projection from IPC. // -// s.TrackWorkspace("core/go-io/task-5", st) +// s.TrackWorkspace("core/go-io/task-5", &WorkspaceStatus{Status: "running", Agent: "codex"}) +// s.TrackWorkspace("core/go-io/task-5", &agentic.WorkspaceStatus{Status: "running", Agent: "codex"}) func (s *Service) TrackWorkspace(name string, st any) { if s.workspaces == nil { return } - // Convert from agentic's type to runner's via JSON round-trip - json := core.JSONMarshalString(st) - var ws WorkspaceStatus - if r := core.JSONUnmarshalString(json, &ws); r.OK { - s.workspaces.Set(name, &ws) - // Remove pending reservation now that the real workspace is tracked - s.workspaces.Delete(core.Concat("pending/", ws.Repo)) + var ws *WorkspaceStatus + switch value := st.(type) { + case *WorkspaceStatus: + ws = value + case *agentic.WorkspaceStatus: + ws = runnerWorkspaceStatusFromAgentic(value) + default: + json := core.JSONMarshalString(st) + var workspace WorkspaceStatus + if r := core.JSONUnmarshalString(json, &workspace); r.OK { + ws = &workspace + } } + if ws == nil { + return + } + s.workspaces.Set(name, ws) + // Remove pending reservation now that the real workspace is tracked + s.workspaces.Delete(core.Concat("pending/", ws.Repo)) } // Workspaces returns the workspace Registry. diff --git a/pkg/runner/runner_test.go b/pkg/runner/runner_test.go index 17c7cf4..ddef1c1 100644 --- a/pkg/runner/runner_test.go +++ b/pkg/runner/runner_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + "dappco.re/go/agent/pkg/agentic" "dappco.re/go/agent/pkg/messages" core "dappco.re/go/core" "github.com/stretchr/testify/assert" @@ -93,6 +94,15 @@ func TestRunner_TrackWorkspace_Good(t *testing.T) { assert.True(t, r.OK) } +func TestRunner_TrackWorkspace_Good_AgenticStatus(t *testing.T) { + svc := New() + svc.TrackWorkspace("core/go-io/dev", &agentic.WorkspaceStatus{ + Status: "running", Agent: "codex", Repo: "go-io", PID: 12345, + }) + r := svc.workspaces.Get("core/go-io/dev") + assert.True(t, r.OK) +} + func TestRunner_TrackWorkspace_Bad_NilWorkspaces(t *testing.T) { svc := &Service{} assert.NotPanics(t, func() {