@saadnvd1/agent-os 0.3.558 → 0.3.560
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/app/api/sessions/route.ts +3 -3
- package/lib/db/migrations.ts +27 -0
- package/lib/db/queries.ts +4 -3
- package/lib/models/plan-step.ts +107 -15
- package/lib/orchestrator/conditions.ts +12 -4
- package/lib/orchestrator/events.ts +23 -0
- package/lib/orchestrator/reconcile.ts +201 -0
- package/lib/plans/features.ts +69 -13
- package/lib/plans/pieces.ts +70 -15
- package/lib/plans/release.ts +46 -4
- package/lib/recipes/settle.ts +3 -0
- package/lib/sidebar/shelves.ts +5 -6
- package/package.json +1 -1
|
@@ -10,9 +10,9 @@ import { isProjectRef } from "@/lib/tasks/project-ref";
|
|
|
10
10
|
export async function GET() {
|
|
11
11
|
try {
|
|
12
12
|
const db = getDb();
|
|
13
|
-
// Merged
|
|
14
|
-
// the machine it moved to
|
|
15
|
-
// in the Archived view:
|
|
13
|
+
// Merged tasks are listed (the sidebar files them under Done). Dropped
|
|
14
|
+
// tasks live in the Tasks panel, a moved one on the machine it moved to,
|
|
15
|
+
// and archived sessions in the Archived view: none is in the sidebar.
|
|
16
16
|
const sessions = queries.getSidebarSessions(db).all() as Session[];
|
|
17
17
|
const groups = queries.getAllGroups(db).all() as Group[];
|
|
18
18
|
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2087,6 +2087,33 @@ const migrations: Migration[] = [
|
|
|
2087
2087
|
`);
|
|
2088
2088
|
},
|
|
2089
2089
|
},
|
|
2090
|
+
{
|
|
2091
|
+
id: 87,
|
|
2092
|
+
name: "plan_pieces_reconcile",
|
|
2093
|
+
up: (db) => {
|
|
2094
|
+
// How many pieces a plan listed and what the checker folded, so a plan
|
|
2095
|
+
// that builds as fewer than it listed is never silent; the PRs agents
|
|
2096
|
+
// opened (from their `gh pr create` output) so one outside its task's
|
|
2097
|
+
// repo is noticed.
|
|
2098
|
+
const columns = db.prepare(`PRAGMA table_info(features)`).all() as {
|
|
2099
|
+
name: string;
|
|
2100
|
+
}[];
|
|
2101
|
+
if (!columns.some((c) => c.name === "listed"))
|
|
2102
|
+
db.exec(`ALTER TABLE features ADD COLUMN listed INTEGER`);
|
|
2103
|
+
if (!columns.some((c) => c.name === "folded"))
|
|
2104
|
+
db.exec(`ALTER TABLE features ADD COLUMN folded TEXT`);
|
|
2105
|
+
db.exec(`
|
|
2106
|
+
CREATE TABLE IF NOT EXISTS agent_prs (
|
|
2107
|
+
url TEXT PRIMARY KEY,
|
|
2108
|
+
session_id TEXT NOT NULL,
|
|
2109
|
+
repo TEXT NOT NULL,
|
|
2110
|
+
number INTEGER NOT NULL,
|
|
2111
|
+
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2112
|
+
told_at TEXT
|
|
2113
|
+
);
|
|
2114
|
+
`);
|
|
2115
|
+
},
|
|
2116
|
+
},
|
|
2090
2117
|
];
|
|
2091
2118
|
|
|
2092
2119
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/queries.ts
CHANGED
|
@@ -56,15 +56,16 @@ export const queries = {
|
|
|
56
56
|
`SELECT ${sessionListColumns(db)} FROM sessions ORDER BY updated_at DESC`
|
|
57
57
|
),
|
|
58
58
|
|
|
59
|
-
// The sidebar's sessions: not archived, and not a task
|
|
60
|
-
// lives on another machine.
|
|
59
|
+
// The sidebar's sessions: not archived, and not a dropped task (a
|
|
60
|
+
// rejection, not finished work) or one that lives on another machine.
|
|
61
|
+
// Merged tasks stay: they are the Done shelf.
|
|
61
62
|
getSidebarSessions: (db: Database.Database) =>
|
|
62
63
|
getStmt(
|
|
63
64
|
db,
|
|
64
65
|
`SELECT ${sessionListColumns(db)} FROM sessions
|
|
65
66
|
WHERE archived_at IS NULL
|
|
66
67
|
AND (task_status IS NULL
|
|
67
|
-
OR task_status NOT IN ('
|
|
68
|
+
OR task_status NOT IN ('dropped', 'moved'))
|
|
68
69
|
ORDER BY updated_at DESC`
|
|
69
70
|
),
|
|
70
71
|
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -22,8 +22,14 @@ import { queueEvent } from "../orchestrator/events";
|
|
|
22
22
|
import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
|
|
23
23
|
import { addNote } from "../orchestrator/notes";
|
|
24
24
|
import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
|
|
25
|
-
import { PIECES_PROMPT, pieceBrief } from "../plans/pieces";
|
|
26
|
-
import {
|
|
25
|
+
import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
|
|
26
|
+
import {
|
|
27
|
+
createFeature,
|
|
28
|
+
ownPieceOf,
|
|
29
|
+
planFor,
|
|
30
|
+
planPathOf,
|
|
31
|
+
splitOf,
|
|
32
|
+
} from "../plans/features";
|
|
27
33
|
|
|
28
34
|
export const PLAN_FILE = ".agentos/plan.md";
|
|
29
35
|
|
|
@@ -52,10 +58,11 @@ export function implementPrompt(
|
|
|
52
58
|
amendment?: string,
|
|
53
59
|
piece?: string
|
|
54
60
|
): string {
|
|
61
|
+
const where = ` Work only in this task's repository; if part of the work belongs in another repository, say so in your final message instead of doing it there.`;
|
|
55
62
|
const base = piece
|
|
56
|
-
? `The plan is in \`${PLAN_FILE}\`. ${piece}\nFinish it as the task's instructions above say. Where the plan turns out wrong, do what's right and say so
|
|
63
|
+
? `The plan is in \`${PLAN_FILE}\`. ${piece}\nFinish it as the task's instructions above say. Where the plan turns out wrong, do what's right and say so.${where}`
|
|
57
64
|
: planned
|
|
58
|
-
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so
|
|
65
|
+
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.${where}`
|
|
59
66
|
: `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
|
|
60
67
|
return amendment
|
|
61
68
|
? `${base}\n\nThe plan was approved with this change, which is now part of the task's scope:\n${amendment}`
|
|
@@ -104,7 +111,7 @@ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
|
|
|
104
111
|
? ` after ${p.afterPosition} is ${p.afterKind}`
|
|
105
112
|
: "";
|
|
106
113
|
const why = p.reasons.length ? ` (${p.reasons.join("+")})` : "";
|
|
107
|
-
return `${p.position} ${p.name}${after}${why}`;
|
|
114
|
+
return `${p.position} ${p.name} [${p.project.name}]${after}${why}`;
|
|
108
115
|
});
|
|
109
116
|
const folded = r.folded.length ? `, ${r.folded.length} folded` : "";
|
|
110
117
|
return `${r.pieces.length} pieces${folded}: ${parts.join("; ")}`;
|
|
@@ -130,6 +137,8 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
|
130
137
|
if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
|
|
131
138
|
const pieces = planFor(s, fs.readFileSync(file, "utf8"));
|
|
132
139
|
if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
|
|
140
|
+
if (pieces?.folded.length)
|
|
141
|
+
head += `\nPlan listed ${pieces.listed} pieces, building ${pieces.pieces.length}: ${pieces.folded.map((f) => `${f.name} folded (${f.why})`).join("; ")}`;
|
|
133
142
|
} catch {
|
|
134
143
|
// No plan file: the line says so.
|
|
135
144
|
}
|
|
@@ -143,6 +152,46 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
|
143
152
|
queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
|
|
144
153
|
}
|
|
145
154
|
|
|
155
|
+
// Plans that build as other than one piece in this repository say so, once.
|
|
156
|
+
function sayHowItSplit(
|
|
157
|
+
s: Session,
|
|
158
|
+
workspaceId: string,
|
|
159
|
+
split: NonNullable<ReturnType<typeof splitOf>>,
|
|
160
|
+
nothingOwn: boolean
|
|
161
|
+
): void {
|
|
162
|
+
if (nothingOwn)
|
|
163
|
+
queueEvent(
|
|
164
|
+
workspaceId,
|
|
165
|
+
`plan-routed:${s.id}`,
|
|
166
|
+
s.id,
|
|
167
|
+
`Task "${s.name}" split its plan into ${split.pieces.length} ${split.pieces.length === 1 ? "piece" : "pieces"} and none is first in its own repository, so it builds nothing itself and ends; each piece starts as its own task: ${split.pieces.map((p) => `${p.position} ${p.name} [${p.project.name}]`).join("; ")}.`
|
|
168
|
+
);
|
|
169
|
+
else if (split.folded.length)
|
|
170
|
+
queueEvent(
|
|
171
|
+
workspaceId,
|
|
172
|
+
`plan-folded:${s.id}`,
|
|
173
|
+
s.id,
|
|
174
|
+
`Plan of "${s.name}" listed ${split.listed} pieces, building ${split.pieces.length}: ${split.folded.map((f) => `${f.name} folded into ${f.into} (${f.why})`).join("; ")}. The task was told to build the folded work too.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A planning task with no piece of its own is finished: its pieces are
|
|
179
|
+
// started as tasks in their projects, now rather than on the next sweep.
|
|
180
|
+
async function handOffToPieces(s: Session): Promise<void> {
|
|
181
|
+
try {
|
|
182
|
+
const { doneSession } = await import("../done");
|
|
183
|
+
await doneSession(s.id, { by: "direct", noMerge: true });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error(`Ending planning task ${s.id} failed:`, e);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const { releasePieces } = await import("../plans/release");
|
|
189
|
+
await releasePieces();
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error("Starting the pieces failed:", e);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
146
195
|
function readPlan(s: Session): string | null {
|
|
147
196
|
try {
|
|
148
197
|
return fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8");
|
|
@@ -180,8 +229,14 @@ async function startImplementing(
|
|
|
180
229
|
});
|
|
181
230
|
const id = implementMessageId(sessionId);
|
|
182
231
|
const planText = readPlan(s);
|
|
183
|
-
|
|
232
|
+
let split = planText ? splitOf(s, planText) : null;
|
|
184
233
|
const workspaceId = sessionWorkspace(s);
|
|
234
|
+
// Approved despite pieces that couldn't be registered: built as one piece,
|
|
235
|
+
// and told so, rather than guessing which repository each belongs to.
|
|
236
|
+
const unreadable = split?.errors.length ? split.errors : null;
|
|
237
|
+
if (unreadable) split = null;
|
|
238
|
+
let nothingOwn = false;
|
|
239
|
+
let featureId: string | null = null;
|
|
185
240
|
const claimed = db.transaction(() => {
|
|
186
241
|
const changed = db
|
|
187
242
|
.prepare(
|
|
@@ -193,18 +248,38 @@ async function startImplementing(
|
|
|
193
248
|
setSessionModel(sessionId, routed);
|
|
194
249
|
let piece: string | undefined;
|
|
195
250
|
let note = amendment;
|
|
251
|
+
if (unreadable)
|
|
252
|
+
note = [
|
|
253
|
+
amendment,
|
|
254
|
+
`The plan's pieces couldn't be registered (${unreadable.join("; ")}); build the whole plan as one piece in this repository.`,
|
|
255
|
+
]
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
.join("\n");
|
|
196
258
|
if (split?.notBuilding.length)
|
|
197
259
|
note = [amendment, `Not building: ${split.notBuilding.join("; ")}`]
|
|
198
260
|
.filter(Boolean)
|
|
199
261
|
.join("\n");
|
|
200
|
-
if (split &&
|
|
201
|
-
const feature = createFeature(s, planText, split.pieces, workspaceId
|
|
202
|
-
|
|
203
|
-
split.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
262
|
+
if (split?.feature && planText && workspaceId) {
|
|
263
|
+
const feature = createFeature(s, planText, split.pieces, workspaceId, {
|
|
264
|
+
listed: split.listed,
|
|
265
|
+
folded: split.folded,
|
|
266
|
+
});
|
|
267
|
+
featureId = feature.id;
|
|
268
|
+
const own = ownPieceOf(split.pieces, s.project_id);
|
|
269
|
+
if (!own) nothingOwn = true;
|
|
270
|
+
else if (split.pieces.length === 1 && split.folded.length)
|
|
271
|
+
piece = foldedBrief(split);
|
|
272
|
+
else
|
|
273
|
+
piece = pieceBrief(
|
|
274
|
+
split.pieces.length,
|
|
275
|
+
own,
|
|
276
|
+
planPathOf(feature.id),
|
|
277
|
+
[],
|
|
278
|
+
own.project.name
|
|
279
|
+
);
|
|
207
280
|
}
|
|
281
|
+
// Every piece is another task's: nothing for this one to implement.
|
|
282
|
+
if (nothingOwn) return true;
|
|
208
283
|
enqueue(sessionId, {
|
|
209
284
|
id,
|
|
210
285
|
text: implementPrompt(
|
|
@@ -224,6 +299,12 @@ async function startImplementing(
|
|
|
224
299
|
console.error("plan-done event failed:", e);
|
|
225
300
|
}
|
|
226
301
|
}
|
|
302
|
+
if (split?.feature && featureId && workspaceId)
|
|
303
|
+
sayHowItSplit(s, workspaceId, split, nothingOwn);
|
|
304
|
+
if (nothingOwn) {
|
|
305
|
+
await handOffToPieces(s);
|
|
306
|
+
return routed.ref;
|
|
307
|
+
}
|
|
227
308
|
await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
|
|
228
309
|
await send(sessionId, id);
|
|
229
310
|
return routed.ref;
|
|
@@ -342,9 +423,12 @@ export async function handoffAfterPlan(
|
|
|
342
423
|
deleteQueued(sessionId, planRetryMessageId(sessionId));
|
|
343
424
|
const retried = waiting.get(sessionId)?.retried ?? false;
|
|
344
425
|
forget(sessionId);
|
|
345
|
-
// No approvals mode doesn't stop at the plan to wait for one.
|
|
426
|
+
// No approvals mode doesn't stop at the plan to wait for one. Pieces that
|
|
427
|
+
// couldn't be registered always do: building on a guess is the bug.
|
|
346
428
|
const auto = !!s.plan_only && noApprovalsOn();
|
|
347
|
-
|
|
429
|
+
const planText = readPlan(s);
|
|
430
|
+
const pieceErrors = (planText ? splitOf(s, planText)?.errors : null) ?? [];
|
|
431
|
+
if ((!s.plan_only || auto) && !pieceErrors.length) {
|
|
348
432
|
const ref = await startImplementing(
|
|
349
433
|
s,
|
|
350
434
|
"planning",
|
|
@@ -374,6 +458,14 @@ export async function handoffAfterPlan(
|
|
|
374
458
|
} catch (e) {
|
|
375
459
|
console.error("plan-done event failed:", e);
|
|
376
460
|
}
|
|
461
|
+
const workspaceId = sessionWorkspace(s);
|
|
462
|
+
if (pieceErrors.length && workspaceId)
|
|
463
|
+
queueEvent(
|
|
464
|
+
workspaceId,
|
|
465
|
+
`plan-pieces:${s.id}`,
|
|
466
|
+
s.id,
|
|
467
|
+
`Plan of "${s.name}": couldn't register its pieces: ${pieceErrors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
|
|
468
|
+
);
|
|
377
469
|
try {
|
|
378
470
|
const text = readPlan(s);
|
|
379
471
|
const planned = text ? planFor(s, text) : null;
|
|
@@ -39,7 +39,9 @@ export interface StackFacts {
|
|
|
39
39
|
}[];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
// How long after a task's turn ended with no PR before it is news: longer
|
|
43
|
+
// than the open-PR cache, so a PR it just opened is seen first.
|
|
44
|
+
export const NO_PR_AFTER_TURN_MS = 2 * 60 * 1000;
|
|
43
45
|
|
|
44
46
|
function hash(text: string): string {
|
|
45
47
|
let h = 0;
|
|
@@ -133,17 +135,23 @@ function sessionConditions(
|
|
|
133
135
|
const shouldPR = (!!f.task || !!f.branch) && !f.quick && !runForSession(f.id);
|
|
134
136
|
const finished = !!f.task && isFinished(f.task.state);
|
|
135
137
|
const idleFor = now - f.lastActive;
|
|
138
|
+
// A turn that ended with no PR and nothing blocked is stuck: it may
|
|
139
|
+
// have opened a PR somewhere AgentOS doesn't look, or be waiting on
|
|
140
|
+
// nothing. Keyed on when it last did anything, so it is one event per
|
|
141
|
+
// stall and anything it does after makes the next stall a new one.
|
|
136
142
|
if (
|
|
137
143
|
shouldPR &&
|
|
138
144
|
!pr &&
|
|
139
145
|
!finished &&
|
|
140
146
|
!f.plan?.awaiting &&
|
|
141
147
|
f.status !== "running" &&
|
|
142
|
-
|
|
148
|
+
blocked === null &&
|
|
149
|
+
idleFor >= NO_PR_AFTER_TURN_MS
|
|
143
150
|
)
|
|
144
151
|
add(
|
|
145
|
-
|
|
146
|
-
`${who}:
|
|
152
|
+
`no-pr:${f.lastActive}`,
|
|
153
|
+
`${who}: ended its turn ${Math.floor(idleFor / 60000)}m ago with no PR${f.activity ? ` (last: ${untrusted(f.name, f.activity)})` : ""}. If it opened a PR in another repository, or finished without one, nothing tracks it.`,
|
|
154
|
+
false,
|
|
147
155
|
false,
|
|
148
156
|
true
|
|
149
157
|
);
|
|
@@ -182,6 +182,29 @@ export function queueEvent(
|
|
|
182
182
|
).run(workspaceId, `${ONCE}${key}`, subject, line, ARMED, iso(now));
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
// Like queueEvent, for a line a sweep would otherwise repeat: a key that
|
|
186
|
+
// already exists, delivered or not, is left alone. Put the state in the key
|
|
187
|
+
// so a change of state is a new line.
|
|
188
|
+
export function queueEventOnce(
|
|
189
|
+
workspaceId: string,
|
|
190
|
+
key: string,
|
|
191
|
+
subject: string | null,
|
|
192
|
+
line: string,
|
|
193
|
+
now = Date.now()
|
|
194
|
+
): boolean {
|
|
195
|
+
return (
|
|
196
|
+
db
|
|
197
|
+
.prepare(
|
|
198
|
+
`INSERT INTO orchestrator_events
|
|
199
|
+
(workspace_id, key, subject, line, sticky, low, hits, created_at, delivered_at)
|
|
200
|
+
VALUES (?, ?, ?, ?, 1, 0, ?, ?, NULL)
|
|
201
|
+
ON CONFLICT(workspace_id, key) DO NOTHING`
|
|
202
|
+
)
|
|
203
|
+
.run(workspaceId, `${ONCE}${key}`, subject, line, ARMED, iso(now))
|
|
204
|
+
.changes > 0
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
185
208
|
// A condition the orchestrator has been told about another way (a one-off
|
|
186
209
|
// event said it), so a later diff doesn't send it again.
|
|
187
210
|
export function markTold(
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// A task's agent opened a PR in a repository that isn't its task's: nothing
|
|
2
|
+
// polls that repository for the task's branch, so no task owns the PR and no
|
|
3
|
+
// CI or review gate runs. The PRs agents open are read back from their
|
|
4
|
+
// stored `gh pr create` calls, and one outside its task's repository is
|
|
5
|
+
// raised to the orchestrator once.
|
|
6
|
+
|
|
7
|
+
import { db, type Session } from "../db";
|
|
8
|
+
import { getSetting, putSetting } from "../settings-store";
|
|
9
|
+
import { sessionWorkspace } from "../models/resolve";
|
|
10
|
+
import { run } from "../tasks/gh";
|
|
11
|
+
import { expandHome } from "../tasks/session";
|
|
12
|
+
import { workspaceProjects } from "./brief";
|
|
13
|
+
import { queueEventOnce } from "./events";
|
|
14
|
+
import { repoSlug } from "./repo-slug";
|
|
15
|
+
|
|
16
|
+
const MARK = "reconcile.chat_items_rowid";
|
|
17
|
+
// An item is stored when its tool call starts and updated when it ends, so a
|
|
18
|
+
// call that finished after the mark moved past it is still inside the window.
|
|
19
|
+
const OVERLAP = 2000;
|
|
20
|
+
const BATCH = 500;
|
|
21
|
+
const EVERY_MS = 2 * 60 * 1000;
|
|
22
|
+
const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/g;
|
|
23
|
+
const CREATE = /\bgh\s+pr\s+create\b/;
|
|
24
|
+
|
|
25
|
+
export interface ReconcileDeps {
|
|
26
|
+
slugOf: (dir: string) => Promise<string | null>;
|
|
27
|
+
// The PR's state and head branch, or null when it can't be read.
|
|
28
|
+
view: (url: string) => Promise<{ state: string; head: string } | null>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const realReconcileDeps: ReconcileDeps = {
|
|
32
|
+
slugOf: repoSlug,
|
|
33
|
+
view: async (url) => {
|
|
34
|
+
try {
|
|
35
|
+
const out = await run(
|
|
36
|
+
"gh",
|
|
37
|
+
["pr", "view", url, "--json", "state,headRefName"],
|
|
38
|
+
process.cwd(),
|
|
39
|
+
15000
|
|
40
|
+
);
|
|
41
|
+
const pr = JSON.parse(out) as { state: string; headRefName: string };
|
|
42
|
+
return { state: pr.state, head: pr.headRefName };
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Stores every PR URL an agent's `gh pr create` printed since last time.
|
|
50
|
+
export function recordAgentPrs(): number {
|
|
51
|
+
const top =
|
|
52
|
+
(
|
|
53
|
+
db.prepare(`SELECT MAX(rowid) AS m FROM chat_items`).get() as {
|
|
54
|
+
m: number | null;
|
|
55
|
+
}
|
|
56
|
+
).m ?? 0;
|
|
57
|
+
const mark = Number(getSetting(MARK) ?? top - OVERLAP);
|
|
58
|
+
const rows = db
|
|
59
|
+
.prepare(
|
|
60
|
+
`SELECT rowid AS rid, session_id, data FROM chat_items
|
|
61
|
+
WHERE rowid > ? AND data LIKE '%pr create%' AND data LIKE '%/pull/%'
|
|
62
|
+
ORDER BY rowid LIMIT ?`
|
|
63
|
+
)
|
|
64
|
+
.all(mark - OVERLAP, BATCH) as {
|
|
65
|
+
rid: number;
|
|
66
|
+
session_id: string;
|
|
67
|
+
data: string;
|
|
68
|
+
}[];
|
|
69
|
+
const insert = db.prepare(
|
|
70
|
+
`INSERT OR IGNORE INTO agent_prs (url, session_id, repo, number) VALUES (?, ?, ?, ?)`
|
|
71
|
+
);
|
|
72
|
+
let added = 0;
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
let item: { kind?: string; input?: unknown; output?: unknown };
|
|
75
|
+
try {
|
|
76
|
+
item = JSON.parse(row.data);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
item.kind !== "tool" ||
|
|
82
|
+
typeof item.output !== "string" ||
|
|
83
|
+
!CREATE.test(JSON.stringify(item.input ?? ""))
|
|
84
|
+
)
|
|
85
|
+
continue;
|
|
86
|
+
for (const m of item.output.matchAll(PR_URL))
|
|
87
|
+
added += insert.run(
|
|
88
|
+
m[0],
|
|
89
|
+
row.session_id,
|
|
90
|
+
`${m[1]}/${m[2]}`.toLowerCase(),
|
|
91
|
+
Number(m[3])
|
|
92
|
+
).changes;
|
|
93
|
+
}
|
|
94
|
+
putSetting(MARK, String(rows.length === BATCH ? rows[BATCH - 1].rid : top));
|
|
95
|
+
return added;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface AgentPr {
|
|
99
|
+
url: string;
|
|
100
|
+
session_id: string;
|
|
101
|
+
repo: string;
|
|
102
|
+
number: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const told = (url: string) =>
|
|
106
|
+
db
|
|
107
|
+
.prepare(`UPDATE agent_prs SET told_at = datetime('now') WHERE url = ?`)
|
|
108
|
+
.run(url);
|
|
109
|
+
|
|
110
|
+
async function ownedBy(
|
|
111
|
+
pr: AgentPr,
|
|
112
|
+
head: string,
|
|
113
|
+
deps: ReconcileDeps
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
const rows = db
|
|
116
|
+
.prepare(
|
|
117
|
+
`SELECT s.pr_url, s.branch_name, p.working_directory FROM sessions s
|
|
118
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
119
|
+
WHERE s.task_status IS NOT NULL AND (LOWER(s.pr_url) = ? OR s.branch_name = ?)`
|
|
120
|
+
)
|
|
121
|
+
.all(pr.url.toLowerCase(), head) as {
|
|
122
|
+
pr_url: string | null;
|
|
123
|
+
branch_name: string | null;
|
|
124
|
+
working_directory: string | null;
|
|
125
|
+
}[];
|
|
126
|
+
for (const r of rows) {
|
|
127
|
+
if (r.pr_url?.toLowerCase() === pr.url.toLowerCase()) return true;
|
|
128
|
+
if (
|
|
129
|
+
r.working_directory &&
|
|
130
|
+
(await deps.slugOf(expandHome(r.working_directory))) === pr.repo
|
|
131
|
+
)
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Raises each unclaimed agent PR outside its task's repository, once.
|
|
138
|
+
export async function classifyAgentPrs(
|
|
139
|
+
deps: ReconcileDeps = realReconcileDeps,
|
|
140
|
+
now = Date.now()
|
|
141
|
+
): Promise<number> {
|
|
142
|
+
const open = db
|
|
143
|
+
.prepare(`SELECT * FROM agent_prs WHERE told_at IS NULL`)
|
|
144
|
+
.all() as AgentPr[];
|
|
145
|
+
let raised = 0;
|
|
146
|
+
for (const pr of open) {
|
|
147
|
+
const task = db
|
|
148
|
+
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
149
|
+
.get(pr.session_id) as Session | undefined;
|
|
150
|
+
const workspaceId = task ? sessionWorkspace(task) : null;
|
|
151
|
+
if (!task || !workspaceId) {
|
|
152
|
+
told(pr.url);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const projects = workspaceProjects(workspaceId);
|
|
156
|
+
const slugs = await Promise.all(
|
|
157
|
+
projects.map(async (p) => ({
|
|
158
|
+
name: p.name,
|
|
159
|
+
id: p.id,
|
|
160
|
+
slug: await deps.slugOf(expandHome(p.working_directory)),
|
|
161
|
+
}))
|
|
162
|
+
);
|
|
163
|
+
const own = slugs.find((p) => p.id === task.project_id);
|
|
164
|
+
// Its own repository is polled for its branch like any task's.
|
|
165
|
+
if (own?.slug === pr.repo) {
|
|
166
|
+
told(pr.url);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const view = await deps.view(pr.url);
|
|
170
|
+
// Unreadable now: look again next time.
|
|
171
|
+
if (!view) continue;
|
|
172
|
+
if (view.state !== "OPEN" || (await ownedBy(pr, view.head, deps))) {
|
|
173
|
+
told(pr.url);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const where = slugs.find((p) => p.slug === pr.repo);
|
|
177
|
+
queueEventOnce(
|
|
178
|
+
workspaceId,
|
|
179
|
+
`stray-pr:${pr.url}`,
|
|
180
|
+
task.id,
|
|
181
|
+
`Task "${task.name}" opened a PR outside its repo: ${pr.url} (${where ? `project ${where.name} of this workspace` : "not a repository of this workspace"}). No task owns it, so no CI or review gate runs on it: review and sign it off as an external PR, or drop it.`,
|
|
182
|
+
now
|
|
183
|
+
);
|
|
184
|
+
told(pr.url);
|
|
185
|
+
raised++;
|
|
186
|
+
}
|
|
187
|
+
return raised;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let last = 0;
|
|
191
|
+
|
|
192
|
+
// From the settle loop; reads stored chat items, so a restart loses nothing.
|
|
193
|
+
export async function reconcileSweep(
|
|
194
|
+
now = Date.now(),
|
|
195
|
+
deps: ReconcileDeps = realReconcileDeps
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
if (now - last < EVERY_MS) return;
|
|
198
|
+
last = now;
|
|
199
|
+
recordAgentPrs();
|
|
200
|
+
await classifyAgentPrs(deps, now);
|
|
201
|
+
}
|
package/lib/plans/features.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
checkPieces,
|
|
13
13
|
parseEdits,
|
|
14
14
|
type CheckContext,
|
|
15
|
+
type Folded,
|
|
15
16
|
type PiecesResult,
|
|
16
17
|
type Piece,
|
|
17
18
|
type ProjectRef,
|
|
@@ -35,6 +36,9 @@ export interface Feature {
|
|
|
35
36
|
cost_usd: number | null;
|
|
36
37
|
duration_ms: number | null;
|
|
37
38
|
created_at: string;
|
|
39
|
+
// Items the plan's Pieces block listed, and what the checker folded.
|
|
40
|
+
listed: number | null;
|
|
41
|
+
folded: string | null;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export interface FeaturePiece {
|
|
@@ -131,33 +135,82 @@ export function featureOfPlan(sessionId: string): Feature | undefined {
|
|
|
131
135
|
export interface Split {
|
|
132
136
|
pieces: Piece[];
|
|
133
137
|
notBuilding: string[];
|
|
138
|
+
listed: number;
|
|
139
|
+
folded: Folded[];
|
|
140
|
+
// Why the plan's pieces can't be registered; when set, nothing is built
|
|
141
|
+
// on a guess.
|
|
142
|
+
errors: string[];
|
|
143
|
+
// Whether the plan needs a feature: false for one piece in the task's own
|
|
144
|
+
// repository that only has edits to carry.
|
|
145
|
+
feature: boolean;
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
// The pieces to build for a plan
|
|
137
|
-
//
|
|
148
|
+
// The pieces to build for a plan, or null when it is one piece in the task's
|
|
149
|
+
// own repository, as almost every plan is. A plan that lists more than it
|
|
150
|
+
// builds, or whose one piece belongs to another project, is not null: it
|
|
151
|
+
// gets a feature so nothing it listed goes missing. Edits Saad made on the
|
|
152
|
+
// plan view apply only here, at approval.
|
|
138
153
|
export function splitOf(s: Session, text: string): Split | null {
|
|
139
154
|
const result = planFor(s, text);
|
|
140
|
-
if (!result
|
|
155
|
+
if (!result) return null;
|
|
141
156
|
const { pieces, notBuilding } = applyEdits(result, parseEdits(s.plan_edits));
|
|
142
|
-
|
|
157
|
+
const elsewhere = pieces.some((p) => p.project.id !== s.project_id);
|
|
158
|
+
const feature = pieces.length > 1 || elsewhere || result.folded.length > 0;
|
|
159
|
+
if (!feature && !notBuilding.length && !result.errors.length) return null;
|
|
160
|
+
return {
|
|
161
|
+
pieces,
|
|
162
|
+
notBuilding,
|
|
163
|
+
listed: result.listed,
|
|
164
|
+
folded: result.folded,
|
|
165
|
+
errors: result.errors,
|
|
166
|
+
feature,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The piece the planning task builds itself: the first one in its own
|
|
171
|
+
// repository that doesn't wait on another. Every other piece starts as its own
|
|
172
|
+
// task in its own project.
|
|
173
|
+
export function ownPieceOf(
|
|
174
|
+
pieces: Piece[],
|
|
175
|
+
ownProjectId: string | null
|
|
176
|
+
): Piece | undefined {
|
|
177
|
+
return pieces.find(
|
|
178
|
+
(p) => p.project.id === ownProjectId && p.afterPosition === null
|
|
179
|
+
);
|
|
143
180
|
}
|
|
144
181
|
|
|
145
182
|
// One transaction: the feature, its pieces, and the plan kept past the
|
|
146
|
-
// worktree.
|
|
183
|
+
// worktree. The planning task's own piece (if it has one) is already
|
|
184
|
+
// building; the rest wait to start as tasks of their own.
|
|
147
185
|
export function createFeature(
|
|
148
186
|
s: Session,
|
|
149
187
|
text: string,
|
|
150
188
|
pieces: Piece[],
|
|
151
|
-
workspaceId: string
|
|
189
|
+
workspaceId: string,
|
|
190
|
+
meta: { listed: number; folded: Folded[] } = {
|
|
191
|
+
listed: pieces.length,
|
|
192
|
+
folded: [],
|
|
193
|
+
}
|
|
152
194
|
): Feature {
|
|
195
|
+
const own = ownPieceOf(pieces, s.project_id)?.position;
|
|
153
196
|
const id = randomUUID();
|
|
154
197
|
fs.mkdirSync(plansDir(), { recursive: true });
|
|
155
198
|
fs.writeFileSync(planPathOf(id), text);
|
|
156
199
|
db.transaction(() => {
|
|
157
200
|
db.prepare(
|
|
158
|
-
`INSERT INTO features (id, workspace_id, name, plan_session_id, plan_text, status)
|
|
159
|
-
VALUES (?, ?, ?, ?, ?, 'building')`
|
|
160
|
-
).run(
|
|
201
|
+
`INSERT INTO features (id, workspace_id, name, plan_session_id, plan_text, status, listed, folded)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, 'building', ?, ?)`
|
|
203
|
+
).run(
|
|
204
|
+
id,
|
|
205
|
+
workspaceId,
|
|
206
|
+
s.name,
|
|
207
|
+
s.id,
|
|
208
|
+
text,
|
|
209
|
+
meta.listed,
|
|
210
|
+
meta.folded.length
|
|
211
|
+
? meta.folded.map((f) => `${f.name}: ${f.why}`).join("\n")
|
|
212
|
+
: null
|
|
213
|
+
);
|
|
161
214
|
const insert = db.prepare(
|
|
162
215
|
`INSERT INTO feature_pieces
|
|
163
216
|
(feature_id, position, name, slice, project_id, after_position, after_kind,
|
|
@@ -175,8 +228,8 @@ export function createFeature(
|
|
|
175
228
|
p.afterKind,
|
|
176
229
|
p.reasons.join("+") || null,
|
|
177
230
|
p.owns.join(", ") || null,
|
|
178
|
-
p.position ===
|
|
179
|
-
p.position ===
|
|
231
|
+
p.position === own ? "building" : "waiting",
|
|
232
|
+
p.position === own ? s.id : null
|
|
180
233
|
);
|
|
181
234
|
})();
|
|
182
235
|
return getFeature(id)!;
|
|
@@ -195,6 +248,9 @@ export function reviewGoal(sessionId: string): string | null {
|
|
|
195
248
|
const owns = piece.owns ? `\nThis piece owns: ${piece.owns}.` : "";
|
|
196
249
|
const head = `Piece ${piece.position} of ${live.length}, "${piece.name}": ${piece.slice}${owns}`;
|
|
197
250
|
const last = live.every((p) => p.id === piece.id || p.status === "merged");
|
|
198
|
-
|
|
199
|
-
|
|
251
|
+
const folded = feature.folded
|
|
252
|
+
? ` Pieces the plan listed that were built into others: ${feature.folded}.`
|
|
253
|
+
: "";
|
|
254
|
+
if (!last || (live.length < 2 && !feature.folded)) return head;
|
|
255
|
+
return `${head}\n\nThis is the last piece.${folded} Also check that the plan as a whole is now done, and list anything in it that no piece built. The plan:\n${feature.plan_text.slice(0, PLAN_IN_REVIEW)}`;
|
|
200
256
|
}
|
package/lib/plans/pieces.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface PiecesResult {
|
|
|
37
37
|
pieces: Piece[];
|
|
38
38
|
folded: Folded[];
|
|
39
39
|
warnings: string[];
|
|
40
|
+
// Items the plan's Pieces block lists (0 when it has none), however well
|
|
41
|
+
// they parsed: a plan that builds as fewer than it listed is never silent.
|
|
42
|
+
listed: number;
|
|
43
|
+
// Problems that must stop a task from building on a guess: the pieces
|
|
44
|
+
// returned are then the one-piece fallback, for a person to approve or fix.
|
|
45
|
+
errors: string[];
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
interface Raw {
|
|
@@ -51,7 +57,10 @@ interface Raw {
|
|
|
51
57
|
|
|
52
58
|
const HEADING = /^##\s+pieces\s*$/i;
|
|
53
59
|
const ITEM = /^\s{0,3}(\d+)[.)]\s+(.*\S)\s*$/;
|
|
54
|
-
|
|
60
|
+
// An item as a person might write it, to count what the plan listed even
|
|
61
|
+
// when the strict parser rejects the block.
|
|
62
|
+
const LOOSE_ITEM = /^\s{0,3}(?:[-*+]\s+)?\**\d+[.)]/;
|
|
63
|
+
const AFTER = /^\s*after:\s*(?:piece\s+)?(\d+)\s+is\s+(merged|deployed)\b.*$/i;
|
|
55
64
|
const WHY = /^\s*why split:\s*(.+?)\s*$/i;
|
|
56
65
|
const OWNS = /^\s*owns:\s*(.+?)\s*$/i;
|
|
57
66
|
|
|
@@ -70,6 +79,14 @@ function block(text: string): string[] | null {
|
|
|
70
79
|
return out;
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
// Bullets and bold around the numbers and keys a model sometimes adds
|
|
83
|
+
// ("- **after:** 1 is merged") don't change what a line says.
|
|
84
|
+
function plain(line: string): string {
|
|
85
|
+
return line
|
|
86
|
+
.replace(/^(\s*)[-*+]\s+(?=\**(?:\d+[.)]|after|why split|owns))/i, "$1")
|
|
87
|
+
.replace(/\*\*|__/g, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
function parseItem(position: number, rest: string): Raw {
|
|
74
91
|
let repo: string | null = null;
|
|
75
92
|
const withRepo = rest.match(/^(.*?)\s*\(repo:\s*([^)]+)\)\s*$/i);
|
|
@@ -93,8 +110,9 @@ function parseItem(position: number, rest: string): Raw {
|
|
|
93
110
|
|
|
94
111
|
function parseBlock(lines: string[]): Raw[] | null {
|
|
95
112
|
const items: Raw[] = [];
|
|
96
|
-
for (const
|
|
97
|
-
if (!
|
|
113
|
+
for (const original of lines) {
|
|
114
|
+
if (!original.trim()) continue;
|
|
115
|
+
const line = plain(original);
|
|
98
116
|
const item = line.match(ITEM);
|
|
99
117
|
if (item) {
|
|
100
118
|
items.push(parseItem(Number(item[1]), item[2]));
|
|
@@ -123,6 +141,8 @@ function parseBlock(lines: string[]): Raw[] | null {
|
|
|
123
141
|
.split(/[,;]/)
|
|
124
142
|
.map((o) => o.trim().toLowerCase())
|
|
125
143
|
.filter(Boolean);
|
|
144
|
+
// A wrapped slice: an indented line that is none of the keys.
|
|
145
|
+
else if (/^\s{2,}\S/.test(original)) current.slice += ` ${line.trim()}`;
|
|
126
146
|
else return null;
|
|
127
147
|
}
|
|
128
148
|
if (!items.length || items.some((it, i) => it.position !== i + 1))
|
|
@@ -136,7 +156,13 @@ export interface CheckContext {
|
|
|
136
156
|
find: (name: string) => ProjectRef | null;
|
|
137
157
|
}
|
|
138
158
|
|
|
139
|
-
function one(
|
|
159
|
+
function one(
|
|
160
|
+
text: string,
|
|
161
|
+
ctx: CheckContext,
|
|
162
|
+
warnings: string[],
|
|
163
|
+
listed = 0,
|
|
164
|
+
errors: string[] = []
|
|
165
|
+
): PiecesResult {
|
|
140
166
|
return {
|
|
141
167
|
pieces: [
|
|
142
168
|
{
|
|
@@ -152,30 +178,41 @@ function one(text: string, ctx: CheckContext, warnings: string[]) {
|
|
|
152
178
|
],
|
|
153
179
|
folded: [] as Folded[],
|
|
154
180
|
warnings,
|
|
181
|
+
listed,
|
|
182
|
+
errors,
|
|
155
183
|
};
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
159
187
|
const lines = block(text);
|
|
160
188
|
if (!lines) return one("", ctx, []);
|
|
189
|
+
const listed = lines.filter((l) => LOOSE_ITEM.test(l)).length;
|
|
161
190
|
const raw = parseBlock(lines);
|
|
162
191
|
if (!raw)
|
|
163
|
-
return one("", ctx, [
|
|
164
|
-
"
|
|
192
|
+
return one("", ctx, [], listed, [
|
|
193
|
+
"the Pieces block couldn't be read (a numbered item followed by anything but after:, why split: and owns: lines)",
|
|
194
|
+
]);
|
|
195
|
+
if (raw.length !== listed)
|
|
196
|
+
return one("", ctx, [], listed, [
|
|
197
|
+
`the Pieces block lists ${listed} items but ${raw.length} were read`,
|
|
165
198
|
]);
|
|
166
199
|
if (raw.length > MAX_PIECES)
|
|
167
|
-
return one("", ctx, [
|
|
168
|
-
`${raw.length} pieces is past the limit of ${MAX_PIECES}
|
|
200
|
+
return one("", ctx, [], listed, [
|
|
201
|
+
`${raw.length} pieces is past the limit of ${MAX_PIECES}`,
|
|
169
202
|
]);
|
|
170
203
|
|
|
171
204
|
const warnings: string[] = [];
|
|
205
|
+
const errors: string[] = [];
|
|
172
206
|
const projects = raw.map((r) => {
|
|
173
207
|
if (!r.repo) return ctx.own;
|
|
174
208
|
const found = ctx.find(r.repo);
|
|
175
209
|
if (!found)
|
|
176
|
-
|
|
210
|
+
errors.push(
|
|
211
|
+
`"${r.name}": (repo: ${r.repo}) is not a project of this workspace`
|
|
212
|
+
);
|
|
177
213
|
return found ?? ctx.own;
|
|
178
214
|
});
|
|
215
|
+
if (errors.length) return one("", ctx, [], listed, errors);
|
|
179
216
|
|
|
180
217
|
// Where each original piece ended up, for `after:` lines that pointed at a
|
|
181
218
|
// piece that was folded.
|
|
@@ -212,12 +249,15 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
212
249
|
}
|
|
213
250
|
|
|
214
251
|
let reasons = r.reasons;
|
|
215
|
-
if (!reasons.length)
|
|
252
|
+
if (!reasons.length)
|
|
253
|
+
return fold("no `why split:` line naming one of (a) to (d)");
|
|
254
|
+
const lost: string[] = [];
|
|
216
255
|
|
|
217
256
|
if (reasons.includes("a")) {
|
|
218
257
|
const others = projects.filter((_, j) => j !== i);
|
|
219
258
|
if (others.some((p) => p.id === project.id)) {
|
|
220
259
|
reasons = reasons.filter((x) => x !== "a");
|
|
260
|
+
lost.push("(a) shares a repo with another piece");
|
|
221
261
|
warnings.push(
|
|
222
262
|
`"${r.name}" claimed (a) but shares a repo with another piece.`
|
|
223
263
|
);
|
|
@@ -240,8 +280,10 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
240
280
|
const dep = afterPosition ? pieces[afterPosition - 1] : null;
|
|
241
281
|
if (afterKind !== "deployed" || !dep) {
|
|
242
282
|
reasons = reasons.filter((x) => x !== "b");
|
|
283
|
+
lost.push("(b) needs `after: N is deployed`, not merged");
|
|
243
284
|
} else if (!dep.project.deploys) {
|
|
244
285
|
reasons = reasons.filter((x) => x !== "b");
|
|
286
|
+
lost.push(`(b) waits on a deploy ${dep.project.name} can't check`);
|
|
245
287
|
afterKind = "merged";
|
|
246
288
|
warnings.push(
|
|
247
289
|
`"${r.name}" waits on a deploy that ${dep.project.name} can't check; it waits for the merge instead.`
|
|
@@ -252,7 +294,8 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
252
294
|
if (!dep?.project.deploys) afterKind = "merged";
|
|
253
295
|
}
|
|
254
296
|
|
|
255
|
-
if (!reasons.length)
|
|
297
|
+
if (!reasons.length)
|
|
298
|
+
return fold(`its reason didn't hold: ${lost.join("; ")}`);
|
|
256
299
|
|
|
257
300
|
keep({
|
|
258
301
|
name: r.name,
|
|
@@ -274,7 +317,15 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
274
317
|
else seen.set(o, p.position);
|
|
275
318
|
}
|
|
276
319
|
|
|
277
|
-
return { pieces, folded, warnings };
|
|
320
|
+
return { pieces, folded, warnings, listed, errors };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// What a task that builds fewer pieces than its plan listed is told, so the
|
|
324
|
+
// folded work is built here and not left for pieces that will never start.
|
|
325
|
+
export function foldedBrief(
|
|
326
|
+
result: Pick<PiecesResult, "listed" | "folded">
|
|
327
|
+
): string {
|
|
328
|
+
return `The plan listed ${result.listed} pieces; they're built as ONE here (${result.folded.map((f) => `${f.name}: ${f.why}`).join("; ")}). Build all of it in this PR, including the folded pieces: ${result.folded.map((f) => f.name).join(", ")}. Nothing else will build them.`;
|
|
278
329
|
}
|
|
279
330
|
|
|
280
331
|
export const PIECES_PROMPT = `At the end of the plan, add a \`## Pieces\` block. The default is exactly one piece, which is what almost every task is, and one piece needs no more than:
|
|
@@ -292,14 +343,15 @@ Diff size is never a reason. A later piece looks like:
|
|
|
292
343
|
after: 1 is merged (or: 1 is deployed)
|
|
293
344
|
why split: (a) + (b)
|
|
294
345
|
owns: migration 81
|
|
295
|
-
A shared resource (a migration id, a new table, a config key) goes on exactly one piece's \`owns:\` line. A piece with no valid reason is folded into the one before it.`;
|
|
346
|
+
A shared resource (a migration id, a new table, a config key) goes on exactly one piece's \`owns:\` line. A piece with no valid reason is folded into the one before it. Reason (b) needs \`after: N is deployed\`; \`after: N is merged\` alone is only ordering, not a reason. A piece in another project runs as its own task in that project; this task builds only the pieces in its own repository.`;
|
|
296
347
|
|
|
297
348
|
// Shown to a piece's agent in place of the whole plan.
|
|
298
349
|
export function pieceBrief(
|
|
299
350
|
total: number,
|
|
300
351
|
piece: Pick<Piece, "position" | "name" | "slice" | "owns">,
|
|
301
352
|
planPath: string,
|
|
302
|
-
before: string[] = []
|
|
353
|
+
before: string[] = [],
|
|
354
|
+
project?: string
|
|
303
355
|
): string {
|
|
304
356
|
const owns = piece.owns.length
|
|
305
357
|
? `\nThis piece owns: ${piece.owns.join(", ")}. No other piece touches it.`
|
|
@@ -307,7 +359,10 @@ export function pieceBrief(
|
|
|
307
359
|
const earlier = before.length
|
|
308
360
|
? `\nPieces before you: ${before.join("; ")}.`
|
|
309
361
|
: "";
|
|
310
|
-
|
|
362
|
+
const where = project
|
|
363
|
+
? `\nWork only in this task's repository (${project}) and its worktree. Don't create worktrees, branches or PRs in another repository: AgentOS starts the pieces that belong there as their own tasks.`
|
|
364
|
+
: "";
|
|
365
|
+
return `Build piece ${piece.position} of ${total} only, "${piece.name}": ${piece.slice}${owns}${earlier}${where}\nThe rest of the plan is other pieces' work; don't start it. Full plan: ${planPath}`;
|
|
311
366
|
}
|
|
312
367
|
|
|
313
368
|
export interface PlanEdits {
|
package/lib/plans/release.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { loadProjectConfig } from "../project-config";
|
|
|
8
8
|
import { createTask } from "../tasks";
|
|
9
9
|
import { startOrQueue } from "../tasks/queue";
|
|
10
10
|
import { braked, BrakeRefused } from "../orchestrator/brakes";
|
|
11
|
-
import { queueEvent } from "../orchestrator/events";
|
|
11
|
+
import { queueEvent, queueEventOnce } from "../orchestrator/events";
|
|
12
12
|
import { amendBrief } from "../orchestrator/amendments";
|
|
13
13
|
import { usageRows } from "../session-logs/gather";
|
|
14
14
|
import { pieceBrief } from "./pieces";
|
|
@@ -190,7 +190,8 @@ async function releaseOne(
|
|
|
190
190
|
)
|
|
191
191
|
.run((piece.session_id ??= randomUUID()), piece.id).changes;
|
|
192
192
|
if (!claimed) return;
|
|
193
|
-
const
|
|
193
|
+
const project = getProject(piece.project_id);
|
|
194
|
+
const prompt = `[Feature "${feature.name}", piece ${piece.position} of ${all.length}, started by AgentOS from an approved plan]\n${pieceBrief(all.length, { position: piece.position, name: piece.name, slice: piece.slice, owns: piece.owns ? piece.owns.split(", ") : [] }, planPathOf(feature.id), beforeLines(all, piece.position), project?.name)}`;
|
|
194
195
|
try {
|
|
195
196
|
const id = await deps.start(feature, piece, prompt, piece.session_id);
|
|
196
197
|
if (id !== piece.session_id)
|
|
@@ -246,16 +247,56 @@ function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
|
|
|
246
247
|
)
|
|
247
248
|
.run(cost, ended > started ? ended - started : null, feature.id).changes;
|
|
248
249
|
if (!claimed) return;
|
|
250
|
+
// A plan that listed more than it built is told, not closed quietly.
|
|
251
|
+
const listed = feature.listed ?? live.length;
|
|
252
|
+
const left = [
|
|
253
|
+
feature.folded ? `folded: ${feature.folded.split("\n").join("; ")}` : "",
|
|
254
|
+
all.some((p) => p.status === "dropped")
|
|
255
|
+
? `dropped: ${all
|
|
256
|
+
.filter((p) => p.status === "dropped")
|
|
257
|
+
.map((p) => p.name)
|
|
258
|
+
.join(", ")}`
|
|
259
|
+
: "",
|
|
260
|
+
].filter(Boolean);
|
|
261
|
+
const short =
|
|
262
|
+
listed > live.length || left.length
|
|
263
|
+
? ` Its plan listed ${listed}; check the rest landed (${left.join("; ") || "not built as pieces"}).`
|
|
264
|
+
: "";
|
|
249
265
|
say(
|
|
250
266
|
feature,
|
|
251
267
|
`done-${feature.id}`,
|
|
252
|
-
`Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}
|
|
268
|
+
`Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}.${short}`
|
|
253
269
|
);
|
|
254
270
|
deps.pushFeatureMerged(feature);
|
|
255
271
|
}
|
|
256
272
|
|
|
273
|
+
// A dependency merged and the piece after it still hasn't started: the sweep
|
|
274
|
+
// that should have started it is stuck, or the start keeps being held.
|
|
275
|
+
export const STUCK_PIECE_MS = 30 * 60 * 1000;
|
|
276
|
+
|
|
277
|
+
function stuckPieces(feature: Feature, all: FeaturePiece[], now: number): void {
|
|
278
|
+
for (const piece of all) {
|
|
279
|
+
if (piece.status !== "waiting" || !piece.after_position) continue;
|
|
280
|
+
const dep = all.find((p) => p.position === piece.after_position);
|
|
281
|
+
const merged = dep ? sessionOf(dep.session_id)?.merged_at : null;
|
|
282
|
+
if (!dep || dep.status !== "merged" || !merged) continue;
|
|
283
|
+
const since = Date.parse(`${merged}Z`);
|
|
284
|
+
const wait =
|
|
285
|
+
piece.after_kind === "deployed" ? STUCK_PIECE_MS * 2 : STUCK_PIECE_MS;
|
|
286
|
+
if (now - since < wait) continue;
|
|
287
|
+
queueEventOnce(
|
|
288
|
+
feature.workspace_id,
|
|
289
|
+
`feature:stuck-${piece.id}-${since}`,
|
|
290
|
+
feature.plan_session_id,
|
|
291
|
+
`Feature "${feature.name}": piece ${piece.position} ${piece.name} is still waiting ${Math.floor((now - since) / 60000)}m after ${dep.position} ${dep.name} merged. Release it anyway or drop it.`,
|
|
292
|
+
now
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
257
297
|
export async function releasePieces(
|
|
258
|
-
deps: ReleaseDeps = realDeps
|
|
298
|
+
deps: ReleaseDeps = realDeps,
|
|
299
|
+
now = Date.now()
|
|
259
300
|
): Promise<void> {
|
|
260
301
|
const features = db
|
|
261
302
|
.prepare(`SELECT * FROM features WHERE status = 'building'`)
|
|
@@ -267,6 +308,7 @@ export async function releasePieces(
|
|
|
267
308
|
await releaseOne(feature, piecesOf(feature.id), piece, deps).catch((e) =>
|
|
268
309
|
console.error(`[features] piece ${piece.id}:`, e)
|
|
269
310
|
);
|
|
311
|
+
stuckPieces(feature, piecesOf(feature.id), now);
|
|
270
312
|
close(feature, piecesOf(feature.id), deps);
|
|
271
313
|
}
|
|
272
314
|
}
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -459,6 +459,9 @@ export function startSettling(): void {
|
|
|
459
459
|
busy = true;
|
|
460
460
|
void settleRuns()
|
|
461
461
|
.then(() => releasePieces())
|
|
462
|
+
.then(async () =>
|
|
463
|
+
(await import("../orchestrator/reconcile")).reconcileSweep()
|
|
464
|
+
)
|
|
462
465
|
.catch((e) => console.error("[recipes] settle:", e))
|
|
463
466
|
.finally(() => {
|
|
464
467
|
busy = false;
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -209,13 +209,12 @@ function wokeUnread(
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
// When a row went to Done: marked by hand, quiet for 3 days, or (a task)
|
|
212
|
-
// last changed.
|
|
212
|
+
// when it was finished, else last changed.
|
|
213
213
|
export function doneAtMs(row: SidebarRow): number {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
: ms(row.session.updated_at);
|
|
214
|
+
if (row.settled === "manual") return ms(row.session.settled_at);
|
|
215
|
+
if (row.settled === "auto") return row.activeAt;
|
|
216
|
+
const { settled_at, merged_at, updated_at } = row.session;
|
|
217
|
+
return ms(settled_at) || ms(merged_at) || ms(updated_at);
|
|
219
218
|
}
|
|
220
219
|
|
|
221
220
|
const OPEN_RUN = ["queued", "starting", "running"];
|