@saadnvd1/agent-os 0.3.485 → 0.3.487
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 +27 -5
- package/app/api/bus/spawn/route.ts +15 -0
- package/app/api/tasks/route.ts +5 -0
- package/components/Tasks/TaskRow.tsx +6 -0
- package/components/Usage/CompareView.tsx +15 -3
- package/lib/agents/spawn.ts +21 -9
- package/lib/db/migrations.ts +21 -0
- package/lib/db/types.ts +5 -0
- package/lib/models/plan-step.ts +52 -0
- package/lib/models/roles.ts +4 -1
- package/lib/orchestrator/act.ts +59 -7
- 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/brief.ts +10 -2
- package/lib/orchestrator/describe.ts +4 -0
- package/lib/orchestrator/facts.ts +6 -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/serve.ts +2 -0
- package/lib/orchestrator/signoff.ts +16 -2
- package/lib/orchestrator/tool-schemas.ts +14 -2
- package/lib/orchestrator/tools.ts +3 -3
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/sessions/launch.ts +24 -5
- package/lib/tasks/index.ts +18 -1
- package/lib/tasks/queue.ts +67 -7
- package/lib/usage/compare.ts +31 -2
- package/lib/usage/turns.ts +2 -1
- package/package.json +1 -1
|
@@ -36,6 +36,14 @@ Events reach you as short lines from "agentos" (a PR opened, CI finished, a sess
|
|
|
36
36
|
|
|
37
37
|
You may act without asking on anything inside this workspace that's additive or reversible: start, steer, stop and drop sessions and tasks; answer blockers; create, update and move cards; run stacks; review PRs; merge what passes the gates. Your tools only reach this workspace's projects, sessions, tasks and stacks; outside it, you can only message other workspaces' orchestrators.
|
|
38
38
|
|
|
39
|
+
## Triage every job
|
|
40
|
+
|
|
41
|
+
Before you start work, decide what it needs, and hand it to the cheapest model that can do it.
|
|
42
|
+
- Small and clear (a known change, a few files, nothing to discover): start_task with plan false and a plan_reason saying why. Write the steps into the prompt yourself, from what Saad or the card said; do not read code in your own context to do it.
|
|
43
|
+
- Big, unclear or cross-cutting (many files, a design choice, unknown ground): start_task with plan true and a plan_reason. The planning model writes the plan, then the implementation model carries it out; you get one event with the plan file's path and a short summary when it is written.
|
|
44
|
+
- Not in a repository (research, a document, a machine task): start_session, which runs on the implementation model, with the steps in the prompt.
|
|
45
|
+
- Implementation on Opus only when the work needs deep reasoning (a subtle concurrency or security change, say). Pass model and say why. Sonnet is the default.
|
|
46
|
+
|
|
39
47
|
## Merging: the gates
|
|
40
48
|
|
|
41
49
|
Merge only through the gates: \`arm\` or \`sign_off\`. \`sign_off\` merges a task's PR, with the project's merge method (squash unless set otherwise), only if all of these hold, and otherwise refuses naming the gate:
|
|
@@ -93,9 +101,9 @@ Reading:
|
|
|
93
101
|
Acting:
|
|
94
102
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
95
103
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
96
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan
|
|
104
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
97
105
|
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
98
|
-
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?):
|
|
106
|
+
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?, model?): a session without a worktree or PR, a chat unless view is terminal. It runs on the implementation model (Sonnet) unless you pass model; use it for work outside a repository, with the steps in the prompt.
|
|
99
107
|
- \`${TOOL_NAMES.quick_change}\` (project, name): a worktree and branch cut from the project's default branch for a small, low-risk edit you make yourself: a typo, a config value, a one-file fix. It returns the worktree's path; edit there with Write and Edit, and run git there only as \`git -C <path> <status|diff|log|show|add|rm|mv|restore|reset>\`. Anything that needs real work, tests or a long investigation is a task: start_task. At most a few open at once.
|
|
100
108
|
- \`${TOOL_NAMES.quick_pr}\` (change, title, body): commit everything in a quick change, push its branch (never the default branch) and open its PR, or push to the PR it has; its review starts at once. Then it's gated like any task: CI, that review (also its code-review gate), scope, then sign_off or arm. It's in \`sessions\` as your quick change; drop it to throw it away, and it's cleaned up like a task when it merges or is dropped.
|
|
101
109
|
- \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
|
|
@@ -37,6 +37,10 @@ function sessionLine(f: SessionFacts): string {
|
|
|
37
37
|
task += `, BLOCKED: ${untrusted(f.name, f.task.blocked)}`;
|
|
38
38
|
parts.push(task);
|
|
39
39
|
}
|
|
40
|
+
if (f.plan)
|
|
41
|
+
parts.push(
|
|
42
|
+
`${f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
|
|
43
|
+
);
|
|
40
44
|
if (f.stack) {
|
|
41
45
|
const card = f.stack.ticket ? ` ${f.stack.ticket}` : "";
|
|
42
46
|
parts.push(
|
|
@@ -41,6 +41,8 @@ export interface SessionFacts {
|
|
|
41
41
|
branch: string | null;
|
|
42
42
|
// A quick change the orchestrator makes itself (quick.ts): no agent.
|
|
43
43
|
quick?: boolean;
|
|
44
|
+
// The orchestrator's call on a plan step, with its reason, when stated.
|
|
45
|
+
plan?: { planned: boolean; reason: string | null } | null;
|
|
44
46
|
task: {
|
|
45
47
|
state: TaskState;
|
|
46
48
|
pr: TaskPR | null;
|
|
@@ -279,6 +281,10 @@ export async function sessionFacts(
|
|
|
279
281
|
),
|
|
280
282
|
branch: s.branch_name,
|
|
281
283
|
quick: !!s.quick_of,
|
|
284
|
+
plan:
|
|
285
|
+
s.plan_decided == null
|
|
286
|
+
? null
|
|
287
|
+
: { planned: !!s.plan_decided, reason: s.plan_reason ?? null },
|
|
282
288
|
task,
|
|
283
289
|
stack: stackOf(s.id),
|
|
284
290
|
};
|
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" &&
|
|
@@ -107,6 +107,7 @@ export async function runTool(
|
|
|
107
107
|
after: a.after,
|
|
108
108
|
model: a.model,
|
|
109
109
|
plan: a.plan,
|
|
110
|
+
planReason: a.plan_reason,
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
case "set_task_model": {
|
|
@@ -122,6 +123,7 @@ export async function runTool(
|
|
|
122
123
|
return startSession(w, a.project, a.prompt, {
|
|
123
124
|
name: a.name,
|
|
124
125
|
view: a.view,
|
|
126
|
+
model: a.model,
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
129
|
case "quick_change": {
|
|
@@ -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);
|
|
@@ -105,9 +105,16 @@ export const TOOL_SHAPES = {
|
|
|
105
105
|
model: modelOverride,
|
|
106
106
|
plan: z
|
|
107
107
|
.boolean()
|
|
108
|
-
.optional()
|
|
109
108
|
.describe(
|
|
110
|
-
"
|
|
109
|
+
"Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
|
|
110
|
+
),
|
|
111
|
+
plan_reason: z
|
|
112
|
+
.string()
|
|
113
|
+
.trim()
|
|
114
|
+
.min(3)
|
|
115
|
+
.max(200)
|
|
116
|
+
.describe(
|
|
117
|
+
'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
|
|
111
118
|
),
|
|
112
119
|
},
|
|
113
120
|
set_task_model: {
|
|
@@ -134,6 +141,11 @@ export const TOOL_SHAPES = {
|
|
|
134
141
|
.optional()
|
|
135
142
|
.describe("A short name, 2-6 words (default: one made from the prompt)"),
|
|
136
143
|
view,
|
|
144
|
+
model: modelRef
|
|
145
|
+
.optional()
|
|
146
|
+
.describe(
|
|
147
|
+
'One model for the session, "provider/model"; default: the implementation model. Opus only when the work needs deep reasoning'
|
|
148
|
+
),
|
|
137
149
|
},
|
|
138
150
|
quick_change: {
|
|
139
151
|
project,
|
|
@@ -61,11 +61,11 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
61
61
|
message_orchestrator:
|
|
62
62
|
"Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
|
|
63
63
|
start_task:
|
|
64
|
-
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Refused while a brake holds.",
|
|
64
|
+
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason: plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.",
|
|
65
65
|
set_task_model:
|
|
66
66
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
67
67
|
start_session:
|
|
68
|
-
|
|
68
|
+
'Start an agent session in a project with a prompt, for work that isn\'t a PR task. It runs on the implementation model unless you give model (one "provider/model"; Opus only when the work needs deep reasoning), as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.',
|
|
69
69
|
quick_change:
|
|
70
70
|
"Make a small, low-risk edit yourself (a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. You may write there (Write, Edit) and run git there as `git -C <path> ...` (status, diff, add, rm, mv, restore, reset, log, show); nowhere else in a project. Anything needing real work, tests or an investigation is a task (start_task).",
|
|
71
71
|
quick_pr:
|
|
@@ -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/sessions/launch.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { db, queries, type Session } from "../db";
|
|
|
11
11
|
import type { AgentType } from "../providers";
|
|
12
12
|
import type { ChatAccess, ChatImage } from "../chat/events";
|
|
13
13
|
import { resolveModelForAgent } from "../model-catalog";
|
|
14
|
-
import { resolveRole, setSessionModel } from "../models/resolve";
|
|
14
|
+
import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
|
|
15
15
|
import { ANTHROPIC } from "../models/roles";
|
|
16
16
|
import { getProject } from "../projects";
|
|
17
17
|
import { getHost, isRemoteHost } from "../hosts";
|
|
@@ -71,6 +71,9 @@ export interface LaunchInput {
|
|
|
71
71
|
name?: string | null;
|
|
72
72
|
view?: "chat" | "terminal";
|
|
73
73
|
parentSessionId?: string | null;
|
|
74
|
+
// The orchestrator session that starts it: it runs on the implementation
|
|
75
|
+
// role's model (or `model`), not the interactive one's.
|
|
76
|
+
startedBy?: string | null;
|
|
74
77
|
groupPath?: string;
|
|
75
78
|
systemPrompt?: string | null;
|
|
76
79
|
}
|
|
@@ -222,10 +225,20 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
222
225
|
: DEFAULT_START_VIEW;
|
|
223
226
|
// Claude with no model picked runs on the interactive role's, here; on
|
|
224
227
|
// another provider only as a chat, whose worker carries its endpoint.
|
|
228
|
+
const role = input.startedBy ? "implement" : "interactive";
|
|
229
|
+
// An orchestrator's explicit model is validated like a task's.
|
|
230
|
+
const picked = input.startedBy ? parseOverride(input.model) : null;
|
|
231
|
+
if (picked && typeof picked !== "string")
|
|
232
|
+
throw new Error(`model: a session takes one "provider/model"`);
|
|
225
233
|
const routed =
|
|
226
|
-
agentType === "claude" && !input.model?.trim() && !remote
|
|
227
|
-
? resolveRole(
|
|
234
|
+
agentType === "claude" && (picked || !input.model?.trim()) && !remote
|
|
235
|
+
? resolveRole(role, {
|
|
236
|
+
workspaceId: project?.workspace_id,
|
|
237
|
+
override: picked,
|
|
238
|
+
})
|
|
228
239
|
: null;
|
|
240
|
+
if (picked && routed && routed.provider.id !== ANTHROPIC && view !== "chat")
|
|
241
|
+
throw new Error("Another provider's models run only as a chat");
|
|
229
242
|
const interactive =
|
|
230
243
|
routed && (routed.provider.id === ANTHROPIC || view === "chat")
|
|
231
244
|
? routed
|
|
@@ -264,8 +277,14 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
264
277
|
runOn
|
|
265
278
|
);
|
|
266
279
|
db.prepare(
|
|
267
|
-
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ? WHERE id = ?`
|
|
268
|
-
).run(
|
|
280
|
+
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ? WHERE id = ?`
|
|
281
|
+
).run(
|
|
282
|
+
view,
|
|
283
|
+
naming.source,
|
|
284
|
+
useWorktree ? "running" : null,
|
|
285
|
+
input.startedBy ?? null,
|
|
286
|
+
id
|
|
287
|
+
);
|
|
269
288
|
if (interactive) setSessionModel(id, interactive);
|
|
270
289
|
if (input.access)
|
|
271
290
|
db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
|
package/lib/tasks/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface TaskView {
|
|
|
96
96
|
// plan step (lib/models).
|
|
97
97
|
model?: string;
|
|
98
98
|
planning?: boolean;
|
|
99
|
+
// What its starter decided about a plan step, and why; null if unstated.
|
|
100
|
+
plan?: { planned: boolean; reason: string | null } | null;
|
|
99
101
|
// A quick change: the orchestrator session that makes it (no agent).
|
|
100
102
|
quickOf?: string | null;
|
|
101
103
|
}
|
|
@@ -111,6 +113,10 @@ export async function createTask(opts: {
|
|
|
111
113
|
model?: ModelOverride;
|
|
112
114
|
// Plan first on the planning model (Settings' default otherwise).
|
|
113
115
|
plan?: boolean;
|
|
116
|
+
// Why the starter chose to plan or not: shown on the task's card.
|
|
117
|
+
planReason?: string | null;
|
|
118
|
+
// The orchestrator session that started it (null: a person).
|
|
119
|
+
startedBy?: string | null;
|
|
114
120
|
// Started from this LumifyHub card, which the task then moves.
|
|
115
121
|
cardId?: string;
|
|
116
122
|
// Stacked: cut from another task's pushed branch, at this exact commit.
|
|
@@ -247,7 +253,8 @@ async function startTask(
|
|
|
247
253
|
// Everything the launch needs is on the row, so a restart can resume it.
|
|
248
254
|
db.prepare(
|
|
249
255
|
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
250
|
-
model_provider = ?, model_overrides = ?, plan_phase =
|
|
256
|
+
model_provider = ?, model_overrides = ?, plan_phase = ?,
|
|
257
|
+
plan_decided = ?, plan_reason = ?, started_by = ? WHERE id = ?`
|
|
251
258
|
).run(
|
|
252
259
|
prompt,
|
|
253
260
|
buildTaskBrief({
|
|
@@ -260,6 +267,9 @@ async function startTask(
|
|
|
260
267
|
routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
|
|
261
268
|
routing.override ? JSON.stringify(routing.override) : null,
|
|
262
269
|
routing.plan ? "planning" : null,
|
|
270
|
+
opts.plan === undefined ? null : opts.plan ? 1 : 0,
|
|
271
|
+
opts.planReason?.trim() || null,
|
|
272
|
+
opts.startedBy ?? null,
|
|
263
273
|
id
|
|
264
274
|
);
|
|
265
275
|
opts.onCreated?.(id);
|
|
@@ -376,6 +386,13 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
376
386
|
armed: !!session.merge_armed_at,
|
|
377
387
|
model: sessionModelRef(session),
|
|
378
388
|
planning: session.plan_phase === "planning",
|
|
389
|
+
plan:
|
|
390
|
+
session.plan_decided == null
|
|
391
|
+
? null
|
|
392
|
+
: {
|
|
393
|
+
planned: !!session.plan_decided,
|
|
394
|
+
reason: session.plan_reason ?? null,
|
|
395
|
+
},
|
|
379
396
|
quickOf: session.quick_of ?? null,
|
|
380
397
|
};
|
|
381
398
|
}
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface QueueRow {
|
|
|
44
44
|
model: string | null;
|
|
45
45
|
// A per-role model map (lib/models), when that's what it was given.
|
|
46
46
|
model_overrides: string | null;
|
|
47
|
+
// The starter's plan decision (1/0, null: not stated), its reason, and the
|
|
48
|
+
// orchestrator session that started it.
|
|
49
|
+
plan: number | null;
|
|
50
|
+
plan_reason: string | null;
|
|
51
|
+
started_by: string | null;
|
|
47
52
|
view: "chat" | "terminal" | null;
|
|
48
53
|
base_branch: string | null;
|
|
49
54
|
host_id: string | null;
|
|
@@ -156,11 +161,19 @@ export function hasFinished(taskId: string): boolean {
|
|
|
156
161
|
return !q || (q.status !== "queued" && q.status !== "starting");
|
|
157
162
|
}
|
|
158
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
|
+
|
|
159
172
|
export function afterMet(r: QueueRow): boolean {
|
|
160
173
|
if (r.after_task && !hasFinished(r.after_task)) return false;
|
|
161
174
|
if (r.after_any) {
|
|
162
175
|
const ids = JSON.parse(r.after_any) as string[];
|
|
163
|
-
if (ids.length && !ids.some(
|
|
176
|
+
if (ids.length && !ids.some((id) => !stillWorking(id))) return false;
|
|
164
177
|
}
|
|
165
178
|
return true;
|
|
166
179
|
}
|
|
@@ -205,6 +218,9 @@ export interface QueueRequest {
|
|
|
205
218
|
prompt: string;
|
|
206
219
|
name?: string;
|
|
207
220
|
model?: ModelOverride;
|
|
221
|
+
plan?: boolean;
|
|
222
|
+
planReason?: string | null;
|
|
223
|
+
startedBy?: string | null;
|
|
208
224
|
view?: "chat" | "terminal";
|
|
209
225
|
baseBranch?: string;
|
|
210
226
|
hostId?: string;
|
|
@@ -236,9 +252,17 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
236
252
|
let afterTask: string | null = null;
|
|
237
253
|
let afterAny: string[] | null = null;
|
|
238
254
|
const after = req.after?.trim();
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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)
|
|
242
266
|
afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
|
|
243
267
|
const full =
|
|
244
268
|
limit !== null &&
|
|
@@ -247,13 +271,13 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
247
271
|
startingIn(workspaceId) >=
|
|
248
272
|
limit ||
|
|
249
273
|
waitingIn(workspaceId) > 0);
|
|
250
|
-
if (!
|
|
274
|
+
if (!waits && !full) return null;
|
|
251
275
|
if (!req.prompt.trim()) throw new Error("Describe the task");
|
|
252
276
|
const id = randomUUID();
|
|
253
277
|
db.prepare(
|
|
254
|
-
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, view, base_branch,
|
|
278
|
+
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, started_by, view, base_branch,
|
|
255
279
|
host_id, after_task, after_any, origin_workspace_id, position)
|
|
256
|
-
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @view, @base_branch,
|
|
280
|
+
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @started_by, @view, @base_branch,
|
|
257
281
|
@host_id, @after_task, @after_any, @origin,
|
|
258
282
|
(SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
|
|
259
283
|
).run({
|
|
@@ -266,6 +290,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
266
290
|
req.model && typeof req.model === "object"
|
|
267
291
|
? JSON.stringify(req.model)
|
|
268
292
|
: null,
|
|
293
|
+
plan: req.plan === undefined ? null : req.plan ? 1 : 0,
|
|
294
|
+
plan_reason: req.planReason?.trim() || null,
|
|
295
|
+
started_by: req.startedBy ?? null,
|
|
269
296
|
view: req.view ?? null,
|
|
270
297
|
base_branch: req.baseBranch || null,
|
|
271
298
|
host_id: req.hostId || null,
|
|
@@ -440,6 +467,9 @@ async function startRow(
|
|
|
440
467
|
model: r.model_overrides
|
|
441
468
|
? (JSON.parse(r.model_overrides) as ModelOverride)
|
|
442
469
|
: (r.model ?? undefined),
|
|
470
|
+
plan: r.plan == null ? undefined : !!r.plan,
|
|
471
|
+
planReason: r.plan_reason,
|
|
472
|
+
startedBy: r.started_by,
|
|
443
473
|
view: r.view ?? undefined,
|
|
444
474
|
baseBranch: r.base_branch ?? undefined,
|
|
445
475
|
hostId: r.host_id ?? undefined,
|
|
@@ -574,6 +604,36 @@ export async function startQueuedNow(id: string): Promise<StartOutcome> {
|
|
|
574
604
|
return startRow(r, { force: true });
|
|
575
605
|
}
|
|
576
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
|
+
|
|
577
637
|
export function removeQueued(id: string): void {
|
|
578
638
|
const changed = db
|
|
579
639
|
.prepare(
|