fix(ax): remove proc.go wrapper layer

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-30 15:48:21 +00:00
parent a757ca81e3
commit 3c2575f45b
22 changed files with 270 additions and 456 deletions

View file

@ -21,7 +21,7 @@ core/go provides the primitives. core/agent composes them.
cmd/core-agent/main.go — entry point: core.New + Run
pkg/agentic/ — orchestration (dispatch, prep, verify, scan, commands)
pkg/agentic/actions.go — named Action handlers (ctx, Options) → Result
pkg/agentic/proc.go — process helpers via s.Core().Process()
pkg/agentic/pid.go — PID lifecycle helpers
pkg/agentic/handlers.go — IPC completion pipeline handlers
pkg/agentic/status.go — workspace status (WriteAtomic + JSONMarshalString)
pkg/agentic/paths.go — paths, fs (NewUnrestricted), helpers
@ -145,23 +145,13 @@ All handlers use `c.ACTION(messages.X{})` — no ChannelNotifier, no callbacks.
All commands via `s.Core().Process()`. Returns `core.Result` — Value is always a string.
```go
func (s *PrepSubsystem) runCmd(ctx context.Context, dir, command string, args ...string) core.Result {
return s.Core().Process().RunIn(ctx, dir, command, args...)
process := s.Core().Process()
r := process.RunIn(ctx, dir, "git", "log", "--oneline", "-20")
if r.OK {
output := core.Trim(r.Value.(string))
}
func (s *PrepSubsystem) runCmdOK(ctx context.Context, dir, command string, args ...string) bool {
return s.runCmd(ctx, dir, command, args...).OK
}
func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result {
return s.runCmd(ctx, dir, "git", args...)
}
func (s *PrepSubsystem) gitOutput(ctx context.Context, dir string, args ...string) string {
r := s.gitCmd(ctx, dir, args...)
if !r.OK { return "" }
return core.Trim(r.Value.(string))
}
r = process.RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test", "./...")
```
go-process is fully Result-native. `Start`, `Run`, `StartWithOptions`, `RunWithOptions` all return `core.Result`. Value is `*Process` for Start, `string` for Run. OK=true guarantees the type.
@ -409,10 +399,9 @@ c.Drive().New(core.NewOptions(
Every exported function MUST have a usage-example comment:
```go
// gitCmd runs a git command in a directory.
// Process runs a git command in a directory.
//
// r := s.gitCmd(ctx, "/repo", "log", "--oneline")
func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result {
// r := s.Core().Process().RunIn(ctx, "/repo", "git", "log", "--oneline")
```
---
@ -436,6 +425,7 @@ func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string)
- 2026-03-29: cmd/core-agent no longer rewrites `os.Args` before startup. The binary-owned commands now use named handlers, keeping the entrypoint on Core CLI primitives instead of repo-local argument mutation.
- 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-26: net/http consolidated to transport.go (ONE file). net/url + io/fs eliminated. RFC-025 updated with 3 new quality gates (net/http, net/url, io/fs). 1:1 test + example test coverage. Array[T].Deduplicate replaces custom helpers.
- 2026-03-25: Quality gates pass. Zero disallowed imports (all 10). encoding/json→Core JSON. path/filepath→Core Path. os→Core Env/Fs. io→Core ReadAll/WriteAll. go-process fully Result-native. ServiceRuntime on all subsystems. 22 named Actions + Task pipeline. ChannelNotifier→IPC. Reference docs synced.
- 2026-03-25: Initial spec — written with full core/go v0.8.0 domain context.

View file

@ -59,7 +59,7 @@ dispatch → prep workspace → spawn agent in Docker container
- **UK English**: colour, organisation, centre, initialise
- **Errors**: `core.E("pkg.Method", "message", err)` — NEVER `fmt.Errorf`
- **File I/O**: Package-level `fs` (go-io Medium) — NEVER `os.ReadFile/WriteFile`
- **Processes**: `proc.go` helpers (go-process) — NEVER `os/exec` directly
- **Processes**: `s.Core().Process()` / go-process — NEVER `os/exec` directly
- **Strings**: `core.Contains/Split/Trim/HasPrefix/Sprintf` — NEVER `strings.*`
- **Returns**: `core.Result{Value, OK}` — NEVER `(value, error)` pairs
- **Comments**: Usage examples showing HOW with real values, not descriptions
@ -86,12 +86,14 @@ One test file per source file. No catch-all files. Names must sort cleanly.
## Process Execution
All external commands go through `pkg/agentic/proc.go` → go-process:
All external commands go through `s.Core().Process()` → go-process:
```go
out, err := runCmd(ctx, dir, "git", "log", "--oneline")
ok := gitCmdOK(ctx, dir, "fetch", "origin", "main")
branch := gitOutput(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
process := s.Core().Process()
out := process.RunIn(ctx, dir, "git", "log", "--oneline")
ok := process.RunIn(ctx, dir, "git", "fetch", "origin", "main").OK
branchResult := process.RunIn(ctx, dir, "git", "rev-parse", "--abbrev-ref", "HEAD")
branch := core.Trim(branchResult.Value.(string))
```
**NEVER import `os/exec`.** Zero source files do.

View file

@ -42,7 +42,7 @@ c.Run()
- Use UK English in comments and docs.
- Use `core.E("pkg.Method", "message", err)` for errors. Never use `fmt.Errorf` or `errors.New`.
- Use Core filesystem helpers or package-level `fs`. Never use raw `os.ReadFile`, `os.WriteFile`, or `filepath.*`.
- Route external commands through `pkg/agentic/proc.go` or `s.Core().Process()`. Never import `os/exec`.
- Route external commands through `s.Core().Process()`. Never import `os/exec`.
- Use Core string helpers such as `core.Contains`, `core.Trim`, and `core.Split` instead of `strings.*`.
- Prefer `core.Result{Value: x, OK: true}` over `(value, error)` return pairs in Core-facing code.
- Comments should show real usage examples, not restate the signature.

View file

@ -21,7 +21,7 @@ core/go provides the primitives. core/agent composes them.
cmd/core-agent/main.go — entry point: core.New + Run
pkg/agentic/ — orchestration (dispatch, prep, verify, scan, commands)
pkg/agentic/actions.go — named Action handlers (ctx, Options) → Result
pkg/agentic/proc.go — process helpers via s.Core().Process()
pkg/agentic/pid.go — PID lifecycle helpers
pkg/agentic/handlers.go — IPC completion pipeline handlers
pkg/agentic/status.go — workspace status (WriteAtomic + JSONMarshalString)
pkg/agentic/paths.go — paths, fs (NewUnrestricted), helpers
@ -145,23 +145,13 @@ All handlers use `c.ACTION(messages.X{})` — no ChannelNotifier, no callbacks.
All commands via `s.Core().Process()`. Returns `core.Result` — Value is always a string.
```go
func (s *PrepSubsystem) runCmd(ctx context.Context, dir, command string, args ...string) core.Result {
return s.Core().Process().RunIn(ctx, dir, command, args...)
process := s.Core().Process()
r := process.RunIn(ctx, dir, "git", "log", "--oneline", "-20")
if r.OK {
output := core.Trim(r.Value.(string))
}
func (s *PrepSubsystem) runCmdOK(ctx context.Context, dir, command string, args ...string) bool {
return s.runCmd(ctx, dir, command, args...).OK
}
func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result {
return s.runCmd(ctx, dir, "git", args...)
}
func (s *PrepSubsystem) gitOutput(ctx context.Context, dir string, args ...string) string {
r := s.gitCmd(ctx, dir, args...)
if !r.OK { return "" }
return core.Trim(r.Value.(string))
}
r = process.RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test", "./...")
```
go-process is fully Result-native. `Start`, `Run`, `StartWithOptions`, `RunWithOptions` all return `core.Result`. Value is `*Process` for Start, `string` for Run. OK=true guarantees the type.
@ -409,10 +399,9 @@ c.Drive().New(core.NewOptions(
Every exported function MUST have a usage-example comment:
```go
// gitCmd runs a git command in a directory.
// Process runs a git command in a directory.
//
// r := s.gitCmd(ctx, "/repo", "log", "--oneline")
func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result {
// r := s.Core().Process().RunIn(ctx, "/repo", "git", "log", "--oneline")
```
---
@ -435,6 +424,7 @@ func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string)
## Changelog
- 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.
- 2026-03-30: plan IDs now come from core.ID(), workspace prep validates org/repo names with core.ValidateName, and plan paths use core.SanitisePath.
- 2026-03-29: cmd/core-agent no longer rewrites `os.Args` before startup. The binary-owned commands now use named handlers, keeping the entrypoint on Core CLI primitives instead of repo-local argument mutation.

View file

@ -24,7 +24,7 @@ After loading all three, you have the full domain model:
After loading, you should be able to answer without looking at code:
- What does `c.Action("agentic.dispatch").Run(ctx, opts)` do?
- Why is `proc.go` being deleted?
- How do direct `s.Core().Process()` calls replace the old process wrapper layer?
- What replaces the ACTION cascade in `handlers.go`?
- Which imports are disallowed and what replaces each one?
- What does `c.Entitled("agentic.concurrency", 1)` check?
@ -43,7 +43,7 @@ Priority order:
5. Replace `core.ID()` (plan.go)
6. Register capabilities as named Actions (OnStartup)
7. Replace ACTION cascade with Task pipeline (handlers.go)
8. Delete `proc.go``s.Core().Process()` (after go-process v0.8.0)
8. Use `s.Core().Process()` directly in call sites. The old `proc.go` wrapper layer has been removed.
9. AX-7 test rename + gap fill
10. Example tests per source file

View file

@ -2,6 +2,8 @@
> Written by Cladius with full core/go + core/agent domain context (2026-03-25).
> Read core/go docs/RFC.md for the full spec. This plan covers what core/agent needs to change.
>
> Status note: the proc.go migration described below has shipped. core/agent now uses direct `s.Core().Process()` calls and `pid.go` for PID helpers. Keep this file as the original migration record.
## What Changed in core/go

View file

@ -19,11 +19,16 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) {
ctx := context.Background()
repoDir := WorkspaceRepoDir(wsDir)
process := s.Core().Process()
// PRs target dev — agents never merge directly to main
base := "dev"
out := s.gitOutput(ctx, repoDir, "log", "--oneline", core.Concat("origin/", base, "..HEAD"))
r := process.RunIn(ctx, repoDir, "git", "log", "--oneline", core.Concat("origin/", base, "..HEAD"))
if !r.OK {
return
}
out := core.Trim(r.Value.(string))
if out == "" {
return
}
@ -37,7 +42,7 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) {
// Push the branch to forge
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo)
if !s.gitCmdOK(ctx, repoDir, "push", forgeRemote, st.Branch) {
if !process.RunIn(ctx, repoDir, "git", "push", forgeRemote, st.Branch).OK {
if st2, err := ReadStatus(wsDir); err == nil {
st2.Question = "PR push failed"
writeStatus(wsDir, st2)

View file

@ -24,12 +24,15 @@ func (s *PrepSubsystem) cloneWorkspaceDeps(ctx context.Context, wsDir, repoDir,
if !r.OK {
return // no go.mod — not a Go project
}
// Parse requires from go.mod
deps := parseCoreDeps(r.Value.(string))
if len(deps) == 0 {
return
}
if s.ServiceRuntime == nil {
return
}
process := s.Core().Process()
// Deduplicate (dappco.re and forge.lthn.ai may map to same repo)
dedupSeen := make(map[string]bool)
@ -52,7 +55,7 @@ func (s *PrepSubsystem) cloneWorkspaceDeps(ctx context.Context, wsDir, repoDir,
}
repoURL := forgeSSHURL(org, dep.repo)
if result := s.gitCmd(ctx, wsDir, "clone", "--depth=1", repoURL, dep.dir); result.OK {
if result := process.RunIn(ctx, wsDir, "git", "clone", "--depth=1", repoURL, dep.dir); result.OK {
cloned = append(cloned, dep.dir)
}
}

View file

@ -443,6 +443,7 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir string) (int, string, er
func (s *PrepSubsystem) runQA(wsDir string) bool {
ctx := context.Background()
repoDir := WorkspaceRepoDir(wsDir)
process := s.Core().Process()
if fs.IsFile(core.JoinPath(repoDir, "go.mod")) {
for _, args := range [][]string{
@ -450,7 +451,7 @@ func (s *PrepSubsystem) runQA(wsDir string) bool {
{"go", "vet", "./..."},
{"go", "test", "./...", "-count=1", "-timeout", "120s"},
} {
if !s.runCmdOK(ctx, repoDir, args[0], args[1:]...) {
if !process.RunIn(ctx, repoDir, args[0], args[1:]...).OK {
core.Warn("QA failed", "cmd", core.Join(" ", args...))
return false
}
@ -459,17 +460,17 @@ func (s *PrepSubsystem) runQA(wsDir string) bool {
}
if fs.IsFile(core.JoinPath(repoDir, "composer.json")) {
if !s.runCmdOK(ctx, repoDir, "composer", "install", "--no-interaction") {
if !process.RunIn(ctx, repoDir, "composer", "install", "--no-interaction").OK {
return false
}
return s.runCmdOK(ctx, repoDir, "composer", "test")
return process.RunIn(ctx, repoDir, "composer", "test").OK
}
if fs.IsFile(core.JoinPath(repoDir, "package.json")) {
if !s.runCmdOK(ctx, repoDir, "npm", "install") {
if !process.RunIn(ctx, repoDir, "npm", "install").OK {
return false
}
return s.runCmdOK(ctx, repoDir, "npm", "test")
return process.RunIn(ctx, repoDir, "npm", "test").OK
}
return true

View file

@ -54,6 +54,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
if maxFiles <= 0 {
maxFiles = 50
}
process := s.Core().Process()
basePath := s.codePath
if basePath == "" {
@ -83,7 +84,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
}
// Fetch github to get current state
s.gitCmdOK(ctx, repoDir, "fetch", "github")
process.RunIn(ctx, repoDir, "git", "fetch", "github")
// Check how far ahead local default branch is vs github
localBase := s.DefaultBranch(repoDir)
@ -119,7 +120,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
// Push local main to github dev (explicit main, not HEAD)
base := s.DefaultBranch(repoDir)
if r := s.gitCmd(ctx, repoDir, "push", "github", core.Concat(base, ":refs/heads/dev"), "--force"); !r.OK {
if r := process.RunIn(ctx, repoDir, "git", "push", "github", core.Concat(base, ":refs/heads/dev"), "--force"); !r.OK {
sync.Skipped = core.Sprintf("push failed: %s", r.Value)
synced = append(synced, sync)
continue
@ -148,9 +149,10 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
// createGitHubPR creates a PR from dev → main using the gh CLI.
func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string, commits, files int) (string, error) {
ghRepo := core.Sprintf("%s/%s", GitHubOrg(), repo)
process := s.Core().Process()
// Check if there's already an open PR from dev
r := s.runCmd(ctx, repoDir, "gh", "pr", "list", "--repo", ghRepo, "--head", "dev", "--state", "open", "--json", "url", "--limit", "1")
r := process.RunIn(ctx, repoDir, "gh", "pr", "list", "--repo", ghRepo, "--head", "dev", "--state", "open", "--json", "url", "--limit", "1")
if r.OK {
out := r.Value.(string)
if core.Contains(out, "url") {
@ -166,7 +168,7 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string
title := core.Sprintf("[sync] %s: %d commits, %d files", repo, commits, files)
r = s.runCmd(ctx, repoDir, "gh", "pr", "create",
r = process.RunIn(ctx, repoDir, "gh", "pr", "create",
"--repo", ghRepo, "--head", "dev", "--base", "main",
"--title", title, "--body", body)
if !r.OK {
@ -183,23 +185,31 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string
// ensureDevBranch creates the dev branch on GitHub if it doesn't exist.
func (s *PrepSubsystem) ensureDevBranch(repoDir string) {
s.gitCmdOK(context.Background(), repoDir, "push", "github", "HEAD:refs/heads/dev")
s.Core().Process().RunIn(context.Background(), repoDir, "git", "push", "github", "HEAD:refs/heads/dev")
}
// hasRemote checks if a git remote exists.
func (s *PrepSubsystem) hasRemote(repoDir, name string) bool {
return s.gitCmdOK(context.Background(), repoDir, "remote", "get-url", name)
return s.Core().Process().RunIn(context.Background(), repoDir, "git", "remote", "get-url", name).OK
}
// commitsAhead returns how many commits HEAD is ahead of the ref.
func (s *PrepSubsystem) commitsAhead(repoDir, base, head string) int {
out := s.gitOutput(context.Background(), repoDir, "rev-list", core.Concat(base, "..", head), "--count")
r := s.Core().Process().RunIn(context.Background(), repoDir, "git", "rev-list", core.Concat(base, "..", head), "--count")
if !r.OK {
return 0
}
out := core.Trim(r.Value.(string))
return parseInt(out)
}
// filesChanged returns the number of files changed between two refs.
func (s *PrepSubsystem) filesChanged(repoDir, base, head string) int {
out := s.gitOutput(context.Background(), repoDir, "diff", "--name-only", core.Concat(base, "..", head))
r := s.Core().Process().RunIn(context.Background(), repoDir, "git", "diff", "--name-only", core.Concat(base, "..", head))
if !r.OK {
return 0
}
out := core.Trim(r.Value.(string))
if out == "" {
return 0
}

View file

@ -156,14 +156,16 @@ func AgentName() string {
// base := s.DefaultBranch("./src")
func (s *PrepSubsystem) DefaultBranch(repoDir string) string {
ctx := context.Background()
if ref := s.gitOutput(ctx, repoDir, "symbolic-ref", "refs/remotes/origin/HEAD", "--short"); ref != "" {
process := s.Core().Process()
if r := process.RunIn(ctx, repoDir, "git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"); r.OK {
ref := core.Trim(r.Value.(string))
if core.HasPrefix(ref, "origin/") {
return core.TrimPrefix(ref, "origin/")
}
return ref
}
for _, branch := range []string{"main", "master"} {
if s.gitCmdOK(ctx, repoDir, "rev-parse", "--verify", branch) {
if process.RunIn(ctx, repoDir, "git", "rev-parse", "--verify", branch).OK {
return branch
}
}

25
pkg/agentic/pid.go Normal file
View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import "syscall"
// PIDAlive checks if an OS process is still alive via PID signal check.
//
// if agentic.PIDAlive(pid) { ... }
func PIDAlive(pid int) bool {
if pid > 0 {
return syscall.Kill(pid, 0) == nil
}
return false
}
// PIDTerminate terminates a process via SIGTERM.
//
// if agentic.PIDTerminate(pid) { ... }
func PIDTerminate(pid int) bool {
if pid > 0 {
return syscall.Kill(pid, syscall.SIGTERM) == nil
}
return false
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"os"
core "dappco.re/go/core"
)
func ExamplePIDAlive() {
core.Println(PIDAlive(os.Getpid()))
// Output: true
}
func ExamplePIDTerminate() {
core.Println(PIDTerminate(0))
// Output: false
}

107
pkg/agentic/pid_test.go Normal file
View file

@ -0,0 +1,107 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"os"
"strconv"
"testing"
"time"
core "dappco.re/go/core"
"dappco.re/go/core/process"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testPrep is the package-level PrepSubsystem for tests that need process execution.
var testPrep *PrepSubsystem
// testCore is the package-level Core with go-process registered.
var testCore *core.Core
// TestMain sets up a PrepSubsystem with go-process registered for all tests in the package.
func TestMain(m *testing.M) {
testCore = core.New(
core.WithService(ProcessRegister),
)
testCore.ServiceStartup(context.Background(), nil)
// Enable pipeline feature flags (matches Register defaults)
testCore.Config().Enable("auto-qa")
testCore.Config().Enable("auto-pr")
testCore.Config().Enable("auto-merge")
testCore.Config().Enable("auto-ingest")
testPrep = &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
os.Exit(m.Run())
}
// newPrepWithProcess creates a PrepSubsystem wired to testCore for tests that
// need process execution via s.Core().Process().
func newPrepWithProcess() *PrepSubsystem {
return &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
}
// --- PIDAlive ---
func TestPid_PIDAlive_Good(t *testing.T) {
pid, _ := strconv.Atoi(core.Env("PID"))
assert.True(t, PIDAlive(pid))
}
func TestPid_PIDAlive_Bad(t *testing.T) {
assert.False(t, PIDAlive(999999))
}
func TestPid_PIDAlive_Ugly(t *testing.T) {
assert.False(t, PIDAlive(0))
}
// --- PIDTerminate ---
func TestPid_PIDTerminate_Good(t *testing.T) {
r := testCore.Process().Start(context.Background(), core.NewOptions(
core.Option{Key: "command", Value: "sleep"},
core.Option{Key: "args", Value: []string{"30"}},
core.Option{Key: "detach", Value: true},
))
require.True(t, r.OK)
proc, ok := r.Value.(*process.Process)
require.True(t, ok)
pid := proc.Info().PID
require.NotZero(t, pid)
defer func() {
_ = proc.Kill()
}()
assert.True(t, PIDTerminate(pid))
select {
case <-proc.Done():
case <-time.After(5 * time.Second):
t.Fatal("PIDTerminate did not stop the process")
}
assert.False(t, PIDAlive(pid))
}
func TestPid_PIDTerminate_Bad(t *testing.T) {
assert.False(t, PIDTerminate(999999))
}
func TestPid_PIDTerminate_Ugly(t *testing.T) {
assert.False(t, PIDTerminate(0))
}

View file

@ -65,11 +65,15 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
}
if st.Branch == "" {
branch := s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD")
if branch == "" {
process := s.Core().Process()
r := process.RunIn(ctx, repoDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
if !r.OK {
return nil, CreatePROutput{}, core.E("createPR", "failed to detect branch", nil)
}
st.Branch = core.Trim(r.Value.(string))
if st.Branch == "" {
return nil, CreatePROutput{}, core.E("createPR", "failed to detect branch", nil)
}
st.Branch = branch
}
org := st.Org
@ -107,7 +111,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
// Push branch to Forge (origin is the local clone, not Forge)
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo)
r := s.gitCmd(ctx, repoDir, "push", forgeRemote, st.Branch)
r := s.Core().Process().RunIn(ctx, repoDir, "git", "push", forgeRemote, st.Branch)
if !r.OK {
return nil, CreatePROutput{}, core.E("createPR", core.Concat("git push failed: ", r.Value.(string)), nil)
}

View file

@ -431,6 +431,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
// Source repo path — org and repo were validated by workspaceDirResult.
repoPath := core.JoinPath(s.codePath, input.Org, input.Repo)
process := s.Core().Process()
// Ensure meta directory exists
if r := fs.EnsureDir(metaDir); !r.OK {
@ -445,17 +446,21 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
// Preserve the current branch on resume. Pull it only if it exists on
// origin; otherwise refresh the default branch refs without abandoning the
// workspace branch.
currentBranch := s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD")
r := process.RunIn(ctx, repoDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
currentBranch := ""
if r.OK {
currentBranch = core.Trim(r.Value.(string))
}
defaultBranch := s.DefaultBranch(repoDir)
if currentBranch == "" || currentBranch == "HEAD" {
currentBranch = defaultBranch
}
if currentBranch != "" {
s.gitCmd(ctx, repoDir, "checkout", currentBranch)
if s.gitCmdOK(ctx, repoDir, "ls-remote", "--exit-code", "--heads", "origin", currentBranch) {
s.gitCmd(ctx, repoDir, "pull", "--ff-only", "origin", currentBranch)
process.RunIn(ctx, repoDir, "git", "checkout", currentBranch)
if process.RunIn(ctx, repoDir, "git", "ls-remote", "--exit-code", "--heads", "origin", currentBranch).OK {
process.RunIn(ctx, repoDir, "git", "pull", "--ff-only", "origin", currentBranch)
} else if defaultBranch != "" {
s.gitCmd(ctx, repoDir, "fetch", "origin", defaultBranch)
process.RunIn(ctx, repoDir, "git", "fetch", "origin", defaultBranch)
}
}
}
@ -470,7 +475,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
if !resumed {
// Clone repo into repo/
if r := s.gitCmd(ctx, ".", "clone", repoPath, repoDir); !r.OK {
if r := process.RunIn(ctx, ".", "git", "clone", repoPath, repoDir); !r.OK {
return nil, PrepOutput{}, core.E("prep", core.Concat("git clone failed for ", input.Repo), nil)
}
@ -487,13 +492,16 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
}
branchName := core.Sprintf("agent/%s", taskSlug)
if r := s.gitCmd(ctx, repoDir, "checkout", "-b", branchName); !r.OK {
if r := process.RunIn(ctx, repoDir, "git", "checkout", "-b", branchName); !r.OK {
return nil, PrepOutput{}, core.E("prep.branch", core.Sprintf("failed to create branch %q", branchName), nil)
}
out.Branch = branchName
} else {
// Resume: read branch from existing checkout
out.Branch = s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD")
r := process.RunIn(ctx, repoDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
if r.OK {
out.Branch = core.Trim(r.Value.(string))
}
}
// Overwrite CODEX.md with language-specific version if needed.
@ -517,7 +525,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
if !fs.IsDir(docsDir) {
docsRepo := core.JoinPath(s.codePath, input.Org, "docs")
if fs.IsDir(core.JoinPath(docsRepo, ".git")) {
s.gitCmd(ctx, ".", "clone", "--depth", "1", docsRepo, docsDir)
process.RunIn(ctx, ".", "git", "clone", "--depth", "1", docsRepo, docsDir)
}
}
@ -789,7 +797,11 @@ func (s *PrepSubsystem) findConsumersList(repo string) (string, int) {
}
func (s *PrepSubsystem) getGitLog(repoPath string) string {
return s.gitOutput(context.Background(), repoPath, "log", "--oneline", "-20")
r := s.Core().Process().RunIn(context.Background(), repoPath, "git", "log", "--oneline", "-20")
if !r.OK {
return ""
}
return core.Trim(r.Value.(string))
}
func (s *PrepSubsystem) pullWikiContent(ctx context.Context, org, repo string) string {

View file

@ -1,86 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
// Process execution helpers — routes all commands through s.Core().Process().
// No direct os/exec or go-process imports.
//
// Requires go-process to be registered with Core via:
//
// core.New(core.WithService(agentic.ProcessRegister))
package agentic
import (
"context"
"syscall"
core "dappco.re/go/core"
)
// runCmd executes a command in a directory. Returns Result{Value: string, OK: bool}.
//
// r := s.runCmd(ctx, repoDir, "git", "log", "--oneline", "-20")
// if r.OK { output := r.Value.(string) }
func (s *PrepSubsystem) runCmd(ctx context.Context, dir string, command string, args ...string) core.Result {
return s.Core().Process().RunIn(ctx, dir, command, args...)
}
// runCmdEnv executes a command with additional environment variables.
//
// r := s.runCmdEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...")
func (s *PrepSubsystem) runCmdEnv(ctx context.Context, dir string, env []string, command string, args ...string) core.Result {
return s.Core().Process().RunWithEnv(ctx, dir, env, command, args...)
}
// runCmdOK executes a command and returns true if it exits 0.
//
// if s.runCmdOK(ctx, repoDir, "go", "build", "./...") { ... }
func (s *PrepSubsystem) runCmdOK(ctx context.Context, dir string, command string, args ...string) bool {
return s.runCmd(ctx, dir, command, args...).OK
}
// gitCmd runs a git command in the given directory.
//
// r := s.gitCmd(ctx, repoDir, "log", "--oneline", "-20")
func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result {
return s.runCmd(ctx, dir, "git", args...)
}
// gitCmdOK runs a git command and returns true if it exits 0.
//
// if s.gitCmdOK(ctx, repoDir, "fetch", "origin", "main") { ... }
func (s *PrepSubsystem) gitCmdOK(ctx context.Context, dir string, args ...string) bool {
return s.gitCmd(ctx, dir, args...).OK
}
// gitOutput runs a git command and returns trimmed stdout.
//
// branch := s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD")
func (s *PrepSubsystem) gitOutput(ctx context.Context, dir string, args ...string) string {
r := s.gitCmd(ctx, dir, args...)
if !r.OK {
return ""
}
return core.Trim(r.Value.(string))
}
// --- Process lifecycle helpers ---
// PIDAlive checks if an OS process is still alive via PID signal check.
//
// if agentic.PIDAlive(st.PID) { ... }
func PIDAlive(pid int) bool {
if pid > 0 {
return syscall.Kill(pid, 0) == nil
}
return false
}
// PIDTerminate terminates a process via SIGTERM.
//
// if agentic.PIDTerminate(st.PID) { ... }
func PIDTerminate(pid int) bool {
if pid > 0 {
return syscall.Kill(pid, syscall.SIGTERM) == nil
}
return false
}

View file

@ -1,39 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
core "dappco.re/go/core"
)
func ExamplePrepSubsystem_runCmd() {
r := testPrep.runCmd(context.Background(), ".", "echo", "hello")
core.Println(r.OK)
// Output: true
}
func ExamplePrepSubsystem_gitCmd() {
r := testPrep.gitCmd(context.Background(), ".", "--version")
core.Println(r.OK)
// Output: true
}
func ExamplePrepSubsystem_gitOutput() {
version := testPrep.gitOutput(context.Background(), ".", "--version")
core.Println(core.HasPrefix(version, "git version"))
// Output: true
}
func ExamplePrepSubsystem_runCmdOK() {
ok := testPrep.runCmdOK(context.Background(), ".", "echo", "test")
core.Println(ok)
// Output: true
}
func ExamplePrepSubsystem_gitCmdOK() {
ok := testPrep.gitCmdOK(context.Background(), ".", "--version")
core.Println(ok)
// Output: true
}

View file

@ -1,239 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"os"
"strconv"
"testing"
"time"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testPrep is the package-level PrepSubsystem for tests that need process execution.
var testPrep *PrepSubsystem
// testCore is the package-level Core with go-process registered.
var testCore *core.Core
// TestMain sets up a PrepSubsystem with go-process registered for all tests in the package.
func TestMain(m *testing.M) {
testCore = core.New(
core.WithService(ProcessRegister),
)
testCore.ServiceStartup(context.Background(), nil)
// Enable pipeline feature flags (matches Register defaults)
testCore.Config().Enable("auto-qa")
testCore.Config().Enable("auto-pr")
testCore.Config().Enable("auto-merge")
testCore.Config().Enable("auto-ingest")
testPrep = &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
os.Exit(m.Run())
}
// newPrepWithProcess creates a PrepSubsystem wired to testCore for tests that
// need process execution via s.Core().Process().
func newPrepWithProcess() *PrepSubsystem {
return &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
}
// --- runCmd ---
func TestProc_RunCmd_Good(t *testing.T) {
dir := t.TempDir()
r := testPrep.runCmd(context.Background(), dir, "echo", "hello")
assert.True(t, r.OK)
assert.Contains(t, core.Trim(r.Value.(string)), "hello")
}
func TestProc_RunCmd_Bad(t *testing.T) {
dir := t.TempDir()
r := testPrep.runCmd(context.Background(), dir, "nonexistent-command-xyz")
assert.False(t, r.OK)
}
func TestProc_RunCmd_Ugly(t *testing.T) {
dir := t.TempDir()
// Empty command string — should error
r := testPrep.runCmd(context.Background(), dir, "")
assert.False(t, r.OK)
}
// --- runCmdEnv ---
func TestProc_RunCmdEnv_Good(t *testing.T) {
dir := t.TempDir()
r := testPrep.runCmdEnv(context.Background(), dir, []string{"MY_CUSTOM_VAR=hello_test"}, "env")
assert.True(t, r.OK)
assert.Contains(t, r.Value.(string), "MY_CUSTOM_VAR=hello_test")
}
func TestProc_RunCmdEnv_Bad(t *testing.T) {
dir := t.TempDir()
r := testPrep.runCmdEnv(context.Background(), dir, []string{"FOO=bar"}, "nonexistent-command-xyz")
assert.False(t, r.OK)
}
func TestProc_RunCmdEnv_Ugly(t *testing.T) {
dir := t.TempDir()
// Empty env slice — should work fine, just no extra vars
r := testPrep.runCmdEnv(context.Background(), dir, []string{}, "echo", "works")
assert.True(t, r.OK)
assert.Contains(t, core.Trim(r.Value.(string)), "works")
}
// --- runCmdOK ---
func TestProc_RunCmdOK_Good(t *testing.T) {
dir := t.TempDir()
assert.True(t, testPrep.runCmdOK(context.Background(), dir, "echo", "ok"))
}
func TestProc_RunCmdOK_Bad(t *testing.T) {
dir := t.TempDir()
assert.False(t, testPrep.runCmdOK(context.Background(), dir, "nonexistent-command-xyz"))
}
func TestProc_RunCmdOK_Ugly(t *testing.T) {
dir := t.TempDir()
// "false" command returns exit 1
assert.False(t, testPrep.runCmdOK(context.Background(), dir, "false"))
}
// --- gitCmd ---
func TestProc_GitCmd_Good(t *testing.T) {
dir := t.TempDir()
r := testPrep.gitCmd(context.Background(), dir, "--version")
assert.True(t, r.OK)
}
func TestProc_GitCmd_Bad(t *testing.T) {
// git log in a non-git dir should fail
dir := t.TempDir()
r := testPrep.gitCmd(context.Background(), dir, "log")
assert.False(t, r.OK)
}
func TestProc_GitCmd_Ugly(t *testing.T) {
dir := t.TempDir()
// Empty args — git with no arguments exits 1
r := testPrep.gitCmd(context.Background(), dir)
assert.False(t, r.OK)
}
// --- gitCmdOK ---
func TestProc_GitCmdOK_Good(t *testing.T) {
dir := t.TempDir()
assert.True(t, testPrep.gitCmdOK(context.Background(), dir, "--version"))
}
func TestProc_GitCmdOK_Bad(t *testing.T) {
// git log in non-git dir returns false
dir := t.TempDir()
assert.False(t, testPrep.gitCmdOK(context.Background(), dir, "log"))
}
func TestProc_GitCmdOK_Ugly(t *testing.T) {
// Empty dir string — git may use cwd, which may or may not be a repo
// Just ensure no panic
assert.NotPanics(t, func() {
testPrep.gitCmdOK(context.Background(), "", "--version")
})
}
// --- gitOutput ---
func TestProc_GitOutput_Good(t *testing.T) {
dir := initTestRepo(t)
branch := testPrep.gitOutput(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD")
assert.Equal(t, "main", branch)
}
func TestProc_GitOutput_Bad(t *testing.T) {
// Non-git dir returns empty string
dir := t.TempDir()
out := testPrep.gitOutput(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD")
assert.Equal(t, "", out)
}
func TestProc_GitOutput_Ugly(t *testing.T) {
// Failed command returns empty string
dir := t.TempDir()
out := testPrep.gitOutput(context.Background(), dir, "log", "--oneline", "-5")
assert.Equal(t, "", out)
}
// --- PIDAlive ---
func TestProc_PIDAlive_Good(t *testing.T) {
// Own PID should be running
pid, _ := strconv.Atoi(core.Env("PID"))
assert.True(t, PIDAlive(pid))
}
func TestProc_PIDAlive_Bad(t *testing.T) {
// PID 999999 should not be running (extremely unlikely to exist)
assert.False(t, PIDAlive(999999))
}
func TestProc_PIDAlive_Ugly(t *testing.T) {
// PID 0 — should return false (invalid PID guard: pid > 0 is false for 0)
assert.False(t, PIDAlive(0))
}
// --- PIDTerminate ---
func TestProc_PIDTerminate_Good(t *testing.T) {
t.Skip("would need real process to kill")
}
func TestProc_PIDTerminate_Bad(t *testing.T) {
// PID 999999 should fail to kill
assert.False(t, PIDTerminate(999999))
}
func TestProc_PIDTerminate_Ugly(t *testing.T) {
// PID 0 — pid > 0 guard returns false
assert.False(t, PIDTerminate(0))
}
// --- initTestRepo creates a git repo with commits for proc tests ---
func initTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
gitEnv := []string{
"GIT_AUTHOR_NAME=Test",
"GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=Test",
"GIT_COMMITTER_EMAIL=test@test.com",
}
run := func(args ...string) {
t.Helper()
r := testCore.Process().RunWithEnv(context.Background(), dir, gitEnv, args[0], args[1:]...)
require.True(t, r.OK, "cmd %v failed: %s", args, r.Value)
}
run("git", "init", "-b", "main")
run("git", "config", "user.name", "Test")
run("git", "config", "user.email", "test@test.com")
require.True(t, fs.Write(core.JoinPath(dir, "README.md"), "# Test").OK)
run("git", "add", "README.md")
run("git", "commit", "-m", "initial commit")
return dir
}

View file

@ -163,6 +163,7 @@ func (s *PrepSubsystem) findReviewCandidates(basePath string) []string {
// reviewRepo runs CodeRabbit on a single repo and takes action.
func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer string, dryRun, localOnly bool) ReviewResult {
result := ReviewResult{Repo: repo}
process := s.Core().Process()
// Check saved rate limit
if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) {
@ -176,7 +177,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
reviewer = "coderabbit"
}
command, args := s.buildReviewCommand(repoDir, reviewer)
r := s.runCmd(ctx, repoDir, command, args...)
r := process.RunIn(ctx, repoDir, command, args...)
output, _ := r.Value.(string)
// Parse rate limit (both reviewers use similar patterns)
@ -250,14 +251,15 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
// pushAndMerge pushes to GitHub dev and merges the PR.
func (s *PrepSubsystem) pushAndMerge(ctx context.Context, repoDir, repo string) error {
if r := s.gitCmd(ctx, repoDir, "push", "github", "HEAD:refs/heads/dev", "--force"); !r.OK {
process := s.Core().Process()
if r := process.RunIn(ctx, repoDir, "git", "push", "github", "HEAD:refs/heads/dev", "--force"); !r.OK {
return core.E("pushAndMerge", core.Concat("push failed: ", r.Value.(string)), nil)
}
// Mark PR ready if draft
s.runCmdOK(ctx, repoDir, "gh", "pr", "ready", "--repo", core.Concat(GitHubOrg(), "/", repo))
process.RunIn(ctx, repoDir, "gh", "pr", "ready", "--repo", core.Concat(GitHubOrg(), "/", repo))
if r := s.runCmd(ctx, repoDir, "gh", "pr", "merge", "--merge", "--delete-branch"); !r.OK {
if r := process.RunIn(ctx, repoDir, "gh", "pr", "merge", "--merge", "--delete-branch"); !r.OK {
return core.E("pushAndMerge", core.Concat("merge failed: ", r.Value.(string)), nil)
}

View file

@ -104,14 +104,15 @@ func (s *PrepSubsystem) attemptVerifyAndMerge(repoDir, org, repo, branch string,
// rebaseBranch rebases the current branch onto the default branch and force-pushes.
func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool {
ctx := context.Background()
process := s.Core().Process()
base := s.DefaultBranch(repoDir)
if !s.gitCmdOK(ctx, repoDir, "fetch", "origin", base) {
if !process.RunIn(ctx, repoDir, "git", "fetch", "origin", base).OK {
return false
}
if !s.gitCmdOK(ctx, repoDir, "rebase", core.Concat("origin/", base)) {
s.gitCmdOK(ctx, repoDir, "rebase", "--abort")
if !process.RunIn(ctx, repoDir, "git", "rebase", core.Concat("origin/", base)).OK {
process.RunIn(ctx, repoDir, "git", "rebase", "--abort")
return false
}
@ -125,7 +126,7 @@ func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool {
repo = st.Repo
}
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo)
return s.gitCmdOK(ctx, repoDir, "push", "--force-with-lease", forgeRemote, branch)
return process.RunIn(ctx, repoDir, "git", "push", "--force-with-lease", forgeRemote, branch).OK
}
// flagForReview adds the "needs-review" label to the PR via Forge API.
@ -217,7 +218,8 @@ func (s *PrepSubsystem) runVerification(repoDir string) verifyResult {
func (s *PrepSubsystem) runGoTests(repoDir string) verifyResult {
ctx := context.Background()
r := s.runCmdEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...", "-count=1", "-timeout", "120s")
process := s.Core().Process()
r := process.RunWithEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...", "-count=1", "-timeout", "120s")
out := resultText(r)
exitCode := 0
if !r.OK {
@ -228,10 +230,11 @@ func (s *PrepSubsystem) runGoTests(repoDir string) verifyResult {
func (s *PrepSubsystem) runPHPTests(repoDir string) verifyResult {
ctx := context.Background()
r := s.runCmd(ctx, repoDir, "composer", "test", "--no-interaction")
process := s.Core().Process()
r := process.RunIn(ctx, repoDir, "composer", "test", "--no-interaction")
if !r.OK {
// Try pest as fallback
r2 := s.runCmd(ctx, repoDir, "./vendor/bin/pest", "--no-interaction")
r2 := process.RunIn(ctx, repoDir, "./vendor/bin/pest", "--no-interaction")
if !r2.OK {
return verifyResult{passed: false, testCmd: "none", output: "No PHP test runner found (composer test and vendor/bin/pest both unavailable)", exitCode: 1}
}
@ -254,7 +257,8 @@ func (s *PrepSubsystem) runNodeTests(repoDir string) verifyResult {
}
ctx := context.Background()
r = s.runCmd(ctx, repoDir, "npm", "test")
process := s.Core().Process()
r = process.RunIn(ctx, repoDir, "npm", "test")
out := resultText(r)
exitCode := 0
if !r.OK {

View file

@ -124,7 +124,7 @@ if run.OK {
- Use UK English in comments and docs.
- Use `core.E("pkg.Method", "message", err)` for errors. Never use `fmt.Errorf` or `errors.New`.
- Use `c.Fs()` or a package-level `fs` helper for file I/O. Never use raw `os.ReadFile`, `os.WriteFile`, or `filepath.*`.
- Route external commands through `c.Process()` or the repo's process helper layer. Never import `os/exec`.
- Route external commands through `c.Process()`. Never import `os/exec`.
- Use Core string and path helpers such as `core.Contains`, `core.Trim`, `core.Split`, `core.Concat`, and `core.JoinPath` instead of raw `strings.*` or path concatenation.
- Prefer `core.Result{Value: x, OK: true}` over `(value, error)` pairs in Core-facing code.
- Comments should show HOW with real values, not restate the signature.