Question: Agent to-do list with task dependencies
Companies: Perplexity
Difficulty: Medium
Problem statement
Build the task tracker an AI agent uses to plan its work. The question comes in three parts. Each part builds on the one before, so keep earlier behavior working.
Approach
The round is staged. Part 1 is a plain task store with a forward-only
status machine. Part 2 adds dependencies, so status changes have to
ripple to other tasks. Part 3 renders the whole list for an LLM. The
evaluation is mostly about whether the part 1 design survives parts 2
and 3 without a rewrite, so pick the data structures for the last part
up front.
Part 1: store and status rules. Keep tasks in a dict keyed by an
auto-incrementing integer ID. A Python dict keeps insertion order, so
iterating it also gives ID order for free. Model the statuses as an
IntEnum in lifecycle order (BLOCKED=1 < READY_TO_EXECUTE=2 < IN_PROGRESS=3 < SUCCEEDED=4 < FAILED=5). Then "forward only" is one
comparison, new_status > current, and a change is refused when the ID
is unknown, the task is already terminal, or the new status is not
strictly higher.
Part 2, brute force. After every status change, loop over all tasks.
Each BLOCKED task checks its dependencies: all succeeded means ready,
any failed means failed. Repeat until nothing changes, because a failure
can travel down a chain of several tasks. That is O(n) per pass and up
to O(n) passes, which is too slow and is exactly what the prompt warns
against.
Part 2, event driven. Store the graph in both directions and keep a
counter per task:
-
dependents[d]lists the tasks that wait ond(the reverse edges). -
pending[t]counts how many oft's dependencies have not succeeded.
When a task succeeds, visit only its direct dependents and decrement
their counters. A BLOCKED dependent whose counter hits zero becomes
READY_TO_EXECUTE. This is Kahn's topological-sort in-degree trick,
driven by events.
When a task fails, run a BFS over the reverse edges. Every BLOCKED
task reached becomes FAILED and is pushed so its own dependents are
visited. A task that is already FAILED is not pushed again, so each
task fails at most once and the whole cascade costs O(tasks + edges it
touches). Only BLOCKED tasks can be reached at all: a task is ready
only when all its dependencies succeeded, and a succeeded task can never
fail.
Dependencies always name earlier tasks, so the graph is a DAG and the
BFS cannot loop. When a task is added with a dependency that already
failed, it can never run, so it starts FAILED instead of sitting
BLOCKED forever. Confirm this choice with the interviewer.
Part 3: rendering. Prefer a format that is easy for a model to parse
over prose: a one-line summary of counts, then a tab-separated table
with one row per task (id, status, depends_on, description).
Write the description as a JSON string so newlines or tabs inside it
cannot break the table.
Invariants:
- Status never moves backward, and a terminal task never changes.
-
pending[t]equals the number oft's dependencies whose status is
notSUCCEEDED. - After every call, no
BLOCKEDtask haspending == 0and no
BLOCKEDtask depends, even indirectly, on aFAILEDtask.
Code
from __future__ import annotations
import json
from collections import deque
from dataclasses import dataclass, field
from enum import IntEnum
class TaskStatus(IntEnum):
BLOCKED = 1
READY_TO_EXECUTE = 2
IN_PROGRESS = 3
SUCCEEDED = 4
FAILED = 5
TERMINAL = {TaskStatus.SUCCEEDED, TaskStatus.FAILED}
@dataclass
class Task:
task_id: int
description: str
status: TaskStatus
dependency_ids: list[int] = field(default_factory=list)
class TodoList:
def __init__(self) -> None:
self._tasks: dict[int, Task] = {} # insertion order = id order
self._dependents: dict[int, list[int]] = {} # dep id -> tasks waiting on it
self._pending: dict[int, int] = {} # task id -> deps not yet SUCCEEDED
self._next_id = 1
def add_task(self, description: str, dependency_ids: list[int] | None = None) -> Task:
deps = list(dict.fromkeys(dependency_ids or [])) # drop duplicates, keep order
task_id = self._next_id
self._next_id += 1
statuses = [self._tasks[d].status for d in deps]
if TaskStatus.FAILED in statuses:
status = TaskStatus.FAILED # can never run; fail fast
elif all(s == TaskStatus.SUCCEEDED for s in statuses):
status = TaskStatus.READY_TO_EXECUTE
else:
status = TaskStatus.BLOCKED
task = Task(task_id, description, status, deps)
self._tasks[task_id] = task
self._dependents[task_id] = []
self._pending[task_id] = sum(s != TaskStatus.SUCCEEDED for s in statuses)
for d in deps:
self._dependents[d].append(task_id)
return task
def get_task(self, task_id: int) -> Task | None:
return self._tasks.get(task_id)
def change_task_status(self, task_id: int, new_status: TaskStatus) -> bool:
task = self._tasks.get(task_id)
if task is None or task.status in TERMINAL or new_status <= task.status:
return False
task.status = new_status
if new_status == TaskStatus.SUCCEEDED:
self._on_succeeded(task_id)
elif new_status == TaskStatus.FAILED:
self._on_failed(task_id)
return True
def _on_succeeded(self, task_id: int) -> None:
for child_id in self._dependents[task_id]:
self._pending[child_id] -= 1
child = self._tasks[child_id]
if self._pending[child_id] == 0 and child.status == TaskStatus.BLOCKED:
child.status = TaskStatus.READY_TO_EXECUTE
def _on_failed(self, task_id: int) -> None:
queue = deque([task_id])
while queue:
for child_id in self._dependents[queue.popleft()]:
child = self._tasks[child_id]
if child.status == TaskStatus.BLOCKED: # each task fails at most once
child.status = TaskStatus.FAILED
queue.append(child_id)
def render_for_llm(self) -> str:
counts = {s.name: 0 for s in TaskStatus}
lines = []
for t in self._tasks.values():
counts[t.status.name] += 1
deps = ",".join(map(str, t.dependency_ids)) or "-"
lines.append(f"{t.task_id}\t{t.status.name}\t{deps}\t{json.dumps(t.description)}")
summary = " ".join(f"{k}={v}" for k, v in counts.items() if v)
header = f"# tasks={len(self._tasks)} {summary}".rstrip()
return "\n".join([header, "id\tstatus\tdepends_on\tdescription", *lines])
Complexity
Let n be the number of tasks and k the dependency count of one task.
-
add_task: O(k) time; O(k) extra space for the edges. -
get_task: O(1). -
change_task_statustoIN_PROGRESSor another non-terminal status: O(1). -
change_task_statustoSUCCEEDED: O(direct dependents). -
change_task_statustoFAILED: O(tasks and edges reached by the
cascade). Each task fails once, so across the whole lifetime of the
list all cascades together cost O(n + total edges). -
render_for_llm: O(n + total edges) time and output size. - Total space: O(n + total edges) for tasks, reverse edges and counters.
Test cases
-
add_task("fetch")on an empty list returns task1,READY_TO_EXECUTE. -
get_task(99)on a list without that ID returnsNone. - Task 1 is
IN_PROGRESS:change_task_status(1, READY_TO_EXECUTE)is
False(backward), andchange_task_status(1, IN_PROGRESS)isFalse
(not strictly higher). -
change_task_status(42, FAILED)for an unknown ID returnsFalse. - Add 2 depending on
[1], 3 on[1, 2], 4 on[3]: all three start
BLOCKED. Succeed 1 and task 2 becomes ready, but task 3 stays
blocked (it still waits on 2). Succeed 2 and task 3 becomes ready,
while task 4 stays blocked. - After task 1 succeeds,
change_task_status(1, FAILED)returnsFalse
(terminal). - A new task whose dependencies have all succeeded starts
READY_TO_EXECUTE. - Cascade: 2 depends on 1, 3 on 2, 4 on
[3, 1], and 5 is independent.
Failing 1 makes 2, 3 and 4FAILEDand leaves 5 ready. - Adding a task that depends on a failed task returns it
FAILED. - Duplicate dependency IDs (
[1, 1]) count once, so succeeding 1 makes
the task ready. -
render_for_llm()on the first scenario gives:# tasks=5 BLOCKED=1 READY_TO_EXECUTE=2 SUCCEEDED=2 id status depends_on description 1 SUCCEEDED - "fetch" 2 SUCCEEDED 1 "parse" 3 READY_TO_EXECUTE 1,2 "index" 4 BLOCKED 3 "rank" 5 READY_TO_EXECUTE 1,2 "ready now"
Follow-ups
-
Retry a failed task. This breaks "forward only", and failures must
be undone downstream. GiveFAILEDa retry path back to
READY_TO_EXECUTE. Cascaded failures then need to know their cause:
storefailed_becauseon each task, and on retry walk the same
reverse edges and return the tasks that failed only because of this
one toBLOCKED, recomputing theirpendingcounts. -
Add dependencies after creation (cycles possible). Before adding
edget -> d, check thattis not reachable fromdwith a DFS over
the forward edges. If it is, reject the edge and return the cycle path,
which a DFS can name and Kahn's algorithm cannot. Then update
pending[t], and movetback toBLOCKEDif it was ready. -
Give an agent the next task to run. Keep a min-heap (or a sorted
set) ofREADY_TO_EXECUTEIDs, updated on every transition, so
next_ready()is O(log n) instead of a scan. Add priorities by
keying the heap on(priority, id). -
Subtasks or a parent that finishes when its children do. This is
the same counter idea pointed the other way: a parent withkopen
children succeeds when the count reaches 0 and fails when any child
fails. -
Large lists in the LLM context. Render only the parts the model
needs: open tasks in full, finished tasks collapsed to a count, and
long failure chains shortened to their root cause.
Interviewer expectations
Mid-level (L4)
- Finishes part 1 cleanly: the enum in lifecycle order, one comparison
for forward-only, and correctFalsecases. - Gets dependency creation right and handles the success transition,
even if failure propagation starts as a rescan. - Writes a few of their own checks and runs them.
Senior (L5)
- Uses reverse edges plus pending counters from the start, and knows
why a rescan is O(n) per change. - Propagates failure through the whole chain with BFS or DFS, and argues
why each task fails at most once. - Raises edge cases unprompted: duplicate dependency IDs, a dependency
that already failed, a manual change on a blocked task. - Picks a parse-friendly render format and makes it robust to messy
descriptions.
Staff (L6+)
- Designs part 1 so parts 2 and 3 add code without rewriting it, and
states the invariants the code keeps. - Talks through what breaks with retries, edges added later (cycle
detection and how to report the cycle), concurrent agents updating
the same list, and persistence. - Thinks about the LLM consumer: token budget, stable IDs the model can
refer back to, and what context the agent needs in order to decide
its next action.