- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
488 lines
38 KiB
Markdown
488 lines
38 KiB
Markdown
final check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/ruff check . && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app
|
||
|
||
# Smart Org v2 — finish plan (integration and closing work)
|
||
|
||
Base: branch `dev`, HEAD `36564e3`. Baseline checked on 2026-10-14: `pytest` 746 passed / 1 skipped,
|
||
`alembic upgrade head` + `alembic check` clean on SQLite, single head `b9c0d1e2f3a4`. None of the tasks
|
||
below needs a migration. If one turns out to need one, use `down_revision = "b9c0d1e2f3a4"`.
|
||
|
||
What the code already has (checked, so NOT planned again):
|
||
- `container_delivery.py` builds the real `LocalGitWorkspaceAdapter` when `GIT_WORKSPACE=local`; the fake
|
||
stays the default (that default is correct for tests and dev). `build_delivery_hooks` returns the real
|
||
`DeliveryService`, and `build_deploy_target_tester` returns the real `DispatchingDeployTargetTester`.
|
||
- `container_org.py` builds the real `MemoryService` and `ModelGrantService`, and
|
||
`container_engine.py` builds the real bootstrap and scheduler.
|
||
- The wizard (`app_setup.py::_build_service`) passes the real `build_workforce_bootstrap` and
|
||
`build_delivery_hooks`. `OrgSetupService._call_hooks` calls `bootstrap_team` and `on_app_created`
|
||
after its commit.
|
||
- After merge and push, `Scheduler._merge_step` calls `DeliveryHooks.on_branch_pushed(source="engine")`,
|
||
and `DeliveryService.on_branch_pushed` queues a `pipeline_run` for `dev`.
|
||
- No duplicate repository files exist: every repository class name is unique. The only duplicated wiring
|
||
is the hand-built `ModelGrantService` in `org_setup_service.py`, which T11 folds in.
|
||
|
||
Integration bugs found while checking (fixed by g1):
|
||
- **Double execution.** Engine tasks are created with coarse status `new`. In the same pass,
|
||
`AiRuntimeWorker._drain_runs` → `TaskRepository.list_runnable` hands them to the legacy one-shot
|
||
`start_run`. That run marks them `done` before `Scheduler.tick()` sees them. In the real worker, an
|
||
order from the manager never goes through the pipeline. → T2
|
||
- **Every new org is paused.** The wizard (and `organizations.py`, and `BillingService.get_wallet` via
|
||
`ensure`) creates a wallet with `balance=0.0`. `Scheduler._budget_reason` pauses on `balance <= 0`, and
|
||
nothing in the code ever debits a wallet from usage. So every org made by the wizard has every task
|
||
paused before its first turn. → T3
|
||
- **No sandbox for a new org.** The engine only reuses an *active* sandbox session, and nothing creates
|
||
one: sandbox mutations are platform-owner-only, and `on_app_created` does not start one. For a fresh
|
||
app, every tool turn fails and the tests gate is always red. → T6
|
||
- **The owner's org policy is ignored by the engine** (`automation_enabled`, `max_employees`,
|
||
`monthly_credit_budget` live only on `ModelGrantService`). → T1, T4, T5
|
||
|
||
Web checks run from `/projects/smart-org/apps/web` with the hoisted `/projects/smart-org/node_modules`
|
||
(this works: vitest + tsc run there). `@agy/contracts` in `node_modules` is a **copy**, not a link. After
|
||
editing `packages/contracts/src/index.ts`, copy it to `node_modules/@agy/contracts/src/index.ts` before
|
||
running tsc (T15 does that in its check).
|
||
|
||
---
|
||
|
||
## T1 — `ModelGrantPort.get_org_policy` (Agent 1 CHANGE REQUEST 1)
|
||
status: done
|
||
group: prepare
|
||
after: none
|
||
files: apps/api/app/application/ports/model_grants.py:15-50, apps/api/tests/engine_fakes.py:184-219
|
||
change:
|
||
1. In `ports/model_grants.py` add a read-only `OrgPolicyView(Protocol)` with `@property` members:
|
||
`max_employees -> int`, `automation_enabled -> bool`, `mcp_enabled -> bool`,
|
||
`monthly_credit_budget -> float | None`. Using properties lets `ModelGrantService.PolicyView` (a plain
|
||
`@dataclass(slots=True)`) and a frozen dataclass both satisfy it under mypy.
|
||
2. Add a frozen dataclass `DefaultOrgPolicy(max_employees: int = 20, automation_enabled: bool = True,
|
||
mcp_enabled: bool = True, monthly_credit_budget: float | None = None)`. Do not import settings in the
|
||
port; the engine applies `settings.engine_max_employees` itself.
|
||
3. Add `async def get_org_policy(self, organization_id: str) -> OrgPolicyView: ...` to the
|
||
`ModelGrantPort` Protocol. Keep the argument **positional**, because that matches the existing
|
||
`ModelGrantService.get_org_policy(organization_id)` at `services/model_grant_service.py:378`.
|
||
4. Implement it on `AllActiveModelsGrants` by returning `DefaultOrgPolicy()`. Update the module
|
||
docstring: the contract was extended at integration.
|
||
5. In `tests/engine_fakes.py::FakeGrants`, add a `policy` kwarg/attribute (default `None`) and
|
||
`async def get_org_policy(self, organization_id)`. When `policy` is None, return
|
||
`DefaultOrgPolicy(max_employees=settings.engine_max_employees)`, read at call time so the
|
||
`monkeypatch` in `test_the_employee_cap_is_enforced` still works.
|
||
6. Do not change `ModelGrantService`. `isinstance(service, ModelGrantPort)` must stay true
|
||
(`tests/test_foundation.py:147`).
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_foundation.py tests/test_model_grants.py tests/test_engine_manager.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/ports/model_grants.py app/application/services/model_grant_service.py
|
||
|
||
## T2 — the legacy run drain must never pick up engine tasks
|
||
status: done
|
||
group: g1
|
||
after: none
|
||
files: apps/api/app/infrastructure/repositories/workforce.py:10,32,444-457, apps/api/tests/test_integration_runtime.py (new)
|
||
change:
|
||
1. In `TaskRepository.list_runnable`, add
|
||
`~exists(select(TaskPipelineStateRow.task_id).where(TaskPipelineStateRow.task_id == TaskRow.id))`
|
||
to the `where`. Import `exists` from sqlalchemy and `TaskPipelineStateRow` from
|
||
`app.infrastructure.db.models_engine`. Extend the docstring: tasks that own a pipeline state belong to
|
||
`Scheduler.tick()`, and running them here would double-bill and mark them `done` behind the engine's
|
||
back.
|
||
2. New test file `tests/test_integration_runtime.py` (fixtures `session_factory`, `seeded` from
|
||
conftest). Make one org with two `TaskRow`s in status `new`, and give one of them a
|
||
`TaskPipelineState` (`PipelineStateRepository.create`, stage `PENDING_MANAGER_REVIEW`; see the
|
||
`_task` helper in `tests/test_engine_flow.py:162`). Assert that `list_runnable()` returns only the
|
||
plain task.
|
||
3. Before editing, run `git log -1 -- apps/api/app/infrastructure/repositories/workforce.py`. If the live
|
||
Agent 2 session has changed the file, rebase your edit onto its version.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_runtime.py tests/test_workers.py tests/test_workforce.py
|
||
|
||
## T3 — an empty (0.0) wallet must not pause the engine; only an overdrawn one does
|
||
status: done
|
||
group: g1
|
||
after: none
|
||
files: apps/api/app/application/engine/scheduler.py:592-604, apps/api/tests/test_engine_flow.py:88-104,535-562
|
||
change:
|
||
1. Decision, recorded in the docstring: usage is post-paid (the ledger feeds invoices) and wallets are
|
||
never debited by usage. A zero balance is therefore every org's starting state, not "out of money".
|
||
In `_budget_reason`, change `wallet.balance <= 0` to `wallet.balance < 0` and update the comment.
|
||
The owner's real spend lever is `monthly_credit_budget` (T4).
|
||
2. In `tests/test_engine_flow.py`, set `_EmptyWallet.balance = -1.0`, rename it `_OverdrawnWallet` and
|
||
update its docstring. Rename the test to `test_an_overdrawn_wallet_pauses_the_task_before_a_turn_is_spent`.
|
||
Add `test_a_zero_wallet_does_not_pause_a_fresh_org`: a 0.0 wallet, stage `PENDING_MANAGER_REVIEW`, one
|
||
tick, and the stage is no longer `PAUSED`. Explain the intentional behaviour change in the test
|
||
docstring (PLAN-v2 §7.4 allows this).
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_flow.py
|
||
|
||
## T4 — scheduler honours the org policy: automation switch + monthly credit budget
|
||
status: done
|
||
group: g1
|
||
after: T1, T3
|
||
files: apps/api/app/application/engine/scheduler.py:162-201,592-604, apps/api/app/infrastructure/repositories/engine.py:491-507, apps/api/tests/test_integration_policy.py (new)
|
||
change:
|
||
1. `TaskSpendRepository`: add `async def spend_for_org_since(self, organization_id: str, since: datetime) -> float`,
|
||
which returns the sum of `UsageEventRow.total_price` for that org with `created_at >= since`.
|
||
It is read-only, in the same style as `spend_for_task`.
|
||
2. `Scheduler.tick`: build a per-tick cache `policies: dict[str, OrgPolicyView]` from
|
||
`await self.manager.grants.get_org_policy(org_id)`. Drop turn **and** merge states whose org has
|
||
`automation_enabled is False`: `report.skipped += 1` plus a Persian note
|
||
("اتوماسیون این سازمان خاموش است"). Do not move the stage. D13 says a human-off switch must stop
|
||
everything up to the merge.
|
||
3. `_budget_reason`: when the policy's `monthly_credit_budget is not None` and `self.spend` is set,
|
||
compare `spend_for_org_since(org, first day of the current UTC month)` with the budget. When spend
|
||
is >= the budget, return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد.". Fetch the policy once
|
||
per task, or pass it in from the tick cache.
|
||
4. New `tests/test_integration_policy.py`. Reuse `world`/`_team`/`_task`/`_scheduler` by importing them
|
||
from `tests.test_engine_flow`, or copy the minimum. Test that: automation off → the task does not move
|
||
and `scripted.calls == 0`; a budget of 0.0 with any ledger spend → `PAUSED`; no policy → unchanged
|
||
behaviour. Pass the policy through `FakeGrants(policy=DefaultOrgPolicy(...))` from T1.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_policy.py tests/test_engine_flow.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py app/infrastructure/repositories/engine.py
|
||
|
||
## T5 — hiring respects the owner's per-org `max_employees`
|
||
status: done
|
||
group: g1
|
||
after: T1
|
||
files: apps/api/app/application/engine/manager.py:290-310, apps/api/tests/test_integration_hire_cap.py (new)
|
||
change:
|
||
1. In `ManagerTurns.hire`, compute
|
||
`cap = min(settings.engine_max_employees, (await self.grants.get_org_policy(organization_id)).max_employees)`
|
||
and use `cap` in the check and in the Persian message, in place of `settings.engine_max_employees`.
|
||
2. New test `tests/test_integration_hire_cap.py`, modelled on `tests/test_engine_manager.py:356`
|
||
(`test_the_employee_cap_is_enforced`). Use `FakeGrants(policy=DefaultOrgPolicy(max_employees=2))`:
|
||
the third hire raises `ConflictError`. A second case checks that the global setting still wins when it
|
||
is lower.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_hire_cap.py tests/test_engine_manager.py
|
||
|
||
## T6 — the engine provisions the app's workspace sandbox session when none is active
|
||
status: done
|
||
group: g1
|
||
after: none
|
||
files: apps/api/app/application/container_engine.py:87-128, apps/api/tests/test_integration_sandbox.py (new)
|
||
change:
|
||
1. In `_EngineSandboxSessions.for_task`, when the loop finds no active session, call a new
|
||
`_provision(task)` instead of returning `""`. `_provision` does the following:
|
||
- Build `build_sandbox_service(self.session)` with a lazy `from app.application.container import ...`.
|
||
- Reuse the first non-`DESTROYED` workspace of the project, or create one. For a new one, take the
|
||
template from `SandboxTemplateRepository(self.session).list()`, preferring
|
||
`str(t.provider) == settings.sandbox_provider`, otherwise the first one. If there are no templates,
|
||
return `""`.
|
||
- Call `service.start_session(organization_id=task.organization_id, workspace_id=...)` and return
|
||
`external_session_id or ""`.
|
||
- On `AppError`, call `record_error(kind="engine.sandbox_provision_failed", organization_id=...,
|
||
metadata={"task_id", "project_id"})` and return `""`. Never swallow silently (rule 6).
|
||
2. Update the class docstring. It currently says "the engine does not create sandboxes". The integration
|
||
decision is that the engine creates the project's single workspace session on demand, because a fresh
|
||
org has no way to start one (sandbox mutations are owner-only).
|
||
3. New `tests/test_integration_sandbox.py`. Use the default mock sandbox provider and seeded templates
|
||
(confirm `seeded` creates `SandboxTemplateRow`s; otherwise insert one). Test that: a project with no
|
||
workspace gets one workspace + one RUNNING session and a non-empty id; a second call reuses it (no
|
||
second session); with no templates, it returns `""` and records an error.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_sandbox.py tests/test_workers.py tests/test_engine_flow.py
|
||
|
||
## T7 — end-to-end smoke: wizard → team → manager order → engine task → push → pipeline run queued
|
||
status: done
|
||
group: g1
|
||
after: T2, T3, T6
|
||
files: apps/api/tests/test_v2_e2e_smoke.py (new)
|
||
change:
|
||
1. One test that drives the v2 seams with the **real** builders. Fakes are allowed only for the model
|
||
(`tests.engine_fakes.ScriptedModel`), git (the default `GIT_WORKSPACE=fake` singleton
|
||
`app.application.container_delivery._fake_workspace`) and the mock sandbox/domain router (the
|
||
defaults).
|
||
- monkeypatch `app.application.container.get_model_provider` → `lambda: scripted`. The engine, memory
|
||
and bootstrap all resolve it lazily from `container`.
|
||
- As the platform owner, grant default models first (see `tests/test_model_grants.py` for the
|
||
`PUT /api/admin/default-model-grants/{model}` helper). Grant at least one model with `"reasoning"`
|
||
and one cheap one, so `bootstrap_team` can hire (read `ManagerTurns.bootstrap_team` for the tiers it
|
||
needs).
|
||
- Create a manager and complete the wizard over HTTP, reusing `_manager`/`_complete` from
|
||
`tests/test_org_wizard.py`. Do **not** monkeypatch `_call_hooks`.
|
||
- Assert: employees exist for the org (manager + team), `app_runtimes` has a row with
|
||
`demo_host == f"{slug}.{settings.preview_root_domain}"`, and the org has `org_model_grants` rows.
|
||
- Send a manager order through the same path as
|
||
`tests/test_engine_manager.py::test_an_order_becomes_tasks_with_pipeline_state`, with the scripted
|
||
reply holding one task tag. Assert that one `task_pipeline_state` exists.
|
||
- Set `_fake_workspace.diffs[task_id] = DiffResult(changed_files=["app/main.py"], text="--- a\n+++ b\n@@\n+x\n")`.
|
||
Queue the replies from `tests/test_engine_flow.py:286-294`, then call
|
||
`build_scheduler(session).tick()` with a commit after each, up to about 10 times, until the stage is
|
||
`COMPLETED`. Do not assert an exact model call count: the reflection (memory) also consumes a call,
|
||
and the default `WORK_REPORT` reply is fine.
|
||
- Assert: task status `done`, and `PipelineRunRepository.list_for_project` holds a run with
|
||
`trigger="engine"`, `branch="dev"` and the pushed sha.
|
||
2. Mark it with a module docstring in the style of `tests/test_e2e_smoke.py`. No network, docker or real
|
||
git.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_v2_e2e_smoke.py
|
||
|
||
## T8 — memory reflection runs only on a model granted to the org
|
||
status: done
|
||
group: g2
|
||
after: none
|
||
files: apps/api/app/application/services/memory_service.py:93-110,372-404, apps/api/app/application/container_org.py:45-63, apps/api/tests/test_memory_grants.py (new)
|
||
change:
|
||
1. `MemoryService.__init__`: add the optional kwarg `grants: ModelGrantPort | None = None` and store it.
|
||
2. `_reflect`: when `self.grants` is set, call `granted_models(organization_id=...)`. If nothing is
|
||
granted, call `record_error(kind="memory.reflection_no_grant", ...)` and return `""`. Otherwise pick
|
||
the first granted model with tier `fast`, then `balanced`, then any.
|
||
`model = await self.models.get(chosen.ai_model_id)`. If it is missing or inactive, record the error
|
||
and return `""`. When `grants` is None, keep the current `pick_for_mode(CHEAP)` path, so existing
|
||
direct constructions in `tests/test_memory.py` keep working.
|
||
3. `container_org.build_memory_service` passes `grants=build_model_grants(session)`.
|
||
4. Add tests to a new file `apps/api/tests/test_memory_grants.py`: an org with no grant → no provider
|
||
call and an error recorded; an org with a granted model → the provider receives that model.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_memory_grants.py tests/test_memory.py
|
||
|
||
## T9 — a caller for `expire_memories` (Agent 1 CHANGE REQUEST 2)
|
||
status: done
|
||
group: g2
|
||
after: none
|
||
files: apps/api/app/workers/billing.py:30-100, apps/api/app/workers/__main__.py:57-72, apps/api/tests/test_memory_expiry_worker.py (new)
|
||
change:
|
||
1. `BillingWorker`: add `async def expire_memories(self) -> WorkerOutcome`. Inside `worker_session()`,
|
||
call `MemoryRepository(session).expire(None)`, which is what `MemoryService.expire_memories`
|
||
delegates to and needs no model provider. Set `processed = <count>` and add a note.
|
||
2. `__main__._billing_pass.one_pass`: run `roll_up(...)`, then `expire_memories()`, and return one merged
|
||
`WorkerOutcome` (sum processed/skipped/failed, concatenate notes). Update the docstring of
|
||
`billing.py`. The README row is T18's job, not this task's.
|
||
3. Add `apps/api/tests/test_memory_expiry_worker.py`. Create an expired unpinned lesson and a pinned one
|
||
(see `tests/test_memory.py:440-460`), run `BillingWorker().expire_memories()`, and assert that only the
|
||
unpinned one is soft-deleted. If `worker_session` needs the test DB, follow how `tests/test_workers.py`
|
||
patches it.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_memory_expiry_worker.py tests/test_workers.py
|
||
|
||
## T10 — MCP `pipeline_status` reads Agent 3's delivery tables (Agent 1 CHANGE REQUEST 3)
|
||
status: done
|
||
group: g2
|
||
after: none
|
||
files: apps/api/app/interfaces/mcp/gateway.py:152-161,562-566, apps/api/tests/test_mcp.py:276-279
|
||
change:
|
||
1. `_pipeline_status`: resolve the org's single app via
|
||
`AppProfileRepository(session).get_for_org(identity.organization_id)`. If there is none, return
|
||
`{"available": False, "message": "اپلیکیشنی برای این سازمان یافت نشد."}`. Otherwise read
|
||
`AppRuntimeRepository.get_for_org`, the last 5 runs of
|
||
`PipelineRunRepository.list_for_project(organization_id=..., project_id=..., limit=5)`, and the
|
||
newest `ProductionDeployRepository.list_for_project` entry. Return `available: True`, plus
|
||
`demo_url`, `runtime_status`, and `runs: [{id, status, trigger, branch, sha[:12], error_summary,
|
||
created_at iso}]`, plus `last_production_deploy: {status, created_at} | None`. Never return ports,
|
||
container ids, hosts of deploy targets or log tails (rule 7).
|
||
2. Remove "در این نسخه در دسترس نیست" from the ToolSpec description.
|
||
3. In `tests/test_mcp.py` at about line 276, change the assertion: an org without an app still returns
|
||
`available is False`, and that test org may have no project, so keep it. Add a case that creates a
|
||
project + `app_runtimes` row + one `pipeline_runs` row and asserts `available is True` and one run,
|
||
with no `host_port` key anywhere in the payload.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_mcp.py
|
||
|
||
## T11 — one import surface + wizard hooks record their failures
|
||
status: done
|
||
group: g2
|
||
after: none
|
||
files: apps/api/app/application/container.py:18-26,168-182, apps/api/app/application/services/org_setup_service.py:383-425
|
||
change:
|
||
1. `container.py`: also import and re-export `build_memory_service`, `build_model_grant_service` (from
|
||
`container_org`) and `build_scheduler` (from `container_engine`), and add them to `__all__`
|
||
(Agent 1 CHANGE REQUEST 6).
|
||
2. `org_setup_service._copy_default_grants`: replace the hand-built `ModelGrantService(...)` with a lazy
|
||
`from app.application.container_org import build_model_grant_service` and
|
||
`build_model_grant_service(self.session)`.
|
||
3. `_call_hooks`: replace the two `logger.warning(...)` swallows with
|
||
`await record_error(kind="wizard.bootstrap_failed" | "wizard.on_app_created_failed", message=type(exc).__name__,
|
||
exc=exc, organization_id=organization_id, metadata={"project_id": project_id})`, using
|
||
`from app.infrastructure.error_log import record_error`. Then `await self.session.rollback()` so a
|
||
failed hook cannot leave the request session unusable for the next hook (rule 6). Do not put the
|
||
exception text into the message if it could carry a secret.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_foundation.py tests/test_org_wizard.py tests/test_model_grants.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/container.py app/application/services/org_setup_service.py
|
||
|
||
## T12 — post-login redirect to the wizard when `needs_onboarding` (Agent 1 CHANGE REQUEST 5, TODO 1.9)
|
||
status: done
|
||
group: g3
|
||
after: none
|
||
files: apps/web/src/app/login/page.tsx:28-44, apps/web/src/test/org-login-redirect.test.tsx (new)
|
||
change:
|
||
1. After `login(...)` succeeds, and in the "already logged in" effect, call `fetchNeedsOnboarding()`
|
||
from `@/lib/api-org`. If it returns true, use `router.replace("/onboarding")`; otherwise keep
|
||
`nextDestination("/dashboard")`. If the call fails, fall back to the current behaviour.
|
||
`AppShell.tsx` and `/dashboard` stay untouched (the dashboard is Agent 2's phase 5).
|
||
2. Test with vitest: mock `@/lib/session` (`login` resolves), `next/navigation` (`replace` spy) and
|
||
`@/lib/api-org` (`fetchNeedsOnboarding` → true, then false). Assert `/onboarding` vs `/dashboard`.
|
||
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-login-redirect.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
|
||
|
||
## T13 — settings: sharing section (pending invites, transfer ownership, leave) (Agent 1 TODO 1.10)
|
||
status: done
|
||
group: g3
|
||
after: none
|
||
files: apps/web/src/app/(app)/settings/_components/sharing-section.tsx (new), apps/web/src/app/(app)/settings/page.tsx:347-479, apps/web/src/test/org-settings-sharing.test.tsx (new)
|
||
change:
|
||
1. New component `SharingSection({ org, members, canManageMembers, onChanged })`:
|
||
- pending invites list (`orgApi.listOrgInvites`, filter `accepted_at == null`) with a revoke button
|
||
(`orgApi.revokeOrgInvite`, `ConfirmDialog`);
|
||
- owner only: "انتقال مالکیت". Pick an admin member, then a `Modal` that requires typing the org slug
|
||
before calling `orgApi.transferOrgOwnership(orgId, {new_owner_user_id, confirm: slug})`;
|
||
- "ترک سازمان" with a `ConfirmDialog`, calling `orgApi.leaveOrganization`, then `refresh()` from
|
||
`useSession` and a redirect to `/onboarding` if no org is left.
|
||
Persian/RTL, `@agy/ui` components only, errors through `ErrorState`, and a server 4xx shown as its
|
||
message.
|
||
2. Render it at the end of the `members` tab in `settings/page.tsx`, after the invite card at about
|
||
line 478.
|
||
3. Test: the invite list renders and revoke calls the API; the transfer button stays disabled until the
|
||
slug is typed; a non-owner sees no transfer section.
|
||
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-settings-sharing.test.tsx src/test/org-sharing.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
|
||
|
||
## T14 — Application page gates admin actions by org role (Agent 3 CHANGE REQUEST 3)
|
||
status: done
|
||
group: g3
|
||
after: none
|
||
files: apps/web/src/app/(app)/app/page.tsx:99-103, apps/web/src/test/delivery-app.test.tsx:82,282-308
|
||
change:
|
||
1. Replace `const canManage = Boolean(user);` with
|
||
`activeOrganization?.role === "owner" || activeOrganization?.role === "admin"`, which matches the
|
||
server's org admin/owner rule for re-run, rollback and production. Keep the comment that the server
|
||
is the boundary.
|
||
2. In `delivery-app.test.tsx`, set `ORG = { id: "org-1", slug: "acme", role: "admin" }`. Make the
|
||
"hides the deploy button…" case use `role: "member"`. All existing cases must pass.
|
||
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/delivery-app.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
|
||
|
||
## T15 — `memory.changed` in `@agy/contracts` (Agent 1 CHANGE REQUEST 4)
|
||
status: done
|
||
group: g3
|
||
after: none
|
||
files: packages/contracts/src/index.ts:816-838, apps/web/src/app/(app)/memory/page.tsx:139
|
||
change:
|
||
1. Add `| "memory.changed"` (with a `// v2 org platform` comment) at the end of the `WsEventType` union.
|
||
Do **not** add engine types (`task.step`, `task.gate`, `run.log`, `run.tool`, `team.changed`). Those
|
||
belong to Agent 2 phase 5, which may be editing this union, so check `git diff` first.
|
||
2. In `memory/page.tsx:139`, drop the `(event.type as string)` cast and compare `event.type ===
|
||
MEMORY_CHANGED_EVENT`. If TS complains about the literal, type `MEMORY_CHANGED_EVENT` in
|
||
`lib/api-org.ts:310` as `WsEventType`, but only if it is needed.
|
||
check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-memory.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
|
||
|
||
## T16 — tick Agent 1's TODO file
|
||
status: done
|
||
group: g3
|
||
after: T12, T13
|
||
files: docs/plan/TODO-agent-1-org-platform.md:25-120
|
||
change: Tick (`- [x]`) every box whose deliverable exists in code. Check each one with the endpoint and
|
||
file lists in `docs/plan/reports/agent-1-final.md` and with `ls`/`grep`. 1.9 and 1.10 are now done
|
||
(T12, T13). For items finished at integration, append a short note, e.g. `(expire hook: T9;
|
||
pipeline_status: T10)`. Leave unticked anything still missing and add `— open:` with the reason.
|
||
check: cd /projects/smart-org && grep -c "^- \[x\]" docs/plan/TODO-agent-1-org-platform.md && grep -n "^- \[ \]" docs/plan/TODO-agent-1-org-platform.md
|
||
|
||
## T17 — tick the foundation and Agent 3 TODO files
|
||
status: done
|
||
group: g4
|
||
after: none
|
||
files: docs/plan/TODO-0-foundation.md:9-60, docs/plan/TODO-agent-3-delivery-infra.md:34-169
|
||
change: Tick every box that is really done. Verify with `ls`/`grep` against
|
||
`docs/plan/reports/agent-3-final.md` §1/§4/§5 and the foundation commit (the stub routers in
|
||
`rpc_app.py`, `services/*/README.md`, `docs/plan/reports/.gitkeep`, the worktrees listed by
|
||
`git worktree list`). The foundation baseline counts are historical, so tick them with a note "(baseline
|
||
recorded at foundation time)". Leave anything not found unticked with `— open:`.
|
||
check: cd /projects/smart-org && grep -c "^- \[ \]" docs/plan/TODO-0-foundation.md docs/plan/TODO-agent-3-delivery-infra.md
|
||
|
||
## T18 — README: v2 runtime, real-mode env, workers
|
||
status: done
|
||
group: g4
|
||
after: none
|
||
files: README.md:233-293
|
||
change:
|
||
1. §8 table: `SANDBOX_PROVIDER` gains `docker`. Add rows for `GIT_WORKSPACE` (`fake` | `local`),
|
||
`ALLOW_CUSTOM_DOMAINS`, `DEPLOY_ALLOW_PRIVATE_HOSTS` and `MCP_GATEWAY_ENABLED`. Add one "real mode"
|
||
line: `SANDBOX_PROVIDER=docker GIT_WORKSPACE=local DOMAIN_ROUTER=caddy GIT_PROVIDER=gitea`, plus the
|
||
host requirements from `docs/plan/reports/agent-3-final.md` §2 (docker/podman, git ≥ 2.20, the caddy
|
||
import line, caddy reload permission).
|
||
2. §9: say "five processes" instead of three. Add `delivery` (`pnpm worker:delivery`: runs queued
|
||
pipeline runs and polls remotes), `ai-runtime` (now also one `Scheduler.tick()` per pass,
|
||
`ENGINE_TICK_SECONDS`), the memory-expiry step in the billing pass (T9), and the MCP gateway
|
||
(`pnpm mcp:dev`, `services/mcp-gateway/README.md`). Persian, same tone.
|
||
check: cd /projects/smart-org && grep -n "GIT_WORKSPACE\|worker:delivery\|mcp:dev\|docker" README.md
|
||
|
||
## T19 — PLAN-v2: integration status section
|
||
status: done
|
||
group: g4
|
||
after: none
|
||
files: docs/plan/PLAN-v2.md:270-281
|
||
change: After §8, add `## 9. Status (2026-10-14)`. It lists: §8.1–8.3 done (merges, single alembic
|
||
head `b9c0d1e2f3a4`, `alembic check` clean); §8.4 done through `todo-finish-plan.md` T1–T11, with the
|
||
three integration bugs fixed (double run, zero-wallet pause, no sandbox) and the e2e smoke test
|
||
`tests/test_v2_e2e_smoke.py`; the production-deploy leg of the smoke stays manual (agent-3-final §6
|
||
steps 7–8, `DEPLOY_IT=1`); the contract extension `ModelGrantPort.get_org_policy` (T1); and what is left
|
||
to Agent 2 phase 5 (the list at the end of this file). Tick each line only if the matching task in this
|
||
file is `status: done` when you write it.
|
||
check: cd /projects/smart-org && grep -n "## 9. Status" docs/plan/PLAN-v2.md
|
||
|
||
## T20 — manager CREATE_TASK falls back to the org's single project
|
||
status: done
|
||
group: prepare
|
||
after: none
|
||
files: apps/api/app/application/engine/manager.py (CREATE_TASK ~lines 533-548), apps/api/tests/test_integration_thread_project.py (new)
|
||
change: in CREATE_TASK use `project_id = thread.project_id or await self._project_id_for(organization_id)` for BOTH the Task and the pipeline state (`_project_id_for` exists ~line 1066; `hire` uses it ~line 322). Why: the real UI (apps/web/src/app/(app)/manager/page.tsx:166) creates threads without project_id so tasks got no worktree/sandbox. Test: create a thread through HTTP `POST /api/organizations/{org}/threads` without project_id (see tests/test_engine_manager.py for how orders are sent), post an order with a scripted task tag, assert the created task and its pipeline state carry the org's project id.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_thread_project.py tests/test_engine_manager.py
|
||
|
||
## T21 — pass has_active_reviewer to resolve_pipeline
|
||
status: done
|
||
group: prepare
|
||
after: T20
|
||
files: apps/api/app/application/engine/manager.py (calls ~line 549 and ~720), apps/api/tests/test_integration_review_fallback.py (new)
|
||
change: compute `has_active_reviewer = any(e.role_key is RoleKey.REVIEWER and e.is_available for e in await self.employees.list_for_org(org_id))` (adapt to the real attribute names) and pass it as `has_active_reviewer=` in both calls (resolve_pipeline in domain/workforce/pipeline.py ~line 403 already falls back without a reviewer). Do NOT add a reviewer to the bootstrap team. Test: an org with the bootstrap team (no reviewer) and a refactor-kind task never lands on a pipeline with a review stage; with a reviewer it does.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_review_fallback.py tests/test_engine_manager.py tests/test_engine_pipeline.py
|
||
|
||
## T22 — the scheduler tick must not starve behind skipped tasks
|
||
status: done
|
||
group: prepare
|
||
after: none
|
||
files: apps/api/app/workers/ai_runtime.py (~line 128), apps/api/tests/test_integration_runtime.py
|
||
change: call `scheduler.tick(limit=500)` instead of `max(limit, 10)` (list_at_stages caps at 500; _select still limits real work via engine_max_concurrent_turns/engine_max_turns_per_org). Add a test: 12 switched-off or unassignable states for org A must not stop org B's task from being picked in one tick (use FakeGrants(policy=DefaultOrgPolicy(automation_enabled=False)) style from tests/test_integration_policy.py, or the simplest honest setup).
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_runtime.py tests/test_workers.py tests/test_integration_policy.py
|
||
|
||
## T23 — fired employees must free a hire slot
|
||
status: done
|
||
group: prepare
|
||
after: none
|
||
files: apps/api/app/infrastructure/repositories/workforce.py (EmployeeRepository.count_for_org ~203-207), apps/api/tests/test_integration_hire_cap.py
|
||
change: exclude ARCHIVED employees (`EmployeeRow.status != str(EmployeeStatus.ARCHIVED)`; confirm the real enum/column names). Add a test: after firing one employee at the cap, a hire succeeds. Leave the default cap value untouched (product decision, not ours).
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_hire_cap.py tests/test_engine_manager.py tests/test_workforce.py
|
||
|
||
## T24 — tenant isolation for project_id used by the engine
|
||
status: done
|
||
group: prepare
|
||
after: T20
|
||
files: apps/api/app/application/services/workforce_service.py (create_thread ~324-339, create_task ~410-437), apps/api/app/application/container_engine.py (_EngineSandboxSessions.for_task and _provision ~117-127), apps/api/tests/test_integration_tenant_project.py (new)
|
||
change: in create_thread/create_task, when project_id is given and is not the caller org's project, raise NotFoundError (404; use the project of the org via the existing app profile / project repository). In for_task and _provision filter workspaces by `w.organization_id == task.organization_id`. Test: org A cannot create a thread or task with org B's project id (404), and for_task never returns org B's session for an org A task.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_tenant_project.py tests/test_integration_sandbox.py tests/test_workforce.py tests/test_engine_flow.py
|
||
|
||
## T25 — login redirect must keep an explicit `next` (pending invites)
|
||
status: done
|
||
group: prepare
|
||
after: none
|
||
files: apps/web/src/app/login/page.tsx, apps/web/src/test/org-login-redirect.test.tsx
|
||
change: in landingAfterSignIn check `next` first (`const next = nextDestination(""); if (next) return next;` — adapt to the real helper) and only then the needs_onboarding check. Add a test case: with `?next=/accept-invite?token=x` and needs_onboarding true it still goes to the next URL. Web check command style above (org-login-redirect.test.tsx).
|
||
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-login-redirect.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
|
||
|
||
## T26 — make the e2e smoke test closer to reality
|
||
status: done
|
||
group: prepare
|
||
after: T20, T21
|
||
files: apps/api/tests/test_v2_e2e_smoke.py
|
||
change: (a) create the thread through HTTP without project_id instead of ThreadRow(project_id=...); (b) drive the engine through `AiRuntimeWorker().drain()` (conftest already patches worker_base.SessionFactory) instead of build_scheduler(...).tick(), still looping until COMPLETED; (c) assert a SandboxSessionRow exists for the org's project; (d) keep REVIEW out of the replies only if the default pipeline still needs it.
|
||
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_v2_e2e_smoke.py
|
||
|
||
## T27 — docs truth
|
||
status: done
|
||
group: prepare
|
||
after: T20-T26 are done in the todo file
|
||
files: docs/plan/PLAN-v2.md (§9 status section), docs/plan/TODO-agent-3-delivery-infra.md (item 6.5)
|
||
change: in PLAN-v2 §9 mark §8.4 done through T1-T27 (tick T6 and T7; mention the review fixes: project fallback, reviewer fallback, tick starvation, archived headcount, tenant check, login next) — only write "done" for tasks whose status in the todo file is `done` at that moment. In TODO-agent-3 item 6.5 remove the claim that no non-admin-deploy-button test exists (T14 added it); only log batching/auto-scroll remain open.
|
||
check: cd /projects/smart-org && grep -n "T27\|## 9. Status" docs/plan/PLAN-v2.md
|
||
|
||
---
|
||
|
||
## Left to Agent 2 (do not touch — the live phase-5 session owns these)
|
||
- `apps/web/src/lib/api-engine.ts`, `(app)/manager/**`, `(app)/tasks/**` (board + `/tasks/[id]`: live log,
|
||
gates, diff), `(app)/workforce/**`, `(app)/dashboard/**`, `(app)/traces/**`, `src/test/engine-*.test.tsx`,
|
||
the engine event types in `@agy/contracts` (`task.step`, `task.gate`, `run.log`, `run.tool`, `team.changed`),
|
||
ticking `docs/plan/TODO-agent-2-ai-workforce.md`, and `docs/plan/reports/agent-2-final.md`.
|
||
- Decisions to hand to Agent 2 / the user (not planned here):
|
||
- Tasks created directly (HTTP `POST /tasks`, MCP `tasks_create`) get **no** pipeline state and still
|
||
run through the legacy one-shot `start_run`. Agent 1 TODO 5.3 says MCP `tasks_create` should "go to
|
||
the manager as a user order". Decide whether direct tasks enter the engine (create a
|
||
`TaskPipelineState` at `PENDING_MANAGER_REVIEW`) and whether `WorkforceService.start_run` should refuse
|
||
a task that owns a pipeline state (the "اجرا" button).
|
||
- A `paused` task (budget or user decision) has no resume path in the API yet. The board will need one.
|
||
|
||
## Not planned (noted only)
|
||
- `DeployTargetSpec` field additions (`post_deploy_command`, ids): Agent 1 phase-1 CR. Nothing is broken.
|
||
- `infra/docker/docker-compose.yml` has no worker or MCP services. The delivery worker needs the host
|
||
docker socket, so running workers on the host (README §9) stays the documented path.
|
||
- MCP rate limiter and `mcp_gateway_enabled` are process-local (Agent 1 known limitations).
|
||
- Disk pruning of `runs/`, `artifacts/`, `ftp-backup-*` (Agent 3 known limitations).
|