@saadnvd1/agent-os 0.3.723 → 0.3.728
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 +1 -0
- package/app/api/loops/route.ts +17 -0
- package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
- package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
- package/app/api/tasks/[id]/loop/next/route.ts +25 -0
- package/app/api/tasks/[id]/loop/route.ts +72 -0
- package/app/api/tasks/[id]/loop/run/route.ts +12 -0
- package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
- package/app/api/tasks/[id]/mode/route.ts +29 -0
- package/app/api/tasks/[id]/report/draft/route.ts +54 -0
- package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
- package/app/api/tasks/[id]/report/route.ts +39 -0
- package/app/api/workspaces/[id]/mode/route.ts +23 -0
- package/app/api/workspaces/[id]/reports/route.ts +17 -0
- package/app/page.tsx +8 -0
- package/components/Analyst/PairChip.tsx +131 -0
- package/components/Analyst/PairSwitch.tsx +76 -0
- package/components/Analyst/ReviewCard.tsx +272 -0
- package/components/Analyst/SignalChips.tsx +39 -0
- package/components/Analyst/StartAnalystSheet.tsx +109 -0
- package/components/Analyst/cardState.test.ts +109 -0
- package/components/Analyst/cardState.ts +95 -0
- package/components/Analyst/pair.ts +81 -0
- package/components/Chat/Code.tsx +3 -0
- package/components/Chat/Messages.tsx +6 -1
- package/components/Chat/itemContext.ts +14 -0
- package/components/Draft/DraftChips.tsx +10 -0
- package/components/Draft/DraftPanel.tsx +7 -0
- package/components/Pane/DesktopTabBar.tsx +6 -0
- package/components/Pane/MobileTabBar.tsx +17 -0
- package/components/Pane/MobileViewPicker.tsx +4 -0
- package/components/Pane/PaneMenu.test.ts +21 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/Pane/PaneViewToggle.tsx +12 -0
- package/components/Pane/index.tsx +19 -0
- package/components/Report/DraftCard.tsx +89 -0
- package/components/Report/EstimateBar.tsx +51 -0
- package/components/Report/EvidenceSheet.tsx +98 -0
- package/components/Report/ModeChooser.tsx +43 -0
- package/components/Report/ReportView.tsx +289 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Sessions/RolePill.tsx +7 -0
- package/components/Tasks/Loop/LoopButton.tsx +74 -0
- package/components/Tasks/Loop/LoopSetup.tsx +146 -0
- package/components/Tasks/Loop/LoopView.tsx +350 -0
- package/components/Tasks/Loop/labels.ts +17 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/components/ui/segmented.tsx +55 -0
- package/components/ui/status-pill.tsx +3 -0
- package/contexts/PaneContext.tsx +21 -1
- package/data/loops/index.ts +3 -0
- package/data/loops/keys.ts +4 -0
- package/data/loops/mutations.ts +68 -0
- package/data/loops/queries.ts +40 -0
- package/data/push/topics.ts +6 -0
- package/data/reports/index.ts +115 -0
- package/lib/analyst/forward.ts +1 -1
- package/lib/db/changes.ts +7 -0
- package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
- package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +2 -0
- package/lib/loops/analyse.ts +46 -0
- package/lib/loops/analyst-prompt.ts +203 -0
- package/lib/loops/checkCriteria.ts +114 -0
- package/lib/loops/compactEvidence.ts +140 -0
- package/lib/loops/controls.ts +317 -0
- package/lib/loops/decideStop.ts +113 -0
- package/lib/loops/deps.ts +128 -0
- package/lib/loops/diffMap.ts +39 -0
- package/lib/loops/draftCriteria.ts +71 -0
- package/lib/loops/gatherEvidence.ts +265 -0
- package/lib/loops/hooks.ts +40 -0
- package/lib/loops/index.ts +7 -0
- package/lib/loops/route.ts +83 -0
- package/lib/loops/runCycle.ts +323 -0
- package/lib/loops/screenInstruction.ts +59 -0
- package/lib/loops/sendInstruction.ts +83 -0
- package/lib/loops/snapshot.ts +38 -0
- package/lib/loops/stopLoop.ts +86 -0
- package/lib/loops/store.ts +481 -0
- package/lib/loops/summary.ts +27 -0
- package/lib/loops/tickLoops.ts +141 -0
- package/lib/loops/types.ts +223 -0
- package/lib/loops/world.test-helpers.ts +223 -0
- package/lib/models/plan-step.ts +4 -1
- package/lib/orchestrator/digest.ts +2 -1
- package/lib/orchestrator/loop-guard.ts +5 -2
- package/lib/orchestrator/pr-wake.ts +4 -0
- package/lib/orchestrator/watcher.ts +8 -1
- package/lib/panes.ts +52 -0
- package/lib/plans/acceptance.ts +23 -0
- package/lib/reports/drafts.ts +87 -0
- package/lib/reports/end-hook.ts +17 -0
- package/lib/reports/estimate.ts +107 -0
- package/lib/reports/evidence/build.ts +193 -0
- package/lib/reports/evidence/gather.ts +229 -0
- package/lib/reports/fixtures.ts +17 -0
- package/lib/reports/generate.ts +159 -0
- package/lib/reports/hooks.ts +30 -0
- package/lib/reports/mode.ts +65 -0
- package/lib/reports/model.ts +115 -0
- package/lib/reports/read.ts +26 -0
- package/lib/reports/store.ts +180 -0
- package/lib/reports/template.ts +117 -0
- package/lib/reports/types.ts +135 -0
- package/lib/reports/validate.ts +142 -0
- package/lib/sidebar/row-menu.ts +9 -0
- package/lib/tasks/contract/routes.ts +5 -0
- package/lib/tasks/contract/types.ts +3 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/package.json +2 -2
- package/stores/analystUi.ts +19 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { db } from "../db";
|
|
3
|
+
import type {
|
|
4
|
+
Analysis,
|
|
5
|
+
Criterion,
|
|
6
|
+
CriterionDraft,
|
|
7
|
+
CriterionStatus,
|
|
8
|
+
CycleRow,
|
|
9
|
+
CycleState,
|
|
10
|
+
CycleTrigger,
|
|
11
|
+
Evidence,
|
|
12
|
+
EvidenceSummary,
|
|
13
|
+
Limits,
|
|
14
|
+
LoopRow,
|
|
15
|
+
Score,
|
|
16
|
+
Sender,
|
|
17
|
+
StopReason,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
const json = <T>(v: unknown): T | null => {
|
|
21
|
+
if (typeof v !== "string") return null;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(v) as T;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
30
|
+
const loopOf = (r: any): LoopRow => ({
|
|
31
|
+
id: r.id,
|
|
32
|
+
sessionId: r.session_id,
|
|
33
|
+
workspaceId: r.workspace_id,
|
|
34
|
+
state: r.state,
|
|
35
|
+
stopReason: r.stop_reason,
|
|
36
|
+
stopDetail: r.stop_detail,
|
|
37
|
+
goal: r.goal,
|
|
38
|
+
maxCycles: r.max_cycles,
|
|
39
|
+
maxCostUsd: r.max_cost_usd,
|
|
40
|
+
maxMinutes: r.max_minutes,
|
|
41
|
+
graceSeconds: r.grace_seconds,
|
|
42
|
+
stallCycles: r.stall_cycles,
|
|
43
|
+
repeatCycles: r.repeat_cycles,
|
|
44
|
+
costBaseUsd: r.cost_base_usd,
|
|
45
|
+
startedAt: r.started_at,
|
|
46
|
+
endedAt: r.ended_at,
|
|
47
|
+
lastCycleAt: r.last_cycle_at,
|
|
48
|
+
runNow: !!r.run_now,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const criterionOf = (r: any): Criterion => ({
|
|
52
|
+
id: r.id,
|
|
53
|
+
loopId: r.loop_id,
|
|
54
|
+
position: r.position,
|
|
55
|
+
text: r.text,
|
|
56
|
+
source: r.source,
|
|
57
|
+
evidenceKind: r.evidence_kind,
|
|
58
|
+
status: r.status,
|
|
59
|
+
evidence: json<Evidence>(r.evidence),
|
|
60
|
+
verified: !!r.verified,
|
|
61
|
+
metCycle: r.met_cycle,
|
|
62
|
+
waivedBy: r.waived_by,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const cycleOf = (r: any): CycleRow => ({
|
|
66
|
+
id: r.id,
|
|
67
|
+
loopId: r.loop_id,
|
|
68
|
+
n: r.n,
|
|
69
|
+
trigger: r.trigger,
|
|
70
|
+
state: r.state,
|
|
71
|
+
snapshotSha: r.snapshot_sha,
|
|
72
|
+
tree: r.tree,
|
|
73
|
+
changed: !!r.changed,
|
|
74
|
+
evidence: json<EvidenceSummary>(r.evidence),
|
|
75
|
+
logPath: r.log_path,
|
|
76
|
+
analysis: json<Analysis>(r.analysis),
|
|
77
|
+
signature: r.signature,
|
|
78
|
+
score: json<Score>(r.score),
|
|
79
|
+
instruction: r.instruction,
|
|
80
|
+
instructionSent: r.instruction_sent,
|
|
81
|
+
sender: r.sender,
|
|
82
|
+
delivery: r.delivery,
|
|
83
|
+
sendAt: r.send_at,
|
|
84
|
+
input: r.input,
|
|
85
|
+
costUsd: r.cost_usd,
|
|
86
|
+
error: r.error,
|
|
87
|
+
startedAt: r.started_at,
|
|
88
|
+
endedAt: r.ended_at,
|
|
89
|
+
});
|
|
90
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
91
|
+
|
|
92
|
+
export function insertLoop(input: {
|
|
93
|
+
sessionId: string;
|
|
94
|
+
workspaceId: string | null;
|
|
95
|
+
goal: string;
|
|
96
|
+
limits: Limits;
|
|
97
|
+
costBaseUsd: number;
|
|
98
|
+
now: number;
|
|
99
|
+
}): LoopRow {
|
|
100
|
+
const id = randomUUID();
|
|
101
|
+
const l = input.limits;
|
|
102
|
+
db.prepare(
|
|
103
|
+
`INSERT INTO task_loops (id, session_id, workspace_id, goal, max_cycles,
|
|
104
|
+
max_cost_usd, max_minutes, grace_seconds, stall_cycles, repeat_cycles,
|
|
105
|
+
cost_base_usd, started_at)
|
|
106
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
107
|
+
).run(
|
|
108
|
+
id,
|
|
109
|
+
input.sessionId,
|
|
110
|
+
input.workspaceId,
|
|
111
|
+
input.goal,
|
|
112
|
+
l.maxCycles,
|
|
113
|
+
l.maxCostUsd,
|
|
114
|
+
l.maxMinutes,
|
|
115
|
+
l.graceSeconds,
|
|
116
|
+
l.stallCycles,
|
|
117
|
+
l.repeatCycles,
|
|
118
|
+
input.costBaseUsd,
|
|
119
|
+
input.now
|
|
120
|
+
);
|
|
121
|
+
return getLoop(id)!;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getLoop(id: string): LoopRow | null {
|
|
125
|
+
const r = db.prepare(`SELECT * FROM task_loops WHERE id = ?`).get(id);
|
|
126
|
+
return r ? loopOf(r) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function liveLoopFor(sessionId: string): LoopRow | null {
|
|
130
|
+
const r = db
|
|
131
|
+
.prepare(
|
|
132
|
+
`SELECT * FROM task_loops WHERE session_id = ? AND state = 'running'`
|
|
133
|
+
)
|
|
134
|
+
.get(sessionId);
|
|
135
|
+
return r ? loopOf(r) : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The running loop, else the one that stopped last.
|
|
139
|
+
export function latestLoopFor(sessionId: string): LoopRow | null {
|
|
140
|
+
const r = db
|
|
141
|
+
.prepare(
|
|
142
|
+
`SELECT * FROM task_loops WHERE session_id = ?
|
|
143
|
+
ORDER BY (state = 'running') DESC, started_at DESC LIMIT 1`
|
|
144
|
+
)
|
|
145
|
+
.get(sessionId);
|
|
146
|
+
return r ? loopOf(r) : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function runningLoops(workspaceId?: string): LoopRow[] {
|
|
150
|
+
const rows = workspaceId
|
|
151
|
+
? db
|
|
152
|
+
.prepare(
|
|
153
|
+
`SELECT * FROM task_loops WHERE state = 'running' AND workspace_id = ?`
|
|
154
|
+
)
|
|
155
|
+
.all(workspaceId)
|
|
156
|
+
: db.prepare(`SELECT * FROM task_loops WHERE state = 'running'`).all();
|
|
157
|
+
return rows.map(loopOf);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function stopLoopRow(
|
|
161
|
+
id: string,
|
|
162
|
+
reason: StopReason,
|
|
163
|
+
detail: string | null,
|
|
164
|
+
now: number
|
|
165
|
+
): boolean {
|
|
166
|
+
return (
|
|
167
|
+
db
|
|
168
|
+
.prepare(
|
|
169
|
+
`UPDATE task_loops SET state = 'stopped', stop_reason = ?, stop_detail = ?, ended_at = ?
|
|
170
|
+
WHERE id = ? AND state = 'running'`
|
|
171
|
+
)
|
|
172
|
+
.run(reason, detail, now, id).changes > 0
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function setLimits(id: string, l: Partial<Limits>): void {
|
|
177
|
+
const map: Record<keyof Limits, string> = {
|
|
178
|
+
maxCycles: "max_cycles",
|
|
179
|
+
maxCostUsd: "max_cost_usd",
|
|
180
|
+
maxMinutes: "max_minutes",
|
|
181
|
+
graceSeconds: "grace_seconds",
|
|
182
|
+
stallCycles: "stall_cycles",
|
|
183
|
+
repeatCycles: "repeat_cycles",
|
|
184
|
+
};
|
|
185
|
+
for (const [k, v] of Object.entries(l))
|
|
186
|
+
if (v !== undefined && k in map)
|
|
187
|
+
db.prepare(
|
|
188
|
+
`UPDATE task_loops SET ${map[k as keyof Limits]} = ? WHERE id = ?`
|
|
189
|
+
).run(v, id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function setRunNow(id: string, on: boolean): void {
|
|
193
|
+
db.prepare(`UPDATE task_loops SET run_now = ? WHERE id = ?`).run(
|
|
194
|
+
on ? 1 : 0,
|
|
195
|
+
id
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function touchLoop(id: string, now: number): void {
|
|
200
|
+
db.prepare(`UPDATE task_loops SET last_cycle_at = ? WHERE id = ?`).run(
|
|
201
|
+
now,
|
|
202
|
+
id
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Resumed from a limit stop: the same loop runs again.
|
|
207
|
+
export function reopenLoop(id: string): boolean {
|
|
208
|
+
return (
|
|
209
|
+
db
|
|
210
|
+
.prepare(
|
|
211
|
+
`UPDATE task_loops SET state = 'running', stop_reason = NULL, stop_detail = NULL, ended_at = NULL
|
|
212
|
+
WHERE id = ? AND state = 'stopped'`
|
|
213
|
+
)
|
|
214
|
+
.run(id).changes > 0
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* Criteria */
|
|
219
|
+
|
|
220
|
+
export function criteriaOf(loopId: string): Criterion[] {
|
|
221
|
+
return db
|
|
222
|
+
.prepare(
|
|
223
|
+
`SELECT * FROM task_loop_criteria WHERE loop_id = ? AND removed = 0 ORDER BY position, id`
|
|
224
|
+
)
|
|
225
|
+
.all(loopId)
|
|
226
|
+
.map(criterionOf);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function addCriteria(
|
|
230
|
+
loopId: string,
|
|
231
|
+
drafts: CriterionDraft[]
|
|
232
|
+
): Criterion[] {
|
|
233
|
+
const start =
|
|
234
|
+
(
|
|
235
|
+
db
|
|
236
|
+
.prepare(
|
|
237
|
+
`SELECT COALESCE(MAX(position), -1) AS p FROM task_loop_criteria WHERE loop_id = ?`
|
|
238
|
+
)
|
|
239
|
+
.get(loopId) as { p: number }
|
|
240
|
+
).p + 1;
|
|
241
|
+
const insert = db.prepare(
|
|
242
|
+
`INSERT INTO task_loop_criteria (loop_id, position, text, source, evidence_kind) VALUES (?, ?, ?, ?, ?)`
|
|
243
|
+
);
|
|
244
|
+
db.transaction(() => {
|
|
245
|
+
drafts.forEach((d, i) =>
|
|
246
|
+
insert.run(
|
|
247
|
+
loopId,
|
|
248
|
+
start + i,
|
|
249
|
+
d.text,
|
|
250
|
+
d.source ?? "user",
|
|
251
|
+
d.evidenceKind ?? null
|
|
252
|
+
)
|
|
253
|
+
);
|
|
254
|
+
})();
|
|
255
|
+
return criteriaOf(loopId);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function editCriterion(
|
|
259
|
+
id: number,
|
|
260
|
+
loopId: string,
|
|
261
|
+
text: string
|
|
262
|
+
): boolean {
|
|
263
|
+
return (
|
|
264
|
+
db
|
|
265
|
+
.prepare(
|
|
266
|
+
`UPDATE task_loop_criteria SET text = ?, status = 'open', evidence = NULL, verified = 1, met_cycle = NULL, waived_by = NULL
|
|
267
|
+
WHERE id = ? AND loop_id = ? AND removed = 0`
|
|
268
|
+
)
|
|
269
|
+
.run(text, id, loopId).changes > 0
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function removeCriterion(id: number, loopId: string): boolean {
|
|
274
|
+
return (
|
|
275
|
+
db
|
|
276
|
+
.prepare(
|
|
277
|
+
`UPDATE task_loop_criteria SET removed = 1 WHERE id = ? AND loop_id = ?`
|
|
278
|
+
)
|
|
279
|
+
.run(id, loopId).changes > 0
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function waiveCriterion(
|
|
284
|
+
id: number,
|
|
285
|
+
loopId: string,
|
|
286
|
+
by: string
|
|
287
|
+
): boolean {
|
|
288
|
+
return (
|
|
289
|
+
db
|
|
290
|
+
.prepare(
|
|
291
|
+
`UPDATE task_loop_criteria SET status = 'waived', waived_by = ?, verified = 1
|
|
292
|
+
WHERE id = ? AND loop_id = ? AND removed = 0`
|
|
293
|
+
)
|
|
294
|
+
.run(by, id, loopId).changes > 0
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function markCriterion(
|
|
299
|
+
id: number,
|
|
300
|
+
status: CriterionStatus,
|
|
301
|
+
evidence: Evidence | null,
|
|
302
|
+
verified: boolean,
|
|
303
|
+
cycleN: number
|
|
304
|
+
): void {
|
|
305
|
+
db.prepare(
|
|
306
|
+
`UPDATE task_loop_criteria SET status = ?, evidence = ?, verified = ?,
|
|
307
|
+
met_cycle = CASE WHEN ? = 'met' THEN ? ELSE NULL END
|
|
308
|
+
WHERE id = ? AND status != 'waived'`
|
|
309
|
+
).run(
|
|
310
|
+
status,
|
|
311
|
+
evidence ? JSON.stringify(evidence) : null,
|
|
312
|
+
verified ? 1 : 0,
|
|
313
|
+
status,
|
|
314
|
+
cycleN,
|
|
315
|
+
id
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* Cycles */
|
|
320
|
+
|
|
321
|
+
export function cyclesOf(
|
|
322
|
+
loopId: string,
|
|
323
|
+
opts: { before?: number; limit?: number } = {}
|
|
324
|
+
): CycleRow[] {
|
|
325
|
+
const rows = db
|
|
326
|
+
.prepare(
|
|
327
|
+
`SELECT * FROM task_loop_cycles WHERE loop_id = ? AND n < ? ORDER BY n DESC LIMIT ?`
|
|
328
|
+
)
|
|
329
|
+
.all(loopId, opts.before ?? Number.MAX_SAFE_INTEGER, opts.limit ?? 20);
|
|
330
|
+
return rows.map(cycleOf);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function getCycle(loopId: string, n: number): CycleRow | null {
|
|
334
|
+
const r = db
|
|
335
|
+
.prepare(`SELECT * FROM task_loop_cycles WHERE loop_id = ? AND n = ?`)
|
|
336
|
+
.get(loopId, n);
|
|
337
|
+
return r ? cycleOf(r) : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function lastCycle(loopId: string): CycleRow | null {
|
|
341
|
+
const r = db
|
|
342
|
+
.prepare(
|
|
343
|
+
`SELECT * FROM task_loop_cycles WHERE loop_id = ? ORDER BY n DESC LIMIT 1`
|
|
344
|
+
)
|
|
345
|
+
.get(loopId);
|
|
346
|
+
return r ? cycleOf(r) : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function cycleCount(loopId: string): number {
|
|
350
|
+
return (
|
|
351
|
+
db
|
|
352
|
+
.prepare(`SELECT COUNT(*) AS c FROM task_loop_cycles WHERE loop_id = ?`)
|
|
353
|
+
.get(loopId) as {
|
|
354
|
+
c: number;
|
|
355
|
+
}
|
|
356
|
+
).c;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Claims the next cycle number; the unique index makes two claims one.
|
|
360
|
+
export function claimCycle(
|
|
361
|
+
loopId: string,
|
|
362
|
+
trigger: CycleTrigger,
|
|
363
|
+
now: number
|
|
364
|
+
): CycleRow | null {
|
|
365
|
+
const n = (lastCycle(loopId)?.n ?? 0) + 1;
|
|
366
|
+
try {
|
|
367
|
+
db.prepare(
|
|
368
|
+
`INSERT INTO task_loop_cycles (loop_id, n, trigger, started_at) VALUES (?, ?, ?, ?)`
|
|
369
|
+
).run(loopId, n, trigger, now);
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
return getCycle(loopId, n);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const CYCLE_COLUMNS = {
|
|
377
|
+
state: "state",
|
|
378
|
+
snapshotSha: "snapshot_sha",
|
|
379
|
+
tree: "tree",
|
|
380
|
+
changed: "changed",
|
|
381
|
+
evidence: "evidence",
|
|
382
|
+
logPath: "log_path",
|
|
383
|
+
analysis: "analysis",
|
|
384
|
+
signature: "signature",
|
|
385
|
+
score: "score",
|
|
386
|
+
instruction: "instruction",
|
|
387
|
+
instructionSent: "instruction_sent",
|
|
388
|
+
sender: "sender",
|
|
389
|
+
delivery: "delivery",
|
|
390
|
+
sendAt: "send_at",
|
|
391
|
+
input: "input",
|
|
392
|
+
costUsd: "cost_usd",
|
|
393
|
+
error: "error",
|
|
394
|
+
endedAt: "ended_at",
|
|
395
|
+
} as const;
|
|
396
|
+
|
|
397
|
+
export type CyclePatch = Partial<{
|
|
398
|
+
state: CycleState;
|
|
399
|
+
snapshotSha: string | null;
|
|
400
|
+
tree: string | null;
|
|
401
|
+
changed: boolean;
|
|
402
|
+
evidence: EvidenceSummary | null;
|
|
403
|
+
logPath: string | null;
|
|
404
|
+
analysis: Analysis | null;
|
|
405
|
+
signature: string | null;
|
|
406
|
+
score: Score | null;
|
|
407
|
+
instruction: string | null;
|
|
408
|
+
instructionSent: string | null;
|
|
409
|
+
sender: Sender | null;
|
|
410
|
+
delivery: string | null;
|
|
411
|
+
sendAt: number | null;
|
|
412
|
+
input: string | null;
|
|
413
|
+
costUsd: number;
|
|
414
|
+
error: string | null;
|
|
415
|
+
endedAt: number | null;
|
|
416
|
+
}>;
|
|
417
|
+
|
|
418
|
+
export function patchCycle(loopId: string, n: number, patch: CyclePatch): void {
|
|
419
|
+
const sets: string[] = [];
|
|
420
|
+
const values: unknown[] = [];
|
|
421
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
422
|
+
const col = CYCLE_COLUMNS[k as keyof typeof CYCLE_COLUMNS];
|
|
423
|
+
if (!col || v === undefined) continue;
|
|
424
|
+
sets.push(`${col} = ?`);
|
|
425
|
+
values.push(
|
|
426
|
+
v !== null && typeof v === "object"
|
|
427
|
+
? JSON.stringify(v)
|
|
428
|
+
: typeof v === "boolean"
|
|
429
|
+
? v
|
|
430
|
+
? 1
|
|
431
|
+
: 0
|
|
432
|
+
: v
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (!sets.length) return;
|
|
436
|
+
db.prepare(
|
|
437
|
+
`UPDATE task_loop_cycles SET ${sets.join(", ")} WHERE loop_id = ? AND n = ?`
|
|
438
|
+
).run(...values, loopId, n);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Moves a cycle from one state to another only if it is still in the first,
|
|
442
|
+
// so two presses (or a press and the timer) act once.
|
|
443
|
+
export function moveCycle(
|
|
444
|
+
loopId: string,
|
|
445
|
+
n: number,
|
|
446
|
+
from: CycleState[],
|
|
447
|
+
patch: CyclePatch & { state: CycleState }
|
|
448
|
+
): boolean {
|
|
449
|
+
const marks = from.map(() => "?").join(",");
|
|
450
|
+
const cur = db
|
|
451
|
+
.prepare(`SELECT state FROM task_loop_cycles WHERE loop_id = ? AND n = ?`)
|
|
452
|
+
.get(loopId, n) as { state: CycleState } | undefined;
|
|
453
|
+
if (!cur || !from.includes(cur.state)) return false;
|
|
454
|
+
const claimed = db
|
|
455
|
+
.prepare(
|
|
456
|
+
`UPDATE task_loop_cycles SET state = ? WHERE loop_id = ? AND n = ? AND state IN (${marks})`
|
|
457
|
+
)
|
|
458
|
+
.run(patch.state, loopId, n, ...from).changes;
|
|
459
|
+
if (!claimed) return false;
|
|
460
|
+
patchCycle(loopId, n, patch);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Chat spend of a session since a time, from the per-turn ledger.
|
|
465
|
+
export function chatSpendSince(sessionId: string, since: number): number {
|
|
466
|
+
const r = db
|
|
467
|
+
.prepare(
|
|
468
|
+
`SELECT COALESCE(SUM(cost_usd), 0) AS c FROM chat_turns WHERE session_id = ? AND at >= ?`
|
|
469
|
+
)
|
|
470
|
+
.get(sessionId, since) as { c: number };
|
|
471
|
+
return r.c;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function analystSpend(loopId: string): number {
|
|
475
|
+
const r = db
|
|
476
|
+
.prepare(
|
|
477
|
+
`SELECT COALESCE(SUM(cost_usd), 0) AS c FROM task_loop_cycles WHERE loop_id = ?`
|
|
478
|
+
)
|
|
479
|
+
.get(loopId) as { c: number };
|
|
480
|
+
return r.c;
|
|
481
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { criteriaOf, cycleCount, latestLoopFor, liveLoopFor } from "./store";
|
|
2
|
+
import type { LoopSummary } from "./types";
|
|
3
|
+
|
|
4
|
+
export function loopSummary(taskId: string): LoopSummary | null {
|
|
5
|
+
const loop = latestLoopFor(taskId);
|
|
6
|
+
if (!loop) return null;
|
|
7
|
+
const criteria = criteriaOf(loop.id);
|
|
8
|
+
return {
|
|
9
|
+
state: loop.state,
|
|
10
|
+
stopReason: loop.stopReason,
|
|
11
|
+
cycle: cycleCount(loop.id),
|
|
12
|
+
maxCycles: loop.maxCycles,
|
|
13
|
+
met: criteria.filter((c) => c.status === "met" || c.status === "waived")
|
|
14
|
+
.length,
|
|
15
|
+
total: criteria.length,
|
|
16
|
+
hold: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A line for the orchestrator's digest: only for a running loop.
|
|
21
|
+
export function loopDigestNote(taskId: string): string {
|
|
22
|
+
if (!liveLoopFor(taskId)) return "";
|
|
23
|
+
const s = loopSummary(taskId);
|
|
24
|
+
return s
|
|
25
|
+
? ` | loop: cycle ${s.cycle}/${s.maxCycles}, ${s.met}/${s.total} criteria met`
|
|
26
|
+
: "";
|
|
27
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { SessionFacts } from "../orchestrator/facts";
|
|
2
|
+
import { limitStop } from "./decideStop";
|
|
3
|
+
import { defaultDeps, type LoopDeps } from "./deps";
|
|
4
|
+
import {
|
|
5
|
+
cycleInFlight,
|
|
6
|
+
deliverCycle,
|
|
7
|
+
processCycle,
|
|
8
|
+
RETRY_MS,
|
|
9
|
+
startCycle,
|
|
10
|
+
} from "./runCycle";
|
|
11
|
+
import {
|
|
12
|
+
cycleCount,
|
|
13
|
+
cyclesOf,
|
|
14
|
+
moveCycle,
|
|
15
|
+
runningLoops,
|
|
16
|
+
setRunNow,
|
|
17
|
+
} from "./store";
|
|
18
|
+
import { finishLoop } from "./stopLoop";
|
|
19
|
+
import type { CycleTrigger, LoopRow } from "./types";
|
|
20
|
+
|
|
21
|
+
// The coder has to be quiet this long before a cycle starts, so a burst of
|
|
22
|
+
// turn ends is one cycle.
|
|
23
|
+
export const DEBOUNCE_MS = 20_000;
|
|
24
|
+
// After an instruction, a coder that never starts a turn is looked at again.
|
|
25
|
+
export const NO_START_MS = 3 * 60_000;
|
|
26
|
+
|
|
27
|
+
// Loops that have seen their coder run since the last instruction.
|
|
28
|
+
const sawRunning = new Set<string>();
|
|
29
|
+
|
|
30
|
+
export const resetLoopMemory = () => sawRunning.clear();
|
|
31
|
+
|
|
32
|
+
function trigger(
|
|
33
|
+
loop: LoopRow,
|
|
34
|
+
fact: SessionFacts,
|
|
35
|
+
last: ReturnType<typeof cyclesOf>[number] | undefined,
|
|
36
|
+
now: number
|
|
37
|
+
): CycleTrigger | null {
|
|
38
|
+
const idle = fact.status !== "running";
|
|
39
|
+
if (fact.status === "running") sawRunning.add(loop.id);
|
|
40
|
+
if (loop.runNow && idle) return "manual";
|
|
41
|
+
if (!idle) return null;
|
|
42
|
+
const quiet = now - fact.lastActive >= DEBOUNCE_MS;
|
|
43
|
+
if (!last) return quiet ? "turn_end" : null;
|
|
44
|
+
if (last.state === "error")
|
|
45
|
+
return now - (last.endedAt ?? last.startedAt) >= RETRY_MS
|
|
46
|
+
? "turn_end"
|
|
47
|
+
: null;
|
|
48
|
+
if (!["sent", "skipped", "superseded"].includes(last.state)) return null;
|
|
49
|
+
const waited = now - (last.endedAt ?? now);
|
|
50
|
+
if (quiet && (sawRunning.has(loop.id) || waited >= NO_START_MS))
|
|
51
|
+
return "turn_end";
|
|
52
|
+
const pr = fact.task?.pr;
|
|
53
|
+
const seen = last.evidence?.ci[0]?.sha ?? null;
|
|
54
|
+
if (
|
|
55
|
+
quiet &&
|
|
56
|
+
pr?.head &&
|
|
57
|
+
pr.head !== seen &&
|
|
58
|
+
(pr.checks === "pass" || pr.checks === "fail")
|
|
59
|
+
)
|
|
60
|
+
return "ci";
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One pass over a workspace's running loops, from the watcher's pipeline:
|
|
65
|
+
// resumes a cycle a restart left, sends what's due, stops on a limit, or
|
|
66
|
+
// opens the next cycle when its coder's turn has ended.
|
|
67
|
+
export async function tickLoops(
|
|
68
|
+
workspaceId: string,
|
|
69
|
+
facts: SessionFacts[],
|
|
70
|
+
deps: LoopDeps = defaultDeps
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
for (const loop of runningLoops(workspaceId)) {
|
|
73
|
+
try {
|
|
74
|
+
await tickLoop(loop, facts, deps);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`Loop ${loop.id}:`, error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function tickLoop(
|
|
82
|
+
loop: LoopRow,
|
|
83
|
+
facts: SessionFacts[],
|
|
84
|
+
deps: LoopDeps
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const now = deps.now();
|
|
87
|
+
const task = deps.task(loop.sessionId);
|
|
88
|
+
if (!task || task.archived_at || task.task_status !== "running") {
|
|
89
|
+
finishLoop(
|
|
90
|
+
loop,
|
|
91
|
+
{ reason: "stopped", detail: "the task finished or moved" },
|
|
92
|
+
{ taskName: task?.name ?? "the task", now }
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (cycleInFlight(loop.id)) return;
|
|
97
|
+
const hold = deps.hold(loop, task);
|
|
98
|
+
if (hold) return;
|
|
99
|
+
const fact = facts.find((f) => f.id === loop.sessionId);
|
|
100
|
+
const ctx = { loop, task, facts: fact, deps };
|
|
101
|
+
const last = cyclesOf(loop.id, { limit: 1 })[0];
|
|
102
|
+
|
|
103
|
+
if (last?.state === "claimed") {
|
|
104
|
+
await processCycle(ctx, last);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (last?.state === "counting") {
|
|
108
|
+
if (last.sendAt !== null && last.sendAt <= now) {
|
|
109
|
+
if (await deps.coderRunning(task))
|
|
110
|
+
moveCycle(loop.id, last.n, ["counting"], {
|
|
111
|
+
state: "superseded",
|
|
112
|
+
endedAt: now,
|
|
113
|
+
});
|
|
114
|
+
else await deliverCycle(ctx, last.n);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (last?.state === "sending") {
|
|
119
|
+
// A restart between claiming the send and finishing it: the message
|
|
120
|
+
// goes by a fixed id, so sending again is safe.
|
|
121
|
+
moveCycle(loop.id, last.n, ["sending"], { state: "counting", sendAt: now });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const limit = limitStop({
|
|
125
|
+
limits: loop,
|
|
126
|
+
startedAt: loop.startedAt,
|
|
127
|
+
spentUsd: deps.spent(loop),
|
|
128
|
+
now,
|
|
129
|
+
cycleCount: cycleCount(loop.id),
|
|
130
|
+
});
|
|
131
|
+
if (limit) {
|
|
132
|
+
finishLoop(loop, limit, { taskName: task.name, now });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!fact) return;
|
|
136
|
+
const why = trigger(loop, fact, last, now);
|
|
137
|
+
if (!why) return;
|
|
138
|
+
sawRunning.delete(loop.id);
|
|
139
|
+
if (loop.runNow) setRunNow(loop.id, false);
|
|
140
|
+
await startCycle(loop.id, why, fact, deps);
|
|
141
|
+
}
|