Skip to main content

Autonomous loop (T4) — dev go-live wiring-completeness audit

FieldValue
Issue#1422 (T4, ADR-0019 adopt-and-wrap)
LLDdocs/lld/LLD-autonomous-loop-langgraph.md
HLDdocs/hld/HLD-autonomous-loop-langgraph.md (founder-approved)
ScopeConfirm EXECUTION_MODE_AUTONOMOUS is wired end-to-end before the first live dev run
Authorbackend-sme

1. Enablement status — ALREADY LIVE on main

The dev/beta-dev flip is already mergeddocker-compose.dev.yml ships ENABLE_AUTONOMOUS_LOOP: "true" for agent-orchestrator (line ~512) and WORKER_MCP_STUB_MODE: "false" (real governed tools) via PR #1460 (commit c65d1b73). No further compose change is required for this go-live.

  • Prod/beta untouched. cmd/agent-orchestrator/config.go:265 keeps the envBool("ENABLE_AUTONOMOUS_LOOP", false) default; the base configmap omits the key, so prod stays off. Prod-enable remains a separate founder/deploy trigger.
  • No T4-5 knob env vars exist to set. config.go reads only ENABLE_AUTONOMOUS_LOOP. StallWindow / ReplanCap / MaxLoops / compaction threshold come from internal/contextengine/knobsconfig constants via autoloop.DefaultConfig() — there is no env override surface, so the dev run uses the built-in defaults by design (StallWindow=3, ReplanCap=4, MaxLoops=32, autonomous compaction threshold=6).
  • Additive guarantee. The flag only makes EXECUTION_MODE_AUTONOMOUS available; the relay path in SendMessage is byte-for-byte unchanged for every other execution mode (lifecycle_server.go:633 branches on autoloopEnabled && execution_mode==AUTONOMOUS; the else falls through to the existing relay ExecuteStep at :638).

2. Per-point wiring audit (file:line)

(a) SendMessage drives the PlanLoop — WIRED ✅

lifecycle_server.go:633 — inside the dial+probe attempt closure, after the worker is dialed and provider keys are fetched, the autonomous branch calls runAutonomous (:771), which builds a StepDriver over the existing relayWorkerEvent (:799), merges the interrupter/observer/re-pinner seams (:808-827), constructs autoloop.NewPlanLoop (:829), and calls loop.Run(...) (:847). FinalResponse / Steps / ClientClosed flow back into the SAME pointers the terminal message_completed audit reads — the autonomous turn is audited through the identical path. End to end intact.

(b) PLAN GENERATION — PARTIAL: degrades to single-todo relay-equivalent ⚠️ (predicted, by design)

This is the most important finding. There is no PLAN-result parser yet.

  • loop.go:67 sends step_goal="decompose goal into todo plan" on the first iteration, and the worker consumes step_goal (langgraph_executor.py:280).
  • BUT reflect() (loop.go:427-431) never parses the worker's decompose output into Plan.Todos. The code comment is explicit: "the worker is expected to have populated todos via a future PLAN-result parser (T4-3 open item 4 / T4-6). Until that parser lands, an undecomposed plan stays a single todo and the loop pursues the goal directly."
  • There is NO empty-plan no-op. The empty-plan guard (terminator.go:122) prevents a false goal-complete; it does not stop the run. A fresh run seeds NewSingleTodoPlan (plan.go:168) so len(Todos)==1 always — the loop pursues the goal as one todo and terminates on goal-complete (model done-marker), stall, or MaxLoops.

Net effect for the first live run: it will NOT immediately terminate or no-op. It runs as a multi-cycle single-todo pursuit (effectively the relay turn repeated up to the budget), terminating when the model emits a done-marker (_TODO_DONE_MARKERS, langgraph_executor.py:487) → goal-complete, or at MaxLoops=32, or on stall. Multi-step decomposition is not active. Flag for the next LLD (PLAN-result parser).

(c) StepDriver → worker step_goal → StepProgress → Terminator — WIRED ✅

  • StepDriver sets ExecutionMode=AUTONOMOUS + step_goal on every ExecuteStep (driver.go:134-143).
  • Worker consumes step_goal (langgraph_executor.py:280) and emits a StepProgress event with all 5 fields only when step_goal is set (langgraph_executor.py:358, builder at :502).
  • StepDriver folds StepProgress into the observation (driver.go:194-204); reflect() feeds it to ProgressEval.Observe (loop.go:447); Terminator.Evaluate (terminator.go:73) reads the 3 axes (budget → stall → goal) from plan state every iteration. Progress signals reach the Terminator.

