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 cmd/core-agent/main.go — entry point: core.New + Run
pkg/agentic/ — orchestration (dispatch, prep, verify, scan, commands) pkg/agentic/ — orchestration (dispatch, prep, verify, scan, commands)
pkg/agentic/actions.go — named Action handlers (ctx, Options) → Result 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/handlers.go — IPC completion pipeline handlers
pkg/agentic/status.go — workspace status (WriteAtomic + JSONMarshalString) pkg/agentic/status.go — workspace status (WriteAtomic + JSONMarshalString)
pkg/agentic/paths.go — paths, fs (NewUnrestricted), helpers 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. All commands via `s.Core().Process()`. Returns `core.Result` — Value is always a string.
```go ```go
func (s *PrepSubsystem) runCmd(ctx context.Context, dir, command string, args ...string) core.Result { process := s.Core().Process()
return s.Core().Process().RunIn(ctx, dir, command, args...) 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 { r = process.RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test", "./...")
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))
}
``` ```
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. 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: Every exported function MUST have a usage-example comment:
```go ```go
// gitCmd runs a git command in a directory. // Process runs a git command in a directory.
// //
// r := s.gitCmd(ctx, "/repo", "log", "--oneline") // r := s.Core().Process().RunIn(ctx, "/repo", "git", "log", "--oneline")
func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result {
``` ```
--- ---
@ -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-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: 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-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: 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. - 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 - **UK English**: colour, organisation, centre, initialise
- **Errors**: `core.E("pkg.Method", "message", err)` — NEVER `fmt.Errorf` - **Errors**: `core.E("pkg.Method", "message", err)` — NEVER `fmt.Errorf`
- **File I/O**: Package-level `fs` (go-io Medium) — NEVER `os.ReadFile/WriteFile` - **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.*` - **Strings**: `core.Contains/Split/Trim/HasPrefix/Sprintf` — NEVER `strings.*`
- **Returns**: `core.Result{Value, OK}` — NEVER `(value, error)` pairs - **Returns**: `core.Result{Value, OK}` — NEVER `(value, error)` pairs
- **Comments**: Usage examples showing HOW with real values, not descriptions - **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 ## Process Execution
All external commands go through `pkg/agentic/proc.go` → go-process: All external commands go through `s.Core().Process()` → go-process:
```go ```go
out, err := runCmd(ctx, dir, "git", "log", "--oneline") process := s.Core().Process()
ok := gitCmdOK(ctx, dir, "fetch", "origin", "main") out := process.RunIn(ctx, dir, "git", "log", "--oneline")
branch := gitOutput(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") 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. **NEVER import `os/exec`.** Zero source files do.

View file

@ -42,7 +42,7 @@ c.Run()
- Use UK English in comments and docs. - 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.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.*`. - 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.*`. - 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. - 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. - 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 cmd/core-agent/main.go — entry point: core.New + Run
pkg/agentic/ — orchestration (dispatch, prep, verify, scan, commands) pkg/agentic/ — orchestration (dispatch, prep, verify, scan, commands)
pkg/agentic/actions.go — named Action handlers (ctx, Options) → Result 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/handlers.go — IPC completion pipeline handlers
pkg/agentic/status.go — workspace status (WriteAtomic + JSONMarshalString) pkg/agentic/status.go — workspace status (WriteAtomic + JSONMarshalString)
pkg/agentic/paths.go — paths, fs (NewUnrestricted), helpers 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. All commands via `s.Core().Process()`. Returns `core.Result` — Value is always a string.
```go ```go
func (s *PrepSubsystem) runCmd(ctx context.Context, dir, command string, args ...string) core.Result { process := s.Core().Process()
return s.Core().Process().RunIn(ctx, dir, command, args...) 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 { r = process.RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test", "./...")
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))
}
``` ```
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. 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: Every exported function MUST have a usage-example comment:
```go ```go
// gitCmd runs a git command in a directory. // Process runs a git command in a directory.
// //
// r := s.gitCmd(ctx, "/repo", "log", "--oneline") // r := s.Core().Process().RunIn(ctx, "/repo", "git", "log", "--oneline")
func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string) core.Result {
``` ```
--- ---
@ -435,6 +424,7 @@ func (s *PrepSubsystem) gitCmd(ctx context.Context, dir string, args ...string)
## Changelog ## 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: 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: 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-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. - 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: After loading, you should be able to answer without looking at code:
- What does `c.Action("agentic.dispatch").Run(ctx, opts)` do? - 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`? - What replaces the ACTION cascade in `handlers.go`?
- Which imports are disallowed and what replaces each one? - Which imports are disallowed and what replaces each one?
- What does `c.Entitled("agentic.concurrency", 1)` check? - What does `c.Entitled("agentic.concurrency", 1)` check?
@ -43,7 +43,7 @@ Priority order:
5. Replace `core.ID()` (plan.go) 5. Replace `core.ID()` (plan.go)
6. Register capabilities as named Actions (OnStartup) 6. Register capabilities as named Actions (OnStartup)
7. Replace ACTION cascade with Task pipeline (handlers.go) 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 9. AX-7 test rename + gap fill
10. Example tests per source file 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). > 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. > 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 ## What Changed in core/go

