Files
smart-org/docs/plan/reports/agent-2-phase-4.md
T
mjalmoudy94 36564e3f2b feat(engine): Phase 4 — scheduler tick, gate chain, platform git, budget
The AI workforce engine's driver. One tick moves every ready task one step:
a turn, a gate, or a platform merge. The AI never runs git.

- scheduler.py: tick() over TURN_STAGES/MERGE_STAGES; selection caps
  (per-org 3, global 8); optimistic advance so two workers cannot both win;
  worktree created for code pipelines, released on completion/cancel/failure
  (kept during error_review); budget pause before any turn is spent; merge
  conflict back-off; transient-failure retries then the manager's error turn.
- gates.py: diff guard (empty/.git/CI/symlink/oversize), no-progress hash,
  tests in the sandbox, green gate that only trusts a green tests row or an
  explicit waiver. Every verdict is a gate_results row + a task.gate event.
  Fixed forbidden_paths: lstrip("./") stripped all leading dots, hiding
  .git and .github from the guard.
- container_engine.build_scheduler + worker drives the engine tick.
- ManagerOutcome.error_kind; TaskSpendRepository.spend_for_task.
- New settings engine_max_concurrent_turns, engine_max_turns_per_org,
  engine_tests_timeout_seconds, engine_max_merge_conflicts,
  engine_max_task_credits (+ .env.example).
- tests: test_engine_gates.py (13), test_engine_flow.py (11).

Checks: pytest 746 passed/1 skipped, ruff clean, mypy clean (160 files).
2026-10-14 19:02:58 +03:30

198 lines
10 KiB
Markdown

