mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=tmux — tmux-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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user