Caveat: budget axis is unbounded in the seed plan — NewSingleTodoPlan (plan.go:168) leaves Budget.LoopsMax/TokensMax/WallMaxMs at 0, and reflect() only increments LoopsUsed. The effective ceiling is the loop's MaxLoops=32 guard (loop.go:196, config.go:97). Sane for dev; not a bug, but the run bounds at 32 worker cycles, not a token/wall budget.

(d) interruptApprover nil condition — NOT hit on a healthy dev stack ✅

lifecycle_interrupt.go:103 returns "interrupt approver not fully wired" only if a.approvals == nil || a.pauses == nil. The approver is built (grpc.go:360) when EnableAutonomousLoop && cfg.ApprovalService != nil AND the session manager is a session.PauseManager. In the dev orchestrator both hold: ApprovalService: approvalSvc is wired (main.go:932, passed at :1434), and the session manager is upgraded to a PauseManager via session.EnablePause (main.go:779-780). A dev run hits the nil-approver error only if EnablePause fails (DB outage) — not on the clean path.

(e) Per-step governance + compaction inside autonomous steps — PARTIAL ⚠️

  • Orchestrator-side PreDispatch governance: REAL and runs. The security pipeline (InputFilter + PromptIntegrityVerifier) executes at lifecycle_server.go:480, BEFORE the autonomous branch at :633; the sanitized message becomes the run Goal. A block returns PermissionDenied before any worker step.
  • Autonomous adds no new tool path — tools ride the same worker ExecuteStep cycle as relay (LLD §9.1). The MCP-proxy egress PEP is the un-bypassable backstop.
  • Worker-side validate_prompt PEP and assemble_context are STUBS (context/client.py:94,129,157). This is a pre-existing platform condition, not introduced by T4 — the worker's ContextEngineClient is a stub that returns empty context, no-op ingest, always-valid prompt. Two consequences for the live run:
    1. The autonomous-mode compaction default (threshold=6, LLD §9.2) does NOT engage through the worker — the worker never calls the real Context Engine AssembleContext, so pipeline.go:719's execution_mode-aware resolution is never reached on the worker path. Long autonomous runs do not get the §3.2 async-compaction benefit until the worker context client is real.
    2. Worker-side per-prompt governance is a no-op; the real enforcement is the orchestrator PreDispatch (above) + the MCP-proxy egress PEP.

(f) Other gaps that would no-op / fail a live run

  1. No PLAN parser (point b) — multi-step plans inactive; runs as single-todo pursuit. Will run, not no-op.
  2. Stub worker ContextEngineClient (point e) — no live compaction via the worker; worker-side validate_prompt no-op. Pre-existing.
  3. Unbounded plan budget (point c) — run bounds at MaxLoops=32, not a token/wall budget. Acceptable dev safety bound.