# Agent 2 — Phase 4 report: the scheduler (one tick → one step → merged code)
**Date:** 2026-10-14 · **Branch:** `dev` · **Status:** done, all checks green
## What was done
### 4.1 The tick — `app/application/engine/scheduler.py` (new)
`Scheduler.tick()` is the engine's driver. One tick:
1. reads every task whose stage **waits for a turn** (`TURN_STAGES`) and every task
ready to **merge** (`MERGE_STAGES = (merge_request,)`),
2. applies the selection caps, then
3. runs **one** step per selected task — a turn, or the platform's merge.
A step never aborts the tick: `_guarded` catches, records and continues. A lost
optimistic race surfaces as `ConflictError` and is counted as *skipped*, because
"another worker moved it first" is a normal outcome, not a failure.
**Selection caps, not execution caps.** `_select` honours
`engine_max_turns_per_org` (3) and `engine_max_concurrent_turns` (8) by *choosing*
that many tasks per tick. Turns themselves run one at a time inside a tick: an
`AsyncSession` is not safe to share across concurrent turns, and correctness
across worker processes comes from the optimistic `advance` (two workers ticking
the same task both try; exactly one wins). This is a deliberate deviation from a
literal reading of "run 8 turns at once" — see *Deviations*.
`TickReport` (selected / acted / skipped / failed / merged / paused, decisions,
notes) is what the worker logs and the tests assert on.
### 4.2 Worktree lifecycle
`_worktree_for` calls `RepoWorkspacePort.create_task_worktree(project_id, task_id,
base="dev")` for any pipeline that `requires_worktree` (i.e. not `analysis_only`)
and a task with a `project_id`; otherwise `None`. The branch is saved onto the task
state (`engine.state` → `task_pipeline_state.branch`) the moment the developer's
diff is accepted, so a merged task records *what* it merged.
The worktree is removed on **completion, cancellation and failure** (TODO 4.2):
`_merge_step` removes it after a successful push, and `_move` calls
`_release_worktree` whenever the target is `failed`/`cancelled`. It is *kept*
during `error_review`, so an `error_review` retry still has the code to move
forward.
### 4.3 The gate chain — `app/application/engine/gates.py` (new)
A `WORK_REPORT` is a claim, not evidence. Four gates check it, and **every verdict
is a `gate_results` row and a `task.gate` event** — a gate that only lives in a log
is unauditable.
- **diff guard** — empty diff for a code task; `.git`/CI paths; symlinks escaping
the repo; files over `MAX_FILE_BYTES` (1 MiB).
- **no-progress** — hashes the diff; the same hash twice in a row means the agent
is spinning, so the task goes back to the developer (and eventually to the
manager via `max_cycles`). `no_progress` returns the new `(hash, count)`, which
the scheduler persists on the state.
- **tests** — the app profile's `test_command` runs in the sandbox with
`engine_tests_timeout_seconds`; the failure tail is stored and handed back. No
command defined → green (nothing to run is not a failure).
- **green** — the tester's `TEST_PASS` is only believed when the tests gate is
green, or when the tester **waives** it with a reason. A waiver is stored on the
state (`waiver_reason`) and in the gate row; it is never inferred.
`GateRunner.record(task, outcome)` is the public entry the scheduler uses to write
a verdict it reasoned about itself (a merge conflict is a diff-guard verdict that
comes from git, not from hashing).
`_escalate_after_repeated_red`: a **second** red `tests` row moves the developer
onto the strongest *granted* model (one escalation only; no stronger model granted
→ left alone).
### 4.4 Commit / merge / push — the platform owns git
The AI never runs git. After `final_review → COMPLETE` the task reaches
`merge_request`, and `_merge_step` does the git:
1. `commit` in the worktree, author `"<employee> <platform@smart-org.local>"`
(`ENGINE_COMMIT_EMAIL`, matching the delivery adapter),
2. `sync_with_base`,
3. `merge_into(branch, target="dev")`,
4. `push(branch="dev")` → `diff_guard` note,
5. `move → completed`, `remove_worktree`, `DeliveryHooks.on_branch_pushed(source="engine")`,
6. `MemoryPort.on_task_finished`.
A merge conflict is retried by sending the task back; after
`engine_max_merge_conflicts` (3) the manager takes over (`error_review`). A failed
push is `ErrorKind.NETWORK` → `error_review`.
### 4.5 Budget
Before any turn, `_budget_reason` checks the org's wallet. A wallet **present** with
`balance <= 0` pauses the task (`Stage.PAUSED`, status `waiting_approval`) with the
Persian note `BUDGET_PAUSED_MESSAGE` — no turn is spent. A **missing** wallet means
no budget was ever configured, and a fresh org must not be frozen out of its first
task (so `get_for_org`, never `ensure`, and `None` is not a pause). An optional
per-task ceiling (`engine_max_task_credits > 0`) reads projected spend from
`TaskSpendRepository.spend_for_task` (a read-only join of the usage ledger to the
task's runs).
### 4.6 Flow test — `tests/test_engine_flow.py` (new)
The end-to-end test: real repositories, real scheduler, real manager, real turn
runner; only the model (scripted), the sandbox (scripted) and git
(`FakeRepoWorkspace`) are fakes. The happy path walks
`manager_review → analysis → implementation → test → final_review → merge →
completed` one tick at a time and asserts the stage, the coarse status, the gate
rows, the git calls (author, target, branch), the removal of the worktree, the
delivery hook and the memory callback. Eleven tests cover: the happy path; git
never flowing through the model; red tests → back → then green; a forbidden diff
never reaching git; a tester failure; a `TEST_PASS` with no green tests; the
`analysis_only` pipeline (no worktree ever); the budget pause; dependencies; a
transient provider failure retried then handed to the manager; and a merge conflict.
### 4.7 Wiring — `container_engine.build_scheduler`, the worker, `.env.example`
- `build_scheduler(session)` assembles the runner, manager, gate runner, git
workspace, sandbox, delivery hooks, wallet and per-task spend repos on the
caller's session — the same rule as the bootstrap (`container.py` is frozen, so
the engine wires itself in my file).
- `sandbox_session_for` reuses the **active** sandbox session of the task's project
(`_EngineSandboxSessions`). The engine does not create sandboxes; when there is
none, the tests gate goes red and sends the work back — never "merge untested
because there was no container".
- `workers/ai_runtime.py` now drives **both** engines in one pass: the legacy run
queue, then one `Scheduler.tick()`. A broken tick is recorded against the worker
and never looks like a clean pass. `__main__.py` defaults the `ai-runtime`
interval to `engine_tick_seconds`; the one-pass (cron) default is unchanged.
- `.env.example` gains the six new `ENGINE_*` settings.
### Bug found and fixed by the tests
`forbidden_paths` normalised with `path.lstrip("./")`, which strips **all** leading
`.` and `/` characters: `.git/config` became `git/config` and
`.github/workflows/ci.yml` became `github/...`, so the diff guard would have
silently allowed changes to git internals and CI configuration. It now strips a
single leading `./` prefix. Caught by `test_forbidden_paths_flag_git_internals_and_ci`.
### Other support changes
- `ManagerOutcome.error_kind` — the manager carries the runner's error vocabulary
so the scheduler can tell a transient provider failure from a permanent one
without re-classifying a string.
- `TaskSpendRepository` (`repositories/engine.py`) — `spend_for_task`, read-only.
- `Scheduler._patch` refreshes the caller's snapshot version after each write, so a
second patch in the same step locks against the version the first write produced
instead of silently losing the write to `StaleStageError`.
- `tests/engine_fakes.py`: `RecordingMemory` now records `record` and
`on_task_finished` (it previously returned `None` and asserted nothing).
## Tests
`tests/test_engine_gates.py` (new, **13 tests**): pure helpers (`diff_hash`,
`forbidden_paths`, `code_task`); the diff guard's four refusals; no-progress; the
tests gate with and without a command; the green gate's waiver rule.
`tests/test_engine_flow.py` (new, **11 tests**): see 4.6.
Together **+24 tests**, all through the real repositories.
## Checks (PLAN-v2 §7.4)
```
cd apps/api && .venv/bin/python -m pytest -q → 746 passed, 1 skipped
cd apps/api && .venv/bin/ruff check . → All checks passed!
cd apps/api && .venv/bin/mypy app → Success: no issues found in 160 source files
```
No web files were touched; no migration was needed (Phase 1's `a2b3c4d5e6f2` already
has the engine tables).
## Deviations & notes
1. **Concurrency is a selection cap, not 8 live turns.** A tick runs at most one
turn per task, sequentially. An `AsyncSession` is not concurrency-safe, so
running several turns on one session in parallel would be a corruption bug, not
a speed feature. `engine_max_concurrent_turns` / `engine_max_turns_per_org`
therefore bound how many tasks are *chosen*, and multi-process safety comes from
the optimistic `advance`. Recorded because it is a deliberate reading of 4.1.
2. **The engine does not create sandbox workspaces.** It reuses the active session
of the task's project; creating one automatically is a product decision the plan
does not make. Absent session → tests gate red → work sent back.
3. **The commit author e-mail is fixed** (`platform@smart-org.local`) and matches the
delivery adapter; the *name* is the employee's, which is what a reviewer reads.
4. `no_progress`/diff verdicts are persisted on the task state
(`last_diff_hash`, `same_diff_count`), so progress is judged across ticks and
across worker processes, not within one process.
## Open questions / CHANGE REQUESTs
- None new. The org automation policy (`automation_enabled`) is still behind
`ModelGrantPort` as planned.
## Next
Phase 5 — the web side: the engine board, the task page (live log, gates, diff),
`/workforce`, `/dashboard` + `/traces`, `src/test/engine-*.test.tsx`, and the final
workstream report.