@saadnvd1/agent-os 0.3.486 → 0.3.488

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -358,6 +358,9 @@ and leave no transcript, so each run's tokens, model and duration are read
358
358
  from its JSON result into `check_runs`, and the review's verdict says them
359
359
  too. Every compaction is kept in `chat_compactions` (who ran it, whether it
360
360
  fired `mid_turn` or `between_turns`, and the context before and after), so cost per task can be compared by date.
361
+ Rows from before the column existed, and any written by a chat worker still
362
+ running older code, have a NULL `phase`: read those as unknown
363
+ (`COALESCE(phase, 'unknown')`), not as either kind.
361
364
 
362
365
  **Esc** stops a running turn, as it does in Claude Code's terminal, unless a
363
366
  menu, dialog or the command list is open (those close first). On a phone the
@@ -464,7 +467,9 @@ default; set it in **Settings → Workspaces**, or `PATCH /api/workspaces/<id>`
464
467
  worktree or agent yet, and starts by itself in line order when one finishes
465
468
  (merged, dropped or done). `aos task --after <task|any>` (and the
466
469
  orchestrator's `start_task` with `after`) holds a task until a named task
467
- finishes, or until any task running now does. Pause stops automatic starts,
470
+ finishes, or until any task still working now does (a running task whose PR
471
+ was closed or merged doesn't count; with none, it starts at once). The
472
+ orchestrator's `drop` removes a queued task by its id. Pause stops automatic starts,
468
473
  and the orchestrator's brakes still apply when its queued tasks start. The
469
474
  line is kept in the database across restarts (`GET /api/tasks/queue`; start
470
475
  now, move up or down, or remove with `/api/tasks/queue/<id>`). The sidebar
@@ -1129,7 +1134,11 @@ it may do on its own, and the lines that always come back to you as asks.
1129
1134
  nothing uncommitted, AgentOS rebases it in the task's worktree and pushes
1130
1135
  only its branch with `--force-with-lease`, and the orchestrator's review
1131
1136
  carries over when the PR's own patch is
1132
- unchanged (same `git patch-id`; a fresh review starts otherwise). Not
1137
+ unchanged (same `git patch-id`; a fresh review starts otherwise). Your
1138
+ merge approval carries over a rebase the same way (any rebase, the task's
1139
+ own included): when the PR's patch is unchanged it follows the new head,
1140
+ else you're asked again, naming the new head and what changed, and an open
1141
+ merge ask follows its PR's head. Not
1133
1142
  clean: the task is asked once to rebase. Either way, one event. Main and
1134
1143
  other tasks' branches are never written.
1135
1144
  - **Tasks woken on CI failures and reviews:** a check failing on a task's
@@ -20,7 +20,12 @@ import {
20
20
  type StartView,
21
21
  } from "../sessions/launch";
22
22
  import { createTask, dropTask } from "../tasks";
23
- import { startOrQueue } from "../tasks/queue";
23
+ import {
24
+ findQueued,
25
+ queuedLabel,
26
+ removeQueued,
27
+ startOrQueue,
28
+ } from "../tasks/queue";
24
29
  import { getStack, previewStack, startStack } from "../stacks";
25
30
  import { treeOrder } from "../stacks/tree";
26
31
  import type { StackItemView } from "../stacks/types";
@@ -235,7 +240,27 @@ export async function drop(
235
240
  reason: string
236
241
  ): Promise<string> {
237
242
  if (!reason.trim()) throw new Error("Say why it's dropped");
238
- const task = workspaceTask(workspaceId, ref);
243
+ // A task still in the queue has no session yet: its id removes it.
244
+ const removeQueuedTask = (
245
+ queued: NonNullable<ReturnType<typeof findQueued>>
246
+ ) => {
247
+ removeQueued(queued.id);
248
+ addNote(
249
+ workspaceId,
250
+ `Removed queued task ${queuedLabel(queued)} before it started: ${reason.trim()}`
251
+ );
252
+ return `Removed queued task "${queuedLabel(queued)}" (id ${queued.id.slice(0, 8)}) from the queue; it never started.`;
253
+ };
254
+ const byId = findQueued(workspaceId, ref);
255
+ if (byId) return removeQueuedTask(byId);
256
+ let task;
257
+ try {
258
+ task = workspaceTask(workspaceId, ref);
259
+ } catch (error) {
260
+ const byName = findQueued(workspaceId, ref, { byName: true });
261
+ if (byName) return removeQueuedTask(byName);
262
+ throw error;
263
+ }
239
264
  await dropTask(task.id);
240
265
  addNote(workspaceId, `Dropped task ${task.name}: ${reason.trim()}`);
241
266
  return `Dropped ${task.name}: its PR is closed and its worktree removed.`;
@@ -0,0 +1,48 @@
1
+ // Saad's merge approval across a rebase. He approved a commit; the task
2
+ // rebased onto the base (only to renumber a migration, say) and its head
3
+ // moved. The approval was for the PR's patch, so it follows the head when
4
+ // that patch is unchanged (the check the review's carry-over uses, patch.ts),
5
+ // and otherwise is asked again, with a note on what changed.
6
+
7
+ import type { AskRow } from "./asks";
8
+ import { getAsk } from "./asks";
9
+ import { rebindApproval } from "./ask-approvals";
10
+ import { addNote } from "./notes";
11
+ import { comparePatches } from "./patch";
12
+ import { fetchRefs } from "./repo";
13
+ import type { Session } from "../db";
14
+
15
+ const short = (sha: string) => sha.slice(0, 7);
16
+
17
+ export async function carryApproval(
18
+ workspaceId: string,
19
+ work: {
20
+ name: string;
21
+ repo: string;
22
+ refs: Pick<Session, "base_branch" | "branch_name">;
23
+ },
24
+ approval: AskRow & { sha: string },
25
+ head: string
26
+ ): Promise<{ approval: AskRow | null; note: string }> {
27
+ let base: string;
28
+ try {
29
+ base = await fetchRefs(work.repo, work.refs);
30
+ } catch {
31
+ return { approval: null, note: "its base couldn't be fetched to compare" };
32
+ }
33
+ const { same, note } = await comparePatches(
34
+ work.repo,
35
+ base,
36
+ approval.sha,
37
+ head
38
+ );
39
+ if (!same) return { approval: null, note };
40
+ const line = `Carried to ${short(head)}: same patch as ${short(approval.sha)} (rebased onto ${base}).`;
41
+ if (rebindApproval(approval.id, approval.sha, head, line))
42
+ addNote(
43
+ workspaceId,
44
+ `Saad's approval of ${work.name} at ${short(approval.sha)} carries to ${short(head)}: same patch, rebased onto ${base}.`
45
+ );
46
+ const now = getAsk(workspaceId, approval.id);
47
+ return { approval: now?.sha === head && !now.used_at ? now : null, note };
48
+ }
@@ -1,7 +1,7 @@
1
1
  // Spending Saad's approvals: each covers one item, once.
2
2
 
3
3
  import { db } from "../db";
4
- import type { AskRow } from "./asks";
4
+ import { MAX_DETAIL, type AskRow } from "./asks";
5
5
 
6
6
  // An approval on the subject not spent yet, newer than maxAgeMs if given.
7
7
  export function unspentApproval(
@@ -104,3 +104,22 @@ export function voidApprovalsOn(subject: string): number {
104
104
  )
105
105
  .run(subject).changes;
106
106
  }
107
+
108
+ // Moves an approval Saad gave to the commit it was rebased into, when the
109
+ // patch is the same: it stays one approval, spent once. Compare-and-swap on
110
+ // the old commit, so two judgements racing move it once.
111
+ export function rebindApproval(
112
+ id: number,
113
+ from: string,
114
+ to: string,
115
+ line: string
116
+ ): boolean {
117
+ return (
118
+ db
119
+ .prepare(
120
+ `UPDATE orchestrator_asks SET sha = ?, detail = substr(detail || char(10) || ?, 1, ?)
121
+ WHERE id = ? AND sha = ? AND status = 'approved' AND used_at IS NULL`
122
+ )
123
+ .run(to, line, MAX_DETAIL, id, from).changes === 1
124
+ );
125
+ }
@@ -0,0 +1,47 @@
1
+ // An open merge ask follows its PR's head. A task that rebases or pushes
2
+ // while the ask waits would otherwise show Saad a commit that's already
3
+ // gone; the ask is moved to the head it would merge, with a line on whether
4
+ // the PR's patch changed. The approve button is bound to the commit shown,
5
+ // so an answer from a screen that predates the move is refused (asks.ts).
6
+
7
+ import { db, type Session } from "../db";
8
+ import { hostLink } from "../hosts/remote-api";
9
+ import { moveAskHead, openAsks, taskSubject } from "./asks";
10
+ import type { SessionFacts } from "./facts";
11
+ import { addNote } from "./notes";
12
+ import { comparePatches } from "./patch";
13
+ import { fetchRefs, repoOf } from "./repo";
14
+
15
+ const short = (sha: string) => sha.slice(0, 7);
16
+
17
+ // Tasks only: a PR no task owns has no facts here.
18
+ export async function followAskHeads(
19
+ workspaceId: string,
20
+ facts: SessionFacts[]
21
+ ): Promise<void> {
22
+ for (const ask of openAsks(workspaceId)) {
23
+ const f = facts.find((x) => taskSubject(x.id) === ask.subject);
24
+ const pr = f?.task?.pr;
25
+ if (!ask.sha || pr?.state !== "OPEN" || !pr.head || pr.head === ask.sha)
26
+ continue;
27
+ const task = db
28
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
29
+ .get(f!.id) as Session | undefined;
30
+ if (!task || hostLink(task.host_id)) continue;
31
+ try {
32
+ const repo = repoOf(task);
33
+ const base = await fetchRefs(repo, task);
34
+ const { same, note } = await comparePatches(repo, base, ask.sha, pr.head);
35
+ const what = same ? `same patch (rebased onto ${base})` : note;
36
+ const line = `Head moved from ${short(ask.sha)} to ${short(pr.head)}: ${what}. Approving merges ${short(pr.head)}.`;
37
+ if (moveAskHead(ask.id, ask.sha, pr.head, line))
38
+ addNote(
39
+ workspaceId,
40
+ `Saad's ask on ${task.name} now names ${short(pr.head)} (${same ? "same patch" : "patch changed"}).`,
41
+ "ask"
42
+ );
43
+ } catch (error) {
44
+ console.error(`Following ${task.name}'s head in its ask:`, error);
45
+ }
46
+ }
47
+ }
@@ -330,3 +330,29 @@ export function resolveAsks(
330
330
  )
331
331
  .run(why, workspaceId, subject).changes;
332
332
  }
333
+
334
+ // An open ask about a commit that has since been replaced (a rebase, a push)
335
+ // now names the head, with a line saying what happened to it. Only while it
336
+ // still names `from`, so a re-raise in between isn't overwritten.
337
+ export function moveAskHead(
338
+ id: number,
339
+ from: string,
340
+ to: string,
341
+ line: string
342
+ ): boolean {
343
+ const ask = db
344
+ .prepare(`SELECT detail FROM orchestrator_asks WHERE id = ?`)
345
+ .get(id) as { detail: string } | undefined;
346
+ if (!ask) return false;
347
+ const rest = ask.detail.split(/\n?Head moved from /)[0];
348
+ const room = Math.max(0, MAX_DETAIL - line.length - 1);
349
+ const detail = `${rest.slice(0, room)}\n${line}`.trim();
350
+ return (
351
+ db
352
+ .prepare(
353
+ `UPDATE orchestrator_asks SET sha = ?, detail = ?
354
+ WHERE id = ? AND status = 'open' AND sha = ?`
355
+ )
356
+ .run(to, detail, id, from).changes === 1
357
+ );
358
+ }
@@ -14,14 +14,15 @@ export function heldVerdict(
14
14
  held: FailureRow[],
15
15
  url: string,
16
16
  sha: string,
17
- pr: number
17
+ pr: number,
18
+ moved?: string
18
19
  ): Verdict {
19
20
  // One the orchestrator raised was judged before this (signoff.ts).
20
21
  const approval = latestApproval(workspaceId, workSubject(task.id));
21
22
  if (approval?.sha === sha)
22
23
  return { ok: true, sha, pr, approval: approval.id };
23
24
  if (approval) {
24
- const why = `Saad approved ${approval.sha ? short(approval.sha) : "an earlier commit"}, but PR #${pr}'s head is now ${short(sha)}`;
25
+ const why = `Saad approved ${approval.sha ? short(approval.sha) : "an earlier commit"}, but PR #${pr}'s head is now ${short(sha)}${moved ? ` and ${moved}` : ""}`;
25
26
  try {
26
27
  raiseAsk({
27
28
  workspaceId,
@@ -0,0 +1,98 @@
1
+ // Whether two commits carry the same patch against a base, as `git
2
+ // patch-id` sees it. The one check behind both carry-overs of a decision
3
+ // across a rebase: the orchestrator's review (rebase.ts) and Saad's merge
4
+ // approval (approval-carry.ts).
5
+
6
+ import { git, gitFetch, gitOk } from "../git-exec";
7
+
8
+ const diffOf = (repo: string, base: string, head: string) =>
9
+ git(repo, [
10
+ "diff",
11
+ "--no-color",
12
+ "--no-ext-diff",
13
+ "--full-index",
14
+ `${base}...${head}`,
15
+ ]).catch(() => null);
16
+
17
+ async function idOf(repo: string, diff: string): Promise<string | null> {
18
+ return git(repo, ["patch-id", "--verbatim"], {
19
+ input: diff,
20
+ timeout: 30000,
21
+ }).then(
22
+ (out) => out.split(" ")[0] || null,
23
+ () => null
24
+ );
25
+ }
26
+
27
+ // A diff split by file, each without what a rebase legitimately changes
28
+ // (blob ids, line offsets), so two diffs can be compared file by file.
29
+ function byFile(diff: string): Map<string, string> {
30
+ const files = new Map<string, string>();
31
+ for (const part of diff.split(/^(?=diff --git )/m)) {
32
+ const name = part.match(/^diff --git a\/(.+?) b\//)?.[1];
33
+ if (!name) continue;
34
+ files.set(
35
+ name,
36
+ part
37
+ .split("\n")
38
+ .filter((l) => !l.startsWith("index "))
39
+ .map((l) =>
40
+ l.replace(/^(@@ )-\d+(?:,\d+)? \+\d+(?:,\d+)?( @@)/, "$1$2")
41
+ )
42
+ .join("\n")
43
+ );
44
+ }
45
+ return files;
46
+ }
47
+
48
+ const names = (list: string[]) =>
49
+ list.slice(0, 5).join(", ") +
50
+ (list.length > 5 ? ` and ${list.length - 5} more` : "");
51
+
52
+ // What differs between two diffs, in a few words.
53
+ export function describeChange(before: string, after: string): string {
54
+ const a = byFile(before);
55
+ const b = byFile(after);
56
+ const added = [...b.keys()].filter((f) => !a.has(f));
57
+ const removed = [...a.keys()].filter((f) => !b.has(f));
58
+ const changed = [...b.keys()].filter(
59
+ (f) => a.has(f) && a.get(f) !== b.get(f)
60
+ );
61
+ const parts = [
62
+ added.length && `now also touches ${names(added)}`,
63
+ removed.length && `no longer touches ${names(removed)}`,
64
+ changed.length && `changes differ in ${names(changed)}`,
65
+ ].filter(Boolean);
66
+ return parts.length
67
+ ? `its patch changed: ${parts.join("; ")}`
68
+ : "its patch changed";
69
+ }
70
+
71
+ // `from` and `to` against `base`: the same patch, or else a note on why not.
72
+ // Fails closed: a commit or diff that can't be read is never "the same".
73
+ export async function comparePatches(
74
+ repo: string,
75
+ base: string,
76
+ from: string,
77
+ to: string
78
+ ): Promise<{ same: boolean; note: string }> {
79
+ if (!(await gitOk(repo, ["cat-file", "-e", `${from}^{commit}`]))) {
80
+ await gitFetch(repo, ["--quiet", "--", "origin", from], {
81
+ timeout: 30000,
82
+ }).catch(() => undefined);
83
+ if (!(await gitOk(repo, ["cat-file", "-e", `${from}^{commit}`])))
84
+ return {
85
+ same: false,
86
+ note: `the earlier commit ${from.slice(0, 7)} can't be read here, so the patches can't be compared`,
87
+ };
88
+ }
89
+ const [before, after] = await Promise.all([
90
+ diffOf(repo, base, from),
91
+ diffOf(repo, base, to),
92
+ ]);
93
+ if (!before || !after)
94
+ return { same: false, note: "its patch can't be read to compare" };
95
+ const [x, y] = await Promise.all([idOf(repo, before), idOf(repo, after)]);
96
+ if (x && x === y) return { same: true, note: "same patch" };
97
+ return { same: false, note: describeChange(before, after) };
98
+ }
@@ -29,6 +29,7 @@ import type { TaskPR } from "../tasks/state";
29
29
  import { run } from "../tasks/gh";
30
30
  import { isAllowedBase } from "../tasks/session";
31
31
  import { git as runGit, gitFetch, revParse } from "../git-exec";
32
+ import { comparePatches } from "./patch";
32
33
  import { outputOf, type Runner } from "../stacks/git";
33
34
  import { restackedHeads, stackNotice } from "../stacks/restack";
34
35
  import { getCheck, putCheck } from "./checks";
@@ -306,25 +307,6 @@ async function rebaseAt(
306
307
  }
307
308
  }
308
309
 
309
- // The PR's own patch against the base, as `git patch-id` sees it.
310
- async function patchId(repo: string, range: string): Promise<string | null> {
311
- const diff = await runGit(repo, [
312
- "diff",
313
- "--no-color",
314
- "--no-ext-diff",
315
- "--full-index",
316
- range,
317
- ]).catch(() => null);
318
- if (!diff) return null;
319
- return runGit(repo, ["patch-id", "--verbatim"], {
320
- input: diff,
321
- timeout: 30000,
322
- }).then(
323
- (out) => out.split(" ")[0] || null,
324
- () => null
325
- );
326
- }
327
-
328
310
  // The review of the head before the rebase, carried to the new head when
329
311
  // the PR's own patch is the same, else a fresh review of the new head.
330
312
  async function carryReview(
@@ -339,11 +321,7 @@ async function carryReview(
339
321
  const prior = getCheck(task.id, from, "review");
340
322
  const scope = getCheck(task.id, from, "scope");
341
323
  const repo = repoOf(task);
342
- const [before, after] = await Promise.all([
343
- patchId(repo, `${onto}...${from}`),
344
- patchId(repo, `${onto}...${to}`),
345
- ]);
346
- const same = !!before && before === after;
324
+ const { same } = await comparePatches(repo, onto, from, to);
347
325
  if (
348
326
  same &&
349
327
  prior?.status === "pass" &&
@@ -29,6 +29,7 @@ import { evaluateGates, holdingEscalations, type GateInput } from "./gates";
29
29
  import { addNote } from "./notes";
30
30
  import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
31
31
  import { workSubject } from "./asks";
32
+ import { carryApproval } from "./approval-carry";
32
33
  import { heldVerdict } from "./held";
33
34
  import { mergeApprovalsOn } from "./merge-approvals";
34
35
  import { isPaused } from "./pause";
@@ -170,7 +171,20 @@ export async function gateVerdict(
170
171
  // its stack parent merged: the orchestrator wrote that ask, and no answer
171
172
  // to it stands in for those. An approval of the gates' own ask on a held
172
173
  // task merges as it always has (heldVerdict).
173
- const approval = latestApproval(workspaceId, workSubject(work.id));
174
+ let approval = latestApproval(workspaceId, workSubject(work.id));
175
+ // The head moved since he approved: his approval follows it when the PR's
176
+ // own patch is unchanged (a rebase), else the re-ask says what changed.
177
+ let moved: string | undefined;
178
+ if (approval?.sha && approval.sha !== sha) {
179
+ const carried = await carryApproval(
180
+ workspaceId,
181
+ work,
182
+ { ...approval, sha: approval.sha },
183
+ sha
184
+ );
185
+ if (carried.approval) approval = carried.approval;
186
+ else moved = carried.note;
187
+ }
174
188
  if (
175
189
  approval?.sha === sha &&
176
190
  (approval.raised_by === "orchestrator" || !held.length)
@@ -187,7 +201,7 @@ export async function gateVerdict(
187
201
  return { ok: true, sha, pr: pr.number, approval: approval.id };
188
202
  }
189
203
  if (held.length)
190
- return heldVerdict(workspaceId, work, held, pr.url, sha, pr.number);
204
+ return heldVerdict(workspaceId, work, held, pr.url, sha, pr.number, moved);
191
205
 
192
206
  const base = await fetchRefs(repo, work.refs);
193
207
  const files = await changedFiles(repo, base, sha);
@@ -74,7 +74,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
74
74
  "Run a LumifyHub board's open cards as stacked tasks (each start braked), or with plan_only just show the plan.",
75
75
  stack_status: "One stack's items, PRs and progress.",
76
76
  land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
77
- drop: "Reject a task: close its PR and remove its worktree. Logged with the reason.",
77
+ drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
78
78
  stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
79
79
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
80
80
  add_project:
@@ -16,6 +16,7 @@ import { BRAKE_SUBJECT, openAsks } from "./asks";
16
16
  import { settleStaleAsks } from "./ask-settle";
17
17
  import { brakesOn, liftBrake } from "./brakes";
18
18
  import { anyArmed, mergeArmed } from "./armed";
19
+ import { followAskHeads } from "./ask-heads";
19
20
  import { rebaseConflicting } from "./rebase";
20
21
  import { wakeTasks } from "./pr-wake";
21
22
  import { awayTick } from "../away/run";
@@ -75,6 +76,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
75
76
  await wakeTasks(workspaceId, facts).catch((error) =>
76
77
  console.error(`Waking tasks in ${workspaceId}:`, error)
77
78
  );
79
+ await followAskHeads(workspaceId, facts).catch((error) =>
80
+ console.error(`Following ask heads in ${workspaceId}:`, error)
81
+ );
78
82
  await rebaseConflicting(workspaceId, facts);
79
83
  await mergeArmed(workspaceId, facts);
80
84
  })()
@@ -161,11 +161,19 @@ export function hasFinished(taskId: string): boolean {
161
161
  return !q || (q.status !== "queued" && q.status !== "starting");
162
162
  }
163
163
 
164
+ // Still something to wait for under "any": in flight, and not a task whose
165
+ // PR was closed or merged and which nobody finished (that one never will).
166
+ function stillWorking(taskId: string): boolean {
167
+ if (hasFinished(taskId)) return false;
168
+ const s = sessionOf(taskId);
169
+ return !s || (s.pr_status !== "closed" && s.pr_status !== "merged");
170
+ }
171
+
164
172
  export function afterMet(r: QueueRow): boolean {
165
173
  if (r.after_task && !hasFinished(r.after_task)) return false;
166
174
  if (r.after_any) {
167
175
  const ids = JSON.parse(r.after_any) as string[];
168
- if (ids.length && !ids.some(hasFinished)) return false;
176
+ if (ids.length && !ids.some((id) => !stillWorking(id))) return false;
169
177
  }
170
178
  return true;
171
179
  }
@@ -244,9 +252,17 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
244
252
  let afterTask: string | null = null;
245
253
  let afterAny: string[] | null = null;
246
254
  const after = req.after?.trim();
247
- if (after && after.toLowerCase() === "any")
248
- afterAny = runningIn({ workspaceId, projectId: project.id });
249
- else if (after)
255
+ // "any" with nothing working is nothing to wait for: it starts at once.
256
+ let waits = !!after;
257
+ if (after && after.toLowerCase() === "any") {
258
+ afterAny = runningIn({ workspaceId, projectId: project.id }).filter(
259
+ stillWorking
260
+ );
261
+ if (!afterAny.length) {
262
+ afterAny = null;
263
+ waits = false;
264
+ }
265
+ } else if (after)
250
266
  afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
251
267
  const full =
252
268
  limit !== null &&
@@ -255,7 +271,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
255
271
  startingIn(workspaceId) >=
256
272
  limit ||
257
273
  waitingIn(workspaceId) > 0);
258
- if (!after && !full) return null;
274
+ if (!waits && !full) return null;
259
275
  if (!req.prompt.trim()) throw new Error("Describe the task");
260
276
  const id = randomUUID();
261
277
  db.prepare(
@@ -588,6 +604,36 @@ export async function startQueuedNow(id: string): Promise<StartOutcome> {
588
604
  return startRow(r, { force: true });
589
605
  }
590
606
 
607
+ // A task still waiting in the line (or failed to start) by id, an id prefix
608
+ // of 4+ characters or, when asked, its name; in one workspace.
609
+ export function findQueued(
610
+ workspaceId: string,
611
+ ref: string,
612
+ opts: { byName?: boolean } = {}
613
+ ): QueueRow | null {
614
+ const want = ref.trim();
615
+ const rows = db
616
+ .prepare(
617
+ `SELECT q.* FROM task_queue q JOIN projects p ON p.id = q.project_id
618
+ WHERE p.workspace_id = ? AND q.status IN ('queued', 'failed')`
619
+ )
620
+ .all(workspaceId) as QueueRow[];
621
+ const lower = want.toLowerCase();
622
+ const hits = rows.filter(
623
+ (r) =>
624
+ r.id === want ||
625
+ (want.length >= 4 && r.id.startsWith(want)) ||
626
+ (opts.byName && label(r).toLowerCase() === lower)
627
+ );
628
+ if (hits.length > 1)
629
+ throw new Error(
630
+ `"${ref}" matches ${hits.length} queued tasks: ${hits.map((h) => `${label(h)} (${h.id.slice(0, 8)})`).join(", ")}`
631
+ );
632
+ return hits[0] ?? null;
633
+ }
634
+
635
+ export const queuedLabel = label;
636
+
591
637
  export function removeQueued(id: string): void {
592
638
  const changed = db
593
639
  .prepare(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.486",
3
+ "version": "0.3.488",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"