feat(rip): sever + delete gc-bridge glue, phased engine keeps non-gc backends (soc-2rtm0 #gcglue-rip wave2) (#513)

Wave 2 of 5 of the orchestration-substrate retirement (soc-2rtm0).
Severs the Gas City (gc) bridge glue from the phased RPI engine and
deletes it. The phased engine keeps its non-gc backends
(auto/direct/stream/tmux); the `gc` runtime mode is removed. Wave 1
(factory) merged as #512.

## What changed (Edge B sever, per recon map)

**Typedef relocation:** Moved the injectable `gcExecFn`/`gcLookFn`
func-typedefs out of the deleted `gc_bridge.go` into the KEEP file
`rpi_phased_context.go`, renamed to `execFn`/`lookFn` (they are no
longer gc-specific — used by `rpi_phased_setup.go`, `tracker_health.go`,
`rpi_phased_stream.go`, `rpi_phased_context.go` for runtime/binary
preflight). All callsites updated.

**Phased selector patched (drop `gc` backend):**
- `rpi_phased_stream.go` — removed the `case "gc":` executor + the
auto-gc-preferred branch (auto now goes straight to stream) + the
`gcExecutor` backendMode type-assertion.
- `rpi_phased.go` — removed the `gc` preflight branch, the auto-gc
branch, `preflightGCRuntimeAvailability`, and `gc` from the `--runtime`
flag help + completion list.
- `rpi_phased_context.go` — `validateRuntimeMode` drops `gc`; removed
the `GasCityClient`/`GCCityPath`/`GCCityName` opts fields (their type
died with `rpi_phased_gc.go`).
- `doctor.go` — removed the GasCity Bridge + GasCity Product Runtime
health checks (`checkGasCityBridge*`, `checkGasCityProductRuntime*`,
`formatGasCityDiagnostic`); rest of doctor (daemon/ledger/openclaw
checks) untouched.
- `rpi_phased_domain_enforce.go` — emptied `opaqueRuntimeModes` (gc was
the only opaque runtime); kept the `unavailable` enforcement schema
path.

**Files deleted:** `gc_bridge.go`, `gc_events.go`, `rpi_phased_gc.go` +
their `_test.go` sidecars + `gc_test_helpers_test.go`.

**Spillover fixes (both viral helpers defined in deleted files):**
- `codex_runtime.go` re-pointed to `bridge.CompareSemver` (was using
`compareSemver`, a thin wrapper that lived in `gc_bridge.go`).
- `writeSSEFrame` test helper relocated into `rpi_integration_test.go`
(the remaining daemon-gascity L3 smoke test that uses it).

**Docs/generated:** scrubbed the CLAUDE.md "Gas City (gc) bridge" line
and `cli/internal/gascity/AGENTS.md` cross-ref; regenerated
`cli/docs/COMMANDS.md` + `registry.json`.

## Scope boundary
The daemon's own GasCity client (`internal/gascity`, `internal/daemon`
`GasCityClientAdapter`, `agentworker`) is a separate surface — left
untouched (that's wave 5). No CI gate referenced the deleted glue
(confirmed).

## Verification (all green)
- `gofmt -l` clean (my files) · `go build ./...` · `go vet ./...` · `go
test ./...` (11834 passed, 68 pkgs)
- `tests/smoke-test.sh` · `tests/cli/test-json-flag-consistency.sh` (15
pass, 0 err)
- `scripts/test-agentops-contract-canaries.sh` (failures=0)
- `scripts/validate-ci-policy-parity.sh` (63 rows) ·
`scripts/generate-registry.sh --check` (up to date)
- `tests/docs/validate-doc-release.sh` (CLI headings 72) ·
`check-docs-learning-references.sh` · `cli-docs-parity` stable ·
`check-test-staleness.sh` (0 stale)

Diff stat: 23 files, +72 / -4099 (delete-heavy rip; insertions
concentrated in deliberately-edited files).

Closes-scenario: soc-2rtm0#cascade-rip
Bounded-context: BC5-Runtime
Evidence: .agents/discovery/2026-05-24-rip-caller-map.md
This commit is contained in:
Bo
2026-05-24 12:57:37 -04:00
committed by GitHub
parent ee748d580d
commit cd6a7031ad
23 changed files with 72 additions and 4099 deletions
+2 -1
View File
@@ -191,7 +191,8 @@ Source of truth: append-only JSONL at `docs/provenance/ledger.jsonl` (schema `ag
- **Multi-phase work:** Route through `ao rpi` (enforces timeouts and stall detection).
- **Before spawning workers:** Verify no file overlap across the wave. File collisions are the #1 swarm failure mode.
- **Before proposing new capability:** Check `ao rpi serve --help`, `.github/workflows/validate.yml`, and `GOALS.md` first.
- **Gas City (gc) bridge:** `cli/cmd/ao/gc_bridge.go`, `gc_events.go`, `rpi_phased_gc.go`. Do not write new tests or features for the legacy RPI lane (`rpi_loop_supervisor.go`, `rpi_c2_events.go`, `rpi_phased_tmux.go`, `rpi_parallel.go`) — but these are **load-bearing, not dead code**: live code references their symbols (`RPIC2Event`/`appendRPIC2Event` across 13+ `rpi_phased*` files + `mine`; `rpiLoopSupervisorConfig`/`runRPISupervisedCycle` in `rpi_loop`/`agentopsd`/`rpi_cancel`; `shellQuote` in `handoff`/`overnight_setup`; tmux helpers in `rpi_nudge`/`rpi_phased_stream`). Deleting any breaks the build; removal needs a caller-migration refactor (soc-1gbpz), not a delete. `rpi_workers.go` and `fire.go` were already removed.
- **Gas City (gc) bridge — REMOVED (soc-2rtm0, wave 2).** The CLI gc-bridge glue (`cli/cmd/ao/gc_bridge.go`, `gc_events.go`, `rpi_phased_gc.go`) was severed and deleted. The phased engine keeps its non-gc backends (`auto`/`direct`/`stream`/`tmux`); `runtime=gc` is no longer a valid mode. The injectable exec/look typedefs (`execFn`/`lookFn`, formerly `gcExecFn`/`gcLookFn`) now live in `rpi_phased_context.go`. The daemon's own GasCity client (`internal/gascity`, `internal/daemon` adapters) is a separate surface, untouched here.
- **Legacy RPI lane — load-bearing, not dead code.** Do not write new tests or features for `rpi_loop_supervisor.go`, `rpi_c2_events.go`, `rpi_phased_tmux.go`, `rpi_parallel.go`, but do NOT delete them: live code references their symbols (`RPIC2Event`/`appendRPIC2Event` across 13+ `rpi_phased*` files + `mine`; `rpiLoopSupervisorConfig`/`runRPISupervisedCycle` in `rpi_loop`/`agentopsd`/`rpi_cancel`; `shellQuote` in `handoff`/`overnight_setup`; tmux helpers in `rpi_nudge`/`rpi_phased_stream`). Deleting any breaks the build; removal needs a caller-migration refactor (soc-1gbpz), not a delete. `rpi_workers.go` and `fire.go` were already removed.
### Execution Discipline
+3 -1
View File
@@ -11,6 +11,8 @@ import (
"slices"
"strings"
"time"
"github.com/boshu2/agentops/cli/internal/bridge"
)
const (
@@ -156,7 +158,7 @@ func detectLifecycleRuntimeProfileWithOptions(forceCodex bool) lifecycleRuntimeP
func codexSupportsNativeHooks(homeDir string) bool {
if version, ok := readCodexLatestVersion(homeDir); ok {
return compareSemver(version, codexNativeHooksMinVersion) >= 0
return bridge.CompareSemver(version, codexNativeHooksMinVersion) >= 0
}
return codexHooksFeatureEnabled(filepath.Join(homeDir, ".codex", "config.toml")) ||
fileExists(filepath.Join(homeDir, ".codex", "hooks.json"))
+1 -1
View File
@@ -130,7 +130,7 @@ func TestFlagCompletions_Registered(t *testing.T) {
{"rpi phased --from", mustFindCompletionCommand(t, "rpi", "phased"), "from",
sortedCompletionValues("discovery", "implementation", "validation", "research", "plan", "pre-mortem", "crank", "vibe", "post-mortem")},
{"rpi phased --runtime", mustFindCompletionCommand(t, "rpi", "phased"), "runtime",
sortedCompletionValues("auto", "direct", "stream", "tmux", "gc")},
sortedCompletionValues("auto", "direct", "stream", "tmux")},
{"overnight curator enqueue --kind", overnightCuratorEnqueueCmd, "kind",
sortedCompletionValues("ingest-claude-session", "lint-wiki", "dream-seed")},
{"overnight curator event --severity", overnightCuratorEventCmd, "severity",
-59
View File
@@ -63,7 +63,6 @@ func gatherDoctorChecks() []doctorCheck {
checkDaemonRuntime(),
checkDaemonLedgerHealth(time.Now(), daemonpkg.LedgerHealthDefaultThresholds()),
checkDaemonTelemetry(),
checkGasCityBridge(),
checkOpenClawConsumer(),
checkKnowledgeBase(),
checkKnowledgeFreshness(),
@@ -272,71 +271,13 @@ func daemonTelemetryClock(baseURL string, events []daemonpkg.LedgerEvent) time.T
return time.Now().UTC()
}
func checkGasCityBridge() doctorCheck {
cityPath := ""
if cwd, err := os.Getwd(); err == nil {
cityPath = gcBridgeCityPath(cwd)
}
return checkGasCityBridgeWith(cityPath, exec.Command, exec.LookPath)
}
func checkGasCityBridgeWith(cityPath string, execCommand gcExecFn, lookPath gcLookFn) doctorCheck {
diag := gcBridgeDiagnose(cityPath, execCommand, lookPath, true)
detail := formatGasCityDiagnostic(diag)
if cityPath != "" {
detail += "; city=" + cityPath
}
if diag.Ready {
return doctorCheck{Name: "GasCity Bridge", Status: "pass", Detail: detail, Required: false}
}
return doctorCheck{Name: "GasCity Bridge", Status: "warn", Detail: detail, Required: false}
}
func gatherDoctorProductRuntimeChecks() []doctorCheck {
return []doctorCheck{
checkDaemonRuntime(),
checkGasCityProductRuntime(),
checkOpenClawConsumer(),
}
}
func checkGasCityProductRuntime() doctorCheck {
cityPath := ""
if cwd, err := os.Getwd(); err == nil {
cityPath = gcBridgeCityPath(cwd)
}
return checkGasCityProductRuntimeWith(cityPath, exec.Command, exec.LookPath)
}
func checkGasCityProductRuntimeWith(cityPath string, execCommand gcExecFn, lookPath gcLookFn) doctorCheck {
diag := gcBridgeDiagnose(cityPath, execCommand, lookPath, false)
detail := formatGasCityDiagnostic(diag)
if cityPath != "" {
detail += "; city=" + cityPath
}
if diag.APIReachable && diag.ReadinessReady && diag.Ready {
return doctorCheck{Name: "GasCity Product Runtime", Status: "pass", Detail: detail, Required: true}
}
return doctorCheck{Name: "GasCity Product Runtime", Status: "fail", Detail: detail, Required: true}
}
func formatGasCityDiagnostic(diag gcBridgeDiagnostics) string {
parts := []string{
fmt.Sprintf("binary=%t", diag.BinaryAvailable),
fmt.Sprintf("version=%s", doctorValueOrDash(diag.Version)),
fmt.Sprintf("version_ok=%t", diag.VersionOK),
fmt.Sprintf("api=%t", diag.APIReachable),
fmt.Sprintf("ready=%t", diag.ReadinessReady),
}
if diag.FallbackEnabled {
parts = append(parts, "fallback=enabled")
}
if diag.Reason != "" {
parts = append(parts, "reason="+diag.Reason)
}
return strings.Join(parts, "; ")
}
func checkOpenClawConsumer() doctorCheck {
cwd, err := os.Getwd()
if err != nil {
+1 -52
View File
@@ -177,64 +177,13 @@ func TestDoctorRuntimeChecksWarnWhenUnavailable(t *testing.T) {
}
}
func TestDoctorGasCityBridgeCheckUsesDiagnostics(t *testing.T) {
mock := newGCMock()
mock.on("version", gcMockHandler{Stdout: "0.14.0"})
mock.on("status --json", gcMockHandler{Stdout: `{"city":"test","controller":{"running":true,"pid":99},"agents":[],"summary":{"running":0,"stopped":0,"total":0}}`})
check := checkGasCityBridgeWith("", mock.execCommand, mock.lookPathFn)
if check.Name != "GasCity Bridge" || check.Status != "pass" {
t.Fatalf("GasCity check = %#v, want pass", check)
}
for _, want := range []string{"binary=true", "version=0.14.0", "api=true", "ready=true"} {
if !strings.Contains(check.Detail, want) {
t.Fatalf("GasCity detail = %q, want %q", check.Detail, want)
}
}
missing := newGCMock()
missing.binaryAvailable = false
check = checkGasCityBridgeWith("", missing.execCommand, missing.lookPathFn)
if check.Status != "warn" || check.Required {
t.Fatalf("missing GasCity check = %#v, want non-required warning", check)
}
if !strings.Contains(check.Detail, "binary=false") || !strings.Contains(check.Detail, "gc binary not found") {
t.Fatalf("missing GasCity detail = %q, want binary diagnostic", check.Detail)
}
}
func TestDoctorProductRuntimeFailsClosedWithoutGasCityAPI(t *testing.T) {
missing := newGCMock()
missing.binaryAvailable = false
check := checkGasCityProductRuntimeWith("", missing.execCommand, missing.lookPathFn)
if check.Name != "GasCity Product Runtime" || check.Status != "fail" || !check.Required {
t.Fatalf("missing product runtime check = %#v, want required fail", check)
}
unready := newGCMock()
unready.on("version", gcMockHandler{Stdout: "0.14.0"})
unready.on("status --json", gcMockHandler{Stdout: `{"city":"test","controller":{"running":false,"pid":0},"agents":[],"summary":{"running":0,"stopped":0,"total":0}}`})
check = checkGasCityProductRuntimeWith("", unready.execCommand, unready.lookPathFn)
if check.Status != "fail" || !strings.Contains(check.Detail, "api=true") || !strings.Contains(check.Detail, "ready=false") {
t.Fatalf("unready product runtime check = %#v, want API reached but readiness fail", check)
}
ready := newGCMock()
ready.on("version", gcMockHandler{Stdout: "0.14.0"})
ready.on("status --json", gcMockHandler{Stdout: `{"city":"test","controller":{"running":true,"pid":99},"agents":[],"summary":{"running":0,"stopped":0,"total":0}}`})
check = checkGasCityProductRuntimeWith("", ready.execCommand, ready.lookPathFn)
if check.Status != "pass" || !check.Required {
t.Fatalf("ready product runtime check = %#v, want required pass", check)
}
}
func TestGatherDoctorChecksIncludesProductRuntimeSurfaces(t *testing.T) {
checks := gatherDoctorChecks()
names := map[string]bool{}
for _, check := range checks {
names[check.Name] = true
}
for _, name := range []string{"Daemon Runtime", "GasCity Bridge", "OpenClaw Consumer"} {
for _, name := range []string{"Daemon Runtime", "OpenClaw Consumer"} {
if !names[name] {
t.Fatalf("gatherDoctorChecks missing %q in %#v", name, names)
}
-225
View File
@@ -1,225 +0,0 @@
// practices: [microservices, sre]
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/boshu2/agentops/cli/internal/bridge"
)
// gcExecFn is the type for exec.Command-compatible functions.
type gcExecFn func(name string, arg ...string) *exec.Cmd
// gcLookFn is the type for exec.LookPath-compatible functions.
type gcLookFn func(file string) (string, error)
// gcDefaultExec returns exec.Command if fn is nil.
func gcDefaultExec(fn gcExecFn) gcExecFn {
if fn != nil {
return fn
}
return exec.Command
}
// gcDefaultLook returns exec.LookPath if fn is nil.
func gcDefaultLook(fn gcLookFn) gcLookFn {
if fn != nil {
return fn
}
return exec.LookPath
}
// gcMinVersion is the minimum gc version required for bridge compatibility.
const gcMinVersion = bridge.GCMinVersion
// GCStatus is an alias for bridge.GCStatus.
type GCStatus = bridge.GCStatus
// GCController is an alias for bridge.GCController.
type GCController = bridge.GCController
// GCAgentInfo is an alias for bridge.GCAgentInfo.
type GCAgentInfo = bridge.GCAgentInfo
// GCStatusSummary is an alias for bridge.GCStatusSummary.
type GCStatusSummary = bridge.GCStatusSummary
// GCSession is an alias for bridge.GCSession.
type GCSession = bridge.GCSession
// gcBridgeDiagnostics is the structured readiness result for the gc bridge.
type gcBridgeDiagnostics struct {
BinaryAvailable bool
Version string
VersionOK bool
APIReachable bool
ReadinessReady bool
FallbackEnabled bool
Ready bool
Reason string
}
var gcVersionPattern = regexp.MustCompile(`\bv?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?\b`)
// gcBridgeAvailable returns true if the gc binary is on PATH.
func gcBridgeAvailable(lookPath gcLookFn) bool {
_, err := gcDefaultLook(lookPath)("gc")
return err == nil
}
// gcBridgeVersion returns the gc version string.
func gcBridgeVersion(execCommand gcExecFn) (string, error) {
out, err := gcDefaultExec(execCommand)("gc", "version").CombinedOutput()
if err != nil {
return "", fmt.Errorf("gc version: %w%s", err, formatCommandOutputContext(out))
}
version, err := parseGCVersionOutput(out)
if err != nil {
return "", fmt.Errorf("gc version: %w", err)
}
return version, nil
}
func parseGCVersionOutput(output []byte) (string, error) {
text := strings.TrimSpace(string(output))
if text == "" {
return "", fmt.Errorf("empty output")
}
version := gcVersionPattern.FindString(text)
if version == "" {
return "", fmt.Errorf("unexpected output %q", truncateCommandOutput(text))
}
return strings.TrimPrefix(version, "v"), nil
}
func formatCommandOutputContext(output []byte) string {
text := strings.TrimSpace(string(output))
if text == "" {
return ""
}
return fmt.Sprintf(": %s", truncateCommandOutput(text))
}
func truncateCommandOutput(text string) string {
const maxLen = 120
if len(text) <= maxLen {
return text
}
return text[:maxLen] + "..."
}
// gcBridgeCompatible checks if the given version meets the minimum requirement.
func gcBridgeCompatible(version string) bool {
return bridge.GCBridgeCompatible(version)
}
// compareSemver returns -1, 0, or 1 comparing two semver strings.
func compareSemver(a, b string) int {
return bridge.CompareSemver(a, b)
}
// parseSemverParts extracts major, minor, patch integers from a version string.
func parseSemverParts(v string) [3]int {
return bridge.ParseSemverParts(v)
}
// gcBridgeReady checks both binary availability AND controller running.
// Returns (ready, reason).
func gcBridgeReady(cityPath string, execCommand gcExecFn, lookPath gcLookFn) (bool, string) {
diag := gcBridgeDiagnose(cityPath, execCommand, lookPath, false)
return diag.Ready, diag.Reason
}
func gcBridgeDiagnose(cityPath string, execCommand gcExecFn, lookPath gcLookFn, fallbackEnabled bool) gcBridgeDiagnostics {
diag := gcBridgeDiagnostics{FallbackEnabled: fallbackEnabled}
if !gcBridgeAvailable(lookPath) {
diag.Reason = "gc binary not found on PATH"
return diag
}
diag.BinaryAvailable = true
v, err := gcBridgeVersion(execCommand)
if err != nil {
diag.Reason = fmt.Sprintf("gc version check failed: %v", err)
return diag
}
diag.Version = v
if !gcBridgeCompatible(v) {
diag.Reason = fmt.Sprintf("gc version %s below minimum %s", v, gcMinVersion)
return diag
}
diag.VersionOK = true
execFn := gcDefaultExec(execCommand)
args := bridge.GCStatusArgs(cityPath)
out, err := execFn("gc", args...).Output()
if err != nil {
diag.Reason = fmt.Sprintf("gc API unavailable: %v", err)
return diag
}
diag.APIReachable = true
status, err := parseGCStatus(out)
if err != nil {
diag.Reason = fmt.Sprintf("gc API status parse error: %v", err)
return diag
}
if !status.Controller.Running {
diag.Reason = "gc readiness failed: controller not running"
return diag
}
diag.ReadinessReady = true
diag.Ready = true
diag.Reason = "gc bridge ready"
return diag
}
// parseGCStatus parses the JSON output of `gc status --json`.
func parseGCStatus(data []byte) (GCStatus, error) {
return bridge.ParseGCStatus(data)
}
// parseGCSessions parses the JSON output of `gc session list --json`.
func parseGCSessions(data []byte) ([]GCSession, error) {
return bridge.ParseGCSessions(data)
}
// gcNudgeArgs returns the command arguments for `gc session nudge`.
func gcNudgeArgs(agent, message string) []string {
return bridge.GCNudgeArgs(agent, message)
}
// gcSessionNewArgs returns the command arguments for `gc session new`.
func gcSessionNewArgs(template, alias string) []string {
return bridge.GCSessionNewArgs(template, alias)
}
// gcPeekArgs returns the command arguments for `gc session peek`.
func gcPeekArgs(agent string, lines int) []string {
return bridge.GCPeekArgs(agent, lines)
}
// gcEventEmitArgs returns the command arguments for `gc event emit`.
func gcEventEmitArgs(eventType, dataJSON string) []string {
return bridge.GCEventEmitArgs(eventType, dataJSON)
}
// gcBridgeCityPath walks up from cwd looking for city.toml.
// Returns the directory containing city.toml, or empty string if not found.
func gcBridgeCityPath(cwd string) string {
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, "city.toml")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
File diff suppressed because it is too large Load Diff
-92
View File
@@ -1,92 +0,0 @@
// practices: [microservices, sre]
package main
import (
"encoding/json"
"fmt"
"time"
)
// gcEventType constants for ao events in the gc event bus.
const (
GCEventAOPhase = "ao:phase"
GCEventAOGate = "ao:gate"
GCEventAOFailure = "ao:failure"
GCEventAOMetric = "ao:metric"
)
// gcEmitPhaseEvent emits an ao:phase event to the gc event bus.
func gcEmitPhaseEvent(cityPath string, phase int, status, runID string, execCommand gcExecFn, lookPath gcLookFn) error {
data := map[string]any{
"phase": phase,
"status": status,
"run_id": runID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
return gcEmitEvent(cityPath, GCEventAOPhase, data, execCommand, lookPath)
}
// gcEmitGateEvent emits an ao:gate event (pre-mortem, vibe, etc.) to the gc event bus.
func gcEmitGateEvent(cityPath string, gate, verdict, runID string, execCommand gcExecFn, lookPath gcLookFn) error {
data := map[string]any{
"gate": gate,
"verdict": verdict,
"run_id": runID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
return gcEmitEvent(cityPath, GCEventAOGate, data, execCommand, lookPath)
}
// gcEmitFailureEvent emits an ao:failure event to the gc event bus.
func gcEmitFailureEvent(cityPath string, errMsg, runID string, phase int, execCommand gcExecFn, lookPath gcLookFn) error {
data := map[string]any{
"error": errMsg,
"phase": phase,
"run_id": runID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
return gcEmitEvent(cityPath, GCEventAOFailure, data, execCommand, lookPath)
}
// gcEmitMetricEvent emits an ao:metric event to the gc event bus.
func gcEmitMetricEvent(cityPath string, metric string, value float64, runID string, execCommand gcExecFn, lookPath gcLookFn) error {
data := map[string]any{
"metric": metric,
"value": value,
"run_id": runID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
return gcEmitEvent(cityPath, GCEventAOMetric, data, execCommand, lookPath)
}
// gcEmitEvent is the low-level event emitter to the gc event bus.
func gcEmitEvent(cityPath, eventType string, data map[string]any, execCommand gcExecFn, lookPath gcLookFn) error {
if !gcBridgeAvailable(lookPath) {
return nil // silently skip if gc not available
}
dataJSON, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal event data: %w", err)
}
args := gcEventEmitArgs(eventType, string(dataJSON))
if cityPath != "" {
args = append([]string{"--city", cityPath}, args...)
}
cmd := gcDefaultExec(execCommand)("gc", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("gc event emit %s: %w (output: %s)", eventType, err, string(out))
}
return nil
}
// gcEventsAvailable returns true if gc event system is accessible.
func gcEventsAvailable(cityPath string, execCommand gcExecFn, lookPath gcLookFn) bool {
if !gcBridgeAvailable(lookPath) {
return false
}
args := []string{"events", "--help"}
if cityPath != "" {
args = append([]string{"--city", cityPath}, args...)
}
return gcDefaultExec(execCommand)("gc", args...).Run() == nil
}
-370
View File
@@ -1,370 +0,0 @@
// practices: [microservices, sre]
package main
import (
"encoding/json"
"os"
"os/exec"
"strings"
"testing"
)
// =============================================================================
// L1: Unit Tests — constants and arg formatting
// =============================================================================
func TestGCEventConstants(t *testing.T) {
expected := map[string]string{
"GCEventAOPhase": "ao:phase",
"GCEventAOGate": "ao:gate",
"GCEventAOFailure": "ao:failure",
"GCEventAOMetric": "ao:metric",
}
actuals := map[string]string{
"GCEventAOPhase": GCEventAOPhase,
"GCEventAOGate": GCEventAOGate,
"GCEventAOFailure": GCEventAOFailure,
"GCEventAOMetric": GCEventAOMetric,
}
for name, want := range expected {
got := actuals[name]
if got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
}
func TestGCEmitPhaseEvent_ArgsFormat(t *testing.T) {
data := map[string]any{
"phase": 1,
"status": "complete",
"run_id": "test-123",
}
dataJSON, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
args := gcEventEmitArgs(GCEventAOPhase, string(dataJSON))
if args[0] != "event" || args[1] != "emit" || args[2] != "ao:phase" {
t.Errorf("unexpected args prefix: %v", args[:3])
}
if args[3] != "--payload" {
t.Errorf("args[3] = %q, want --data", args[3])
}
// Verify payload is valid JSON
var decoded map[string]any
if err := json.Unmarshal([]byte(args[4]), &decoded); err != nil {
t.Errorf("payload is not valid JSON: %v", err)
}
}
func TestGCEmitGateEvent_ArgsFormat(t *testing.T) {
data := map[string]any{
"gate": "pre-mortem",
"verdict": "PASS",
"run_id": "test-456",
}
dataJSON, _ := json.Marshal(data)
args := gcEventEmitArgs(GCEventAOGate, string(dataJSON))
if args[2] != "ao:gate" {
t.Errorf("event type = %q, want %q", args[2], "ao:gate")
}
var decoded map[string]any
if err := json.Unmarshal([]byte(args[4]), &decoded); err != nil {
t.Errorf("payload not valid JSON: %v", err)
}
if decoded["gate"] != "pre-mortem" {
t.Errorf("gate = %v, want pre-mortem", decoded["gate"])
}
}
func TestGCEmitFailureEvent_ArgsFormat(t *testing.T) {
data := map[string]any{
"error": "phase timeout",
"phase": 2,
"run_id": "test-789",
}
dataJSON, _ := json.Marshal(data)
args := gcEventEmitArgs(GCEventAOFailure, string(dataJSON))
if args[2] != "ao:failure" {
t.Errorf("event type = %q, want %q", args[2], "ao:failure")
}
}
func TestGCEmitMetricEvent_ArgsFormat(t *testing.T) {
data := map[string]any{
"metric": "phase_duration_s",
"value": 42.5,
"run_id": "test-metric",
}
dataJSON, _ := json.Marshal(data)
args := gcEventEmitArgs(GCEventAOMetric, string(dataJSON))
if args[2] != "ao:metric" {
t.Errorf("event type = %q, want %q", args[2], "ao:metric")
}
var decoded map[string]any
if err := json.Unmarshal([]byte(args[4]), &decoded); err != nil {
t.Errorf("payload not valid JSON: %v", err)
}
if decoded["value"] != 42.5 {
t.Errorf("value = %v, want 42.5", decoded["value"])
}
}
// =============================================================================
// L1: Mocked exec tests — gcEmitEvent and gcEventsAvailable
// =============================================================================
func TestGCEmitEvent_NoBinary_SilentNoop(t *testing.T) {
mock := newGCMock()
mock.binaryAvailable = false
mock.install(t)
err := gcEmitEvent("", "ao:test", map[string]any{"test": true}, mock.execCommand, mock.lookPathFn)
if err != nil {
t.Errorf("gcEmitEvent should return nil when gc not available, got: %v", err)
}
if mock.callCount() != 0 {
t.Errorf("expected no gc calls when binary unavailable, got %d", mock.callCount())
}
}
func TestGCEmitEvent_Mocked_Success(t *testing.T) {
mock := newGCMock()
mock.on("event emit ao:phase --payload", gcMockHandler{Stdout: ""})
mock.install(t)
err := gcEmitEvent("", GCEventAOPhase, map[string]any{
"phase": 1,
"status": "started",
}, mock.execCommand, mock.lookPathFn)
if err != nil {
t.Errorf("gcEmitEvent should succeed, got: %v", err)
}
calls := mock.callsMatching("event emit")
if len(calls) != 1 {
t.Errorf("expected 1 event emit call, got %d", len(calls))
}
}
func TestGCEmitEvent_Mocked_WithCityPath(t *testing.T) {
mock := newGCMock()
mock.install(t)
err := gcEmitEvent("/my/city", GCEventAOGate, map[string]any{
"gate": "vibe",
"verdict": "PASS",
}, mock.execCommand, mock.lookPathFn)
if err != nil {
t.Errorf("gcEmitEvent with city path error: %v", err)
}
calls := mock.callsMatching("--city")
if len(calls) == 0 {
t.Error("expected --city flag in event emit call")
}
}
func TestGCEmitEvent_Mocked_CommandFails(t *testing.T) {
mock := newGCMock()
mock.on("event emit ao:failure", gcMockHandler{ExitCode: 1, Stderr: "event bus error"})
mock.install(t)
err := gcEmitEvent("", GCEventAOFailure, map[string]any{"error": "test"}, mock.execCommand, mock.lookPathFn)
if err == nil {
t.Fatal("gcEmitEvent should return error when command fails")
}
if !strings.Contains(err.Error(), "gc event emit") {
t.Errorf("error should mention gc event emit, got: %v", err)
}
}
func TestGCEventsAvailable_NoBinary(t *testing.T) {
mock := newGCMock()
mock.binaryAvailable = false
mock.install(t)
if gcEventsAvailable("", mock.execCommand, mock.lookPathFn) {
t.Error("gcEventsAvailable should return false when gc not available")
}
}
func TestGCEventsAvailable_Mocked_Success(t *testing.T) {
mock := newGCMock()
mock.on("events --help", gcMockHandler{ExitCode: 0})
mock.install(t)
if !gcEventsAvailable("", mock.execCommand, mock.lookPathFn) {
t.Error("gcEventsAvailable should return true when events --help succeeds")
}
}
func TestGCEventsAvailable_Mocked_WithCityPath(t *testing.T) {
mock := newGCMock()
mock.on("events --help", gcMockHandler{ExitCode: 0})
mock.install(t)
if !gcEventsAvailable("/my/city", mock.execCommand, mock.lookPathFn) {
t.Error("gcEventsAvailable with city path should return true")
}
calls := mock.callsMatching("--city")
if len(calls) == 0 {
t.Error("expected --city flag in events --help call")
}
}
func TestGCEventsAvailable_Mocked_CommandFails(t *testing.T) {
mock := newGCMock()
mock.on("events --help", gcMockHandler{ExitCode: 1})
mock.install(t)
if gcEventsAvailable("", mock.execCommand, mock.lookPathFn) {
t.Error("gcEventsAvailable should return false when events --help fails")
}
}
// =============================================================================
// L2: Integration Tests — event emitters through gcEmitEvent
// =============================================================================
func TestGCEmitPhaseEvent_Mocked_FullChain(t *testing.T) {
mock := newGCMock()
mock.install(t)
err := gcEmitPhaseEvent("/city", 3, "complete", "run-abc", mock.execCommand, mock.lookPathFn)
if err != nil {
t.Errorf("gcEmitPhaseEvent error: %v", err)
}
calls := mock.callsMatching("event emit")
if len(calls) != 1 {
t.Fatalf("expected 1 event emit call, got %d", len(calls))
}
full := strings.Join(calls[0].Args, " ")
if !strings.Contains(full, "ao:phase") {
t.Errorf("call should contain ao:phase, got: %s", full)
}
}
func TestGCEmitGateEvent_Mocked_FullChain(t *testing.T) {
mock := newGCMock()
mock.install(t)
err := gcEmitGateEvent("/city", "pre-mortem", "PASS", "run-def", mock.execCommand, mock.lookPathFn)
if err != nil {
t.Errorf("gcEmitGateEvent error: %v", err)
}
calls := mock.callsMatching("ao:gate")
if len(calls) != 1 {
t.Errorf("expected 1 ao:gate call, got %d", len(calls))
}
}
func TestGCEmitFailureEvent_Mocked_FullChain(t *testing.T) {
mock := newGCMock()
mock.install(t)
err := gcEmitFailureEvent("/city", "timeout", "run-ghi", 2, mock.execCommand, mock.lookPathFn)
if err != nil {
t.Errorf("gcEmitFailureEvent error: %v", err)
}
calls := mock.callsMatching("ao:failure")
if len(calls) != 1 {
t.Errorf("expected 1 ao:failure call, got %d", len(calls))
}
}
func TestGCEmitMetricEvent_Mocked_FullChain(t *testing.T) {
mock := newGCMock()
mock.install(t)
err := gcEmitMetricEvent("/city", "duration_s", 123.45, "run-jkl", mock.execCommand, mock.lookPathFn)
if err != nil {
t.Errorf("gcEmitMetricEvent error: %v", err)
}
calls := mock.callsMatching("ao:metric")
if len(calls) != 1 {
t.Errorf("expected 1 ao:metric call, got %d", len(calls))
}
}
func TestGCEmitEvent_Mocked_PayloadContainsTimestamp(t *testing.T) {
mock := newGCMock()
mock.install(t)
// gcEmitPhaseEvent adds a timestamp — verify the JSON payload
// is correctly structured through the chain
err := gcEmitPhaseEvent("", 1, "started", "ts-test", mock.execCommand, mock.lookPathFn)
if err != nil {
t.Fatalf("gcEmitPhaseEvent error: %v", err)
}
calls := mock.callsMatching("event emit")
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
// Find the --data arg and verify it contains timestamp
full := strings.Join(calls[0].Args, " ")
if !strings.Contains(full, "timestamp") {
t.Errorf("event payload should contain timestamp field, got: %s", full)
}
}
// =============================================================================
// L3: Live Integration Tests — real gc binary
// =============================================================================
func TestGCEmitEvent_Live_SilentDegradation(t *testing.T) {
if _, err := exec.LookPath("gc"); err != nil {
// gc not installed: emitEvent should silently no-op
err := gcEmitEvent("", "ao:test", map[string]any{"test": true}, nil, nil)
if err != nil {
t.Errorf("gcEmitEvent should silently no-op when gc unavailable, got: %v", err)
}
return
}
// gc IS installed — emit should either succeed or fail gracefully
cwd, _ := os.Getwd()
cityPath := gcBridgeCityPath(cwd)
if cityPath == "" {
t.Skip("no city.toml found")
}
ready, reason := gcBridgeReady(cityPath, nil, nil)
if !ready {
t.Skipf("gc controller not running: %s", reason)
}
// Controller running — emit a real event
err := gcEmitPhaseEvent(cityPath, 0, "test", "live-test-run", nil, nil)
if err != nil {
t.Errorf("gcEmitPhaseEvent with live controller: %v", err)
}
}
func TestGCEmitAllEventTypes_Live(t *testing.T) {
if _, err := exec.LookPath("gc"); err != nil {
t.Skip("gc not on PATH")
}
cwd, _ := os.Getwd()
cityPath := gcBridgeCityPath(cwd)
if cityPath == "" {
t.Skip("no city.toml found")
}
ready, reason := gcBridgeReady(cityPath, nil, nil)
if !ready {
t.Skipf("gc controller not running: %s", reason)
}
runID := "live-all-events"
if err := gcEmitPhaseEvent(cityPath, 1, "test-started", runID, nil, nil); err != nil {
t.Errorf("phase event: %v", err)
}
if err := gcEmitGateEvent(cityPath, "test-gate", "PASS", runID, nil, nil); err != nil {
t.Errorf("gate event: %v", err)
}
if err := gcEmitFailureEvent(cityPath, "test-error", runID, 1, nil, nil); err != nil {
t.Errorf("failure event: %v", err)
}
if err := gcEmitMetricEvent(cityPath, "test_metric", 99.9, runID, nil, nil); err != nil {
t.Errorf("metric event: %v", err)
}
}
-189
View File
@@ -1,189 +0,0 @@
// practices: [microservices, sre]
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
// gcMock provides injectable exec/lookPath functions for deterministic testing.
// It records all command invocations and returns preconfigured outputs.
type gcMock struct {
mu sync.Mutex
calls []gcMockCall
handlers map[string]gcMockHandler
// If true, lookPath returns success (binary "found")
binaryAvailable bool
}
// gcMockCall records a single invocation of execCommand.
type gcMockCall struct {
Args []string
}
// gcMockHandler defines what a mocked command should return.
type gcMockHandler struct {
Stdout string
Stderr string
ExitCode int
}
// newGCMock creates a mock with gc available by default.
func newGCMock() *gcMock {
return &gcMock{
handlers: make(map[string]gcMockHandler),
binaryAvailable: true,
}
}
// install is a no-op kept for backward compat; tests should use m.execCommand and m.lookPathFn.
func (m *gcMock) install(t *testing.T) {
t.Helper()
// No globals to swap — tests pass m.execCommand / m.lookPathFn directly.
}
// execCommand returns an exec.Command-compatible function that records calls and returns mock output.
func (m *gcMock) execCommand(name string, args ...string) *exec.Cmd {
m.mu.Lock()
m.calls = append(m.calls, gcMockCall{Args: append([]string{name}, args...)})
m.mu.Unlock()
key := m.commandKey(args)
handler, ok := m.handlers[key]
if !ok {
handler = gcMockHandler{ExitCode: 0}
}
if runtime.GOOS != "windows" {
cmd := exec.Command("/bin/sh", "-c", `printf "%s" "$GC_MOCK_STDOUT"; printf "%s" "$GC_MOCK_STDERR" >&2; exit "$GC_MOCK_EXIT"`)
cmd.Env = append(os.Environ(),
fmt.Sprintf("GC_MOCK_EXIT=%d", handler.ExitCode),
"GC_MOCK_STDOUT="+handler.Stdout,
"GC_MOCK_STDERR="+handler.Stderr,
)
return cmd
}
cs := []string{"-test.run=TestGCHelperProcess", "--", fmt.Sprintf("exit=%d", handler.ExitCode), fmt.Sprintf("stdout=%s", handler.Stdout), fmt.Sprintf("stderr=%s", handler.Stderr)}
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = append(os.Environ(), "GO_TEST_HELPER_PROCESS=1")
return cmd
}
// lookPathFn returns a LookPath-compatible function based on binaryAvailable.
func (m *gcMock) lookPathFn(file string) (string, error) {
if m.binaryAvailable && file == "gc" {
return "/usr/local/bin/gc", nil
}
return "", &exec.Error{Name: file, Err: exec.ErrNotFound}
}
// commandKey produces a lookup key from command args (skipping "gc" binary name).
// It also tries prefix matching: "event emit ao:phase" matches args like
// "event emit ao:phase --data {...json...}".
func (m *gcMock) commandKey(args []string) string {
// Strip --city <path> prefix if present
cleaned := args
if len(cleaned) >= 2 && cleaned[0] == "--city" {
cleaned = cleaned[2:]
}
full := strings.Join(cleaned, " ")
// Try exact match first
if _, ok := m.handlers[full]; ok {
return full
}
// Try prefix match (longest first)
bestKey := ""
for key := range m.handlers {
if strings.HasPrefix(full, key) && len(key) > len(bestKey) {
bestKey = key
}
}
if bestKey != "" {
return bestKey
}
return full
}
// on registers a handler for a specific command pattern.
func (m *gcMock) on(argsPattern string, h gcMockHandler) {
m.handlers[argsPattern] = h
}
// callCount returns the number of times any gc command was invoked.
func (m *gcMock) callCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.calls)
}
// callsMatching returns calls whose args contain the given substring.
func (m *gcMock) callsMatching(substr string) []gcMockCall {
m.mu.Lock()
defer m.mu.Unlock()
var matched []gcMockCall
for _, c := range m.calls {
full := strings.Join(c.Args, " ")
if strings.Contains(full, substr) {
matched = append(matched, c)
}
}
return matched
}
// TestGCHelperProcess is the subprocess helper for mocked exec.Command.
// It's invoked by the mocked gcExecCommand, not directly by tests.
func TestGCHelperProcess(t *testing.T) {
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
return
}
args := os.Args
// Find the "--" separator
idx := -1
for i, a := range args {
if a == "--" {
idx = i
break
}
}
if idx < 0 || idx+1 >= len(args) {
os.Exit(0)
}
exitCode := 0
stdout := ""
stderr := ""
for _, arg := range args[idx+1:] {
if strings.HasPrefix(arg, "exit=") {
fmt.Sscanf(arg, "exit=%d", &exitCode)
} else if strings.HasPrefix(arg, "stdout=") {
stdout = strings.TrimPrefix(arg, "stdout=")
} else if strings.HasPrefix(arg, "stderr=") {
stderr = strings.TrimPrefix(arg, "stderr=")
}
}
if stdout != "" {
fmt.Fprint(os.Stdout, stdout)
}
if stderr != "" {
fmt.Fprint(os.Stderr, stderr)
}
os.Exit(exitCode)
}
// setupCityDir creates a temp directory with a city.toml file.
func setupCityDir(t *testing.T, cityName string) string {
t.Helper()
dir := t.TempDir()
content := fmt.Sprintf("[city]\nname = %q\n", cityName)
if err := os.WriteFile(filepath.Join(dir, "city.toml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
return dir
}
+17
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -180,3 +181,19 @@ func newFakeGasCityRPIServer(t *testing.T) *httptest.Server {
}
}))
}
// writeSSEFrame writes a single Server-Sent-Events frame for the fake GasCity
// event stream used by the daemon RPI L3 smoke test.
func writeSSEFrame(t *testing.T, w http.ResponseWriter, id string, event gascity.EventStreamEnvelope) {
t.Helper()
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("marshal SSE event: %v", err)
}
fmt.Fprintf(w, "id: %s\n", id)
fmt.Fprintln(w, "event: event")
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
+2 -20
View File
@@ -109,7 +109,7 @@ Examples:
phasedCmd.Flags().BoolVar(&phasedSwarmFirst, "swarm-first", true, "Default each phase to swarm/agent-team execution; fall back to direct execution if swarm runtime is unavailable")
phasedCmd.Flags().BoolVar(&phasedAutoCleanStale, "auto-clean-stale", false, "Run stale-run cleanup before starting phased execution")
phasedCmd.Flags().DurationVar(&phasedAutoCleanStaleAfter, "auto-clean-stale-after", 24*time.Hour, "Only clean stale runs older than this age when auto-clean is enabled")
phasedCmd.Flags().StringVar(&phasedRuntimeMode, "runtime", "auto", "Phase runtime mode: auto|direct|stream|tmux|gc")
phasedCmd.Flags().StringVar(&phasedRuntimeMode, "runtime", "auto", "Phase runtime mode: auto|direct|stream|tmux")
phasedCmd.Flags().StringVar(&phasedRuntimeCommand, "runtime-cmd", "claude", "Runtime command used for phase prompts (Claude uses '-p'; Codex uses 'exec')")
phasedCmd.Flags().IntVar(&phasedTmuxWorkers, "tmux-workers", 1, "When --runtime tmux, number of worker sessions spawned per phase")
phasedCmd.Flags().BoolVar(&phasedNoDashboard, "no-dashboard", false, "Disable auto-opening the web dashboard")
@@ -123,7 +123,7 @@ Examples:
phasedCmd.Flags().StringVar(&phasedScaffoldDomain, "scaffold-domain", "", "Write a domain-slice manifest template at docs/domains/<name>/manifest.yaml and exit (does NOT run RPI)")
phasedCmd.Flags().BoolVar(&phasedForce, "force", false, "With --scaffold-domain: overwrite an existing manifest")
_ = phasedCmd.RegisterFlagCompletionFunc("from", staticCompletionFunc("discovery", "implementation", "validation", "research", "plan", "pre-mortem", "crank", "vibe", "post-mortem"))
_ = phasedCmd.RegisterFlagCompletionFunc("runtime", staticCompletionFunc("auto", "direct", "stream", "tmux", "gc"))
_ = phasedCmd.RegisterFlagCompletionFunc("runtime", staticCompletionFunc("auto", "direct", "stream", "tmux"))
rpiCmd.AddCommand(phasedCmd)
}
@@ -267,12 +267,6 @@ func preflightOpts(opts *phasedEngineOptions) error {
return err
}
switch opts.RuntimeMode {
case "gc":
return preflightGCRuntimeAvailability(*opts)
case "auto":
if gcExecutorAvailable(opts.WorkingDir, opts.ExecCommand, opts.LookPath) {
return nil
}
case "tmux":
if _, err := defaultLookPath(opts.LookPath)(opts.TmuxCommand); err != nil {
return fmt.Errorf("tmux executable %q not found on PATH (required for runtime=tmux)", opts.TmuxCommand)
@@ -281,18 +275,6 @@ func preflightOpts(opts *phasedEngineOptions) error {
return preflightRuntimeAvailability(opts.RuntimeCommand, opts.LookPath)
}
func preflightGCRuntimeAvailability(opts phasedEngineOptions) error {
cityPath := gcCityPathFromOpts(opts)
if cityPath == "" {
return fmt.Errorf("gc runtime requires city.toml (walk up from %s)", opts.WorkingDir)
}
ready, reason := gcBridgeReady(cityPath, opts.ExecCommand, opts.LookPath)
if !ready {
return fmt.Errorf("gc runtime unavailable: %s", reason)
}
return nil
}
func minPositiveDuration(a, b time.Duration) time.Duration {
return cliRPI.MinPositiveDuration(a, b)
}
+15 -8
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"text/template"
"time"
@@ -14,6 +15,15 @@ import (
cliRPI "github.com/boshu2/agentops/cli/internal/rpi"
)
// execFn is the type for exec.Command-compatible functions. It is an injectable
// dependency point that lets the phased engine and runtime preflight checks be
// tested without spawning real processes.
type execFn func(name string, arg ...string) *exec.Cmd
// lookFn is the type for exec.LookPath-compatible functions. It is an injectable
// dependency point for runtime/binary availability checks (tmux, tracker CLI).
type lookFn func(file string) (string, error)
// phasedEngineOptions captures all configurable parameters for runPhasedEngine.
// This allows the loop and other callers to invoke the phased engine programmatically
// without depending on global cobra flag variables.
@@ -38,12 +48,9 @@ type phasedEngineOptions struct {
BDCommand string
TmuxCommand string
TmuxWorkers int
GCCityPath string // explicit city.toml directory for gc backend; empty = auto-discover
GCCityName string `json:"-"` // API city name override; empty = derive from city path
GasCityClient rpiGasCityClient `json:"-"` // injectable GasCity API client
ExecCommand gcExecFn `json:"-"` // nil = exec.Command; injectable for testing
LookPath gcLookFn `json:"-"` // nil = exec.LookPath; injectable for testing
Mixed bool // opt-in cross-vendor mixed-model execution
ExecCommand execFn `json:"-"` // nil = exec.Command; injectable for testing
LookPath lookFn `json:"-"` // nil = exec.LookPath; injectable for testing
Mixed bool // opt-in cross-vendor mixed-model execution
NoBudget bool
BudgetSpec string
WorkingDir string `json:"-"` // runtime-only; base directory for repo/worktree resolution
@@ -181,10 +188,10 @@ func effectiveTmuxCommand(command string) string {
func validateRuntimeMode(mode string) error {
switch normalizeRuntimeMode(mode) {
case "auto", "direct", "stream", "tmux", "gc":
case "auto", "direct", "stream", "tmux":
return nil
default:
return fmt.Errorf("invalid runtime %q (valid: auto|direct|stream|tmux|gc)", mode)
return fmt.Errorf("invalid runtime %q (valid: auto|direct|stream|tmux)", mode)
}
}
+4 -6
View File
@@ -35,12 +35,10 @@ const (
)
// opaqueRuntimeModes are runtime modes whose executor sessions the phased
// orchestrator cannot instrument at all. Gas City sessions run out-of-process
// on a remote substrate that does not surface per-tool-call telemetry back to
// `ao`, so reads inside them are unobservable.
var opaqueRuntimeModes = map[string]bool{
"gc": true,
}
// orchestrator cannot instrument at all — reads inside them are unobservable,
// so the read fence resolves to `unavailable`. No current runtime is opaque;
// this map is the extension point for any future out-of-process substrate.
var opaqueRuntimeModes = map[string]bool{}
// domainEnforcementDecision is the resolved enforcement posture for a run,
// including the reason so JSON evidence and stdout are self-explanatory.
+12 -12
View File
@@ -7,15 +7,15 @@ import (
)
// TestRPIPhasedDomainEnforce_ResolveEnforcement is the core table test: under
// the hookless model each runtime resolves to exactly one of the two honest
// modes — `audited` for observable runtimes, `unavailable` for opaque ones.
// the hookless model every current runtime resolves to `audited` (observable,
// visible-evidence-only). The `unavailable` mode remains in the schema for any
// future opaque (out-of-process) substrate but no current runtime produces it.
func TestRPIPhasedDomainEnforce_ResolveEnforcement(t *testing.T) {
cases := []struct {
name string
runtimeMode string
wantMode domainEnforcementMode
}{
{"gc runtime is always unavailable", "gc", domainEnforcementUnavailable},
{"direct runtime is audited", "direct", domainEnforcementAudited},
{"stream runtime is audited", "stream", domainEnforcementAudited},
{"tmux runtime is audited", "tmux", domainEnforcementAudited},
@@ -81,8 +81,8 @@ func TestRPIPhasedDomainEnforce_GateFailed(t *testing.T) {
// TestRPIPhasedDomainEnforce_AuditDistinguishesModes verifies the persisted JSON
// evidence carries each resolvable enforcement mode verbatim. Under the hookless
// model the resolver yields `audited` for observable runtimes and `unavailable`
// for opaque (Gas City) runtimes; neither hard-fails the gate.
// model the resolver yields `audited` for observable runtimes; the `unavailable`
// mode remains in the schema for any future opaque substrate. Neither hard-fails.
func TestRPIPhasedDomainEnforce_AuditDistinguishesModes(t *testing.T) {
evidence := auditTestEvidence()
deniedRef := []outOfDomainRef{
@@ -107,16 +107,16 @@ func TestRPIPhasedDomainEnforce_AuditDistinguishesModes(t *testing.T) {
t.Errorf("hookless audited run must not record an EnforcementHookSource, got %q", audited.EnforcementHookSource)
}
// --- unavailable: opaque gc path warns, no enforcement claim ---
gcState := &phasedState{
DomainManifest: evidence,
Opts: phasedEngineOptions{RuntimeMode: "gc"},
// --- unavailable: schema-level opaque-substrate decision, no enforcement claim ---
unavailableDecision := domainEnforcementDecision{
Mode: domainEnforcementUnavailable,
Reason: "runtime runs agent sessions out-of-process; reads are unobservable",
RuntimeMode: "opaque",
}
gcDecision := resolveDomainEnforcement(t.TempDir(), gcState)
unavailable := buildDomainScopeAuditWithEnforcement(
"run-gc", evidence, deniedRef, []string{"phase-result.json artifacts"}, gcDecision)
"run-opaque", evidence, deniedRef, []string{"phase-result.json artifacts"}, unavailableDecision)
if unavailable.Enforcement != "unavailable" {
t.Fatalf("gc run Enforcement = %q, want unavailable", unavailable.Enforcement)
t.Fatalf("opaque run Enforcement = %q, want unavailable", unavailable.Enforcement)
}
if unavailable.GateFailed {
t.Error("unavailable run must NOT hard-fail the gate — no enforcement claim is made")
-494
View File
@@ -1,494 +0,0 @@
// practices: [agile-manifesto, dora-metrics]
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/boshu2/agentops/cli/internal/gascity"
cliRPI "github.com/boshu2/agentops/cli/internal/rpi"
)
type rpiGasCityClient interface {
CityReadiness(context.Context, string) (gascity.ReadinessResponse, error)
CreateSession(context.Context, string, gascity.SessionCreateRequest) (gascity.Session, gascity.ResponseMeta, error)
SubmitSession(context.Context, string, string, gascity.SessionSubmitRequest) (gascity.SessionSubmitResponse, gascity.ResponseMeta, error)
GetSession(context.Context, string, string, gascity.SessionGetOptions) (gascity.Session, gascity.ResponseMeta, error)
SessionTranscript(context.Context, string, string, gascity.TranscriptOptions) (gascity.TranscriptResponse, gascity.ResponseMeta, error)
ListCityEvents(context.Context, string, gascity.EventListParams) (gascity.EventListResponse, gascity.ResponseMeta, error)
StreamCityEvents(context.Context, string, gascity.EventStreamOptions) (rpiGasCityEventStream, gascity.ResponseMeta, error)
EmitCityEvent(context.Context, string, gascity.EventEmitRequest) (gascity.EventEmitResponse, gascity.ResponseMeta, error)
}
type rpiGasCityEventStream interface {
NextEvent() (gascity.EventStreamFrame, error)
Close() error
}
type rpiGasCityAPIAdapter struct {
client *gascity.Client
}
func (a *rpiGasCityAPIAdapter) CityReadiness(ctx context.Context, cityName string) (gascity.ReadinessResponse, error) {
return a.client.CityReadiness(ctx, cityName)
}
func (a *rpiGasCityAPIAdapter) CreateSession(ctx context.Context, cityName string, req gascity.SessionCreateRequest) (gascity.Session, gascity.ResponseMeta, error) {
return a.client.CreateSession(ctx, cityName, req)
}
func (a *rpiGasCityAPIAdapter) SubmitSession(ctx context.Context, cityName string, id string, req gascity.SessionSubmitRequest) (gascity.SessionSubmitResponse, gascity.ResponseMeta, error) {
return a.client.SubmitSession(ctx, cityName, id, req)
}
func (a *rpiGasCityAPIAdapter) GetSession(ctx context.Context, cityName string, id string, opts gascity.SessionGetOptions) (gascity.Session, gascity.ResponseMeta, error) {
return a.client.GetSession(ctx, cityName, id, opts)
}
func (a *rpiGasCityAPIAdapter) SessionTranscript(ctx context.Context, cityName string, id string, opts gascity.TranscriptOptions) (gascity.TranscriptResponse, gascity.ResponseMeta, error) {
return a.client.SessionTranscript(ctx, cityName, id, opts)
}
func (a *rpiGasCityAPIAdapter) ListCityEvents(ctx context.Context, cityName string, params gascity.EventListParams) (gascity.EventListResponse, gascity.ResponseMeta, error) {
return a.client.ListCityEvents(ctx, cityName, params)
}
func (a *rpiGasCityAPIAdapter) StreamCityEvents(ctx context.Context, cityName string, opts gascity.EventStreamOptions) (rpiGasCityEventStream, gascity.ResponseMeta, error) {
return a.client.StreamCityEvents(ctx, cityName, opts)
}
func (a *rpiGasCityAPIAdapter) EmitCityEvent(ctx context.Context, cityName string, req gascity.EventEmitRequest) (gascity.EventEmitResponse, gascity.ResponseMeta, error) {
return a.client.EmitCityEvent(ctx, cityName, req)
}
type rpiGasCityPhaseRecord struct {
CityName string
SessionAlias string
SessionID string
CreateRequestID string
SubmitRequestID string
EventStreamRequestID string
LastEventID string
StartedEventSeen bool
TerminalStatus string
EvidencePath string
}
// gcExecutor implements PhaseExecutor using Gas City's gc CLI for session management.
// It starts a gc session with the phase prompt via `gc session nudge`, monitors
// progress via `gc session peek`, and emits ao:phase events to the gc event bus.
type gcExecutor struct {
cityPath string // path to city.toml directory; empty = auto-discover
phaseTimeout time.Duration // max time per phase
pollInterval time.Duration // how often to check session status
execCommand gcExecFn // nil = exec.Command
lookPath gcLookFn // nil = exec.LookPath
apiClient rpiGasCityClient
apiCityName string
apiLastPhase rpiGasCityPhaseRecord
}
func (g *gcExecutor) Name() string { return "gc" }
func (g *gcExecutor) backendMode() string {
if _, ok := g.gasCityClient(); ok {
return "gc-api"
}
return "gc-cli-fallback"
}
func (g *gcExecutor) gasCityClient() (rpiGasCityClient, bool) {
if g == nil || g.apiClient == nil {
return nil, false
}
return g.apiClient, true
}
func (g *gcExecutor) Execute(ctx context.Context, prompt, cwd, runID string, phaseNum int) error {
cityPath := g.resolveCityPath(cwd)
if cityPath == "" {
return fmt.Errorf("gc executor: no city.toml found (walk up from %s)", cwd)
}
if client, ok := g.gasCityClient(); ok {
return g.executeAPISession(ctx, client, cityPath, cwd, prompt, runID, phaseNum)
}
ready, reason := gcBridgeReady(cityPath, g.execCommand, g.lookPath)
if !ready {
return fmt.Errorf("gc executor: not ready: %s", reason)
}
_ = gcEmitPhaseEvent(cityPath, phaseNum, "started", runID, g.execCommand, g.lookPath)
sessionAlias := fmt.Sprintf("rpi-%s-p%d", runID, phaseNum)
if err := gcRunCommand(g.execCommand, cityPath, gcSessionNewArgs("worker", sessionAlias)...); err != nil {
return fmt.Errorf("gc executor: create session %q: %w", sessionAlias, err)
}
if err := gcRunCommand(g.execCommand, cityPath, gcNudgeArgs(sessionAlias, prompt)...); err != nil {
return fmt.Errorf("gc executor: nudge session %q: %w", sessionAlias, err)
}
return g.pollSessionCompletion(ctx, cityPath, sessionAlias, runID, phaseNum)
}
func (g *gcExecutor) executeAPISession(
ctx context.Context,
client rpiGasCityClient,
cityPath string,
cwd string,
prompt string,
runID string,
phaseNum int,
) error {
cityName := g.resolveAPICityName(cityPath)
if cityName == "" {
return fmt.Errorf("gc executor: api city name is required")
}
ready, err := client.CityReadiness(ctx, cityName)
if err != nil {
return fmt.Errorf("gc executor: api city readiness for %q: %w", cityName, err)
}
if !ready.Ready {
return fmt.Errorf("gc executor: api city %q not ready: %s", cityName, ready.Status)
}
sessionAlias := fmt.Sprintf("rpi-%s-p%d", runID, phaseNum)
session, createMeta, err := client.CreateSession(ctx, cityName, gascity.SessionCreateRequest{
Kind: "agent",
Name: "worker",
Alias: sessionAlias,
Async: true,
})
if err != nil {
return fmt.Errorf("gc executor: api create session %q: %w", sessionAlias, err)
}
sessionID := strings.TrimSpace(session.ID)
if sessionID == "" {
return fmt.Errorf("gc executor: api create session %q returned empty session ID", sessionAlias)
}
g.apiLastPhase = rpiGasCityPhaseRecord{
CityName: cityName,
SessionAlias: sessionAlias,
SessionID: sessionID,
CreateRequestID: createMeta.RequestID,
}
_, submitMeta, err := client.SubmitSession(ctx, cityName, sessionID, gascity.SessionSubmitRequest{
Message: prompt,
Intent: "follow_up",
})
if err != nil {
return fmt.Errorf("gc executor: api submit session %q: %w", sessionID, err)
}
g.apiLastPhase.SubmitRequestID = submitMeta.RequestID
return g.waitAPIEventCompletion(ctx, client, cityName, sessionID, cwd, runID, phaseNum)
}
func (g *gcExecutor) waitAPIEventCompletion(
ctx context.Context,
client rpiGasCityClient,
cityName string,
sessionID string,
cwd string,
runID string,
phaseNum int,
) error {
timeout := g.phaseTimeout
if timeout == 0 {
timeout = 90 * time.Minute
}
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
stream, meta, err := client.StreamCityEvents(waitCtx, cityName, gascity.EventStreamOptions{})
if err != nil {
return fmt.Errorf("gc executor: api event stream for %q: %w", sessionID, err)
}
g.apiLastPhase.EventStreamRequestID = meta.RequestID
defer func() { _ = stream.Close() }()
for {
frame, err := stream.NextEvent()
if err != nil {
if waitCtx.Err() != nil {
return fmt.Errorf("gc executor: api event wait for %q: %w", sessionID, waitCtx.Err())
}
return fmt.Errorf("gc executor: api event stream ended before terminal state for %q: %w", sessionID, err)
}
if cursor := gascity.CursorFromFrame(frame); cursor != "" {
g.apiLastPhase.LastEventID = cursor
}
event := frame.CityEvent
if event == nil || !apiEventMatchesPhase(event, g.apiLastPhase) {
continue
}
if apiEventLooksStarted(event.Type, event.Payload) {
g.apiLastPhase.StartedEventSeen = true
}
classification := gascity.ClassifyTerminalState(gascity.TerminalStateInput{
EventType: event.Type,
EventPayload: event.Payload,
})
if !classification.Terminal {
continue
}
g.apiLastPhase.TerminalStatus = classification.Status
if classification.Status == gascity.TerminalStatusCompleted && !classification.Degraded {
return g.captureAPITerminalEvidence(waitCtx, client, cityName, sessionID, cwd, runID, phaseNum)
}
if classification.Reason != "" {
return fmt.Errorf("gc executor: api session %q terminal %s: %s", sessionID, classification.Status, classification.Reason)
}
return fmt.Errorf("gc executor: api session %q terminal %s", sessionID, classification.Status)
}
}
func (g *gcExecutor) captureAPITerminalEvidence(
ctx context.Context,
client rpiGasCityClient,
cityName string,
sessionID string,
cwd string,
runID string,
phaseNum int,
) error {
transcript, meta, err := client.SessionTranscript(ctx, cityName, sessionID, gascity.TranscriptOptions{
Format: "conversation",
})
if err != nil {
return fmt.Errorf("gc executor: api transcript for %q: %w", sessionID, err)
}
requestIDs := map[string]string{}
addRequestID := func(key, value string) {
if strings.TrimSpace(value) != "" {
requestIDs[key] = value
}
}
addRequestID("create", g.apiLastPhase.CreateRequestID)
addRequestID("submit", g.apiLastPhase.SubmitRequestID)
addRequestID("stream", g.apiLastPhase.EventStreamRequestID)
addRequestID("transcript", meta.RequestID)
artifacts := make([]cliRPI.GasCityTranscriptArtifact, 0, len(transcript.Artifacts))
for _, artifact := range transcript.Artifacts {
artifacts = append(artifacts, cliRPI.GasCityTranscriptArtifact{
Path: artifact.Path,
Kind: artifact.Kind,
})
}
path, err := cliRPI.WriteGasCityPhaseEvidence(cwd, cliRPI.GasCityPhaseEvidence{
RunID: runID,
Phase: phaseNum,
PhaseName: cliRPI.PhaseNameForNumber(phaseNum),
CityName: cityName,
SessionID: sessionID,
SessionAlias: g.apiLastPhase.SessionAlias,
Status: g.apiLastPhase.TerminalStatus,
EventCursor: g.apiLastPhase.LastEventID,
RequestIDs: requestIDs,
TranscriptID: firstNonEmpty(transcript.ID, transcript.SessionID, sessionID),
TranscriptFormat: transcript.Format,
TranscriptTurnCount: len(transcript.Turns),
TranscriptMsgCount: len(transcript.Messages),
TranscriptArtifacts: artifacts,
TranscriptCapturedAt: time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
return err
}
g.apiLastPhase.EvidencePath = path
return nil
}
// resolveCityPath returns the city path from the executor config or discovers it.
func (g *gcExecutor) resolveCityPath(cwd string) string {
if g.cityPath != "" {
return g.cityPath
}
return gcBridgeCityPath(cwd)
}
func (g *gcExecutor) resolveAPICityName(cityPath string) string {
if g.apiCityName != "" {
return strings.TrimSpace(g.apiCityName)
}
if cityPath == "" {
return ""
}
return strings.TrimSpace(filepath.Base(cityPath))
}
func apiEventMatchesPhase(event *gascity.EventStreamEnvelope, phase rpiGasCityPhaseRecord) bool {
if event == nil {
return false
}
for _, candidate := range []string{event.Subject, apiEventPayloadString(event.Payload, "session_id"), apiEventPayloadString(event.Payload, "sessionId"), apiEventPayloadString(event.Payload, "alias")} {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
switch candidate {
case phase.SessionID, phase.SessionAlias:
return true
}
}
return false
}
func apiEventLooksStarted(eventType string, payload map[string]any) bool {
eventType = strings.ToLower(strings.TrimSpace(eventType))
status := strings.ToLower(strings.TrimSpace(apiEventPayloadString(payload, "status")))
return strings.Contains(eventType, ".started") ||
strings.Contains(eventType, ".created") ||
status == "running" ||
status == "started"
}
func apiEventPayloadString(payload map[string]any, key string) string {
value, ok := payload[key]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
return typed
default:
return fmt.Sprint(typed)
}
}
// pollSessionCompletion blocks until the gc session finishes, is cancelled, or times out.
func (g *gcExecutor) pollSessionCompletion(ctx context.Context, cityPath, sessionAlias, runID string, phaseNum int) error {
pollInterval := g.pollInterval
if pollInterval == 0 {
pollInterval = 10 * time.Second
}
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
timeout := g.phaseTimeout
if timeout == 0 {
timeout = 90 * time.Minute
}
deadline := time.After(timeout)
for {
select {
case <-ctx.Done():
_ = gcEmitPhaseEvent(cityPath, phaseNum, "cancelled", runID, g.execCommand, g.lookPath)
return ctx.Err()
case <-deadline:
return fmt.Errorf("gc executor: phase %d timed out after %v", phaseNum, timeout)
case <-ticker.C:
done, err := g.checkSessionDone(cityPath, sessionAlias)
if err != nil {
var lostErr *cliRPI.ProviderSessionLostError
if errors.As(err, &lostErr) {
_ = gcEmitPhaseEvent(cityPath, phaseNum, cliRPI.ProviderSessionLost, runID, g.execCommand, g.lookPath)
return err
}
continue // transient error, retry on next tick
}
if done {
_ = gcEmitPhaseEvent(cityPath, phaseNum, "complete", runID, g.execCommand, g.lookPath)
return nil
}
}
}
}
// checkSessionDone returns true if the session is closed/completed.
func (g *gcExecutor) checkSessionDone(cityPath, sessionAlias string) (bool, error) {
out, err := gcDefaultExec(g.execCommand)("gc", "--city", cityPath, "session", "list", "--json").Output()
if err != nil {
return false, fmt.Errorf("gc session list: %w", err)
}
sessions, err := parseGCSessions(out)
if err != nil {
return false, fmt.Errorf("parse sessions: %w", err)
}
for _, s := range sessions {
if s.Alias == sessionAlias {
return gcSessionDone(s), nil
}
}
return false, &cliRPI.ProviderSessionLostError{SessionAlias: sessionAlias}
}
func gcSessionDone(s GCSession) bool {
if s.Closed {
return true
}
switch strings.ToLower(strings.TrimSpace(s.State)) {
case "closed", "completed", "asleep", "suspended", "drained", "archived", "stopped":
return true
default:
return false
}
}
// gcRunCommand runs a gc CLI command with optional city path prefix.
func gcRunCommand(execCommand gcExecFn, cityPath string, args ...string) error {
if cityPath != "" {
// Check if --city is already in args
hasCity := false
for _, a := range args {
if a == "--city" {
hasCity = true
break
}
}
if !hasCity {
args = append([]string{"--city", cityPath}, args...)
}
}
cmd := gcDefaultExec(execCommand)("gc", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// gcExecutorAvailable returns true if gc bridge is ready for use as a phase executor.
// This is used by selectExecutorFromCaps to determine if the gc backend should be offered.
func gcExecutorAvailable(cwd string, execCommand gcExecFn, lookPath gcLookFn) bool {
ready, _ := gcExecutorAvailability(cwd, execCommand, lookPath)
return ready
}
func gcExecutorAvailability(cwd string, execCommand gcExecFn, lookPath gcLookFn) (bool, string) {
cityPath := gcBridgeCityPath(cwd)
if cityPath == "" {
return false, fmt.Sprintf("no city.toml found (walk up from %s)", cwd)
}
ready, reason := gcBridgeReady(cityPath, execCommand, lookPath)
if ready {
return true, "gc bridge ready"
}
return false, reason
}
func gcExecutorSelectionReason(prefix string, client rpiGasCityClient, degradedReason string) string {
mode := "gc-cli-fallback"
if client != nil {
mode = "gc-api"
}
if strings.TrimSpace(degradedReason) != "" {
return fmt.Sprintf("%s backend=%s degraded=%q", prefix, mode, degradedReason)
}
return fmt.Sprintf("%s backend=%s", prefix, mode)
}
// gcCityPathFromOpts extracts the city path from opts or discovers it from cwd.
func gcCityPathFromOpts(opts phasedEngineOptions) string {
if p := strings.TrimSpace(opts.GCCityPath); p != "" {
return p
}
return gcBridgeCityPath(opts.WorkingDir)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -23,7 +23,7 @@ func isIssueIDLike(goal string) bool {
return rpiIssueIDPattern.MatchString(strings.TrimSpace(goal))
}
func preflightRuntimeAvailability(runtimeCommand string, lookPathFn gcLookFn) error {
func preflightRuntimeAvailability(runtimeCommand string, lookPathFn lookFn) error {
if GetDryRun() {
return nil
}
+5 -30
View File
@@ -174,8 +174,8 @@ func probeBackendCapabilities(liveStatus bool, runtimeMode string) backendCapabi
// Selection order (first match wins):
// 1. runtime=stream — always stream
// 2. runtime=direct — always direct
// 3. runtime=gc Gas City session management
// 4. runtime=auto — stream when live-status enabled, otherwise direct
// 3. runtime=tmuxtmux-multiplexed worker sessions
// 4. runtime=auto — stream
func selectExecutorFromCaps(caps backendCapabilities, statusPath string, allPhases []PhaseProgress, opts phasedEngineOptions) (PhaseExecutor, string) {
stdWriter := opts.StdoutWriter
if stdWriter == nil {
@@ -208,29 +208,7 @@ func selectExecutorFromCaps(caps backendCapabilities, statusPath string, allPhas
pollInterval: 5 * time.Second,
workerCount: opts.TmuxWorkers,
}, "runtime=tmux"
case "gc":
return &gcExecutor{
cityPath: gcCityPathFromOpts(opts),
phaseTimeout: opts.PhaseTimeout,
pollInterval: 10 * time.Second,
execCommand: opts.ExecCommand,
lookPath: opts.LookPath,
apiClient: opts.GasCityClient,
apiCityName: opts.GCCityName,
}, gcExecutorSelectionReason("runtime=gc", opts.GasCityClient, "")
default: // auto — prefer gc when available, fall back to stream
gcReady, gcReason := gcExecutorAvailability(opts.WorkingDir, opts.ExecCommand, opts.LookPath)
if gcReady {
return &gcExecutor{
cityPath: gcCityPathFromOpts(opts),
phaseTimeout: opts.PhaseTimeout,
pollInterval: 10 * time.Second,
execCommand: opts.ExecCommand,
lookPath: opts.LookPath,
apiClient: opts.GasCityClient,
apiCityName: opts.GCCityName,
}, gcExecutorSelectionReason("runtime=auto", opts.GasCityClient, "")
}
default: // auto — stream
return &streamExecutor{
runtimeCommand: opts.RuntimeCommand,
statusPath: statusPath,
@@ -240,7 +218,7 @@ func selectExecutorFromCaps(caps backendCapabilities, statusPath string, allPhas
streamStartupTimeout: opts.StreamStartupTimeout,
stallCheckInterval: opts.StallCheckInterval,
stdoutWriter: stdWriter,
}, fmt.Sprintf("runtime=auto backend=stream gc-degraded=%q", gcReason)
}, "runtime=auto backend=stream"
}
}
@@ -261,9 +239,6 @@ func selectExecutorWithLog(statusPath string, allPhases []PhaseProgress, logPath
caps := probeBackendCapabilities(liveStatus, opts.RuntimeMode)
executor, reason := selectExecutorFromCaps(caps, statusPath, allPhases, opts)
displayBackend := executor.Name()
if gcExec, ok := executor.(*gcExecutor); ok {
displayBackend = gcExec.backendMode()
}
msg := fmt.Sprintf("backend=%s reason=%q", displayBackend, reason)
fmt.Printf("Executor backend: %s (%s)\n", displayBackend, reason)
if logPath != "" {
@@ -590,7 +565,7 @@ func cleanEnvNoClaude() []string {
// --- Runtime process helpers ---
// defaultLookPath returns exec.LookPath if fn is nil.
func defaultLookPath(fn gcLookFn) gcLookFn {
func defaultLookPath(fn lookFn) lookFn {
if fn != nil {
return fn
}
+1 -1
View File
@@ -20,7 +20,7 @@ type trackerHealth struct {
Error string `json:"error,omitempty"`
}
func detectTrackerHealth(command string, lookPathFn gcLookFn) trackerHealth {
func detectTrackerHealth(command string, lookPathFn lookFn) trackerHealth {
command = effectiveBDCommand(command)
executable, _ := splitRuntimeCommand(command)
if executable == "" {
+1 -1
View File
@@ -2868,7 +2868,7 @@ ao rpi phased <goal> [flags]
--no-test-first Opt out of strict-quality spec-first execution (do not pass --test-first to /crank)
--no-worktree Disable worktree isolation (run in current directory)
--phase-timeout duration Maximum wall-clock runtime per phase (0 disables timeout) (default 1h30m0s)
--runtime string Phase runtime mode: auto|direct|stream|tmux|gc (default "auto")
--runtime string Phase runtime mode: auto|direct|stream|tmux (default "auto")
--runtime-cmd string Runtime command used for phase prompts (Claude uses '-p'; Codex uses 'exec') (default "claude")
--scaffold-domain string Write a domain-slice manifest template at docs/domains/<name>/manifest.yaml and exit (does NOT run RPI)
--stall-timeout duration Maximum time without progress before declaring stall (0 disables) (default 10m0s)
+5 -5
View File
@@ -51,10 +51,10 @@ is the only place that speaks GasCity-over-HTTP from inside `ao`.
## Cross-references
- `cli/cmd/ao/gc_bridge.go`, `gc_events.go`, `rpi_phased_gc.go` — CLI bridge
that holds `Client` instances. CLAUDE.md flags these as the live wiring;
deprecated tmux-mode files (`rpi_phased_tmux.go`, `rpi_loop_supervisor.go`,
etc.) must NOT be revived.
- `cli/internal/daemon/` — agentopsd daemon owns lifecycle and may invoke
this client.
this client (`daemonpkg.GasCityClientAdapter`, `internal/agentworker`). This
is the remaining live consumer of the GasCity client.
- The former `cli/cmd/ao/gc_bridge.go`, `gc_events.go`, `rpi_phased_gc.go` CLI
bridge was removed in soc-2rtm0 (wave 2); `ao rpi phased` no longer offers a
`gc` runtime backend.
- External: GasCity supervisor (Go service, not in this repo).
+2 -14
View File
@@ -1,13 +1,13 @@
{
"schema_version": 1,
"generated_at": "2026-05-24T16:19:51Z",
"generated_at": "2026-05-24T16:42:59Z",
"summary": {
"skills": 80,
"hooks": 0,
"knowledge_stores": 5,
"job_types": 14,
"eval_files": 57,
"cli_commands": 174
"cli_commands": 171
},
"surfaces": {
"skills": [
@@ -1088,14 +1088,6 @@
"name": "gate_cmd",
"path": "cli/cmd/ao/gate_cmd.go"
},
{
"name": "gc_bridge",
"path": "cli/cmd/ao/gc_bridge.go"
},
{
"name": "gc_events",
"path": "cli/cmd/ao/gc_events.go"
},
{
"name": "goals_add",
"path": "cli/cmd/ao/goals_add.go"
@@ -1416,10 +1408,6 @@
"name": "rpi_phased_discovery",
"path": "cli/cmd/ao/rpi_phased_discovery.go"
},
{
"name": "rpi_phased_gc",
"path": "cli/cmd/ao/rpi_phased_gc.go"
},
{
"name": "rpi_phased_processing",
"path": "cli/cmd/ao/rpi_phased_processing.go"