-
Notifications
You must be signed in to change notification settings - Fork 357
Link tasks to the runs and worktrees that execute them #646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
6e7b582
Keep unpairable tool output as text and repair a rejected history once
jamiepine 30666fa
Restore the goal a task revision recorded
jamiepine ad18ab9
Stop the timeline fighting the reader and hiding old workers
jamiepine 08db4c5
Send the bearer token from the interface API client
jamiepine e204d45
Persist the worktree a task runs in
jamiepine 1ca4452
Record every worker run against its task
jamiepine c84c4b0
Close task attempts left open by a process that exited
jamiepine c86b78f
Show a task's worker runs in the task panel
jamiepine 1c44b1f
Fail closed when a task attempt cannot be reserved
jamiepine 5d931ed
Record the outcome the commit settled on
jamiepine 0a39fec
Retry tool-history repair on the streaming path
jamiepine 436acba
Send the OpenCode session lookup with the auth token
jamiepine 06886ae
Recover a committed outcome before sweeping an attempt as interrupted
jamiepine File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| import {useEffect, useRef, useState} from "react"; | ||
| import {useQuery, useQueryClient} from "@tanstack/react-query"; | ||
| import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; | ||
| import { | ||
| faCheck, | ||
| faChevronDown, | ||
| faChevronRight, | ||
| faCircleHalfStroke, | ||
| faHourglassEnd, | ||
| faPlug, | ||
| faSpinner, | ||
| faStop, | ||
| faTriangleExclamation, | ||
| faXmark, | ||
| } from "@fortawesome/free-solid-svg-icons"; | ||
| import {Badge} from "@spacedrive/primitives"; | ||
| import {api, type TaskAttempt, type TaskAttemptOutcome} from "@/api/client"; | ||
| import {useLiveContext} from "@/hooks/useLiveContext"; | ||
|
|
||
| type BadgeVariant = "info" | "success" | "warning" | "error" | "default"; | ||
|
|
||
| const OUTCOME_LABEL: Record<TaskAttemptOutcome, string> = { | ||
| succeeded: "Succeeded", | ||
| partial: "Partial", | ||
| blocked: "Blocked", | ||
| failed: "Failed", | ||
| cancelled: "Cancelled", | ||
| timed_out: "Timed out", | ||
| interrupted: "Interrupted", | ||
| }; | ||
|
|
||
| const OUTCOME_ICON: Record<TaskAttemptOutcome, typeof faCheck> = { | ||
| succeeded: faCheck, | ||
| partial: faCircleHalfStroke, | ||
| blocked: faTriangleExclamation, | ||
| failed: faXmark, | ||
| cancelled: faStop, | ||
| timed_out: faHourglassEnd, | ||
| interrupted: faPlug, | ||
| }; | ||
|
|
||
| const OUTCOME_VARIANT: Record<TaskAttemptOutcome, BadgeVariant> = { | ||
| succeeded: "success", | ||
| partial: "info", | ||
| blocked: "warning", | ||
| failed: "error", | ||
| cancelled: "default", | ||
| timed_out: "warning", | ||
| interrupted: "default", | ||
| }; | ||
|
|
||
| function formatTimestamp(value: string): string { | ||
| const parsed = new Date(value); | ||
| return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); | ||
| } | ||
|
|
||
| /** Wall-clock duration, or how long a live run has been going. */ | ||
| function formatDuration(startedAt: string, endedAt?: string | null): string | null { | ||
| const start = new Date(startedAt).getTime(); | ||
| const end = endedAt ? new Date(endedAt).getTime() : Date.now(); | ||
| if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null; | ||
|
|
||
| const seconds = Math.round((end - start) / 1000); | ||
| if (seconds < 60) return `${seconds}s`; | ||
| const minutes = Math.floor(seconds / 60); | ||
| if (minutes < 60) return `${minutes}m ${seconds % 60}s`; | ||
| return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; | ||
| } | ||
|
|
||
| /** | ||
| * The run's own output, fetched only when asked for. | ||
| * | ||
| * The attempt row records how the run ended; the worker holds what it actually | ||
| * produced, and that is often long enough to bury everything else. | ||
| */ | ||
| function AttemptOutput({agentId, workerId}: {agentId: string; workerId: string}) { | ||
| const [expanded, setExpanded] = useState(false); | ||
|
|
||
| const {data, isLoading, error} = useQuery({ | ||
| queryKey: ["worker-detail", agentId, workerId], | ||
| queryFn: () => api.workerDetail(agentId, workerId), | ||
| enabled: expanded, | ||
| staleTime: 60_000, | ||
| }); | ||
|
|
||
| return ( | ||
| <div className="mt-1.5"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setExpanded((open) => !open)} | ||
| aria-expanded={expanded} | ||
| className="inline-flex items-center gap-1.5 text-[11px] text-ink-dull hover:text-ink" | ||
| > | ||
| <FontAwesomeIcon | ||
| icon={expanded ? faChevronDown : faChevronRight} | ||
| className="text-[9px]" | ||
| /> | ||
| <span>Worker output</span> | ||
| </button> | ||
|
|
||
| {expanded && ( | ||
| <div className="mt-1.5 rounded border border-app-line/60 bg-app-box/40 p-2"> | ||
| {isLoading ? ( | ||
| <span className="text-[11px] text-ink-faint">Loading worker output…</span> | ||
| ) : error ? ( | ||
| <span className="text-[11px] text-red-400"> | ||
| Worker run is no longer available. | ||
| </span> | ||
| ) : ( | ||
| <pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-dull"> | ||
| {data?.result?.trim() || "This worker recorded no output."} | ||
| </pre> | ||
| )} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function AttemptRow({attempt, agentId}: {attempt: TaskAttempt; agentId?: string}) { | ||
| const live = !attempt.ended_at; | ||
| const outcome = attempt.outcome ?? null; | ||
| const duration = formatDuration(attempt.started_at, attempt.ended_at); | ||
|
|
||
| return ( | ||
| <li className="border-b border-app-line/40 py-2.5 last:border-b-0"> | ||
| <div className="mb-1 flex flex-wrap items-center gap-2"> | ||
| <span className="font-mono text-[11px] text-ink-faint">#{attempt.attempt}</span> | ||
|
|
||
| {live ? ( | ||
| <Badge variant="info" size="sm"> | ||
| <FontAwesomeIcon icon={faSpinner} className="animate-spin text-[10px]" /> | ||
| <span>Running</span> | ||
| </Badge> | ||
| ) : outcome ? ( | ||
| <Badge variant={OUTCOME_VARIANT[outcome]} size="sm"> | ||
| <FontAwesomeIcon icon={OUTCOME_ICON[outcome]} className="text-[10px]" /> | ||
| <span>{OUTCOME_LABEL[outcome]}</span> | ||
| </Badge> | ||
| ) : ( | ||
| <Badge variant="default" size="sm"> | ||
| <span>Ended without an outcome</span> | ||
| </Badge> | ||
| )} | ||
|
|
||
| <span className="font-mono text-[10px] text-ink-faint"> | ||
| {attempt.worker_id.slice(0, 8)} | ||
| </span> | ||
| <span className="text-[10px] text-ink-faint"> | ||
| {formatTimestamp(attempt.started_at)} | ||
| {duration ? ` · ${duration}` : ""} | ||
| </span> | ||
| {attempt.channel_id && ( | ||
| <span className="text-[10px] text-ink-faint">via {attempt.channel_id}</span> | ||
| )} | ||
| </div> | ||
|
|
||
| {attempt.outcome_summary && ( | ||
| <p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-ink-dull"> | ||
| {attempt.outcome_summary} | ||
| </p> | ||
| )} | ||
|
|
||
| {agentId && <AttemptOutput agentId={agentId} workerId={attempt.worker_id} />} | ||
| </li> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Every worker run attempted against this task. | ||
| * | ||
| * The task row names only the run executing now, so without this a task that | ||
| * failed twice before succeeding looks identical to one that worked first time. | ||
| */ | ||
| export function TaskAttempts({ | ||
| taskNumber, | ||
| agentId, | ||
| }: { | ||
| taskNumber: number; | ||
| agentId?: string; | ||
| }) { | ||
| const queryClient = useQueryClient(); | ||
| const {workerEventVersion} = useLiveContext(); | ||
| const queryKey = ["task-attempts", taskNumber]; | ||
|
|
||
| // A run starting or finishing arrives over SSE. | ||
| const previousVersion = useRef(workerEventVersion); | ||
| useEffect(() => { | ||
| if (workerEventVersion !== previousVersion.current) { | ||
| previousVersion.current = workerEventVersion; | ||
| void queryClient.invalidateQueries({queryKey}); | ||
| } | ||
| }, [workerEventVersion, queryClient, taskNumber]); | ||
|
|
||
| const {data, isLoading, error} = useQuery({ | ||
| queryKey, | ||
| queryFn: () => api.listTaskAttempts(taskNumber), | ||
| }); | ||
|
|
||
| const attempts = data?.attempts ?? []; | ||
|
|
||
| return ( | ||
| <div className="border-t border-app-line/40 px-4 py-3"> | ||
| <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-ink-dull"> | ||
| Runs{attempts.length > 0 ? ` (${attempts.length})` : ""} | ||
| </h3> | ||
|
|
||
| {isLoading ? ( | ||
| <p className="text-xs text-ink-faint">Loading runs…</p> | ||
| ) : error ? ( | ||
| <p className="text-xs text-red-400">Failed to load the run history.</p> | ||
| ) : attempts.length === 0 ? ( | ||
| <p className="text-xs text-ink-faint"> | ||
| Not worked yet. Every worker run against this task is recorded here. | ||
| </p> | ||
| ) : ( | ||
| <> | ||
| {data?.summary && ( | ||
| <p className="mb-2 text-[11px] text-ink-faint">{data.summary}</p> | ||
| )} | ||
| <ul> | ||
| {attempts.map((attempt) => ( | ||
| <AttemptRow key={attempt.id} attempt={attempt} agentId={agentId} /> | ||
| ))} | ||
| </ul> | ||
| </> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| -- Every worker run attempted against a task, kept whole. | ||
| -- | ||
| -- `tasks.worker_id` points at the run currently executing and is overwritten by | ||
| -- the next spawn, so a task retried three times remembers only the last one. | ||
| -- This table is the history: append-only, one row per attempt. | ||
| -- | ||
| -- `worker_id` carries no foreign key on purpose. Tasks live in the instance | ||
| -- database and `worker_runs` lives in the per-agent database, so the reference | ||
| -- crosses a database boundary and cannot be enforced by SQLite. A run whose | ||
| -- worker row has been pruned still records that the attempt happened and how it | ||
| -- ended. | ||
| CREATE TABLE task_worker_runs ( | ||
| id TEXT PRIMARY KEY, | ||
| task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, | ||
| worker_id TEXT NOT NULL, | ||
| -- 1 for the first attempt on this task, incrementing per attempt. | ||
| attempt INTEGER NOT NULL, | ||
| -- Who or what asked for this run, and through which surface. | ||
| author_type TEXT NOT NULL DEFAULT 'system', | ||
| author_id TEXT, | ||
| agent_id TEXT, | ||
| channel_id TEXT, | ||
| started_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), | ||
| -- Null until the run reaches a terminal state. | ||
| outcome_kind TEXT, | ||
| outcome_summary TEXT, | ||
| ended_at TIMESTAMP, | ||
| UNIQUE (task_id, worker_id), | ||
| UNIQUE (task_id, attempt) | ||
| ); | ||
|
|
||
| CREATE INDEX task_worker_runs_task ON task_worker_runs(task_id, attempt DESC); | ||
| CREATE INDEX task_worker_runs_worker ON task_worker_runs(worker_id); | ||
|
|
||
| -- Resolving "is this task already being worked on" must not scan the table. | ||
| CREATE INDEX task_worker_runs_live ON task_worker_runs(task_id, ended_at); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Delete task attempts explicitly when deleting a task.
Line 14 adds a task child row.
TaskStore::deleteexplicitly deletes comments, revisions, and dependencies because SQLite cascade behavior depends on foreign-key enforcement. It does not deletetask_worker_runs.When foreign keys are disabled, deleting a task leaves orphaned attempt rows, including worker identifiers and outcome text. Add
task_worker_runsto the explicit deletion list. Extend the task-deletion test to create an attempt and assert that no attempt rows remain.🤖 Prompt for AI Agents