11 KiB
Handoff: Tasks & SubTasks — how the backend services write them
Audience: the agent working in the frontend app that reads task/subtask progress directly from the shared Postgres database.
Purpose: this doc describes the two DB tables the FE renders (tasks,
sub_task), the meaning of each field, the status roll-up rules, and how each
backend service (magicplan, pashub_fetcher, modelling, plan, etc.) writes rows
into them. The DB schema is owned partly by the FE (Drizzle migrations) and
partly by the backend — this explains what the backend puts in the columns so
the FE can render it correctly.
The backend source of truth for the shapes below:
domain/tasks/tasks.py,domain/tasks/subtasks.py,infrastructure/postgres/task_table.py,infrastructure/postgres/subtask_table.py, anddocs/adr/0055-modelling-run-batches-attach-to-the-app-owned-task.md.
1. The data model
A Task is one unit of work the user cares about (a magicplan fetch, a pashub
evidence download, one modelling run). A Task has one or more SubTasks —
the actual executions. The FE renders a task's progress as
count(complete subtasks) / count(all subtasks).
Table tasks
| Column | Type | Notes |
|---|---|---|
id |
UUID (PK) | |
task_source |
text | Which worker/code produced the task. e.g. magic_plan, pashub_fetcher, modelling_e2e, abri_api, bulk_document_download, hubspot_scraper. Set by worker Lambdas. |
service |
text, nullable | App-facing label, set only for tasks the app creates (e.g. modelling_run, plan_engine, plan_categorisation). Worker-created tasks leave this NULL. See §4 for how to identify a task. |
status |
text | One of waiting, in progress, complete, failed (lowercase, space in "in progress"). Derived — see §3. |
source |
enum, nullable | What the task hangs off: portfolio_id, hubspot_deal_id, or property_id. |
source_id |
text, nullable | The id of that source entity (a hubspot deal id, portfolio id, …). Use source + source_id together to link a task back to the thing on screen. |
inputs |
text (JSON), nullable | FE-owned column (Drizzle). Holds the task's original request as a JSON string. The app writes it; the backend at most reads it. Per-execution inputs live on the sub_tasks, not here. |
job_started |
timestamptz, nullable | |
job_completed |
timestamptz, nullable | Cleared back to NULL if a failed task is re-run and rolls back to in-progress. |
updated_at |
timestamptz |
Table sub_task
| Column | Type | Notes |
|---|---|---|
id |
UUID (PK) | |
task_id |
UUID (FK → tasks.id) |
Parent task. |
status |
text | Same four values as tasks: waiting, in progress, complete, failed. |
inputs |
text (JSON), nullable | The exact payload this execution ran on. For a re-runnable batch it is literally the re-run recipe (re-send these inputs). |
outputs |
text (JSON), nullable | Result on success ({"result": …}); on failure {"error": "…"} plus any structured detail (e.g. {"succeeded": n, "failed": [{"property_id", "error"}]}). |
cloud_logs_url |
text, nullable | Deep link to the CloudWatch logs for this execution. Good to surface in the UI. |
job_started / job_completed |
timestamptz, nullable | |
updated_at |
timestamptz |
⚠️ Column naming: the real DB columns are
snake_caseexactly as above (task_id,cloud_logs_url). Some backend FastAPI code refers to them with camelCase attribute aliases (taskId,cloudLogsURL) — ignore that; go by the column names in this table, which match the SQLModel definitions and the Drizzle schema.
inputsandoutputsare TEXT holding a JSON string, not JSON/JSONB columns. The FE mustJSON.parsethem (and tolerateNULL).
2. Lifecycle — how a subtask moves
Written by orchestration/task_orchestrator.py and the
domain methods. Every state change to a subtask is followed by a re-roll-up of
the parent task (§3), so the FE never has to compute a task's status itself —
just read tasks.status.
create → waiting
start → in progress (sets job_started, sets cloud_logs_url)
complete → complete (sets job_completed, writes outputs.result)
fail → failed (sets job_completed, writes outputs.error [+details])
A failed subtask may be restarted (failed → in progress → complete).
That is deliberate re-run, not automatic retry — recovery re-sends the
subtask's own inputs. A complete subtask can never restart.
3. Status roll-up (the rule the FE depends on)
tasks.status is always recomputed from its subtasks' statuses after any
child changes (Task.recalculate_from_subtasks). The rule:
| Children | Task rolls up to |
|---|---|
any child failed |
failed |
all children complete |
complete |
any in progress or complete (rest waiting) |
in progress |
all waiting |
waiting |
Consequences the FE should rely on:
- A mix of complete + waiting is
in progress, neverwaiting. A run that's partway done reads as in-progress. - Failure is not sticky: if a failed batch is fixed and re-run to complete,
the parent flips
failed → complete. Don't cache a task as permanently failed. - A task with zero subtasks stays at its created status (roll-up is a no-op on empty). The app avoids creating zero-subtask tasks on purpose.
For progress bars, count subtasks: complete / total. The denominator is fixed
once the subtasks exist (see attach mode, §5).
4. Identifying a task in the FE
Two creation patterns leave two different fingerprints:
- Worker-created (a Lambda owns the whole job):
task_sourceis set to the worker name,serviceisNULL. - App-created (the FE/API created the task before dispatching work):
serviceis set (modelling_run,plan_engine, …) andtask_sourceis whatever the creating route passed.
So: read service first; fall back to task_source to label a task.
Use source + source_id to associate it with the on-screen entity (deal,
portfolio, property).
5. How each service writes tasks/subtasks
There are three shapes. Knowing which shape a service uses tells the FE how many subtasks to expect and who created them.
Shape A — one Lambda owns one task + one subtask (@task_handler)
The most common. The Lambda receives one SQS message, creates its own Task and a single SubTask, runs the whole job inside that subtask, and rolls up. One message → one task → one subtask.
Services using this shape (from
utilities/aws_lambda/task_handler.py call sites):
| Service | task_source |
source |
What it does |
|---|---|---|---|
| magicplan | magic_plan |
hubspot_deal_id |
Fetches a MagicPlan floor plan for a deal's address, stores it. applications/magic_plan/handler.py |
| pashub_fetcher | pashub_fetcher |
hubspot_deal_id |
Downloads PasHub/CoordinationHub evidence files for a job, uploads to S3/SharePoint, parses site notes. applications/pashub_fetcher/handler.py → pashub_fetcher_orchestrator.py |
| abri | abri_api |
hubspot_deal_id |
Abri portal API calls (LogJob/AmendJob/etc). applications/abri/handler.py |
| hubspot_scraper | hubspot_scraper |
hubspot_deal_id |
etl/hubspot/scripts/scraper/main.py |
| bulk_document_download | bulk_document_download |
portfolio_id |
applications/bulk_document_download/handler.py |
For these, the FE renders a single subtask. Its outputs/cloud_logs_url are
the whole job's result and logs. Note: magicplan and pashub write the
business result (plans, uploaded files) via their own tables/S3 — the
task/subtask row is the progress + audit surface, not where the FE finds the
downloaded artifacts.
Shape B — one Lambda fans out into many child subtasks (run_subtasks)
modelling_e2e (task_source modelling_e2e), in its standalone mode,
creates its own Task then fans one child subtask per property under it
(TaskOrchestrator.run_subtasks). Per-item
failures are isolated — a failing property fails its own subtask, siblings still
run. Expect N subtasks under one task.
Shape C — app creates the task, workers attach (@subtask_handler, ADR-0055)
This is the important one for FE-driven runs. For a Modelling Run:
- The app (
POST /v1/modelling/trigger-run) creates the Task up front —service = modelling_run,status = in progress, the full request JSON intasks.inputs— and returns202immediately. - A distributor pre-creates one
waitingsubtask per batch (≈50 properties per batch, one per (scenario, batch)) and putstask_id+subtask_idinto each SQS message. Each subtask'sinputsis that batch's exact payload. - Each worker runs in attach mode: because the message carries
task_id+subtask_id,@subtask_handler/@task_handlercreate nothing — they juststart/complete/failthe supplied subtask.
Why the FE cares:
- The progress denominator is correct the instant the endpoint returns 202 —
all the
waitingsubtasks already exist. Progress counts batches, not properties (≈ceil(N/50) × scenarios). - A batch subtask marked
failedcarries per-property detail inoutputs({succeeded, failed:[{property_id, error}]}). Surface that for the failure view. - A failed batch is recoverable: re-sending its
inputsre-runs it and can flip the whole task back tocomplete. Don't treatfailedas terminal.
Other @subtask_handler workers that operate on pre-created subtasks the same
way include audit_generator, postcode_splitter, landlord_description_overrides,
bulk_upload_finaliser, ara_first_run, and the OS/uprn combiner lambdas.
The plan services
The plan FastAPI routes create tasks directly with an explicit service
label — plan_categorisation and plan_engine
(backend/app/plan/router.py) — then dispatch work. Treat these
as app-created (Shape C-ish): read service to label them.
6. Quick reference for the FE
- Poll
tasks.statusfor the headline; don't recompute it — the backend keeps it in sync with the children. - Progress =
complete subtasks / total subtasks. Denominator is stable for attach-mode runs (Shape C) from t=0. inputs/outputsare JSON-in-TEXT — parse and null-check.- Label a task by
service(if set) elsetask_source; associate it viasource+source_id. failedis recoverable, not terminal. A later re-run can move a task tocomplete.cloud_logs_urlon a subtask is a ready-made "view logs" link.- Status vocabulary is exactly:
waiting,in progress,complete,failed.
Generated from the backend repo (Hestia-Homes/model) as a cross-repo handoff.
If a service's behaviour here disagrees with what you observe in the DB, trust
the linked source files — they are the source of truth.