View file

@ -19,11 +19,16 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) {
ctx := context.Background() ctx := context.Background()
repoDir := WorkspaceRepoDir(wsDir) repoDir := WorkspaceRepoDir(wsDir)
process := s.Core().Process()
// PRs target dev — agents never merge directly to main // PRs target dev — agents never merge directly to main
base := "dev" 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 == "" { if out == "" {
return return
} }
@ -37,7 +42,7 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) {
// Push the branch to forge // Push the branch to forge
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) 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 { if st2, err := ReadStatus(wsDir); err == nil {
st2.Question = "PR push failed" st2.Question = "PR push failed"
writeStatus(wsDir, st2) writeStatus(wsDir, st2)

View file

@ -24,12 +24,15 @@ func (s *PrepSubsystem) cloneWorkspaceDeps(ctx context.Context, wsDir, repoDir,
if !r.OK { if !r.OK {
return // no go.mod — not a Go project return // no go.mod — not a Go project
} }
// Parse requires from go.mod // Parse requires from go.mod
deps := parseCoreDeps(r.Value.(string)) deps := parseCoreDeps(r.Value.(string))
if len(deps) == 0 { if len(deps) == 0 {
return return
} }
if s.ServiceRuntime == nil {
return
}
process := s.Core().Process()
// Deduplicate (dappco.re and forge.lthn.ai may map to same repo) // Deduplicate (dappco.re and forge.lthn.ai may map to same repo)
dedupSeen := make(map[string]bool) dedupSeen := make(map[string]bool)
@ -52,7 +55,7 @@ func (s *PrepSubsystem) cloneWorkspaceDeps(ctx context.Context, wsDir, repoDir,
} }
repoURL := forgeSSHURL(org, dep.repo) 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) 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 { func (s *PrepSubsystem) runQA(wsDir string) bool {
ctx := context.Background() ctx := context.Background()
repoDir := WorkspaceRepoDir(wsDir) repoDir := WorkspaceRepoDir(wsDir)
process := s.Core().Process()
if fs.IsFile(core.JoinPath(repoDir, "go.mod")) { if fs.IsFile(core.JoinPath(repoDir, "go.mod")) {
for _, args := range [][]string{ for _, args := range [][]string{
@ -450,7 +451,7 @@ func (s *PrepSubsystem) runQA(wsDir string) bool {
{"go", "vet", "./..."}, {"go", "vet", "./..."},
{"go", "test", "./...", "-count=1", "-timeout", "120s"}, {"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...)) core.Warn("QA failed", "cmd", core.Join(" ", args...))
return false return false
} }
@ -459,17 +460,17 @@ func (s *PrepSubsystem) runQA(wsDir string) bool {
} }
if fs.IsFile(core.JoinPath(repoDir, "composer.json")) { 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 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 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 false
} }
return s.runCmdOK(ctx, repoDir, "npm", "test") return process.RunIn(ctx, repoDir, "npm", "test").OK
} }
return true return true

View file

@ -54,6 +54,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
if maxFiles <= 0 { if maxFiles <= 0 {
maxFiles = 50 maxFiles = 50
} }
process := s.Core().Process()
basePath := s.codePath basePath := s.codePath
if basePath == "" { if basePath == "" {
@ -83,7 +84,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
} }
// Fetch github to get current state // 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 // Check how far ahead local default branch is vs github
localBase := s.DefaultBranch(repoDir) 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) // Push local main to github dev (explicit main, not HEAD)
base := s.DefaultBranch(repoDir) 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) sync.Skipped = core.Sprintf("push failed: %s", r.Value)
synced = append(synced, sync) synced = append(synced, sync)
continue 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. // 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) { func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string, commits, files int) (string, error) {
ghRepo := core.Sprintf("%s/%s", GitHubOrg(), repo) ghRepo := core.Sprintf("%s/%s", GitHubOrg(), repo)
process := s.Core().Process()
// Check if there's already an open PR from dev // 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 { if r.OK {
out := r.Value.(string) out := r.Value.(string)
if core.Contains(out, "url") { 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) 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", "--repo", ghRepo, "--head", "dev", "--base", "main",
"--title", title, "--body", body) "--title", title, "--body", body)
if !r.OK { 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. // ensureDevBranch creates the dev branch on GitHub if it doesn't exist.
func (s *PrepSubsystem) ensureDevBranch(repoDir string) { 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. // hasRemote checks if a git remote exists.
func (s *PrepSubsystem) hasRemote(repoDir, name string) bool { 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. // commitsAhead returns how many commits HEAD is ahead of the ref.
func (s *PrepSubsystem) commitsAhead(repoDir, base, head string) int { 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) return parseInt(out)
} }
// filesChanged returns the number of files changed between two refs. // filesChanged returns the number of files changed between two refs.
func (s *PrepSubsystem) filesChanged(repoDir, base, head string) int { 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 == "" { if out == "" {
return 0 return 0
} }

View file

@ -156,14 +156,16 @@ func AgentName() string {
// base := s.DefaultBranch("./src") // base := s.DefaultBranch("./src")
func (s *PrepSubsystem) DefaultBranch(repoDir string) string { func (s *PrepSubsystem) DefaultBranch(repoDir string) string {
ctx := context.Background() 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/") { if core.HasPrefix(ref, "origin/") {
return core.TrimPrefix(ref, "origin/") return core.TrimPrefix(ref, "origin/")
} }
return ref return ref
} }
for _, branch := range []string{"main", "master"} { 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 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 == "" { if st.Branch == "" {
branch := s.gitOutput(ctx, repoDir, "rev-parse", "--abbrev-ref", "HEAD") process := s.Core().Process()
if branch == "" { 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) return nil, CreatePROutput{}, core.E("createPR", "failed to detect branch", nil)
} }
st.Branch = branch
} }
org := st.Org 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) // 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) 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 { if !r.OK {
return nil, CreatePROutput{}, core.E("createPR", core.Concat("git push failed: ", r.Value.(string)), nil) 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. // Source repo path — org and repo were validated by workspaceDirResult.
repoPath := core.JoinPath(s.codePath, input.Org, input.Repo) repoPath := core.JoinPath(s.codePath, input.Org, input.Repo)
process := s.Core().Process()
// Ensure meta directory exists // Ensure meta directory exists
if r := fs.EnsureDir(metaDir); !r.OK { 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 // Preserve the current branch on resume. Pull it only if it exists on
// origin; otherwise refresh the default branch refs without abandoning the // origin; otherwise refresh the default branch refs without abandoning the
// workspace branch. // 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) defaultBranch := s.DefaultBranch(repoDir)
if currentBranch == "" || currentBranch == "HEAD" { if currentBranch == "" || currentBranch == "HEAD" {
currentBranch = defaultBranch currentBranch = defaultBranch
} }
if currentBranch != "" { if currentBranch != "" {
s.gitCmd(ctx, repoDir, "checkout", currentBranch) process.RunIn(ctx, repoDir, "git", "checkout", currentBranch)
if s.gitCmdOK(ctx, repoDir, "ls-remote", "--exit-code", "--heads", "origin", currentBranch) { if process.RunIn(ctx, repoDir, "git", "ls-remote", "--exit-code", "--heads", "origin", currentBranch).OK {
s.gitCmd(ctx, repoDir, "pull", "--ff-only", "origin", currentBranch) process.RunIn(ctx, repoDir, "git", "pull", "--ff-only", "origin", currentBranch)
} else if defaultBranch != "" { } 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 { if !resumed {
// Clone repo into repo/ // 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) 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) 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) return nil, PrepOutput{}, core.E("prep.branch", core.Sprintf("failed to create branch %q", branchName), nil)
} }
out.Branch = branchName out.Branch = branchName
} else { } else {
// Resume: read branch from existing checkout // 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. // 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) { if !fs.IsDir(docsDir) {
docsRepo := core.JoinPath(s.codePath, input.Org, "docs") docsRepo := core.JoinPath(s.codePath, input.Org, "docs")
if fs.IsDir(core.JoinPath(docsRepo, ".git")) { 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 { 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 { 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. // 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 { func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer string, dryRun, localOnly bool) ReviewResult {
result := ReviewResult{Repo: repo} result := ReviewResult{Repo: repo}
process := s.Core().Process()
// Check saved rate limit // Check saved rate limit
if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) { 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" reviewer = "coderabbit"
} }
command, args := s.buildReviewCommand(repoDir, reviewer) command, args := s.buildReviewCommand(repoDir, reviewer)
r := s.runCmd(ctx, repoDir, command, args...) r := process.RunIn(ctx, repoDir, command, args...)
output, _ := r.Value.(string) output, _ := r.Value.(string)
// Parse rate limit (both reviewers use similar patterns) // 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. // pushAndMerge pushes to GitHub dev and merges the PR.
func (s *PrepSubsystem) pushAndMerge(ctx context.Context, repoDir, repo string) error { 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) return core.E("pushAndMerge", core.Concat("push failed: ", r.Value.(string)), nil)
} }
// Mark PR ready if draft // 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) 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. // rebaseBranch rebases the current branch onto the default branch and force-pushes.
func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool { func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool {
ctx := context.Background() ctx := context.Background()
process := s.Core().Process()
base := s.DefaultBranch(repoDir) base := s.DefaultBranch(repoDir)
if !s.gitCmdOK(ctx, repoDir, "fetch", "origin", base) { if !process.RunIn(ctx, repoDir, "git", "fetch", "origin", base).OK {
return false return false
} }
if !s.gitCmdOK(ctx, repoDir, "rebase", core.Concat("origin/", base)) { if !process.RunIn(ctx, repoDir, "git", "rebase", core.Concat("origin/", base)).OK {
s.gitCmdOK(ctx, repoDir, "rebase", "--abort") process.RunIn(ctx, repoDir, "git", "rebase", "--abort")
return false return false
} }
@ -125,7 +126,7 @@ func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool {
repo = st.Repo repo = st.Repo
} }
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, 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. // 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 { func (s *PrepSubsystem) runGoTests(repoDir string) verifyResult {
ctx := context.Background() 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) out := resultText(r)
exitCode := 0 exitCode := 0
if !r.OK { if !r.OK {
@ -228,10 +230,11 @@ func (s *PrepSubsystem) runGoTests(repoDir string) verifyResult {
func (s *PrepSubsystem) runPHPTests(repoDir string) verifyResult { func (s *PrepSubsystem) runPHPTests(repoDir string) verifyResult {
ctx := context.Background() 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 { if !r.OK {
// Try pest as fallback // 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 { 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} 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() ctx := context.Background()
r = s.runCmd(ctx, repoDir, "npm", "test") process := s.Core().Process()
r = process.RunIn(ctx, repoDir, "npm", "test")
out := resultText(r) out := resultText(r)
exitCode := 0 exitCode := 0
if !r.OK { if !r.OK {

View file

@ -124,7 +124,7 @@ if run.OK {
- Use UK English in comments and docs. - 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.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.*`. - 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. - 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. - 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. - Comments should show HOW with real values, not restate the signature.