Autonomous loop (T4) — dev go-live wiring-completeness audit
| Field | Value |
|---|---|
| Issue | #1422 (T4, ADR-0019 adopt-and-wrap) |
| LLD | docs/lld/LLD-autonomous-loop-langgraph.md |
| HLD | docs/hld/HLD-autonomous-loop-langgraph.md (founder-approved) |
| Scope | Confirm EXECUTION_MODE_AUTONOMOUS is wired end-to-end before the first live dev run |
| Author | backend-sme |
1. Enablement status — ALREADY LIVE on main
The dev/beta-dev flip is already merged — docker-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:265keeps theenvBool("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.goreads onlyENABLE_AUTONOMOUS_LOOP. StallWindow / ReplanCap / MaxLoops / compaction threshold come frominternal/contextengine/knobsconfigconstants viaautoloop.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_AUTONOMOUSavailable; the relay path inSendMessageis byte-for-byte unchanged for every other execution mode (lifecycle_server.go:633branches onautoloopEnabled && execution_mode==AUTONOMOUS; theelsefalls through to the existing relayExecuteStepat: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:67sendsstep_goal="decompose goal into todo plan"on the first iteration, and the worker consumesstep_goal(langgraph_executor.py:280).- BUT
reflect()(loop.go:427-431) never parses the worker's decompose output intoPlan.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 seedsNewSingleTodoPlan(plan.go:168) solen(Todos)==1always — 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_goalon everyExecuteStep(driver.go:134-143). - Worker consumes
step_goal(langgraph_executor.py:280) and emits aStepProgressevent with all 5 fields only when step_goal is set (langgraph_executor.py:358, builder at:502). - StepDriver folds
StepProgressinto the observation (driver.go:194-204);reflect()feeds it toProgressEval.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
ExecuteStepcycle as relay (LLD §9.1). The MCP-proxy egress PEP is the un-bypassable backstop. - Worker-side
validate_promptPEP andassemble_contextare STUBS (context/client.py:94,129,157). This is a pre-existing platform condition, not introduced by T4 — the worker'sContextEngineClientis a stub that returns empty context, no-op ingest, always-valid prompt. Two consequences for the live run:- 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, sopipeline.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. - Worker-side per-prompt governance is a no-op; the real enforcement is the orchestrator PreDispatch (above) + the MCP-proxy egress PEP.
- The autonomous-mode compaction default (threshold=6, LLD §9.2) does NOT
engage through the worker — the worker never calls the real Context
Engine
(f) Other gaps that would no-op / fail a live run
- No PLAN parser (point b) — multi-step plans inactive; runs as single-todo pursuit. Will run, not no-op.
- Stub worker ContextEngineClient (point e) — no live compaction via the worker; worker-side validate_prompt no-op. Pre-existing.
- 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-streamingAgentEvent). NOT QuadService. Proto:upsquad.runtime.v1.LifecycleService. - Pre-req:
CreateSessionfirst (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 becomesRunInput.Goal→ the planning + pursuit step_goal). There is no separategoal/taskfield; the goal ISmessage.execution_mode = EXECUTION_MODE_AUTONOMOUS(enum value3) — 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 anInterruptRequiredand the session parks SUSPENDED pending an approvalRecordDecision. - 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.