Phase 3 of the AI-workforce engine (TODO-agent-2):
- engine/manager.py: ManagerTurns.chat (CREATE_TASK/PAUSE/RESUME/CANCEL,
HIRE/UPDATE_EMPLOYEE/FIRE, KNOWLEDGE), triage (ASSIGN/REJECT/ASK_USER),
final_review (COMPLETE/REVISE +cycle), error_review (RESOLVE_ERROR/CANCEL),
bootstrap_team; post_user_message now delegates to it.
- One billing path: call_model/BillingScope in runner.py is the single model
entry point (fixes the agybot chat-billing bug).
- Team changes capped at 3/turn, employee cap, last manager cannot be fired,
hires pick a granted model of the requested tier strictly.
- container_engine.py wires the real WorkforceBootstrapPort.
- Fixes: REVISE is a body tag (prompt and parser now agree); ASSIGN
employee="" means pick-for-me; the runner's protocol-error retry is capped by
engine_max_protocol_retries (was unbounded until the turn timeout).
- 20 new manager tests + regression tests; report in
docs/plan/reports/agent-2-phase-3.md.
Phase 2 of TODO-agent-2: one employee, one turn, real tools in the sandbox.
- engine/prompt.py: the stable, cache-friendly prefix in the plan's order
(rules → role protocol → toolkit → app profile → memory), a task brief
capped at 1200 chars, role-specific stage context (spec for the developer,
the diff only for the reviewer, test output for the tester) and a hard
prompt cap that drops the oldest tool results first.
- engine/tools.py: READ/WRITE/EDIT/LIST_DIR/SEARCH/RUN executed inside the
sandbox through SandboxProviderPort, rooted at the worktree's sandbox path.
Path escapes, .. and .git are refused; git push/remote/config are refused
with "the platform does git"; RUN clamps its timeout (120s default, 600s
max) and keeps the tail of its output; every execution writes a tool_calls
row so the approvals UI shows real work.
- engine/runner.py: the loop — model call, parse tags, execute tool tags,
feed results back — until a terminal tag, the tool cap, the turn timeout or
a cancellation. The employee's model is checked against ModelGrantPort
before the first call (ungranted → error_kind=auth, no spend), errors are
classified transient vs fatal, and every model call writes usage events
through the one shared record_model_usage path (extracted from
WorkforceService so manager chat counts too). Writes engine_turns, run_steps
and tool_calls.
- engine/events.py: RunFeed batches run.log (≤1 event / 500 ms / stream, ≤200
lines) and emits run.tool and task.gate through the generic envelope.
- tests: engine_fakes.py (ScriptedModel, FakeSandbox, FakeGrants,
RecordingMemory) plus 54 new tests in test_engine_runner,
test_engine_prompt and test_engine_tools.
Suite: 648 passed, ruff clean, mypy clean.
Phase 1 of TODO-agent-2: pure logic plus the tables it writes to.
- domain/workforce/pipeline.py: Stage/PipelineKey/Outcome, the four pipelines,
TRANSITIONS, next_stage, STAGE_ROLE (role+mode), resolve_pipeline with the
no-reviewer fallback, task-kind defaults, stage -> coarse tasks.status, and
the TaskPipelineState record (with the optimistic version).
- application/engine/protocol.py: the [[TAG attr="v"]]body[[/TAG]] parser and
its self-closing form, the per-role/mode allow-list, literal bodies (a
bracket inside a body is not a tag), typed readers (ASSIGN/CREATE_TASK/
team changes/KNOWLEDGE/EDIT_FILE blocks) and the per-reply limits
(3 tasks, 3 team changes, 3 knowledge items).
- application/engine/state.py: advance() with the optimistic compare-and-set
and park_dependents() so a failed dependency never deadlocks its children.
- db/models_engine.py + migration a2b3c4d5e6f2: task_pipeline_state,
employee_profiles, engine_turns, gate_results as side tables (tasks and
employees stay locked). Linear head; alembic check reports no drift.
- repositories/engine.py + 58 new tests (test_engine_protocol,
test_engine_pipeline) covering every pipeline's happy path, illegal
transitions, exactly-one-winner concurrent advance, dependency parking and
all parser edge cases.
Suite: 594 passed, ruff clean, mypy clean.
Gap-closing on top of the merged A3-3..A3-6 work:
- ProjectService.create_preview_domain now refuses a label that is a reserved
platform name (git, mcp, api, panel, ...) via PROTECTED_HOST_LABELS. The rules
set existed but only the owner-facing validate_extra_host consulted it, so the
tenant path could reserve one.
- process.py: signal.SIGKILL is resolved through getattr with a SIGTERM
fallback, so the module imports and type-checks on Windows where SIGKILL does
not exist.
- Three more Windows test repairs: a git-created .git must be unlinked and
recreated rather than overwritten in place; POSIX-only chmod and symlink
assertions are gated on os.name (the security property they assert is checked
everywhere, the platform-specific mechanics only where they apply).
- docs/plan/reports/agent-3-final.md: the integration hand-off — host
requirements, every setting, every endpoint added or changed, the tables, the
open CHANGE REQUESTS, a 12-step manual QA script and the known limitations.
Checks: ruff clean, mypy clean (136 files), 521 API tests pass, 83 web tests
pass, tsc clean, next lint clean.
The user-visible payoff of the whole delivery workstream.
- (app)/app: four tabs — pipeline (runs + the eight steps + a live console),
releases (with demo rollback), production (target summary, confirmed deploy,
history), Git. The header answers "is it live": demo URL, live release,
health state.
- Live console: batches onto an animation frame, stops following the tail the
moment the user scrolls away from the bottom, strips ANSI colour codes and
carriage-return redraws. A console that yanks the view back is worse than none.
- Production panel: the target is shown as the operator recognises their server
plus has_secret/ثبت شده. No field anywhere could render a credential.
- contracts: the v2 delivery types and seven new WsEventType members.
- /admin/sandbox (owner): per-role container counts, running containers, per-org
limits, image mapping, stop with the id typed back (audited). A provider without
the capability reports an empty list rather than an error.
- /admin/domains (owner): every route with its owning app, attach/detach (host
typed back), idempotent re-sync.
- /projects removed entirely, with a permanent redirect to /app.
Tests: 502 API (+3), 78 web (+11); ruff, mypy, tsc and lint clean.
Report: docs/plan/reports/agent-3-phase-6.md
The one path that touches a customer's server, and the one the platform cannot
undo. It is human-triggered, confirmed and audited; there is deliberately no
code path from a pipeline run into it (D13).
- deploy_ssh.py: SshDeployer. Release lands in releases/<ts>-<sha7>/, the
post-deploy command runs with cwd there, and only then does `current` move —
atomically (relative symlink + mv -T). A failed hook or extraction never moves
it, so the previous release keeps serving. Host key is pinned on first success
(TOFU); a mismatch is a hard failure. Prunes to the newest five.
- deploy_ftp.py: FtpDeployer. FTP has no atomic switch and no rollback except a
backup, so a backup is mandatory: every file about to be overwritten is
archived under ARTIFACTS_ROOT before anything is uploaded. The artifact is
extracted, then the build dir (dist/build/public/out) is chosen — a static
deploy must not publish the repository. Archive members are sanitised: absolute
paths, `..` and escaping links are refused.
- deploy_target.py: the tester that dispatches on `kind`.
- deploy_service.py + production_deploys (migration a3b4c5d6e7f4) + 4 routes.
One deploy per project at a time; the org slug must be typed back; the target
summary reports has_secret, never the secret.
Tests: 499 API (+26) with fake SSH/FTP transports; ruff, mypy, alembic clean.
Report: docs/plan/reports/agent-3-phase-5.md
Domains are infrastructure, and in v2 infrastructure is the platform owner's
alone (D9). A tenant gets one automatic demo host and nothing else.
- admin_domains.py (owner-only): list every route with its owning app, attach an
extra host, detach with the host typed back as confirmation (audited), re-sync
the router from the database, and read the suggested MCP gateway route.
- domain/platform/rules.py: validate_extra_host. Two shapes only — a single
label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS
is on. A wildcard is refused in both (it would match another tenant's
subdomain), and so are 30 protected platform labels (git, mcp, panel, api...).
Enforced in the router *and* again in the adapter.
- Both router adapters gained routes_fingerprint / list_all_routes /
sync_routes. Re-sync writes and reloads only when the rendered state differs:
a reload drops every in-flight connection on the host, including live demos.
Adapter-level extras, not additions to the frozen DomainRouterPort.
- The member-facing preview-domain routes are owner-only now. They are kept
because the demo host is still created through them at app-creation time.
Tests: 473 API (+58); ruff and mypy clean.
Report: docs/plan/reports/agent-3-phase-4.md
A push to `dev` now becomes a live demo, and a failed build never takes the
demo down.
- Four tables (pipeline_runs, pipeline_steps, releases, app_runtimes) and their
migration; alembic upgrade/downgrade/check all clean.
- DeliveryService implements DeliveryHooks: on_app_created provisions the demo
port + host + first build (idempotently); on_branch_pushed queues a run for
`dev` and supersedes older *queued* runs, leaving a running one alone.
- The 8-step pipeline. Blue/green is real: the replacement starts on a staging
port, is healthchecked, and only then does the route move — then the previous
container is stopped. A failure removes the replacement, releases its port and
leaves the old container serving.
- The demo port ledger is the DATABASE, not the router adapters' in-memory maps:
the API and the delivery worker are separate processes.
- Runs are claimed with a conditional UPDATE, so two workers never build the
same run.
- Live logs are batched (1 event / 500 ms, 200 lines) and redacted as a whole
tail — per-chunk redaction misses a secret split across pipe reads, and the
*stored* tail leaked until this was fixed.
- delivery worker: drain the queue, then poll remotes for pushes made outside
the engine.
- 7 routes; reads need membership, actions need projects.manage and are audited.
Tests: 415 API (+21, 1 docker-marked skipped); ruff, mypy, alembic check clean.
Report: docs/plan/reports/agent-3-phase-3.md
The platform's side of D6: git runs on the host, in a gitdir that is never
mounted into a container, so repository credentials never reach the model.
- adapters/git_workspace.py: LocalGitWorkspaceAdapter implementing
RepoWorkspacePort. Bare shared gitdir + a dev work tree mounted at
/workspace + one linked worktree per task under work/.worktrees/<id>
(excluded via the gitdir's info/exclude, not the project's .gitignore).
Explicit --git-dir/--work-tree, core.hooksPath=/dev/null, fsmonitor off,
quotePath off, GIT_CONFIG_NOSYSTEM, a private HOME per project, prompts off,
one asyncio.Lock per project. Token via GIT_ASKPASS reading a 0600 file; SSH
key as a 0600 temp file with a pinned known_hosts. Everything deleted in a
finally; only a *path* ever travels through the environment.
- The bug this phase existed to find: --git-dir=<shared> does not address a
linked worktree, so `git commit` in a task worktree committed straight to
`dev`. Operations now address <gitdir>/worktrees/<task_id> explicitly — and
never read the worktree's .git file, which a repository can rewrite.
- domain/platform/rules.py: is_blocked_address (pure) + assert_remote_host_allowed
(resolves, refuses if any address is loopback/private/link-local/metadata).
Shared by the git remote test and, later, every deploy target.
- ProjectExternalGit domain model + repository; container_delivery wires the
real adapter behind GIT_WORKSPACE=local with a resolver that joins the
project, its repo and its (decrypted) credentials.
Tests: 394 API (+48) against real git and a local bare repo; ruff and mypy clean.
Report: docs/plan/reports/agent-3-phase-2.md
The execution boundary for v2. Adds the local docker/podman sandbox adapter
and the hardened host subprocess runner every other delivery adapter will use.
- adapters/process.py: the one place the platform shells out. argv-only (no
shell), start_new_session + killpg on timeout, concurrent stdout/stderr
drains with a byte cap, explicit minimal env (never os.environ), and a
missing binary reported as spawn_failed rather than an exception.
- adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort.
--user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits,
--read-only with tmpfs scratch, one bind mount (the work tree, never the git
dir), labels, no published ports. Path-safe file access; secrets refused in
`docker exec -e`. Extras for later phases: start_demo_container (loopback
publish only), list_managed_containers, remove_container.
- registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry
survives create_workspace -> start_session.
- sandbox_gateway worker: reap_orphans() removes labelled containers no live
session points at, audited as sandbox.reap; run_pass() does both passes.
- sandbox router: every mutating route now requires the platform owner
(PLAN-v2 §7.7); read-only status routes stay open to org members.
- sandbox-panel: canManageInfra prop (fails closed) hides the create form,
the session panel and the action buttons from non-owners.
Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean.
Three existing tests now drive the sandbox as the platform owner, since that
is who may touch it.
Report: docs/plan/reports/agent-3-phase-1.md