feat(architect): provision persistent Docker container via reconcile (#1131) #1134
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1134
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "code-lead/1131"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
claude-hooks-architect-defaultcontainer reconciled byreconcileOne/reconcileAllexactly like every other agent — no orchestration-layer special-casing. Hot lifecycle, sharedclaude-hooks:devimage, single bind-mount (the credentials dir →CLAUDE_CONFIG_DIR)./architect/chatontodocker execbecomes a routing change instead of a deploy.host_modeis decoupled fromcontainer.enabled— derived fromrole === "host", matching the existingis_host_modederivation inmain.ts. Architect declarescontainer.enabled: true, lifecycle: "hot"while staying host_mode for dispatch.dockerRun/matchesDesiredcollapse the bind-mount set to a single credentials bind for host-mode instances (skip projects sub-bind, skip~/.cursor). The named state volume is kept so the relay can use/statewithout a recreate.specs/architect-dockerize.md, plus updates todocs/architect.md,docs/containers.md, and the projectCLAUDE.mdoverview block.Closes #1131.
Test plan
bun test apps/server/src/infrastructure/container/— 56 tests pass, including 3 new ones for the host-mode singleton (created action, single-bind argv shape, no spurious projects-bind drift).bun test apps/server/src/— full server suite (3303 tests) passes.just qa— typecheck + lint + format + sql-layer + paraglide + i18n + flow-schema all green (bun installwas needed first to pull in a missing pre-existingreact-resizable-panelsdep on thewebtypecheck).just agents-syncshould reportcreatedforarchitect-default;docker inspect claude-hooks-architect-default | jq '[.Mounts[] | select(.Type=="bind")] | length'should print1;just restartleaves the container running;just containers-rebuild architect-defaultrecreates only it.🤖 Generated with Claude Code
Bring the architect under the same per-agent container deployment model as every other instance in the fleet — `claude-hooks-architect-default` is now a hot, persistent singleton reconciled by `reconcileOne` / `reconcileAll` exactly like code-lead, dev, reviewer, … with no orchestration-layer special-casing. The architect chat path keeps dispatching inline (host_mode=true). The container is a warm placeholder for the upcoming relay story; flipping chat onto `docker exec` against this container becomes a routing change instead of a deploy. Decouple `host_mode` from `container.enabled` by deriving it from `role === "host"`, matching the existing `is_host_mode` derivation in main.ts. Architect now declares `container.enabled: true, lifecycle: "hot"` and stays host_mode for dispatch routing. `dockerRun` / `matchesDesired` collapse the bind-mount set to a single credentials bind for host-mode instances (no projects sub-bind, no ~/.cursor) so `docker inspect claude-hooks-architect-default` reports exactly one Type=bind mount per the AC. The named state volume is kept so the relay can use /state without a recreate. New test coverage: `reconcileOne("architect-default")` emits 'created', asserts the single-bind argv shape, and stays 'unchanged' on a matching present container (regression for a spurious projects-bind drift loop). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Review — feat(architect): provision persistent Docker container via reconcile
Overall the implementation is clean: the
minimalMountbranch indockerRun, thematchesDesiredgate for the projects-bind check, and thehost_mode: type.role === "host"re-derivation are all principled. The spec doc and test suite are thorough. One blocking correctness issue needs fixing before merge, plus two stale guard sites that should be updated in the same pass.🔴 Blocking — three dispatch safety rails now silently let the architect through
resolveAgentByUser,resolveAgentByType, andresolveAgentByRoleeach contain:Their JSDoc is explicit: "webhook dispatch paths must never reach them". Before this PR that held because the architect declared
container.enabled: false. Now that the architect declarescontainer.enabled: truethe guard is dead for it — all three functions return the full resolved agent for the architect.Practical risk today is low (the architect Forgejo user will not be auto-assigned; no flow YAML should call
resolve_agentwithtype: "architect"). But the contract stated in the JSDoc is now broken,dispatchable: falseis not checked by any of these callers (janitor, pr-flow-impl, tail-pr-rebase-watchdog), and the relay story will make the architect more prominent. Fix now rather than carry the landmine.Required fix — update all three guards to key on
rolerather thancontainer.enabled:This is consistent with the
host_modere-derivation already applied inmergeAgentand keeps the semantic ("host-mode singletons are invisible to webhook dispatch") true regardless of their container state.🟡 Medium — two stale guard sites anchored to
container.enabled === false1. Non-anthropic provider check (
webhook-config.ts~line 1522):With
container.enabled: trueon the architect, this guard no longer fires for it. A future misconfiguration ofprovider: "cursor"on the architect would slip through without a load-time error. Update the condition and the stale comment to userole === "host"consistently.2. Stale inline comment (same file, ~line 1518):
Update to reflect the new derivation:
host_mode = role === "host".✅ What's good
minimalMountindockerRun— clean branch; the conditionalmkdirand the spread into the args array are correctly in lockstep, sodocker inspectreports exactly oneType=bindentry as the AC demands.matchesDesiredgate — correctly skips the projects-bind drift check forhost_modeinstances; prevents the spurious recreate-loop the third test exercises.host_mode: type.role === "host"— principled unification with the pre-existingis_host_modederivation inmain.ts. Decouples dispatch routing from container provisioning cleanly.volumeFlagsbind-counting approach is readable and will catch regressions.specs/architect-dockerize.md— honest about scope, in/out-of-scope items are clear, AC mirrors the issue checklist.agents-default-config.ts—lifecycle: "hot"added alongsideenabled: true; the decoupling comment is exactly what future readers need.Summary
Fix the three
resolveAgentBy*guards fromcontainer?.enabled === false→role === "host", and update the stale comment + provider guard in the same pass. Everything else is merge-ready.Thanks for the careful read — the dispatch landmine was real and worth fixing now. Pushed
82993f2addressing all three points:Blocking — resolver guards (
webhook-config.ts):resolveAgentByUser,resolveAgentByType,resolveAgentByRolenow gate ontype.role === "host"instead oftype.container?.enabled === false. JSDoc on each is refreshed to explain the #1131 context (warm-placeholder container ≠ dispatchable).Medium — provider chain + stale comment (
webhook-config.ts:1517-1526):parseProviderChain's signature is(typeName, raw, role: AgentRole = "code")—containerwas only used for that one branch, so I dropped it rather than thread an unused param. Comment + error message updated to referencerole="host".parseTypeRouting;handleUpsertDispatchlooks it up viaroleForType(name) ?? "code"(DB-first, same fallback chain as the existing dispatch path).getContainerConfigForProviderValidationexport.Also caught while sweeping:
loadWebhookConfig's token-file forge-validator loop (line ~2546) had the samecontainer?.enabled === falseopt-out comment. Flipped torole === "host"with a #1131 reference.Tests:
container.enabled: true, role: "host"shape — one per resolver, plus one for the provider-chain guard. Without the role-keyed gate, all four would have silently let the architect through.(container.enabled=false)→(role: host)) so future readers don't get the wrong impression about what the rail keys on.bun test apps/server/src/— 3312 pass (was 3307; +5 new tests, no regressions).just qagreen.Re-requesting review.
Code Review — feat(architect): provision persistent Docker container via reconcile (#1131)
APPROVED — code is correct and all AC are met. CI (
qa / qa-1) is still running; deferring merge to the post-CI path.Overview
Two-commit PR:
claude-hooks-architect-defaultas a hot persistent singleton via the existingreconcileOne/reconcileAllmachinery — no orchestration-level special-casing. The only architect-specific logic lives insidedockerRun:minimalMount: truecollapses the bind set to the single credentials bind.resolveAgentByUser/ByType/ByRole) and one provider-chain guard (parseProviderChain) fromcontainer?.enabled === falsetorole === "host". This was a necessary follow-on because enablingcontainer.enabled: truefor architect silently opened all four gates that previously relied on that flag.The approach is sound:
role === "host"is semantically richer, already matches theis_host_modederivation inmain.ts, and decouples dispatch routing from the container lifecycle config.Acceptance Criteria Verification
reconcileAll()createsclaude-hooks-architect-defaultwhen instance row existsreconcileOnetest emits'created';reconcileAlldelegates to itreconcileOne("architect-default")via standard CRUD, no special-casingdockerRunis parameterisedjust restartleaves it running)lifecycle: "hot"→--restart unless-stoppedclaude-hooks:devimageCLAUDE_CONFIG_DIR)minimalMountgate + test assertsbinds.length === 1process.cwd()/ other host pathsminimalMountskips projects + cursor spreadsdocker inspectconfirms oneType=bind/to count bindsdockerRunpath as all other agentsjust agents-sync/containers-rebuild/render-agent-envworkreconcileOneemits'created'for absent containercontainer-reconcile.test.ts:935just qapassesqa-1in progress; all other jobs greenCode Quality
Strengths
const minimalMount = agent.host_mode === trueis well-named and its single boolean cleanly gates both themkdirpre-create and the two--volumespread args — easy to follow.matchesDesiredgated onagent.host_mode !== trueis correctly paired with a dedicated "no spurious drift" regression test — the class of bug this guards against (infinite recreate loop) is exactly the one worth pinning.loadConfigWithArchitect()fixture in tests mirrors the exact production shape (container.enabled: true, lifecycle: "hot", role: "host") rather than a simplified stub — the tests exercise the real boot path.getContainerConfigForProviderValidationis correctly removed; its only caller (handleUpsertDispatch) now resolves role directly viaroleForType.claude-hooks-architect-default-state:/stateis preserved without adding to the bind count, keeping the relay story's migration todocker execdep-free.Minor observations (non-blocking)
matchesDesireddoesn't check the cursor bind for normal agents. A manually added cursor bind wouldn't be detected as drift. This is a pre-existing gap; the PR doesn't make it worse.roleForType(name) ?? "code"fallback inhandleUpsertDispatch. Safe in practice (only"host"triggers the constraint), but worth knowing: a type added to agents.json but not yet flushed to the DB would silently pass the guard on a live PUT request. Unreachable in normal operation.Summary
Solid implementation. The decoupling of
host_modefromcontainer.enabledis the right call and brings the two derivation paths (mergeAgentandmain.ts) into alignment. Test coverage is thorough — all three resolver guards and the provider guard are regression-pinned with the exactcontainer.enabled: true, role: "host"shape from this PR. Will merge onceqa / qa-1goes green.