None of these wiring gaps make the run fail or no-op: the path executes end to end, streams to the portal, terminates via goal-complete/stall/MaxLoops, and audits. However — a separate connection-lifetime bug (#1610) DID make every run > 60 s fail hard until fixed; see §5. Static wiring completeness was necessary but not sufficient.

3. How to trigger an autonomous run (smoke recipe)

  • RPC: LifecycleService.SendMessage (server-streaming AgentEvent). NOT QuadService. Proto: upsquad.runtime.v1.LifecycleService.
  • Pre-req: CreateSession first (the session must exist).
  • Fields on SendMessageRequest:
    • session_id — the created session id (required)
    • agent_id — agent config id (required)
    • message — the autonomous goal text (this becomes RunInput.Goal → the planning + pursuit step_goal). There is no separate goal/task field; the goal IS message.
    • execution_mode = EXECUTION_MODE_AUTONOMOUS (enum value 3) — REQUIRED; any other value takes the relay path.
  • Tenant: supplied out-of-band via the x-tenant-id / auth metadata the gateway injects (same as relay) — not a request field.
  • Expected stream: token/status events relayed exactly as relay, plus (internally) StepProgress per step; terminal CompletionEvent. On a gated tool mid-step you get an InterruptRequired and the session parks SUSPENDED pending an approval RecordDecision.
  • Termination to expect on the first run: goal-complete (model emits a done-marker), or MaxLoops=32, or stall — NOT multi-step plan completion (no PLAN parser yet).

4. Verification performed

  • go build ./... — clean.
  • go vet ./internal/runtime/autoloop/... ./internal/runtime/server/... ./cmd/agent-orchestrator/... — clean.
  • Live docker smoke run performed post-merge — see §5.

5. Live dev go-live smoke (2026-06-29) — and the #1610 finding

Performed on beta-dev (devbox docker stack), model gemma3:1b, driving the orchestrator gRPC directly at 127.0.0.1:50052 (bypasses the context-engine Connect proxy's idle timeout). CreateSession then SendMessage with execution_mode=EXECUTION_MODE_AUTONOMOUS.

What the smoke surfaced — #1610 (blocking, now fixed)

The first live autonomous runs ALL died at ≤ 66 s / 2–3 worker steps with plan_client_closed {client_closed:false, wall_ms ~36k–66k} and worker-side rpc error: code = Canceled desc = grpc: the client connection is closing (ErrClientConnClosing).

Root cause: the worker gRPC connection-pool idle reaper (internal/runtime/worker/pool.go, defaultIdleTTL = 60s) closed the cached *grpc.ClientConn mid-plan. poolEntry.lastUsed was stamped once in getOrDial (at acquire) and never refreshed during use; PlanLoop.Run (loop.go:154) re-uses ONE dialed client across all steps, so after 60 s the reaper swept the live connection out from under the in-flight ExecuteStep. The wall_ms > 60000 + client_closed:false audit tell proved the cancellation came from the pool, not the portal client.

This corrects §2(f)/§4 above: a > 60 s run did NOT "execute end to end" — it failed hard. Static wiring was complete; a runtime resource-lifetime bug the static audit could not see was the real go-live blocker. (Lesson: when a feature changes a resource's lifetime — short RPC → long reused conn — audit every sweeper/reaper that keys on "last activity".)

Fix: PR #1611 (closes #1610) — poolEntry gains an inFlight lease; the reaper skips in-flight entries; ExecuteStep is wrapped in a touchingStream that refreshes lastUsed on each Recv and releases the lease on the first of Recv-terminal / CloseSend / ctx cancellation (the ctx path stops the abandon-on-interrupt/hangup paths from pinning the conn forever). Merged 2026-06-29; full internal/runtime/worker package green under -race with two new regression tests.

Post-fix re-smoke — reaper no longer kills long runs ✅

After deploying the fix and restarting worker + context-engine:

plan_started {goal_len: 76}
plan_client_closed {steps: 5, loops_used: 4, wall_ms: 173437, tokens_used: 10821, client_closed: false}

The loop ran 5 worker steps over ~173 s (4 PURSUE loops) continuously, with NO reaper involvement and NO connection is closing in the orchestrator logs. It ended only because the smoke client hit its own 240 s -max-time cap (DeadlineExceeded → RST_STREAM CANCEL → inbound ctx cancel) — i.e. the client gave up, not the system. That is 4× past the old ~66 s death point.

Natural-termination disposition — proven by unit tests, not captured live

A natural terminal (plan_completed / plan_stalled / plan_budget_exhausted) was not captured in the live smoke: gemma3:1b is ~35–40 s/step on CPU and does not reliably emit a _TODO_DONE_MARKER or a flat response_delta_hash, so the stall axis (StallWindow=3) and goal-complete need many minutes, and MaxLoops=32 would take ~20 min. The three terminator axes are instead deterministically covered in internal/runtime/autoloop (TestPlanLoop_Budget
Terminates, _MaxLoopsSafetyBound, _GoalCompleteViaModelFlag, TestTerminator_NoProgressAxis / _BudgetAxis / _ReplanCapAxis — all pass). The agent_audit_log table is the source of truth for live disposition regardless of streaming-client longevity.

Net go-live status: T4 autonomous mode is enabled and executes end-to-end across many steps on dev. Remaining quality items (not go-live blockers): PLAN-result multi-step parser (§2b), un-stub the worker Context Engine client (§2e), and a capable BYOK model for meaningful long-horizon autonomy.