@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 +11 -2
- package/lib/orchestrator/act.ts +27 -2
- package/lib/orchestrator/approval-carry.ts +48 -0
- package/lib/orchestrator/ask-approvals.ts +20 -1
- package/lib/orchestrator/ask-heads.ts +47 -0
- package/lib/orchestrator/asks.ts +26 -0
- package/lib/orchestrator/held.ts +3 -2
- package/lib/orchestrator/patch.ts +98 -0
- package/lib/orchestrator/rebase.ts +2 -24
- package/lib/orchestrator/signoff.ts +16 -2
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/tasks/queue.ts +51 -5
- package/package.json +1 -1
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
|
|
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).
|
|
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
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -20,7 +20,12 @@ import {
|
|
|
20
20
|
type StartView,
|
|
21
21
|
} from "../sessions/launch";
|
|
22
22
|
import { createTask, dropTask } from "../tasks";
|
|
23
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/orchestrator/asks.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/orchestrator/held.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
})()
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 (!
|
|
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(
|