Skip to content

feat(taskctl): Phase 1 — Task store: types, storage, scheduler, validation, CRUD tool #202

@randomm

Description

@randomm

Context

Part of the taskctl epic (#201). This is the foundation that every other phase builds on — get this right before touching anything else.

What you're building: A JSON file-based task store on disk, a scheduler that understands dependencies and conflict labels, a validation module, and a basic taskctl tool that PM can use to manually create and inspect tasks.

No pipeline, no agents, no Pulse yet. Just the data layer.

Background reading

Before starting, read lievo/plan-v2.md sections: "Task Data Model", "Tool Permission Model", and "Phase 1" under Implementation Phases.

Files to create

packages/opencode/src/tasks/
  types.ts        ← TypeScript types for Task, Job, Comment, PipelineEvent, AdversarialVerdict
  store.ts        ← read/write tasks to disk, index management, atomic writes
  scheduler.ts    ← getNextTasks(): dependency filtering + conflict label detection + priority sort
  validation.ts   ← validateGraph(): cycle detection, missing acceptance criteria warnings
  tool.ts         ← Tool.define() registration + sub-command routing

Storage location and file layout

Tasks are stored at ~/.local/share/opencode/tasks/<projectId>/ — follow the existing pattern used by sessions at ~/.local/share/opencode/storage/session/.

Look at packages/opencode/src/project/instance.ts to understand how projectId is derived. Look at packages/opencode/src/storage/ to see how existing data directories are structured.

Files in the directory:

index.json          ← fast-lookup index: { taskId: { status, priority, labels, depends_on, updated_at } }
activity.ndjson     ← append-only event log, one JSON object per line
<task-id>.json      ← one file per task containing the full Task object

Atomic writes — critical, do this from day one

Never write a task file directly. Always write to a temp file first, then rename:

const tmp = `${taskPath}.tmp`
await Bun.write(tmp, JSON.stringify(task, null, 2))
await fs.rename(tmp, taskPath)  // atomic on same filesystem

This prevents corrupted task files if OpenCode crashes mid-write. Look at how Lock.write() is used in existing code (search for it in packages/opencode/src/) to understand the locking pattern already in use.

Task data model

type Task = {
  id: string                    // human-readable slug e.g. "add-oauth2-schema"
  title: string
  description: string
  acceptance_criteria: string
  parent_issue: number          // GitHub issue number this task belongs to
  job_id: string                // which taskctl start invocation created this
  status: "open" | "in_progress" | "review" | "blocked" | "closed" | "failed" | "stopped" | "blocked_on_conflict"
  priority: 0 | 1 | 2 | 3 | 4  // 0 = highest priority
  task_type: "implementation" | "test" | "research"
  labels: string[]              // e.g. ["module:auth", "file:src/auth/oauth.ts"]
  depends_on: string[]          // task IDs that must be closed before this can start
  assignee: string | null       // session ID of agent currently working on this
  assignee_pid: number | null   // OS process ID for zombie detection (Phase 3)
  worktree: string | null       // absolute path to git worktree when active
  branch: string | null         // e.g. "feature/issue-123"
  created_at: string            // ISO 8601
  updated_at: string
  close_reason: string | null
  comments: Comment[]
  pipeline: {
    stage: "idle" | "developing" | "reviewing" | "committing" | "done" | "failed" | "stopped"
    attempt: number             // how many adversarial review cycles have happened
    last_activity: string | null
    last_steering: string | null
    history: PipelineEvent[]
    adversarial_verdict: AdversarialVerdict | null
  }
}

Scheduler logic

getNextTasks(jobId, count) should return tasks that are ready to work on, in priority order. A task is "ready" when:

  1. Its status is "open"
  2. All tasks in its depends_on array have status "closed"
  3. No currently in_progress task shares a conflict label with it

Conflict labels are labels prefixed with module: or file:. Two tasks conflict if they share any label with these prefixes. The point is to prevent two developers from touching the same files simultaneously.

Example:

Task A (in_progress): labels = ["module:auth", "priority:high"]
Task B (open):        labels = ["module:auth", "feature:login"]
→ Task B is NOT ready because it conflicts with Task A on "module:auth"

Labels like "priority:high" or "feature:login" (no module:/file: prefix) do NOT cause conflicts.

Sort ready tasks by priority (0 first), then by id alphabetically as a tiebreaker.

Validation

validateGraph(jobId) should check:

  • No circular dependencies (task A depends on B which depends on A)
  • Every task has non-empty acceptance_criteria
  • Every task has at least one conflict label (module: or file: prefix)
  • All IDs in depends_on arrays actually exist in the store

Return an object like:

{
  valid: boolean,
  errors: string[],    // things that block execution (e.g. circular deps)
  warnings: string[]   // things PM should review but that don't block execution
}

For cycle detection, use depth-first search. Keep a visited set and a path set. If you visit a node that's already in path, you've found a cycle.

Tool registration

Look at how existing tools are registered — for example packages/opencode/src/tool/rg.ts is a simple example. Your tool should:

  1. Be defined with Tool.define()
  2. Be registered with ToolRegistry.register()
  3. Accept a command string parameter plus any sub-command-specific params
  4. Route internally to the right store/scheduler/validation function based on command

Commands to implement in this phase

taskctl create --title "..." --parent-issue 123 --acceptance-criteria "..."
               [--description "..."] [--labels "module:auth,file:src/auth.ts"]
               [--depends-on "other-task-id"] [--priority 0-4]
               [--type implementation|test|research]

taskctl list   [--status open|in_progress|...] [--issue 123]
taskctl get    <task-id>
taskctl update <task-id> [--status ...] [--description ...] [--acceptance-criteria ...]
taskctl close  <task-id> --reason "..."
taskctl comment <task-id> "message text"
taskctl depends <task-id> --on <other-task-id>
taskctl split   <task-id> --into "new title 1" "new title 2"
taskctl next   [--count N]     ← uses scheduler, returns ready tasks
taskctl validate --issue 123   ← uses validation module

For split: create two new tasks inheriting the original's parent_issue, job_id, labels, and depends_on. Mark the original task closed with reason "split into: <id1>, <id2>".

For depends: add otherId to taskId's depends_on array. Validate no cycle is created. If a cycle would result, return an error — do not write to disk.

Tool permission model

For now, give full access to PM only. The per-agent restriction will be wired up in Phase 3 when we know which agents exist. Add a comment in the code explaining the intended final permission table (see lievo/plan-v2.md Tool Permission Model section).

Testing

Write tests in packages/opencode/test/tasks/. Test at minimum:

  • getNextTasks() returns tasks in correct priority order
  • getNextTasks() correctly excludes tasks with unmet dependencies
  • getNextTasks() correctly excludes tasks with conflicting labels
  • validateGraph() catches circular dependencies
  • validateGraph() warns on missing acceptance criteria
  • Atomic write: verify that a crash after Bun.write(tmp) but before rename leaves no corrupt file

Run bun test and bun run typecheck — both must pass before marking this done.

Acceptance criteria

  • src/tasks/types.ts defines all types from the data model above
  • src/tasks/store.ts reads/writes tasks using atomic temp+rename pattern
  • src/tasks/scheduler.ts returns dependency- and conflict-aware ordered task list
  • src/tasks/validation.ts catches circular dependencies and surfaces warnings
  • src/tasks/tool.ts registers the taskctl tool and routes all commands listed above
  • All commands work end-to-end: PM can create tasks, query them, validate the graph
  • bun test passes (scheduler and validation have test coverage)
  • bun run typecheck passes with 0 errors
  • No // TODO, @ts-ignore, or as any in submitted code

What NOT to build yet

  • No Pulse, no pipeline, no agent spawning
  • No start, stop, status, resume, override, retry, inspect commands (Phase 3)
  • No Composer (Phase 2)
  • No job lock files (Phase 3)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions