@saadnvd1/agent-os 0.3.533 → 0.3.534
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 +68 -2
- package/app/api/features/pieces/[id]/release/route.ts +16 -0
- package/app/api/features/route.ts +8 -0
- package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
- package/app/api/recipes/[id]/preview/route.ts +19 -0
- package/app/api/recipes/[id]/route.ts +51 -0
- package/app/api/recipes/[id]/run/route.ts +35 -0
- package/app/api/recipes/[id]/runs/route.ts +23 -0
- package/app/api/recipes/draft/route.ts +14 -0
- package/app/api/recipes/route.ts +35 -0
- package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
- package/components/CommandPalette/useAppCommands.ts +20 -0
- package/components/Features/FeaturesSection.tsx +100 -0
- package/components/Features/index.ts +1 -0
- package/components/Recipes/RecipeForm.tsx +251 -0
- package/components/Recipes/RecipePage.tsx +127 -0
- package/components/Recipes/RecipesPanel.tsx +225 -0
- package/components/Recipes/RunChip.tsx +16 -0
- package/components/Recipes/RunHistory.tsx +88 -0
- package/components/Recipes/RunSheet.tsx +84 -0
- package/components/Recipes/TimerFields.tsx +193 -0
- package/components/Recipes/format.ts +51 -0
- package/components/Recipes/index.ts +1 -0
- package/components/Schedules/SchedulesPanel.tsx +1 -0
- package/components/SessionList/RowSubtitle.tsx +16 -0
- package/components/SessionList/SessionRowMenu.tsx +29 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Tasks/PlanReview.tsx +253 -0
- package/components/Tasks/TaskRow.tsx +19 -44
- package/components/Tasks/TasksDialog.tsx +2 -0
- package/data/features/index.ts +40 -0
- package/data/push/topics.ts +8 -0
- package/data/recipes/index.ts +129 -0
- package/data/tasks/index.ts +14 -0
- package/lib/away/run.ts +4 -1
- package/lib/db/changes.ts +8 -0
- package/lib/db/migrations.ts +127 -0
- package/lib/db/queries.ts +4 -1
- package/lib/db/types.ts +6 -0
- package/lib/models/plan-step.ts +67 -6
- package/lib/notify/copy.ts +28 -10
- package/lib/notify/push/events.ts +36 -2
- package/lib/notify/push/policy.ts +2 -0
- package/lib/notify/push-types.ts +7 -3
- package/lib/orchestrator/brief.ts +13 -0
- package/lib/orchestrator/conditions.ts +3 -1
- package/lib/orchestrator/deliver.ts +2 -0
- package/lib/orchestrator/digest.ts +27 -1
- package/lib/orchestrator/plan-pieces.ts +41 -0
- package/lib/orchestrator/recipe-tools.ts +159 -0
- package/lib/orchestrator/review.ts +2 -1
- package/lib/orchestrator/serve.ts +25 -0
- package/lib/orchestrator/tool-names.ts +5 -0
- package/lib/orchestrator/tool-schemas.ts +54 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/plans/features.ts +200 -0
- package/lib/plans/pieces.ts +366 -0
- package/lib/plans/release.ts +323 -0
- package/lib/plans/view.ts +85 -0
- package/lib/recipes/api.ts +19 -0
- package/lib/recipes/chip.ts +40 -0
- package/lib/recipes/compose.ts +78 -0
- package/lib/recipes/draft.ts +93 -0
- package/lib/recipes/index.ts +54 -0
- package/lib/recipes/result.ts +59 -0
- package/lib/recipes/run.ts +202 -0
- package/lib/recipes/settle.ts +465 -0
- package/lib/recipes/store.ts +378 -0
- package/lib/recipes/test-fixtures.ts +49 -0
- package/lib/recipes/timer.ts +107 -0
- package/lib/recipes/types.ts +116 -0
- package/lib/schedules/index.ts +4 -1
- package/lib/schedules/start.ts +33 -9
- package/lib/schedules/store.ts +6 -2
- package/lib/security/demo.ts +1 -0
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/loop-types.ts +23 -0
- package/lib/tasks/plan-view.ts +38 -1
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/recipesUi.ts +53 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `## Pieces` block a plan ends with, and the deterministic check that
|
|
3
|
+
* decides how many pieces it really is. A plan is one piece unless it names
|
|
4
|
+
* one of four reasons for splitting, and these rules (not a model) enforce
|
|
5
|
+
* that: a piece without a standing reason is folded into the one before it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MAX_PIECES = 6;
|
|
9
|
+
|
|
10
|
+
export type PieceReason = "a" | "b" | "c" | "d";
|
|
11
|
+
|
|
12
|
+
export interface ProjectRef {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
// Whether the project's agentos.json names a `deploy.deployed` file.
|
|
16
|
+
deploys: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Piece {
|
|
20
|
+
position: number;
|
|
21
|
+
name: string;
|
|
22
|
+
slice: string;
|
|
23
|
+
project: ProjectRef;
|
|
24
|
+
afterPosition: number | null;
|
|
25
|
+
afterKind: "merged" | "deployed" | null;
|
|
26
|
+
reasons: PieceReason[];
|
|
27
|
+
owns: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Folded {
|
|
31
|
+
name: string;
|
|
32
|
+
into: number;
|
|
33
|
+
why: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PiecesResult {
|
|
37
|
+
pieces: Piece[];
|
|
38
|
+
folded: Folded[];
|
|
39
|
+
warnings: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Raw {
|
|
43
|
+
position: number;
|
|
44
|
+
name: string;
|
|
45
|
+
slice: string;
|
|
46
|
+
repo: string | null;
|
|
47
|
+
after: { position: number; kind: "merged" | "deployed" } | null;
|
|
48
|
+
reasons: PieceReason[];
|
|
49
|
+
owns: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HEADING = /^##\s+pieces\s*$/i;
|
|
53
|
+
const ITEM = /^\s{0,3}(\d+)[.)]\s+(.*\S)\s*$/;
|
|
54
|
+
const AFTER = /^\s*after:\s*(\d+)\s+is\s+(merged|deployed)\s*$/i;
|
|
55
|
+
const WHY = /^\s*why split:\s*(.+?)\s*$/i;
|
|
56
|
+
const OWNS = /^\s*owns:\s*(.+?)\s*$/i;
|
|
57
|
+
|
|
58
|
+
function block(text: string): string[] | null {
|
|
59
|
+
const lines = text.split("\n");
|
|
60
|
+
let start = -1;
|
|
61
|
+
lines.forEach((l, i) => {
|
|
62
|
+
if (HEADING.test(l.trim())) start = i;
|
|
63
|
+
});
|
|
64
|
+
if (start < 0) return null;
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
for (const l of lines.slice(start + 1)) {
|
|
67
|
+
if (/^#{1,2}\s/.test(l)) break;
|
|
68
|
+
out.push(l);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseItem(position: number, rest: string): Raw {
|
|
74
|
+
let repo: string | null = null;
|
|
75
|
+
const withRepo = rest.match(/^(.*?)\s*\(repo:\s*([^)]+)\)\s*$/i);
|
|
76
|
+
if (withRepo) {
|
|
77
|
+
rest = withRepo[1];
|
|
78
|
+
repo = withRepo[2].trim();
|
|
79
|
+
}
|
|
80
|
+
const split = rest.indexOf(":");
|
|
81
|
+
const name = (split > 0 ? rest.slice(0, split) : rest).trim();
|
|
82
|
+
const slice = (split > 0 ? rest.slice(split + 1) : rest).trim();
|
|
83
|
+
return {
|
|
84
|
+
position,
|
|
85
|
+
name: name.slice(0, 80) || `Piece ${position}`,
|
|
86
|
+
slice: slice || name,
|
|
87
|
+
repo,
|
|
88
|
+
after: null,
|
|
89
|
+
reasons: [],
|
|
90
|
+
owns: [],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseBlock(lines: string[]): Raw[] | null {
|
|
95
|
+
const items: Raw[] = [];
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (!line.trim()) continue;
|
|
98
|
+
const item = line.match(ITEM);
|
|
99
|
+
if (item) {
|
|
100
|
+
items.push(parseItem(Number(item[1]), item[2]));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const current = items[items.length - 1];
|
|
104
|
+
if (!current) return null;
|
|
105
|
+
const after = line.match(AFTER);
|
|
106
|
+
const why = line.match(WHY);
|
|
107
|
+
const owns = line.match(OWNS);
|
|
108
|
+
if (after)
|
|
109
|
+
current.after = {
|
|
110
|
+
position: Number(after[1]),
|
|
111
|
+
kind: after[2].toLowerCase() as "merged" | "deployed",
|
|
112
|
+
};
|
|
113
|
+
else if (why)
|
|
114
|
+
current.reasons = [
|
|
115
|
+
...new Set(
|
|
116
|
+
(why[1].toLowerCase().match(/\(([abcd])\)/g) ?? []).map(
|
|
117
|
+
(m) => m[1] as PieceReason
|
|
118
|
+
)
|
|
119
|
+
),
|
|
120
|
+
];
|
|
121
|
+
else if (owns)
|
|
122
|
+
current.owns = owns[1]
|
|
123
|
+
.split(/[,;]/)
|
|
124
|
+
.map((o) => o.trim().toLowerCase())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
else return null;
|
|
127
|
+
}
|
|
128
|
+
if (!items.length || items.some((it, i) => it.position !== i + 1))
|
|
129
|
+
return null;
|
|
130
|
+
return items;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface CheckContext {
|
|
134
|
+
// The project the plan's task works in: a piece naming none gets this.
|
|
135
|
+
own: ProjectRef;
|
|
136
|
+
find: (name: string) => ProjectRef | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function one(text: string, ctx: CheckContext, warnings: string[]) {
|
|
140
|
+
return {
|
|
141
|
+
pieces: [
|
|
142
|
+
{
|
|
143
|
+
position: 1,
|
|
144
|
+
name: "Plan",
|
|
145
|
+
slice: text.trim().slice(0, 500),
|
|
146
|
+
project: ctx.own,
|
|
147
|
+
afterPosition: null,
|
|
148
|
+
afterKind: null,
|
|
149
|
+
reasons: [],
|
|
150
|
+
owns: [],
|
|
151
|
+
} satisfies Piece,
|
|
152
|
+
],
|
|
153
|
+
folded: [] as Folded[],
|
|
154
|
+
warnings,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
159
|
+
const lines = block(text);
|
|
160
|
+
if (!lines) return one("", ctx, []);
|
|
161
|
+
const raw = parseBlock(lines);
|
|
162
|
+
if (!raw)
|
|
163
|
+
return one("", ctx, [
|
|
164
|
+
"The Pieces block couldn't be read, so this is one piece.",
|
|
165
|
+
]);
|
|
166
|
+
if (raw.length > MAX_PIECES)
|
|
167
|
+
return one("", ctx, [
|
|
168
|
+
`${raw.length} pieces is past the limit of ${MAX_PIECES}; built as one piece.`,
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const warnings: string[] = [];
|
|
172
|
+
const projects = raw.map((r) => {
|
|
173
|
+
if (!r.repo) return ctx.own;
|
|
174
|
+
const found = ctx.find(r.repo);
|
|
175
|
+
if (!found)
|
|
176
|
+
warnings.push(`"${r.name}": no project named ${r.repo}; using this one.`);
|
|
177
|
+
return found ?? ctx.own;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Where each original piece ended up, for `after:` lines that pointed at a
|
|
181
|
+
// piece that was folded.
|
|
182
|
+
const landed = new Map<number, number>();
|
|
183
|
+
const pieces: Piece[] = [];
|
|
184
|
+
const folded: Folded[] = [];
|
|
185
|
+
|
|
186
|
+
raw.forEach((r, i) => {
|
|
187
|
+
const project = projects[i];
|
|
188
|
+
const fold = (why: string) => {
|
|
189
|
+
const into = pieces[pieces.length - 1];
|
|
190
|
+
into.slice = `${into.slice}\n${r.name}: ${r.slice}`;
|
|
191
|
+
into.owns = [...new Set([...into.owns, ...r.owns])];
|
|
192
|
+
landed.set(r.position, into.position);
|
|
193
|
+
folded.push({ name: r.name, into: into.position, why });
|
|
194
|
+
};
|
|
195
|
+
const keep = (p: Omit<Piece, "position">) => {
|
|
196
|
+
const position = pieces.length + 1;
|
|
197
|
+
pieces.push({ ...p, position });
|
|
198
|
+
landed.set(r.position, position);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (i === 0) {
|
|
202
|
+
keep({
|
|
203
|
+
name: r.name,
|
|
204
|
+
slice: r.slice,
|
|
205
|
+
project,
|
|
206
|
+
afterPosition: null,
|
|
207
|
+
afterKind: null,
|
|
208
|
+
reasons: [],
|
|
209
|
+
owns: r.owns,
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let reasons = r.reasons;
|
|
215
|
+
if (!reasons.length) return fold("no reason for a separate piece");
|
|
216
|
+
|
|
217
|
+
if (reasons.includes("a")) {
|
|
218
|
+
const others = projects.filter((_, j) => j !== i);
|
|
219
|
+
if (others.some((p) => p.id === project.id)) {
|
|
220
|
+
reasons = reasons.filter((x) => x !== "a");
|
|
221
|
+
warnings.push(
|
|
222
|
+
`"${r.name}" claimed (a) but shares a repo with another piece.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let afterPosition: number | null = null;
|
|
228
|
+
let afterKind = r.after?.kind ?? null;
|
|
229
|
+
if (r.after) {
|
|
230
|
+
const target = landed.get(r.after.position);
|
|
231
|
+
if (target === undefined || r.after.position >= r.position) {
|
|
232
|
+
warnings.push(`"${r.name}": after: ${r.after.position} isn't earlier.`);
|
|
233
|
+
afterKind = null;
|
|
234
|
+
} else {
|
|
235
|
+
afterPosition = target;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (reasons.includes("b")) {
|
|
240
|
+
const dep = afterPosition ? pieces[afterPosition - 1] : null;
|
|
241
|
+
if (afterKind !== "deployed" || !dep) {
|
|
242
|
+
reasons = reasons.filter((x) => x !== "b");
|
|
243
|
+
} else if (!dep.project.deploys) {
|
|
244
|
+
reasons = reasons.filter((x) => x !== "b");
|
|
245
|
+
afterKind = "merged";
|
|
246
|
+
warnings.push(
|
|
247
|
+
`"${r.name}" waits on a deploy that ${dep.project.name} can't check; it waits for the merge instead.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} else if (afterKind === "deployed") {
|
|
251
|
+
const dep = afterPosition ? pieces[afterPosition - 1] : null;
|
|
252
|
+
if (!dep?.project.deploys) afterKind = "merged";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!reasons.length) return fold("its reason didn't hold");
|
|
256
|
+
|
|
257
|
+
keep({
|
|
258
|
+
name: r.name,
|
|
259
|
+
slice: r.slice,
|
|
260
|
+
project,
|
|
261
|
+
afterPosition,
|
|
262
|
+
afterKind: afterPosition ? (afterKind ?? "merged") : null,
|
|
263
|
+
reasons,
|
|
264
|
+
owns: r.owns,
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const seen = new Map<string, number>();
|
|
269
|
+
for (const p of pieces)
|
|
270
|
+
for (const o of p.owns) {
|
|
271
|
+
const first = seen.get(o);
|
|
272
|
+
if (first !== undefined && first !== p.position)
|
|
273
|
+
warnings.push(`"${o}" is owned by pieces ${first} and ${p.position}.`);
|
|
274
|
+
else seen.set(o, p.position);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { pieces, folded, warnings };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
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:
|
|
281
|
+
|
|
282
|
+
## Pieces
|
|
283
|
+
1. <name>: <one-line slice> (repo: <project>)
|
|
284
|
+
|
|
285
|
+
Add another piece only when one of these holds, and say which on a \`why split:\` line:
|
|
286
|
+
(a) it spans repositories (a task works in one repo);
|
|
287
|
+
(b) one part must be live (merged and deployed) before the other can merge;
|
|
288
|
+
(c) one part needs the user mid-way (signing, a credential, a product call) and the rest doesn't;
|
|
289
|
+
(d) one part must be revertible on its own: a migration that rewrites or deletes existing rows, or an auth or security change bundled with unrelated work. An additive migration doesn't count.
|
|
290
|
+
Diff size is never a reason. A later piece looks like:
|
|
291
|
+
2. <name>: <one-line slice> (repo: <project>)
|
|
292
|
+
after: 1 is merged (or: 1 is deployed)
|
|
293
|
+
why split: (a) + (b)
|
|
294
|
+
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.`;
|
|
296
|
+
|
|
297
|
+
// Shown to a piece's agent in place of the whole plan.
|
|
298
|
+
export function pieceBrief(
|
|
299
|
+
total: number,
|
|
300
|
+
piece: Pick<Piece, "position" | "name" | "slice" | "owns">,
|
|
301
|
+
planPath: string,
|
|
302
|
+
before: string[] = []
|
|
303
|
+
): string {
|
|
304
|
+
const owns = piece.owns.length
|
|
305
|
+
? `\nThis piece owns: ${piece.owns.join(", ")}. No other piece touches it.`
|
|
306
|
+
: "";
|
|
307
|
+
const earlier = before.length
|
|
308
|
+
? `\nPieces before you: ${before.join("; ")}.`
|
|
309
|
+
: "";
|
|
310
|
+
return `Build piece ${piece.position} of ${total} only, "${piece.name}": ${piece.slice}${owns}${earlier}\nThe rest of the plan is other pieces' work; don't start it. Full plan: ${planPath}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface PlanEdits {
|
|
314
|
+
// Positions to fold into the piece before them, and to leave out.
|
|
315
|
+
merge?: number[];
|
|
316
|
+
drop?: number[];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function parseEdits(raw: string | null | undefined): PlanEdits {
|
|
320
|
+
try {
|
|
321
|
+
const o = JSON.parse(raw ?? "{}") as PlanEdits;
|
|
322
|
+
const ints = (v: unknown) =>
|
|
323
|
+
Array.isArray(v) ? v.filter((n) => Number.isInteger(n)) : [];
|
|
324
|
+
return { merge: ints(o.merge), drop: ints(o.drop) };
|
|
325
|
+
} catch {
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Saad's edits to the pieces before approving: a merged piece joins the one
|
|
331
|
+
// before it, a dropped one is left out (and listed, so nothing vanishes).
|
|
332
|
+
// Piece 1 can't be dropped or merged.
|
|
333
|
+
export function applyEdits(
|
|
334
|
+
result: PiecesResult,
|
|
335
|
+
edits: PlanEdits
|
|
336
|
+
): { pieces: Piece[]; notBuilding: string[] } {
|
|
337
|
+
const drop = new Set(edits.drop ?? []);
|
|
338
|
+
const merge = new Set(edits.merge ?? []);
|
|
339
|
+
const notBuilding: string[] = [];
|
|
340
|
+
const kept: Piece[] = [];
|
|
341
|
+
const landed = new Map<number, number>();
|
|
342
|
+
for (const p of result.pieces) {
|
|
343
|
+
const before = kept[kept.length - 1];
|
|
344
|
+
if (p.position > 1 && drop.has(p.position)) {
|
|
345
|
+
notBuilding.push(`${p.name}: ${p.slice}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (p.position > 1 && merge.has(p.position) && before) {
|
|
349
|
+
before.slice = `${before.slice}\n${p.name}: ${p.slice}`;
|
|
350
|
+
before.owns = [...new Set([...before.owns, ...p.owns])];
|
|
351
|
+
landed.set(p.position, before.position);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const position = kept.length + 1;
|
|
355
|
+
landed.set(p.position, position);
|
|
356
|
+
const after =
|
|
357
|
+
p.afterPosition === null ? undefined : landed.get(p.afterPosition);
|
|
358
|
+
kept.push({
|
|
359
|
+
...p,
|
|
360
|
+
position,
|
|
361
|
+
afterPosition: after && after < position ? after : null,
|
|
362
|
+
afterKind: after && after < position ? p.afterKind : null,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return { pieces: kept, notBuilding };
|
|
366
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Pieces of a feature after the plan is approved: noticing which merged,
|
|
2
|
+
// starting the ones whose dependency is met, and closing the feature.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { db, type Session } from "../db";
|
|
6
|
+
import { getProject } from "../projects";
|
|
7
|
+
import { loadProjectConfig } from "../project-config";
|
|
8
|
+
import { createTask } from "../tasks";
|
|
9
|
+
import { startOrQueue } from "../tasks/queue";
|
|
10
|
+
import { braked, BrakeRefused } from "../orchestrator/brakes";
|
|
11
|
+
import { queueEvent } from "../orchestrator/events";
|
|
12
|
+
import { amendBrief } from "../orchestrator/amendments";
|
|
13
|
+
import { usageRows } from "../session-logs/gather";
|
|
14
|
+
import { pieceBrief } from "./pieces";
|
|
15
|
+
import {
|
|
16
|
+
getFeature,
|
|
17
|
+
planPathOf,
|
|
18
|
+
piecesOf,
|
|
19
|
+
type Feature,
|
|
20
|
+
type FeaturePiece,
|
|
21
|
+
} from "./features";
|
|
22
|
+
|
|
23
|
+
export type DependencyState = "ok" | "pending" | "failed";
|
|
24
|
+
|
|
25
|
+
export interface ReleaseDeps {
|
|
26
|
+
// Whether a merged piece's change is live (for `after: N is deployed`).
|
|
27
|
+
deployed: (dep: FeaturePiece, session: Session) => Promise<DependencyState>;
|
|
28
|
+
start: (
|
|
29
|
+
feature: Feature,
|
|
30
|
+
piece: FeaturePiece,
|
|
31
|
+
prompt: string,
|
|
32
|
+
sessionId: string
|
|
33
|
+
) => Promise<string>;
|
|
34
|
+
pushFeatureMerged: (feature: Feature) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sessionOf = (id: string | null) =>
|
|
38
|
+
id
|
|
39
|
+
? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
40
|
+
| Session
|
|
41
|
+
| undefined)
|
|
42
|
+
: undefined;
|
|
43
|
+
|
|
44
|
+
const setPiece = (id: number, status: string, note: string | null = null) =>
|
|
45
|
+
db
|
|
46
|
+
.prepare(`UPDATE feature_pieces SET status = ?, note = ? WHERE id = ?`)
|
|
47
|
+
.run(status, note, id);
|
|
48
|
+
|
|
49
|
+
// What a piece's task did: merged, or ended some other way.
|
|
50
|
+
function syncPieces(pieces: FeaturePiece[]): void {
|
|
51
|
+
for (const p of pieces) {
|
|
52
|
+
if (p.status !== "building") continue;
|
|
53
|
+
const s = sessionOf(p.session_id);
|
|
54
|
+
if (!s) {
|
|
55
|
+
// Still in the queue, or the queue gave up on it.
|
|
56
|
+
const q = db
|
|
57
|
+
.prepare(`SELECT status, error FROM task_queue WHERE id = ?`)
|
|
58
|
+
.get(p.session_id) as
|
|
59
|
+
| { status: string; error: string | null }
|
|
60
|
+
| undefined;
|
|
61
|
+
if (q?.status === "removed")
|
|
62
|
+
setPiece(p.id, "dropped", "removed from the queue");
|
|
63
|
+
else if (q?.status === "failed")
|
|
64
|
+
setPiece(p.id, "blocked", q.error ?? "it couldn't start");
|
|
65
|
+
else if (!q) setPiece(p.id, "blocked", "its task is gone");
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (s.task_status === "merged") setPiece(p.id, "merged");
|
|
69
|
+
else if (s.task_status === "dropped" || s.task_status === "done")
|
|
70
|
+
setPiece(p.id, "dropped", `its task was ${s.task_status}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const realDeps: ReleaseDeps = {
|
|
75
|
+
// Only a confirmed deploy releases the next piece: anything that can't be
|
|
76
|
+
// checked blocks it (a missing project or PR) or keeps it waiting.
|
|
77
|
+
deployed: async (dep, session) => {
|
|
78
|
+
const project = getProject(dep.project_id);
|
|
79
|
+
if (!project || !session.pr_number || !session.merged_at) return "failed";
|
|
80
|
+
const repo = project.working_directory.replace(
|
|
81
|
+
/^~/,
|
|
82
|
+
process.env.HOME ?? "~"
|
|
83
|
+
);
|
|
84
|
+
const config = loadProjectConfig(repo).config.deploy;
|
|
85
|
+
const { checkDeploy } = await import("../away/deploy");
|
|
86
|
+
const { mergeCommit } = await import("../away/run");
|
|
87
|
+
const verdict = await checkDeploy({
|
|
88
|
+
repo,
|
|
89
|
+
config,
|
|
90
|
+
mergeSha: config ? await mergeCommit(repo, session.pr_number) : null,
|
|
91
|
+
mergedAt: Date.parse(`${session.merged_at}Z`),
|
|
92
|
+
now: Date.now(),
|
|
93
|
+
});
|
|
94
|
+
return verdict.state === "ok"
|
|
95
|
+
? "ok"
|
|
96
|
+
: verdict.state === "failed"
|
|
97
|
+
? "failed"
|
|
98
|
+
: "pending";
|
|
99
|
+
},
|
|
100
|
+
start: async (feature, piece, prompt, sessionId) => {
|
|
101
|
+
const plan = sessionOf(feature.plan_session_id);
|
|
102
|
+
const req = {
|
|
103
|
+
projectId: piece.project_id,
|
|
104
|
+
prompt,
|
|
105
|
+
name: `${feature.name} · ${piece.name}`,
|
|
106
|
+
plan: false,
|
|
107
|
+
planReason: "feature piece",
|
|
108
|
+
// A retry under the same id clears what a failed attempt left behind.
|
|
109
|
+
reclaim: true,
|
|
110
|
+
startedBy: plan?.started_by ?? null,
|
|
111
|
+
originWorkspaceId: feature.workspace_id,
|
|
112
|
+
};
|
|
113
|
+
// A queued task keeps its queue id as its session id once it starts.
|
|
114
|
+
const out = await startOrQueue(req, () =>
|
|
115
|
+
braked(
|
|
116
|
+
feature.workspace_id,
|
|
117
|
+
"task",
|
|
118
|
+
() => createTask({ ...req, id: sessionId }),
|
|
119
|
+
(s) => (s as { id?: string }).id ?? null,
|
|
120
|
+
{ spendApproval: false }
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
return "queued" in out ? out.queued.id : sessionId;
|
|
124
|
+
},
|
|
125
|
+
pushFeatureMerged: (feature) =>
|
|
126
|
+
void import("../notify/push/events").then((m) =>
|
|
127
|
+
m.pushFeatureMerged(feature.id, feature.name)
|
|
128
|
+
),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function say(feature: Feature, key: string, line: string): void {
|
|
132
|
+
queueEvent(
|
|
133
|
+
feature.workspace_id,
|
|
134
|
+
`feature:${key}`,
|
|
135
|
+
feature.plan_session_id,
|
|
136
|
+
line
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function beforeLines(all: FeaturePiece[], upTo: number): string[] {
|
|
141
|
+
return all
|
|
142
|
+
.filter((p) => p.position < upTo && p.status === "merged")
|
|
143
|
+
.map((p) => {
|
|
144
|
+
const pr = sessionOf(p.session_id)?.pr_number;
|
|
145
|
+
return `${p.position} ${p.name} merged${pr ? ` as #${pr}` : ""}`;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function releaseOne(
|
|
150
|
+
feature: Feature,
|
|
151
|
+
all: FeaturePiece[],
|
|
152
|
+
piece: FeaturePiece,
|
|
153
|
+
deps: ReleaseDeps
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const dep = piece.after_position
|
|
156
|
+
? all.find((p) => p.position === piece.after_position)
|
|
157
|
+
: undefined;
|
|
158
|
+
if (dep) {
|
|
159
|
+
if (dep.status === "dropped" || dep.status === "blocked") {
|
|
160
|
+
const note = `waits on ${dep.position} ${dep.name}, which was ${dep.status === "dropped" ? "dropped" : "blocked"}`;
|
|
161
|
+
setPiece(piece.id, "blocked", note);
|
|
162
|
+
say(
|
|
163
|
+
feature,
|
|
164
|
+
`blocked-${piece.id}`,
|
|
165
|
+
`Feature "${feature.name}": piece ${piece.position} ${piece.name} ${note}. Release it anyway or drop it.`
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (dep.status !== "merged") return;
|
|
170
|
+
if (piece.after_kind === "deployed") {
|
|
171
|
+
const session = sessionOf(dep.session_id);
|
|
172
|
+
const state = session ? await deps.deployed(dep, session) : "failed";
|
|
173
|
+
if (state === "pending") return;
|
|
174
|
+
if (state === "failed") {
|
|
175
|
+
const note = `${dep.position} ${dep.name} merged but its deploy failed or couldn't be confirmed`;
|
|
176
|
+
setPiece(piece.id, "blocked", note);
|
|
177
|
+
say(
|
|
178
|
+
feature,
|
|
179
|
+
`blocked-${piece.id}`,
|
|
180
|
+
`Feature "${feature.name}": piece ${piece.position} ${piece.name} is held: ${note}.`
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const claimed = db
|
|
187
|
+
.prepare(
|
|
188
|
+
`UPDATE feature_pieces SET status = 'building', session_id = ?, note = NULL
|
|
189
|
+
WHERE id = ? AND status = 'waiting'`
|
|
190
|
+
)
|
|
191
|
+
.run((piece.session_id ??= randomUUID()), piece.id).changes;
|
|
192
|
+
if (!claimed) return;
|
|
193
|
+
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))}`;
|
|
194
|
+
try {
|
|
195
|
+
const id = await deps.start(feature, piece, prompt, piece.session_id);
|
|
196
|
+
if (id !== piece.session_id)
|
|
197
|
+
db.prepare(`UPDATE feature_pieces SET session_id = ? WHERE id = ?`).run(
|
|
198
|
+
id,
|
|
199
|
+
piece.id
|
|
200
|
+
);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof BrakeRefused) {
|
|
203
|
+
// Held for the next sweep: nothing was made.
|
|
204
|
+
db.prepare(
|
|
205
|
+
`UPDATE feature_pieces SET status = 'waiting', session_id = NULL WHERE id = ?`
|
|
206
|
+
).run(piece.id);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Anything else would fail the same way every sweep: hold it, say so
|
|
210
|
+
// once. Its id is kept, so "Release anyway" starts it by the same id
|
|
211
|
+
// and clears what this attempt left.
|
|
212
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
213
|
+
setPiece(piece.id, "blocked", `it couldn't start: ${why}`.slice(0, 300));
|
|
214
|
+
say(
|
|
215
|
+
feature,
|
|
216
|
+
`start-${piece.id}`,
|
|
217
|
+
`Feature "${feature.name}": piece ${piece.position} ${piece.name} couldn't start: ${why}. Release it anyway to try again, or drop it.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
|
|
223
|
+
const live = all.filter((p) => p.status !== "dropped");
|
|
224
|
+
if (!live.length) {
|
|
225
|
+
db.prepare(`UPDATE features SET status = 'dropped' WHERE id = ?`).run(
|
|
226
|
+
feature.id
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (live.some((p) => p.status !== "merged")) return;
|
|
231
|
+
const sessions = live
|
|
232
|
+
.map((p) => sessionOf(p.session_id))
|
|
233
|
+
.filter(Boolean) as Session[];
|
|
234
|
+
const cost = sessions.reduce(
|
|
235
|
+
(n, s) => n + usageRows(s.id).reduce((m, r) => m + r.costUsd, 0),
|
|
236
|
+
0
|
|
237
|
+
);
|
|
238
|
+
const ended = Math.max(
|
|
239
|
+
...sessions.map((s) => (s.merged_at ? Date.parse(`${s.merged_at}Z`) : 0))
|
|
240
|
+
);
|
|
241
|
+
const started = Date.parse(`${feature.created_at}Z`);
|
|
242
|
+
const claimed = db
|
|
243
|
+
.prepare(
|
|
244
|
+
`UPDATE features SET status = 'done', done_at = datetime('now'), cost_usd = ?, duration_ms = ?
|
|
245
|
+
WHERE id = ? AND status = 'building'`
|
|
246
|
+
)
|
|
247
|
+
.run(cost, ended > started ? ended - started : null, feature.id).changes;
|
|
248
|
+
if (!claimed) return;
|
|
249
|
+
say(
|
|
250
|
+
feature,
|
|
251
|
+
`done-${feature.id}`,
|
|
252
|
+
`Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}.`
|
|
253
|
+
);
|
|
254
|
+
deps.pushFeatureMerged(feature);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function releasePieces(
|
|
258
|
+
deps: ReleaseDeps = realDeps
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
const features = db
|
|
261
|
+
.prepare(`SELECT * FROM features WHERE status = 'building'`)
|
|
262
|
+
.all() as Feature[];
|
|
263
|
+
for (const feature of features) {
|
|
264
|
+
syncPieces(piecesOf(feature.id));
|
|
265
|
+
const all = piecesOf(feature.id);
|
|
266
|
+
for (const piece of all.filter((p) => p.status === "waiting"))
|
|
267
|
+
await releaseOne(feature, piecesOf(feature.id), piece, deps).catch((e) =>
|
|
268
|
+
console.error(`[features] piece ${piece.id}:`, e)
|
|
269
|
+
);
|
|
270
|
+
close(feature, piecesOf(feature.id), deps);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// "Release anyway": a blocked piece stops waiting on what held it.
|
|
275
|
+
export function releaseAnyway(pieceId: number): boolean {
|
|
276
|
+
return (
|
|
277
|
+
db
|
|
278
|
+
.prepare(
|
|
279
|
+
`UPDATE feature_pieces SET status = 'waiting', after_position = NULL,
|
|
280
|
+
after_kind = NULL, note = NULL WHERE id = ? AND status = 'blocked'`
|
|
281
|
+
)
|
|
282
|
+
.run(pieceId).changes > 0
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The orchestrator's second look at a plan while pieces still wait: fold a
|
|
287
|
+
// waiting piece into the one before it, or leave it out.
|
|
288
|
+
export function editWaiting(
|
|
289
|
+
featureId: string,
|
|
290
|
+
edits: { merge?: number[]; drop?: number[] }
|
|
291
|
+
): string[] {
|
|
292
|
+
const done: string[] = [];
|
|
293
|
+
const all = piecesOf(featureId);
|
|
294
|
+
const feature = getFeature(featureId);
|
|
295
|
+
for (const n of [...(edits.drop ?? []), ...(edits.merge ?? [])]) {
|
|
296
|
+
const piece = all.find((p) => p.position === n);
|
|
297
|
+
if (!piece || piece.status !== "waiting" || n < 2 || !feature) continue;
|
|
298
|
+
if (!(edits.merge ?? []).includes(n)) {
|
|
299
|
+
// Pieces that wait on it are then held, not freed.
|
|
300
|
+
setPiece(piece.id, "dropped", "dropped");
|
|
301
|
+
done.push(`${n} dropped`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const into = all
|
|
305
|
+
.filter((p) => p.position < n && p.status !== "dropped")
|
|
306
|
+
.pop();
|
|
307
|
+
if (!into) continue;
|
|
308
|
+
const slice = `${piece.name}: ${piece.slice}`;
|
|
309
|
+
if (into.status === "waiting")
|
|
310
|
+
db.prepare(
|
|
311
|
+
`UPDATE feature_pieces SET slice = slice || ? WHERE id = ?`
|
|
312
|
+
).run(`\n${slice}`, into.id);
|
|
313
|
+
else if (into.status === "building" && into.session_id)
|
|
314
|
+
amendBrief(feature.workspace_id, into.session_id, slice);
|
|
315
|
+
else continue;
|
|
316
|
+
setPiece(piece.id, "dropped", `merged into ${into.position}`);
|
|
317
|
+
db.prepare(
|
|
318
|
+
`UPDATE feature_pieces SET after_position = ? WHERE feature_id = ? AND after_position = ?`
|
|
319
|
+
).run(into.position, featureId, n);
|
|
320
|
+
done.push(`${n} merged into ${into.position}`);
|
|
321
|
+
}
|
|
322
|
+
return done;
|
|
323
|
+
}
|