-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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 filesystemThis 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:
- Its status is
"open" - All tasks in its
depends_onarray have status"closed" - No currently
in_progresstask 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:orfile:prefix) - All IDs in
depends_onarrays 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:
- Be defined with
Tool.define() - Be registered with
ToolRegistry.register() - Accept a
commandstring parameter plus any sub-command-specific params - 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 ordergetNextTasks()correctly excludes tasks with unmet dependenciesgetNextTasks()correctly excludes tasks with conflicting labelsvalidateGraph()catches circular dependenciesvalidateGraph()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.tsdefines all types from the data model above -
src/tasks/store.tsreads/writes tasks using atomic temp+rename pattern -
src/tasks/scheduler.tsreturns dependency- and conflict-aware ordered task list -
src/tasks/validation.tscatches circular dependencies and surfaces warnings -
src/tasks/tool.tsregisters thetaskctltool and routes all commands listed above - All commands work end-to-end: PM can create tasks, query them, validate the graph
-
bun testpasses (scheduler and validation have test coverage) -
bun run typecheckpasses with 0 errors - No
// TODO,@ts-ignore, oras anyin submitted code
What NOT to build yet
- No Pulse, no pipeline, no agent spawning
- No
start,stop,status,resume,override,retry,inspectcommands (Phase 3) - No Composer (Phase 2)
- No job lock files (Phase 3)