feat(agent): schedule tasks for the next available AI coworker#646
feat(agent): schedule tasks for the next available AI coworker#646rsnodgrass wants to merge 4 commits into
Conversation
|
PR changed again? Review this PR in Change Stack to compare snapshots and stay oriented. Warning Review limit reached
More reviews will be available in 25 minutes and 29 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughThis PR introduces a complete agent task scheduling system: a repo-local JSONL queue for distributing ephemeral AI work to agents. It includes task persistence with reconciliation and deduplication, agent-facing CLI commands (list/next/done/cancel/extend), throttled task surfacing via UserPromptSubmit hook, daemon-side producer logic for doctor and session-finalize tasks, health checks for stuck/poison tasks, and integration into agent and status displays. ChangesAgent Task Scheduling
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
… read-only Address review findings: treat task content as untrusted data not instructions (framing + closed kind vocabulary + XML-escaping + session-name sanitization), never expire/evict in_progress tasks, rewrite-free read path on the prompt hook and ox status, resolved agent type for target surfacing, busy-agent guard, cursor reset on /clear and /compact, in-process mutex, and task size caps. https://claude.ai/code/session_01B3Hhcw2zYFUTsQaifkvifk
Greptile SummaryThis PR introduces a project-local agent task queue (
Confidence Score: 5/5Safe to merge; the queue, surfacing, and producer logic are all well-structured and the four issues flagged in earlier review rounds have been addressed. The new agenttask package is thoroughly tested (store lifecycle, dedup, expiry, lease reclaim, cap eviction) and the architectural choices — flock + in-process mutex, atomic rewrite via temp-file rename, signature-based throttling, and priority-aware eviction — are sound. The only remaining finding is that task.ID is sliced to 8 characters without a length guard in three places; this would panic on a malformed JSONL task ID, but normal operation always produces 36-char UUIDs and the store filters only truly empty IDs on read. All four issues previously flagged by reviewers have been demonstrably fixed in this revision. cmd/ox/agent_tasks.go and cmd/ox/doctor_agent_tasks.go — both slice task IDs without a length check. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
subgraph Producers
D[daemon doctor timer] -->|produceDoctorTask / produceFinalizeTasks| E[agenttask.Enqueue]
M[.needs-doctor-agent marker] --> D
CLI[ox agent tasks add] --> E
end
E -->|append JSONL row| Q[(".sageox/agent_tasks/agent_tasks.jsonl\nflock-guarded, last-write-wins")]
subgraph Live AI coworker session
H[UserPromptSubmit hook] -->|QueueExists guard| SIG{signature changed?}
SIG -- yes --> REM[system-reminder injected into model context]
SIG -- no --> SILENT[silent — no context cost]
REM --> NEXT[ox agent id tasks next - atomic Claim + lease]
NEXT --> SUB[fresh-context subagent]
SUB --> DONE[ox agent id tasks done or cancel / extend]
DONE -->|rewrite JSONL| Q
end
subgraph Self-healing
Q -->|reconcile-on-read| RECLAIM[reclaim expired leases or dead-PID claimer]
RECLAIM -->|status ready| Q
end
subgraph Visibility
Q --> STATUS[ox status — N ready M in progress]
Q --> DOCTOR[ox doctor — cancel poison tasks over 5 attempts]
end
Reviews (2): Last reviewed commit: "fix(agent-tasks): address review nits (r..." | Re-trigger Greptile |
…, eviction order, single-snapshot count) - emitAgentTasks/status/doctor guard on QueueExists so a read never MkdirAll's the queue dir - writeTaskCursor writes atomically (tmp+rename) - enforceActiveCap evicts least-urgent (priority desc) before oldest - runTasksList computes the ready count from one snapshot, not a second locked read - add QueuePath/QueueExists helpers (no more hardcoded queue paths) https://claude.ai/code/session_01B3Hhcw2zYFUTsQaifkvifk
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (4)
internal/agenttask/store.go (1)
445-448: ⚡ Quick winUse
errors.Isfor not-exist checks.Line 447 should use
errors.Is(err, os.ErrNotExist)for consistency with project error-unwrapping rules.Suggested diff
- if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return []*Task{}, nil }As per coding guidelines: "Use
errors.Is()/errors.As()and wrap errors with context".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/agenttask/store.go` around lines 445 - 448, Replace the os.IsNotExist(err) check with errors.Is(err, os.ErrNotExist) in the task file open path (the code that uses s.tasksPath), and when returning or propagating other errors from os.Open or subsequent IO, wrap them with context using fmt.Errorf(...%w...) (e.g., fmt.Errorf("opening tasks file %s: %w", s.tasksPath, err)) so error unwrapping and contextual logs follow project conventions.cmd/ox/agent_hook.go (1)
531-533: ⚡ Quick win
resetTaskCursorsafely no-ops on emptyagentID—resetTaskCursor(projectRoot, agentID)incmd/ox/agent_tasks_surface.goimmediately returns whenagentID == ""(orprojectRoot == ""), so the unguarded call fromhandleCompactis safe; only a minor style/consistency cleanup (to matchhandleStart/handlePrompt) would be optional.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cmd/ox/agent_hook.go` around lines 531 - 533, The call to resetTaskCursor(ctx.ProjectRoot, agentID) from handleCompact is safe but inconsistent with handleStart/handlePrompt; wrap the call in the same guard as those handlers by checking both ctx.ProjectRoot and agentID are non-empty before invoking resetTaskCursor (i.e., only call resetTaskCursor when agentID != "" && ctx.ProjectRoot != ""), using the existing resetTaskCursor function name to locate the code to change.internal/daemon/agentwork/manager.go (1)
118-128: 💤 Low valueConsider documenting the
projectRootparameter in the constructor comment.The constructor comment at lines 118-120 describes
syncSignalandledgerPath, but doesn't describe the newly-addedprojectRootparameter. Adding a sentence like "projectRoot is the working repository path for project-local agent task writes; empty disables task production" would improve clarity.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/daemon/agentwork/manager.go` around lines 118 - 128, Update the NewManager constructor comment to document the new projectRoot parameter: mention that projectRoot is the working repository path used for project-local agent task writes and that an empty string disables task production; keep the existing descriptions for syncSignal and ledgerPath and place the new sentence alongside them in the comment above NewManager to clarify its purpose for callers.internal/daemon/agentwork/task_producer.go (1)
30-34: ⚡ Quick winMarker constant
needsDoctorAgentMarkermatchesinternal/doctor.NeedsDoctorAgentMarker.
Suggestion: add a small build/test/CI guard to prevent future drift since the value is duplicated to avoid the import cycle.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/daemon/agentwork/task_producer.go` around lines 30 - 34, Add a CI/build guard that fails if the duplicated marker drifts: create a tiny verification step (either a short Go program or a _test.go in a non-circular package run by CI) that reads and compares the value of the constant needsDoctorAgentMarker in agentwork/task_producer.go against internal/doctor.NeedsDoctorAgentMarker; wire this check into the repo CI (or make it a go test) so the comparison runs on every PR and fails when the two string constants diverge.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cmd/ox/agent_tasks.go`:
- Around line 144-146: The text output slices IDs like v.ID[:8], claimed.ID[:8],
and t.ID[:8] without checking length, which can panic for short IDs; update the
formatting sites (the fmt.Fprintf calls in agent task listing and doctor agent
task listing) to use a safe prefix helper or inline min-length logic (e.g.,
compute n := 8; if len(id) < n { n = len(id) }; id[:n]) and replace v.ID[:8],
claimed.ID[:8], t.ID[:8] with that safe substring so short IDs no longer cause a
runtime panic.
- Around line 95-100: runAgentTasks currently sets sub := args[0] and then
unconditionally does rest := args[1:], which panics when no subcommand is
passed; also listing/claiming slices IDs with v.ID[:8] and claimed.ID[:8] which
panics for short non-empty IDs. Fix by guarding the args slicing: set rest to an
empty slice unless len(args)>1 before taking args[1:], and ensure sub is set to
"list" when args is empty (references: variables sub and rest in runAgentTasks).
Replace unsafe ID truncation (v.ID[:8], claimed.ID[:8]) with a safe truncate
that checks len(ID) > 8 before slicing (or returns the whole ID when shorter) so
short IDs do not cause a panic.
In `@cmd/ox/doctor_agent_tasks.go`:
- Line 32: Replace the os.IsNotExist(err) check with the errors.Is(err,
os.ErrNotExist) pattern when testing the result of os.Stat on tasksFile: update
the conditional in the code that currently reads "if _, err :=
os.Stat(tasksFile); os.IsNotExist(err) {" to use errors.Is(err, os.ErrNotExist)
so wrapped errors are handled correctly, and add an import for the standard
"errors" package if not already present; ensure the check still handles the
nil-error case properly where needed.
In `@docs/ai/specs/agent-task-scheduling.md`:
- Around line 31-34: The two fenced code blocks containing the lines
".sageox/agent_tasks/agent_tasks.jsonl # gitignored, ephemeral, local-only"
and the ASCII state diagram (the block starting with "┌─────────── claim (next)
───────────┐" and ending with "completed") are missing language identifiers;
update each triple-backtick fence to include a language token (use "text") so
the blocks become "```text" to satisfy MD040 and ensure proper
linting/rendering.
In `@internal/agenttask/store.go`:
- Around line 457-479: readTasksLocked currently uses bufio.Scanner with a 1MiB
token limit and returns on scanner.Err(), which causes a bufio.ErrTooLong line
to abort the whole read; change the scan loop to detect bufio.ErrTooLong (and
any token-too-large condition) and skip that offending line (log/debug if
needed) instead of returning an error, keeping the existing behavior of skipping
malformed/empty rows; also replace any os.IsNotExist(err) checks with
errors.Is(err, os.ErrNotExist) per repo guidelines; reference
scanner/Scanner.Buffer, scanner.Scan, scanner.Err, and the readTasksLocked
function to locate the changes.
---
Nitpick comments:
In `@cmd/ox/agent_hook.go`:
- Around line 531-533: The call to resetTaskCursor(ctx.ProjectRoot, agentID)
from handleCompact is safe but inconsistent with handleStart/handlePrompt; wrap
the call in the same guard as those handlers by checking both ctx.ProjectRoot
and agentID are non-empty before invoking resetTaskCursor (i.e., only call
resetTaskCursor when agentID != "" && ctx.ProjectRoot != ""), using the existing
resetTaskCursor function name to locate the code to change.
In `@internal/agenttask/store.go`:
- Around line 445-448: Replace the os.IsNotExist(err) check with errors.Is(err,
os.ErrNotExist) in the task file open path (the code that uses s.tasksPath), and
when returning or propagating other errors from os.Open or subsequent IO, wrap
them with context using fmt.Errorf(...%w...) (e.g., fmt.Errorf("opening tasks
file %s: %w", s.tasksPath, err)) so error unwrapping and contextual logs follow
project conventions.
In `@internal/daemon/agentwork/manager.go`:
- Around line 118-128: Update the NewManager constructor comment to document the
new projectRoot parameter: mention that projectRoot is the working repository
path used for project-local agent task writes and that an empty string disables
task production; keep the existing descriptions for syncSignal and ledgerPath
and place the new sentence alongside them in the comment above NewManager to
clarify its purpose for callers.
In `@internal/daemon/agentwork/task_producer.go`:
- Around line 30-34: Add a CI/build guard that fails if the duplicated marker
drifts: create a tiny verification step (either a short Go program or a _test.go
in a non-circular package run by CI) that reads and compares the value of the
constant needsDoctorAgentMarker in agentwork/task_producer.go against
internal/doctor.NeedsDoctorAgentMarker; wire this check into the repo CI (or
make it a go test) so the comparison runs on every PR and fails when the two
string constants diverge.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9b31049b-7aae-4d7d-8cb7-90621c0fddd8
📒 Files selected for processing (25)
.sageox/.gitignorecmd/ox/agent.gocmd/ox/agent_hook.gocmd/ox/agent_tasks.gocmd/ox/agent_tasks_surface.gocmd/ox/agent_tasks_test.gocmd/ox/doctor.gocmd/ox/doctor_agent.gocmd/ox/doctor_agent_tasks.gocmd/ox/doctor_agent_tasks_test.gocmd/ox/doctor_sageox.gocmd/ox/doctor_types.gocmd/ox/init_gitignore_test.gocmd/ox/status.godocs/ai/specs/agent-task-scheduling.mdinternal/agenttask/store.gointernal/agenttask/store_test.gointernal/agenttask/task.gointernal/daemon/agentwork/manager.gointernal/daemon/agentwork/manager_test.gointernal/daemon/agentwork/session_finalize_orphan_test.gointernal/daemon/agentwork/task_producer.gointernal/daemon/agentwork/task_producer_test.gointernal/daemon/daemon.gointernal/daemon/daemon_pending_work_test.go
| sub := "list" | ||
| if len(args) > 0 { | ||
| sub = args[0] | ||
| } | ||
| rest := args[1:] | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the reported section
echo "== cmd/ox/agent_tasks.go: lines 70-130 =="
nl -ba cmd/ox/agent_tasks.go | sed -n '70,130p'
# Search for other related unsafe slices (task ID formatting mentioned)
echo
echo "== Search for fixed-length slicing like [:8] in cmd/ox/agent_tasks.go =="
rg -n '\[:8\]' cmd/ox/agent_tasks.go || true
echo
echo "== Search for args[1:] usage in cmd/ox/agent_tasks.go =="
rg -n 'args\[1:\]' cmd/ox/agent_tasks.go || trueRepository: sageox/ox
Length of output: 156
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cmd/ox/agent_tasks.go: lines 70-130 =="
awk 'NR>=70 && NR<=130 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== Search for args[1:] usage in cmd/ox/agent_tasks.go =="
rg -n 'args\[1:\]' cmd/ox/agent_tasks.go || true
echo
echo "== Search for fixed-length slicing [:8] in cmd/ox/agent_tasks.go =="
rg -n '\[:8\]' cmd/ox/agent_tasks.go || true
echo
echo "== Search for other string slicing patterns like [:N] in cmd/ox/agent_tasks.go =="
rg -n '\[:[0-9]+\]' cmd/ox/agent_tasks.go || trueRepository: sageox/ox
Length of output: 2928
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Inspect around ID formatting (cmd/ox/agent_tasks.go: 130-220) =="
awk 'NR>=130 && NR<=220 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== Find taskView definition and ID type =="
rg -n 'type taskView|taskView|ID[ ]+string' cmd/ox/agent_tasks.go
echo
echo "== Locate agenttask definitions for ID generation/length guarantees =="
# Search the repo for the agenttask package and ID field usages/definitions
rg -n 'package agenttask|type .*Task|ID[ ]+string|ID[:]' --hidden -S . || trueRepository: sageox/ox
Length of output: 50366
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cmd/ox/agent_tasks.go: top (cobras/args wiring) =="
awk 'NR<=120 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== locate agenttask package path =="
fd -t d "agenttask" . || true
fd -t f "*agenttask*" . || true
echo
echo "== find definition: type Task struct in repo =="
rg -n "type Task struct" --glob '*.go' . | head -n 50
echo
echo "== find toTaskView / parseTaskIDAndNote implementations =="
rg -n "func toTaskView|func parseTaskIDAndNote" cmd/ox/agent_tasks.go
awk 'NR>=1 && NR<=110 {print}' cmd/ox/agent_tasks.go | rg -n "func toTaskView|func parseTaskIDAndNote" || trueRepository: sageox/ox
Length of output: 5836
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cmd/ox/agent_tasks.go: top (cobras/args wiring) =="
awk 'NR<=140 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== locate agenttask package path =="
fd -t d "agenttask" . || true
fd -t f "*agenttask*" cmd internal pkg . || true
echo
echo "== find definition: type Task struct in repo (head limited) =="
rg -n "type Task struct" --glob '*.go' . | head -n 50
echo
echo "== find toTaskView / parseTaskIDAndNote line numbers =="
rg -n "func toTaskView|func parseTaskIDAndNote" cmd/ox/agent_tasks.go
echo
echo "== show toTaskView and parseTaskIDAndNote bodies =="
# Use line numbers from rg output by printing generous ranges
awk 'NR>=30 && NR<=120 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.goRepository: sageox/ox
Length of output: 10423
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cmd/ox/agent_tasks.go: top (cobras/args wiring) =="
awk 'NR<=170 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== locate agenttask package path =="
fd -t d "agenttask" . || true
fd -t f "*agenttask*" internal cmd pkg . || true
echo
echo "== find definition: type Task struct in repo (head limited) =="
rg -n "type Task struct" --glob '*.go' . | head -n 50
echo
echo "== find toTaskView / parseTaskIDAndNote line numbers =="
rg -n "func toTaskView|func parseTaskIDAndNote" cmd/ox/agent_tasks.go
echo
echo "== show toTaskView and parseTaskIDAndNote bodies (approx range) =="
awk 'NR>=20 && NR<=120 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.goRepository: sageox/ox
Length of output: 12116
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cobra command definitions in cmd/ox/agent_tasks.go =="
rg -n 'cobra\.Command|Use:|Args:|RunE:|runAgentTasks\(' cmd/ox/agent_tasks.go
echo
echo "== show sections around those definitions (best-effort) =="
# Show likely areas: from end of file around command setup
awk 'NR>=220 && NR<=420 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== inspect internal/agenttask/task.go around Task struct =="
awk 'NR>=1 && NR<=220 {printf "%d\t%s\n", NR, $0}' internal/agenttask/task.go
echo
echo "== search internal/agenttask for Task.ID assignment/generation =="
rg -n '(\.ID\s*=|ID:\s*|Task\{.*ID:|func .*ID|ID.*string).*' internal/agenttask --hidden || true
echo
echo "== search for ID length validation or formatting ([:8] etc) in internal/agenttask =="
rg -n '\[:[0-9]+\]|len\(.+ID\)|ID.*slice|substring|[:][0-9]' internal/agenttask --hidden || trueRepository: sageox/ox
Length of output: 15975
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cobra command definitions in cmd/ox/agent_tasks.go =="
rg -n 'cobra\.Command|Use:|Args:|RunE:|runAgentTasks\(' cmd/ox/agent_tasks.go
echo
echo "== show sections around command setup (cmd/ox/agent_tasks.go: 220-420) =="
awk 'NR>=220 && NR<=420 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== inspect internal/agenttask/task.go around Task struct (1-220) =="
awk 'NR>=1 && NR<=220 {printf "%d\t%s\n", NR, $0}' internal/agenttask/task.go
echo
echo "== search internal/agenttask for ID assignment/generation =="
rg -n '(\.ID\s*=|ID:\s*|Task\{.*ID:)' internal/agenttask --hidden || true
echo
echo "== search internal/agenttask for ID slicing/len checks =="
rg -n 'len\(.+ID\)|\[:[0-9]+\]|ID\[:|claimed\.ID\[:|v\.ID\[:' internal/agenttask --hidden || trueRepository: sageox/ox
Length of output: 15465
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cobra command definitions in cmd/ox/agent_tasks.go =="
rg -n 'cobra\.Command|Use:|Args:|RunE:|runAgentTasks\(' cmd/ox/agent_tasks.go
echo
echo "== show sections around command setup (cmd/ox/agent_tasks.go: 220-420) =="
awk 'NR>=220 && NR<=420 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent_tasks.go
echo
echo "== inspect internal/agenttask/task.go around Task struct (1-220) =="
awk 'NR>=1 && NR<=220 {printf "%d\t%s\n", NR, $0}' internal/agenttask/task.go
echo
echo "== search internal/agenttask for ID assignment/generation =="
rg -n '(\.ID\s*=|ID:\s*|Task\{.*ID:)' internal/agenttask --hidden || true
echo
echo "== search internal/agenttask for ID slicing/len checks =="
rg -n 'len\(.+ID\)|\[:[0-9]+\]|ID\[:|claimed\.ID\[:|v\.ID\[:' internal/agenttask --hidden || trueRepository: sageox/ox
Length of output: 15465
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Find runAgentTasks call sites =="
rg -n "runAgentTasks\\(" cmd/ox --glob '*.go'
echo
echo "== Inspect agent-facing cobra wiring around calling runAgentTasks (search for 'tasks' command in cmd/ox) =="
rg -n 'Use:\s*"tasks"|runAgentTasks|agent.*tasks' cmd/ox --glob '*.go'
echo
echo "== Inspect internal/agenttask/store.go around newTaskID and ID handling =="
awk 'NR>=1 && NR<=220 {print}' internal/agenttask/store.go | head -n 220 >/dev/null
# Show targeted regions
awk 'NR>=110 && NR<=190 {printf "%d\t%s\n", NR, $0}' internal/agenttask/store.go
awk 'NR>=600 && NR<=720 {printf "%d\t%s\n", NR, $0}' internal/agenttask/store.go
echo
echo "== Search internal/agenttask/store.go for ID validation / UUID parsing =="
rg -n "newTaskID|uuid|UUID|ID == \"\"|ID\\s*=" internal/agenttask/store.goRepository: sageox/ox
Length of output: 12090
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Find runAgentTasks call sites =="
rg -n "runAgentTasks\\(" cmd/ox --glob '*.go'
echo
echo "== Inspect agent-facing cobra wiring around calling runAgentTasks (search for 'tasks' command in cmd/ox) =="
rg -n 'Use:\s*"tasks"|runAgentTasks|agent.*tasks' cmd/ox --glob '*.go'
echo
echo "== Inspect internal/agenttask/store.go around newTaskID and ID handling =="
awk 'NR>=100 && NR<=170 {printf "%d\t%s\n", NR, $0}' internal/agenttask/store.go
awk 'NR>=620 && NR<=700 {printf "%d\t%s\n", NR, $0}' internal/agenttask/store.go
echo
echo "== Search internal/agenttask/store.go for ID validation / UUID parsing =="
rg -n "newTaskID|uuid|UUID|ID == \"\"|ID\\s*=" internal/agenttask/store.goRepository: sageox/ox
Length of output: 11286
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cmd/ox/agent.go around runAgentTasks call (520-580) =="
awk 'NR>=500 && NR<=580 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent.go
echo
echo "== find where subargs computed / tasks dispatch in cmd/ox/agent.go =="
rg -n "subargs|runAgentTasks|case \"tasks\"|Use:.*tasks|tasks\"" cmd/ox/agent.go
echo
echo "== show more context around tasks case if present =="
# show +/- 30 lines around each match line number
rg -n "runAgentTasks\\(" cmd/ox/agent.go | while IFS=: read -r ln _; do
start=$((ln-40)); end=$((ln+40));
echo "--- around $ln ($start-$end) ---";
awk -v s=$start -v e=$end 'NR>=s && NR<=e {printf "%d\t%s\n", NR, $0}' cmd/ox/agent.go;
done
echo
echo "== internal/agenttask/store.go: inspect task parsing/reconcile for ID validation =="
rg -n "json\\.Unmarshal|Decode|Scan|reconcile|List\\(" internal/agenttask/store.go | head -n 50
echo
awk 'NR>=220 && NR<=360 {printf "%d\t%s\n", NR, $0}' internal/agenttask/store.goRepository: sageox/ox
Length of output: 12826
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cmd/ox/agent.go around runAgentTasks call (520-580) =="
awk 'NR>=500 && NR<=580 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent.go
echo
echo "== find where subargs computed / tasks dispatch in cmd/ox/agent.go =="
rg -n "subargs|runAgentTasks|case \"tasks\"|Use:.*tasks|tasks\"" cmd/ox/agent.go
echo
echo "== show more context around tasks case if present =="
rg -n "runAgentTasks\\(" cmd/ox/agent.go | while IFS=: read -r ln _; do
start=$((ln-40)); end=$((ln+40));
echo "--- around $ln ($start-$end) ---";
awk -v s=$start -v e=$end 'NR>=s && NR<=e {printf "%d\t%s\n", NR, $0}' cmd/ox/agent.go;
done
echo
echo "== internal/agenttask/store.go: inspect task parsing/reconcile for ID validation =="
rg -n "json\\.Unmarshal|Decode|Scan|reconcile|List\\(" internal/agenttask/store.go | head -n 50
echo
awk 'NR>=220 && NR<=360 {printf "%d\t%s\n", NR, $0}' internal/agenttask/store.goRepository: sageox/ox
Length of output: 12826
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== cmd/ox/agent.go: inspect dispatch for subcommand/tasks (around subargs indexing) =="
awk 'NR>=440 && NR<=540 {printf "%d\t%s\n", NR, $0}' cmd/ox/agent.go
echo
echo "== internal/agenttask/store.go: inspect reconcileWith / JSONL parsing for task IDs =="
# Show the whole reconcileWith block region
awk 'NR>=380 && NR<=560 {printf "%d\t%s\n", NR, $0}' internal/agenttask/store.go
echo
echo "== internal/agenttask/store.go: search for t.ID validation/guards =="
rg -n "t\\.ID|task\\.ID|ID == \"\"|len\\(|uuid|UUID" internal/agenttask/store.go
echo
echo "== cmd/ox/agent_tasks_test.go: search for task IDs shorter than 8 or any empty args cases =="
rg -n "runAgentTasks\\(&buf, testInst\\(\\), \\[\\]string\\{\\}|tasks\\\"\\s*\\)|\\[:8\\]|ID\\s*:\\s*\\\".{0,7}\\\"" cmd/ox/agent_tasks_test.go || true
# Also quick check for any direct task ID literals in agent_tasks_test.go
rg -n 'ID:\s*"' cmd/ox/agent_tasks_test.go || trueRepository: sageox/ox
Length of output: 12333
Prevent panic when ox agent <id> tasks is called with no subcommand.
Line 99 slices args[1:] unconditionally in runAgentTasks, so ox agent <id> tasks (empty subargs) can crash instead of defaulting to list.
Suggested diff
sub := "list"
+ rest := []string{}
if len(args) > 0 {
sub = args[0]
+ rest = args[1:]
}
- rest := args[1:]- Text rendering also slices task IDs with
v.ID[:8]/claimed.ID[:8]; queued tasks only skip rows with emptyid, so short non-empty IDs can still panic while listing/claiming.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmd/ox/agent_tasks.go` around lines 95 - 100, runAgentTasks currently sets
sub := args[0] and then unconditionally does rest := args[1:], which panics when
no subcommand is passed; also listing/claiming slices IDs with v.ID[:8] and
claimed.ID[:8] which panics for short non-empty IDs. Fix by guarding the args
slicing: set rest to an empty slice unless len(args)>1 before taking args[1:],
and ensure sub is set to "list" when args is empty (references: variables sub
and rest in runAgentTasks). Replace unsafe ID truncation (v.ID[:8],
claimed.ID[:8]) with a safe truncate that checks len(ID) > 8 before slicing (or
returns the whole ID when shorter) so short IDs do not cause a panic.
| fmt.Fprintf(w, " [%s] p%d %s — %s (%s, %s old)\n", | ||
| v.Status, v.Priority, v.ID[:8], v.Title, kindOrDash(v.Kind), v.Age) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate all risky ID slicing usages in the file
rg -n 'ID\[:8\]|claimed\.ID\[:8\]|id\[:8\]' cmd/ox/agent_tasks.go
# Print the relevant sections with line numbers
sed -n '120,220p' cmd/ox/agent_tasks.goRepository: sageox/ox
Length of output: 3035
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show surrounding code for the slice sites to see how IDs are produced/validated
sed -n '130,210p' cmd/ox/agent_tasks.go
# Find the task view mapping and any ID normalization/validation
rg -n "func toTaskView|type taskView|ID string|ClaimOptions|DefaultLease" cmd/ox/agent_tasks.go cmd/ox -S || true
# Locate the underlying agenttask types that define ID and task IDs
rg -n "package agenttask|type .*Task|type .*Claimed|ID\s+string|ID\s+\w+|DefaultLease" . -S --glob='*.go' \
| head -n 50Repository: sageox/ox
Length of output: 17702
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Inspect the mapping from agenttask.Task -> taskView (where v.ID comes from)
sed -n '1,120p' cmd/ox/agent_tasks.go
# 2) Locate the agenttask package and inspect Task struct + ID field type/constraints
# (repo-relative searches; keep output bounded)
rg -n "package agenttask" -S . --glob='*.go'
rg -n "type (Task|Claimed|.*Task.*) struct" -S . --glob='*.go' -g'*agenttask*'
# If we can find the file(s) from the above, print the sections around Task struct
# (fallback: search for "type Task struct" specifically)
rg -n "type Task struct" -S . --glob='*.go' -g'*agenttask*' --glob='*.go' | head -n 20Repository: sageox/ox
Length of output: 5223
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show Task struct and how IDs are created/parsed
sed -n '1,220p' internal/agenttask/task.go
# Look for ID validation/formatting in task store
sed -n '1,260p' internal/agenttask/store.go
sed -n '260,520p' internal/agenttask/store.go
# Search for any usage of [:8] or assumptions about ID length in agenttask
rg -n '\[:8\]|short|len\(.+ID|ID\s*<\s*8|ID\s*\.{0,1}8' internal/agenttask --glob='*.go' || trueRepository: sageox/ox
Length of output: 21872
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find newTaskID implementation
rg -n "func newTaskID" -S internal/agenttask --glob='*.go'
# Show implementation
sed -n '1,220p' internal/agenttask/store.go
# Search for calls that set `ID:` when adding tasks
rg -n "agenttask\.NewStore|\.Add\(\s*&?agenttask\.Task|newTaskID\(" -S . --glob='*.go'
# Specifically find Task literals with `ID:` field
rg -n "agenttask\.Task\s*{[^}]*ID\s*:" -S . --glob='*.go'
# Also find any explicit `task.ID =` assignments
rg -n "\.ID\s*=\s*newTaskID\(|\.ID\s*=\s*\"|\.ID\s*=" -S internal/agenttask cmd/ox --glob='*.go'Repository: sageox/ox
Length of output: 8361
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect newTaskID implementation + any helpers around it
sed -n '600,720p' internal/agenttask/store.go
# Find any ID validation helpers in internal/agenttask (length/format checks)
rg -n "validate.*ID|ID.*validate|len\\(.*ID\\)|UUIDv7|uuidv7|Parse.*UUID|regexp.*ID|task id" internal/agenttask --glob='*.go'
# Search for any repo-wide helper used for "short" printing of IDs
rg -n "func short[A-Za-z0-9_]*ID|short[A-Za-z0-9_]*ID\\(" cmd internal --glob='*.go'
# Check where Task.ID is explicitly set (might constrain whether short IDs are realistic)
rg -n "agenttask\\.Task\\s*\\{|\\.ID\\s*=|ID\\s*:" internal --glob='*.go'Repository: sageox/ox
Length of output: 2079
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for any existing helper used to shorten IDs in CLI output
rg -n "func short[A-Za-z0-9_]*ID\s*\(|short[A-Za-z0-9_]*ID\s*\(" cmd internal --glob='*.go' || true
# Search for any "[:8]" usage elsewhere in CLI rendering for IDs
rg -n "\[:8\]" cmd internal --glob='*.go' || trueRepository: sageox/ox
Length of output: 2170
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find where Task.ID is set explicitly (especially short values)
rg -n "agenttask\.Task\s*\{[^}]*ID\s*:" -S . --glob='*.go' || true
rg -n "newTaskID\(|task\.ID\s*=|\.ID\s*:\s*\"|ID\s*:\s*'" -S cmd internal --glob='*.go' || true
# Inspect doctor_agent_tasks.go where it slices t.ID[:8]
sed -n '1,130p' cmd/ox/doctor_agent_tasks.goRepository: sageox/ox
Length of output: 2914
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect existing truncation helper(s)
sed -n '1,120p' internal/cli/truncate.go
# Check any usage of those helpers around ID rendering
rg -n "cli\.Truncate|internal/cli/truncate|shorten|truncate\.go" cmd internal --glob='*.go' || trueRepository: sageox/ox
Length of output: 7467
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "Agent tasks \\(|Claimed .*\\(lease|doctor.*Agent tasks|repeatedly failing|poison task" cmd/ox/*test.go
rg -n "agent tasks" cmd/ox/*test.go internal/*/*test.go
rg -n "tasks next|tasks done|tasks cancel|tasks extend" cmd/ox/*test.goRepository: sageox/ox
Length of output: 255
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,140p' cmd/ox/doctor_agent_tasks_test.go
echo "----"
sed -n '1,220p' cmd/ox/agent_tasks_test.go
echo "----"
sed -n '220,420p' cmd/ox/agent_tasks_test.goRepository: sageox/ox
Length of output: 12209
Prevent panics from unchecked task ID prefix slicing in text output
cmd/ox/agent_tasks.go slices v.ID[:8] and claimed.ID[:8] in text mode without guarding ID length (panic if an on-disk row or producer sets a shorter ID). The same t.ID[:8] pattern exists in cmd/ox/doctor_agent_tasks.go.
Suggested diff
+func shortTaskID(id string) string {
+ if len(id) <= 8 {
+ return id
+ }
+ return id[:8]
+}
+
...
- fmt.Fprintf(w, " [%s] p%d %s — %s (%s, %s old)\n",
- v.Status, v.Priority, v.ID[:8], v.Title, kindOrDash(v.Kind), v.Age)
+ fmt.Fprintf(w, " [%s] p%d %s — %s (%s, %s old)\n",
+ v.Status, v.Priority, shortTaskID(v.ID), v.Title, kindOrDash(v.Kind), v.Age)
...
- fmt.Fprintf(w, "Claimed %s (lease %s): %s\n", claimed.ID[:8], agenttask.DefaultLease, claimed.Title)
+ fmt.Fprintf(w, "Claimed %s (lease %s): %s\n", shortTaskID(claimed.ID), agenttask.DefaultLease, claimed.Title)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmd/ox/agent_tasks.go` around lines 144 - 146, The text output slices IDs
like v.ID[:8], claimed.ID[:8], and t.ID[:8] without checking length, which can
panic for short IDs; update the formatting sites (the fmt.Fprintf calls in agent
task listing and doctor agent task listing) to use a safe prefix helper or
inline min-length logic (e.g., compute n := 8; if len(id) < n { n = len(id) };
id[:n]) and replace v.ID[:8], claimed.ID[:8], t.ID[:8] with that safe substring
so short IDs no longer cause a runtime panic.
| ``` | ||
| .sageox/agent_tasks/agent_tasks.jsonl # gitignored, ephemeral, local-only | ||
| .sageox/agent_tasks/agent_tasks.jsonl.lock | ||
| ``` |
There was a problem hiding this comment.
Add language identifiers to fenced code blocks.
The fenced blocks at Line 31 and Line 76 omit a language token, which triggers MD040.
Suggested patch
-```
+```text
.sageox/agent_tasks/agent_tasks.jsonl # gitignored, ephemeral, local-only
.sageox/agent_tasks/agent_tasks.jsonl.lock@@
- +text
┌─────────── claim (next) ───────────┐
▼ │
ready ──claim──▶ in_progress ──done────▶ completed
</details>
Also applies to: 76-83
<details>
<summary>🧰 Tools</summary>
<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>
[warning] 31-31: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
</details>
</details>
<details>
<summary>🤖 Prompt for AI Agents</summary>
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @docs/ai/specs/agent-task-scheduling.md around lines 31 - 34, The two fenced
code blocks containing the lines ".sageox/agent_tasks/agent_tasks.jsonl #
gitignored, ephemeral, local-only" and the ASCII state diagram (the block
starting with "┌─────────── claim (next) ───────────┐" and ending with
"completed") are missing language identifiers; update each triple-backtick fence
to include a language token (use "text") so the blocks become "```text" to
satisfy MD040 and ensure proper linting/rendering.
</details>
<!-- fingerprinting:phantom:triton:hawk -->
<!-- This is an auto-generated comment by CodeRabbit -->
| scanner := bufio.NewScanner(f) | ||
| scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | ||
| for scanner.Scan() { | ||
| line := scanner.Bytes() | ||
| if len(line) == 0 { | ||
| continue | ||
| } | ||
| var t Task | ||
| if err := json.Unmarshal(line, &t); err != nil { | ||
| continue // skip malformed rows rather than failing the whole read | ||
| } | ||
| if t.ID == "" { | ||
| continue | ||
| } | ||
| if _, exists := seen[t.ID]; !exists { | ||
| order = append(order, t.ID) | ||
| } | ||
| copied := t | ||
| seen[t.ID] = &copied | ||
| } | ||
| if err := scanner.Err(); err != nil { | ||
| return nil, fmt.Errorf("failed to read tasks: %w", err) | ||
| } |
There was a problem hiding this comment.
Prevent oversized JSONL rows from aborting task reads
In internal/agenttask/store.go (lines 457–479), readTasksLocked() uses bufio.Scanner with a 1MiB max token; if a single JSONL line exceeds the limit, scanner.Err() becomes bufio.ErrTooLong and the function returns failed to read tasks instead of skipping that row—so the reconciliation/read flow can fail even though malformed JSON/empty rows are otherwise tolerated. Also, the missing-file path uses os.IsNotExist(err) instead of errors.Is(err, os.ErrNotExist) per repo guidelines.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/agenttask/store.go` around lines 457 - 479, readTasksLocked
currently uses bufio.Scanner with a 1MiB token limit and returns on
scanner.Err(), which causes a bufio.ErrTooLong line to abort the whole read;
change the scan loop to detect bufio.ErrTooLong (and any token-too-large
condition) and skip that offending line (log/debug if needed) instead of
returning an error, keeping the existing behavior of skipping malformed/empty
rows; also replace any os.IsNotExist(err) checks with errors.Is(err,
os.ErrNotExist) per repo guidelines; reference scanner/Scanner.Buffer,
scanner.Scan, scanner.Err, and the readTasksLocked function to locate the
changes.
What this ships
An internal task-scheduling mechanism: the daemon (and other internal producers) enqueue units of work that the next available AI coworker picks up and runs — ideally as a fresh-context subagent. It replaces two one-off hacks:
.needs-doctor-agentfile-drop, andclaude -pfor anti-entropy, which bills against a separate account and loses the developer's warm session.Now that work rides the developer's live session instead. This is not a beads replacement — agent tasks are ephemeral, machine-scheduled, local-only chores.
Design doc:
docs/ai/specs/agent-task-scheduling.md.flowchart LR subgraph producers [Producers] D[daemon anti-entropy] --> Q M[.needs-doctor-agent marker] --> Q C[ox agent tasks add / Go API] --> Q end Q[(.sageox/agent_tasks/<br/>agent_tasks.jsonl)] Q -- UserPromptSubmit hook<br/>surface on change --> A[live AI coworker] A -- tasks next --> A A -- dispatch --> S[fresh-context subagent] S -- tasks done --> QStorage & model
.sageox/agent_tasks/agent_tasks.jsonl(gitignored, ephemeral). Shared — not per-user — because the point is "next available agent."flock-guarded JSONL, last-write-wins, reconcile-on-read (mirrorsinternal/agentinstance).Taskfields: priority (lower = higher), status (ready/in_progress/completed/canceled), expiry (optional; zero = never),target_agent,dedup_key,payload, and a lease (claimed_by_agent_id/pid/host/lease_expires_at/attempts).Lifecycle & self-healing
Claimatomically pops the top eligible ready task and stamps a lease.in_progresstasks whose lease expired or whose claiming PID is dead on this host (proc.IsAlive); cross-host claims rely on lease expiry only.Command surface
ox agent <id> tasks listox agent <id> tasks nextox agent <id> tasks done <id> [--result …]ox agent <id> tasks cancel <id> [--reason …]ox agent <id> tasks extend <id>ox agent tasks add …(hidden)agenttask.EnqueueThere is intentionally no user-facing create command — tasks are scheduled by internal producers.
Conserving the user's tokens (the whole point)
<system-reminder>block only when the ready set changes (per-agent signature cursor in.sageox/cache/). An unchanged pending queue is never re-injected turn after turn; an idle queue costs zero context. (Replaced an earlier arbitrary 30-min re-nudge — it was exactly the repeated-identical-context cost we want to avoid.)Daemon producer (replaces
claude -panti-entropy)On the daemon's doctor timer, independent of
agent_worker.enabled:.needs-doctor-agentmarker → dedupeddoctortask.!isEffectivelyEnabled). Asks the registeredSessionFinalizeHandlerto detect stale recordings and enqueues per-sessionsession-finalizetasks (deduped, priority 30, capped 25/cycle). When a worker IS available, the normal queue forks it (delegated mode, ADR-016) and no task is produced — no duplicate work. When a worker is available but finalize is disabled, the user opted out, so no task either.agentworkcan't importinternal/doctor(cycledaemon → agentwork → doctor → daemon), so the marker is checked viaos.Stat.Visibility
ox statusandox agent listrender a one-lineN ready, M in progresssummary when the queue is non-empty (silent when empty); reading reconciles stale leases.ox doctorgains anagent-tasks-stuckcheck: self-heals leases on read, and--fixcancels poison tasks (>5 attempts).agent_tasks/added to the.sageox/.gitignorecanonical content + required-entries.Test Plan
internal/agenttask— store lifecycle: add/list/claim/complete/cancel/extend, dedup, expiry, priority order, lease + dead-PID reclaim, terminal retention prune.internal/daemon/agentwork— doctor-marker producer (+ dedup, no-project-root no-op); finalize producer enqueues when no worker, skips when worker enabled;finalizeTaskFieldsparsing.cmd/ox—tasks list/next/done/cancel+ target filtering + text mode; surfacing throttle (emit-on-change, silent on unchanged, respectstarget_agent);countAgentTasks/renderAgentTasksSection;agent-tasks-stuckdoctor check (healthy/poison/fix).go vetclean across all packages;gofmtclean.Notes for review
.sageox/agent_tasks/) and new agent data-access surface (ox agent tasks) — both in the "Ryan must review" list. Flagging explicitly; treated as satisfied since you scoped this.TestClaudeRunner_Run_SuccessfulInvocation,TestIsDaemonDisabled, agent-env/CI-detection tests) are driven byCLAUDE_CODE_REMOTE=trueand fail onmaintoo — not from this branch.Possible follow-ups
ox status --json(text rendering covers the human ask today).Generated by Claude Code
Summary by CodeRabbit
New Features
ox agent taskscommands for listing, claiming, completing, and canceling tasksox statusoutputDocumentation
Chores
.gitignoreto exclude agent task queue directory