@saadnvd1/agent-os 0.3.637 → 0.3.644
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/bus/branches/route.ts +25 -0
- package/app/api/bus/resurrect/route.test.ts +3 -6
- package/app/api/sessions/[id]/pr/route.ts +3 -3
- package/app/api/sessions/[id]/setup/route.ts +3 -3
- package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
- package/app/api/tasks/[id]/arm/route.ts +6 -5
- package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
- package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
- package/app/api/tasks/[id]/checks/route.ts +1 -1
- package/app/api/tasks/[id]/diff/route.ts +1 -1
- package/app/api/tasks/[id]/done/route.ts +2 -2
- package/app/api/tasks/[id]/drop/route.ts +6 -2
- package/app/api/tasks/[id]/export/route.ts +3 -5
- package/app/api/tasks/[id]/merge/route.ts +3 -3
- package/app/api/tasks/[id]/message/route.ts +2 -2
- package/app/api/tasks/[id]/model/route.ts +5 -1
- package/app/api/tasks/[id]/move/route.ts +5 -1
- package/app/api/tasks/[id]/moved/route.ts +6 -8
- package/app/api/tasks/[id]/plan/route.ts +4 -6
- package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
- package/app/api/tasks/[id]/priority/route.ts +4 -1
- package/app/api/tasks/[id]/resume/route.ts +3 -4
- package/app/api/tasks/[id]/stop/route.ts +2 -2
- package/app/api/tasks/route.ts +1 -1
- package/app/api/worktree-sweep/route.test.ts +40 -0
- package/app/api/worktree-sweep/route.ts +31 -0
- package/app/api/worktree-sweep/run/route.ts +26 -0
- package/components/Health/HealthPanel.tsx +4 -1
- package/components/Health/SweepRuns.tsx +125 -0
- package/components/Merge/GlobalMergeSection.tsx +2 -0
- package/components/Merge/ProjectMergeSection.tsx +2 -0
- package/components/Merge/WorktreeSweepSection.tsx +125 -0
- package/components/Settings/NoApprovalsSection.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/data/worktree-sweep/index.ts +56 -0
- package/lib/approvals/enable.ts +1 -1
- package/lib/away/report.ts +5 -5
- package/lib/away/run.ts +2 -2
- package/lib/bookmarks/store.ts +3 -3
- package/lib/bus/held-reply.ts +2 -2
- package/lib/bus/index.ts +3 -3
- package/lib/chat/compact-context.ts +1 -1
- package/lib/chat/hold.ts +3 -3
- package/lib/chat/refresh.ts +1 -1
- package/lib/chat/registry.ts +3 -3
- package/lib/chat/runner.ts +1 -1
- package/lib/chat/worker/main.ts +1 -1
- package/lib/db/backup.ts +49 -0
- package/lib/db/index.ts +5 -1
- package/lib/db/lumifyhub.ts +8 -9
- package/lib/db/machine-scope.ts +12 -0
- package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
- package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
- package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
- package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
- package/lib/db/migrations/README.md +11 -0
- package/lib/db/queries.ts +16 -31
- package/lib/db/session-parts/columns.ts +186 -0
- package/lib/db/session-parts/fill.ts +33 -0
- package/lib/db/session-parts/index.ts +10 -0
- package/lib/db/session-parts/pauses.ts +11 -0
- package/lib/db/session-parts/placement.ts +11 -0
- package/lib/db/session-parts/plans.ts +31 -0
- package/lib/db/session-parts/prs.ts +31 -0
- package/lib/db/session-parts/rows.ts +69 -0
- package/lib/db/session-parts/tasks.ts +27 -0
- package/lib/db/session-parts/view.ts +50 -0
- package/lib/db/session-parts/write.ts +61 -0
- package/lib/done/archive.ts +2 -2
- package/lib/done/auto-archive.ts +1 -1
- package/lib/done/bulk.ts +1 -1
- package/lib/done/index.ts +8 -14
- package/lib/done/remote.ts +9 -20
- package/lib/health/evidence.ts +1 -1
- package/lib/health/facts.ts +1 -1
- package/lib/health/pieces.ts +1 -1
- package/lib/hosts/peer-actions.ts +2 -2
- package/lib/hosts/peer-mirror.ts +2 -2
- package/lib/hosts/peer-sync.ts +69 -32
- package/lib/learning-cards/input.ts +1 -1
- package/lib/learning-cards/store.ts +1 -1
- package/lib/models/plan-step.ts +10 -24
- package/lib/models/resume.ts +5 -7
- package/lib/notify/index.ts +1 -1
- package/lib/notify/push/events.ts +1 -1
- package/lib/notify/push/index.ts +1 -1
- package/lib/orchestrator/act.ts +1 -1
- package/lib/orchestrator/armed.test-helpers.ts +228 -0
- package/lib/orchestrator/armed.ts +18 -16
- package/lib/orchestrator/ask-approvals.ts +1 -1
- package/lib/orchestrator/ask-heads.ts +1 -1
- package/lib/orchestrator/ask-settle.ts +1 -1
- package/lib/orchestrator/ask-verdict.ts +111 -0
- package/lib/orchestrator/asks.ts +26 -0
- package/lib/orchestrator/brakes.ts +1 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +2 -2
- package/lib/orchestrator/escalate.ts +25 -12
- package/lib/orchestrator/external-gates.ts +1 -0
- package/lib/orchestrator/external-pr.ts +2 -2
- package/lib/orchestrator/facts.ts +4 -28
- package/lib/orchestrator/gates.ts +94 -25
- package/lib/orchestrator/held-asks.ts +8 -11
- package/lib/orchestrator/held-review.ts +8 -4
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/loop-guard.ts +145 -0
- package/lib/orchestrator/merge-approvals.ts +2 -2
- package/lib/orchestrator/overview.ts +1 -1
- package/lib/orchestrator/pr-wake.ts +3 -5
- package/lib/orchestrator/questions.ts +1 -1
- package/lib/orchestrator/quick-store.ts +1 -1
- package/lib/orchestrator/quick.ts +11 -8
- package/lib/orchestrator/read.ts +1 -1
- package/lib/orchestrator/rebase.ts +8 -11
- package/lib/orchestrator/reconcile.ts +2 -2
- package/lib/orchestrator/review-prompt.ts +2 -0
- package/lib/orchestrator/review.ts +14 -4
- package/lib/orchestrator/signoff.test-helpers.ts +322 -0
- package/lib/orchestrator/signoff.ts +49 -11
- package/lib/orchestrator/targets.ts +1 -1
- package/lib/orchestrator/task-state.ts +4 -2
- package/lib/orchestrator/testing.ts +11 -14
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/plans/features.ts +1 -1
- package/lib/plans/release.ts +1 -1
- package/lib/ports.ts +1 -1
- package/lib/recipes/test-fixtures.ts +2 -3
- package/lib/resurrect/find.ts +1 -1
- package/lib/resurrect/index.ts +44 -20
- package/lib/resurrect/swept.ts +61 -0
- package/lib/schedules/store.ts +1 -1
- package/lib/session-audit/sources.ts +3 -3
- package/lib/session-logs/index.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/setup-progress.ts +1 -1
- package/lib/sessions/stop.ts +1 -1
- package/lib/sessions/worktree-setup.ts +2 -1
- package/lib/simulator/index.ts +1 -1
- package/lib/simulator/task-devices.ts +1 -1
- package/lib/stacks/board.ts +1 -1
- package/lib/stacks/land.ts +1 -1
- package/lib/stacks/restack.ts +1 -1
- package/lib/stacks/start.ts +2 -2
- package/lib/stacks/testing.ts +17 -16
- package/lib/stacks/tick.ts +1 -1
- package/lib/stacks/view.ts +1 -1
- package/lib/status/incremental.ts +1 -1
- package/lib/tasks/contract/actions.ts +35 -0
- package/lib/tasks/contract/authorize.ts +82 -0
- package/lib/tasks/contract/client.ts +296 -0
- package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
- package/lib/tasks/contract/index.ts +28 -0
- package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
- package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
- package/lib/tasks/contract/routes.ts +26 -0
- package/lib/tasks/contract/start.ts +122 -0
- package/lib/tasks/contract/sync.ts +126 -0
- package/lib/tasks/contract/types.ts +41 -0
- package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
- package/lib/tasks/finish.ts +30 -23
- package/lib/tasks/held-replies.ts +7 -13
- package/lib/tasks/held.ts +14 -18
- package/lib/tasks/import.ts +19 -15
- package/lib/tasks/index.ts +34 -22
- package/lib/tasks/launch-gate.ts +1 -1
- package/lib/tasks/move-carry.ts +15 -7
- package/lib/tasks/move-flow.ts +29 -36
- package/lib/tasks/move-guard.ts +1 -1
- package/lib/tasks/move-recover.ts +6 -25
- package/lib/tasks/move-testing.ts +16 -7
- package/lib/tasks/move.ts +23 -23
- package/lib/tasks/moved-away.ts +1 -1
- package/lib/tasks/pause-hold.ts +13 -5
- package/lib/tasks/pause.ts +19 -23
- package/lib/tasks/plan-view.ts +1 -1
- package/lib/tasks/pr-activity.ts +11 -2
- package/lib/tasks/prune-branches.ts +193 -0
- package/lib/tasks/prune-scope.ts +22 -0
- package/lib/tasks/queue-placement.ts +1 -1
- package/lib/tasks/queue-rows.ts +4 -4
- package/lib/tasks/queue-start.ts +5 -3
- package/lib/tasks/queue-tick.ts +2 -2
- package/lib/tasks/queue.ts +14 -6
- package/lib/tasks/remote-branch.ts +170 -0
- package/lib/tasks/session.ts +7 -4
- package/lib/tasks/start.ts +4 -4
- package/lib/tasks/starter.ts +8 -11
- package/lib/tasks/testing.ts +13 -7
- package/lib/usage/calls.ts +1 -1
- package/lib/usage/compare.ts +1 -1
- package/lib/usage/quality.ts +1 -1
- package/lib/usage/turns.ts +1 -1
- package/lib/voice/vocab.ts +1 -1
- package/lib/worktree-sweep/commits.ts +65 -0
- package/lib/worktree-sweep/findOrphans.ts +139 -0
- package/lib/worktree-sweep/findSettled.ts +112 -0
- package/lib/worktree-sweep/index.ts +10 -0
- package/lib/worktree-sweep/judge.ts +31 -0
- package/lib/worktree-sweep/panes.ts +18 -0
- package/lib/worktree-sweep/runSweep.ts +243 -0
- package/lib/worktree-sweep/settings.ts +83 -0
- package/lib/worktree-sweep/start.ts +64 -0
- package/lib/worktree-sweep/state.ts +36 -0
- package/lib/worktree-sweep/store.ts +96 -0
- package/lib/worktree-sweep/types.ts +53 -0
- package/package.json +1 -1
- package/scripts/backfill-session-logs.ts +1 -1
- package/scripts/screenshots/seed.ts +19 -18
- package/server.ts +3 -0
- package/lib/tasks/held-routes.ts +0 -33
- package/lib/tasks/remote.ts +0 -255
|
@@ -28,6 +28,7 @@ import { review } from "./review";
|
|
|
28
28
|
import { judge, mergeJudged } from "./signoff";
|
|
29
29
|
import { workspaceTask } from "./targets";
|
|
30
30
|
import { markStatusDirty } from "../status/dirty";
|
|
31
|
+
import { setPr } from "../db/session-parts";
|
|
31
32
|
|
|
32
33
|
// Judged again at least this often, for what the fingerprint can't see.
|
|
33
34
|
export const ARMED_RECHECK_MS = 5 * 60 * 1000;
|
|
@@ -35,10 +36,18 @@ export const ARMED_RECHECK_MS = 5 * 60 * 1000;
|
|
|
35
36
|
const short = (sha: string) => sha.slice(0, 7);
|
|
36
37
|
|
|
37
38
|
export function setArmed(sessionId: string, armed: boolean): void {
|
|
38
|
-
db.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
db.transaction(() => {
|
|
40
|
+
const row = db
|
|
41
|
+
.prepare(`SELECT merge_armed_at FROM session_rows WHERE id = ?`)
|
|
42
|
+
.get(sessionId) as { merge_armed_at: string | null } | undefined;
|
|
43
|
+
setPr(db, sessionId, {
|
|
44
|
+
merge_armed_at: !armed
|
|
45
|
+
? null
|
|
46
|
+
: (row?.merge_armed_at ??
|
|
47
|
+
new Date().toISOString().slice(0, 19).replace("T", " ")),
|
|
48
|
+
merge_armed_seen: null,
|
|
49
|
+
});
|
|
50
|
+
})();
|
|
42
51
|
looked.delete(sessionId);
|
|
43
52
|
markStatusDirty(sessionId);
|
|
44
53
|
notifyStatusChanged();
|
|
@@ -57,7 +66,7 @@ export function armingWorkspace(task: Session): string | null {
|
|
|
57
66
|
function armedTasks(workspaceId: string): Session[] {
|
|
58
67
|
return db
|
|
59
68
|
.prepare(
|
|
60
|
-
`SELECT s.* FROM
|
|
69
|
+
`SELECT s.* FROM session_rows s JOIN projects p ON p.id = s.project_id
|
|
61
70
|
WHERE p.workspace_id = ? AND s.merge_armed_at IS NOT NULL
|
|
62
71
|
AND s.task_status = 'running' AND s.peer_mirror = 0
|
|
63
72
|
AND s.held_by_device IS NULL`
|
|
@@ -70,7 +79,7 @@ function armedTasks(workspaceId: string): Session[] {
|
|
|
70
79
|
function stillArmed(workspaceId: string, taskId: string): boolean {
|
|
71
80
|
if (isPaused(workspaceId)) return false;
|
|
72
81
|
const row = db
|
|
73
|
-
.prepare(`SELECT merge_armed_at FROM
|
|
82
|
+
.prepare(`SELECT merge_armed_at FROM session_rows WHERE id = ?`)
|
|
74
83
|
.get(taskId) as { merge_armed_at: string | null } | undefined;
|
|
75
84
|
return !!row?.merge_armed_at;
|
|
76
85
|
}
|
|
@@ -249,17 +258,13 @@ async function mergeOne(
|
|
|
249
258
|
const seen = `${pr.head}:${failures(verdict.text)}`;
|
|
250
259
|
if (task.merge_armed_seen === seen) return;
|
|
251
260
|
// Counted as sign_off would count it: once per head (the database keys
|
|
252
|
-
// it), and a
|
|
261
|
+
// it), and only a task stuck in a loop goes to Saad (loop-guard.ts).
|
|
253
262
|
if (!stillArmed(workspaceId, task.id)) return;
|
|
254
263
|
const counted = await deps.judge(workspaceId, task);
|
|
255
264
|
if (counted.ok) return merge(workspaceId, task, counted, deps);
|
|
256
265
|
const toSaad = holdingEscalations(task.id).length > 0;
|
|
257
266
|
if (toSaad) setArmed(task.id, false);
|
|
258
|
-
else
|
|
259
|
-
db.prepare(`UPDATE sessions SET merge_armed_seen = ? WHERE id = ?`).run(
|
|
260
|
-
seen,
|
|
261
|
-
task.id
|
|
262
|
-
);
|
|
267
|
+
else setPr(db, task.id, { merge_armed_seen: seen });
|
|
263
268
|
event(
|
|
264
269
|
workspaceId,
|
|
265
270
|
task,
|
|
@@ -283,10 +288,7 @@ async function merge(
|
|
|
283
288
|
const why = error instanceof Error ? error.message : String(error);
|
|
284
289
|
const seen = `${verdict.sha}:merge:${why}`;
|
|
285
290
|
if (task.merge_armed_seen === seen) return;
|
|
286
|
-
db.
|
|
287
|
-
seen,
|
|
288
|
-
task.id
|
|
289
|
-
);
|
|
291
|
+
setPr(db, task.id, { merge_armed_seen: seen });
|
|
290
292
|
event(
|
|
291
293
|
workspaceId,
|
|
292
294
|
task,
|
|
@@ -76,7 +76,7 @@ export function releaseInterruptedClaims(): number {
|
|
|
76
76
|
`UPDATE orchestrator_asks SET used_at = NULL
|
|
77
77
|
WHERE status = 'approved' AND used_at IS NOT NULL
|
|
78
78
|
AND COALESCE(answer, '') != 'approve (void)'
|
|
79
|
-
AND (subject IN (SELECT 'task:' || id FROM
|
|
79
|
+
AND (subject IN (SELECT 'task:' || id FROM session_rows WHERE task_status = 'running')
|
|
80
80
|
-- A PR no task owns: a merge that happened can't happen again.
|
|
81
81
|
OR subject LIKE 'pr:%')`
|
|
82
82
|
)
|
|
@@ -34,7 +34,7 @@ export async function followAskHeads(
|
|
|
34
34
|
)
|
|
35
35
|
continue;
|
|
36
36
|
const task = db
|
|
37
|
-
.prepare(`SELECT * FROM
|
|
37
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
38
38
|
.get(f!.id) as Session | undefined;
|
|
39
39
|
if (!task || hostLink(task.host_id)) continue;
|
|
40
40
|
try {
|
|
@@ -44,7 +44,7 @@ function workspaceTasks(workspaceId: string): TaskRow[] {
|
|
|
44
44
|
return db
|
|
45
45
|
.prepare(
|
|
46
46
|
`SELECT s.id, s.task_status, s.pr_status, s.pr_url, s.pr_number
|
|
47
|
-
FROM
|
|
47
|
+
FROM session_rows s JOIN projects p ON p.id = s.project_id
|
|
48
48
|
WHERE p.workspace_id = ? AND s.task_status IS NOT NULL`
|
|
49
49
|
)
|
|
50
50
|
.all(workspaceId) as TaskRow[];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// A PR the loop guard sent to Saad is still judged: the commit his ask
|
|
2
|
+
// names gets the independent review it would have had, and the ask shows
|
|
3
|
+
// CI and that review, so he never approves blind. His approval still
|
|
4
|
+
// merges that commit once (signoff.ts); the verdict informs it, nothing more.
|
|
5
|
+
|
|
6
|
+
import { getCheck, type CheckRow } from "./checks";
|
|
7
|
+
import { EXTERNAL_PREFIX, openAsks, setAskVerdict, type AskRow } from "./asks";
|
|
8
|
+
import { reviewTarget } from "./external-gates";
|
|
9
|
+
import type { SessionFacts } from "./facts";
|
|
10
|
+
import { holdingEscalations } from "./gates";
|
|
11
|
+
import { APPROVAL_GATES } from "./merge-approvals";
|
|
12
|
+
import { untrusted } from "./untrusted";
|
|
13
|
+
import type { TaskPR } from "../tasks/state";
|
|
14
|
+
|
|
15
|
+
export interface AskVerdictDeps {
|
|
16
|
+
review: (workspaceId: string, ref: string) => Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaults: AskVerdictDeps = {
|
|
20
|
+
review: (w, ref) => reviewTarget(w, ref),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Asks whose review was started here, so one that couldn't start (the head
|
|
24
|
+
// moved, gh failed) isn't retried every pass.
|
|
25
|
+
const started = new Set<string>();
|
|
26
|
+
const STARTED_MAX = 1000;
|
|
27
|
+
|
|
28
|
+
const workId = (subject: string) =>
|
|
29
|
+
subject.startsWith(EXTERNAL_PREFIX)
|
|
30
|
+
? subject
|
|
31
|
+
: subject.startsWith("task:")
|
|
32
|
+
? subject.slice("task:".length)
|
|
33
|
+
: null;
|
|
34
|
+
|
|
35
|
+
// Held by the loop guard, not only by an approval Saad asked to give.
|
|
36
|
+
const loopHeld = (id: string) =>
|
|
37
|
+
holdingEscalations(id).some((h) => !APPROVAL_GATES.includes(h.gate));
|
|
38
|
+
|
|
39
|
+
export async function followAskVerdicts(
|
|
40
|
+
workspaceId: string,
|
|
41
|
+
facts: SessionFacts[],
|
|
42
|
+
deps: AskVerdictDeps = defaults
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
for (const ask of openAsks(workspaceId)) {
|
|
45
|
+
const id = workId(ask.subject);
|
|
46
|
+
if (ask.kind !== "gate" || !ask.sha || !id || !loopHeld(id)) continue;
|
|
47
|
+
try {
|
|
48
|
+
await follow(
|
|
49
|
+
workspaceId,
|
|
50
|
+
ask as AskRow & { sha: string },
|
|
51
|
+
id,
|
|
52
|
+
facts,
|
|
53
|
+
deps
|
|
54
|
+
);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`Judging ask ${ask.id}'s commit:`, error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function follow(
|
|
62
|
+
workspaceId: string,
|
|
63
|
+
ask: AskRow & { sha: string },
|
|
64
|
+
id: string,
|
|
65
|
+
facts: SessionFacts[],
|
|
66
|
+
deps: AskVerdictDeps
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const key = `${ask.id}:${ask.sha}`;
|
|
69
|
+
if (!getCheck(id, ask.sha, "review") && !started.has(key)) {
|
|
70
|
+
if (started.size >= STARTED_MAX) started.clear();
|
|
71
|
+
started.add(key);
|
|
72
|
+
await deps.review(workspaceId, id);
|
|
73
|
+
}
|
|
74
|
+
const pr = facts.find((f) => f.id === id)?.task?.pr;
|
|
75
|
+
setAskVerdict(
|
|
76
|
+
ask.id,
|
|
77
|
+
ask.sha,
|
|
78
|
+
verdictText(
|
|
79
|
+
pr?.head === ask.sha ? pr : null,
|
|
80
|
+
getCheck(id, ask.sha, "review")
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const REVIEW_MAX = 600;
|
|
86
|
+
|
|
87
|
+
export function verdictText(
|
|
88
|
+
pr: Pick<TaskPR, "checks" | "failing"> | null,
|
|
89
|
+
review: CheckRow | null
|
|
90
|
+
): string {
|
|
91
|
+
const ci = !pr
|
|
92
|
+
? null
|
|
93
|
+
: pr.checks === "pass"
|
|
94
|
+
? "CI passed."
|
|
95
|
+
: pr.checks === "fail"
|
|
96
|
+
? `CI failed${pr.failing ? ` (${untrusted("CI", pr.failing)})` : ""}.`
|
|
97
|
+
: pr.checks === "pending"
|
|
98
|
+
? "CI is running."
|
|
99
|
+
: "No CI checks.";
|
|
100
|
+
const detail = (review?.detail ?? "").slice(0, REVIEW_MAX);
|
|
101
|
+
const said = !review
|
|
102
|
+
? "Independent review: not started yet."
|
|
103
|
+
: review.status === "running"
|
|
104
|
+
? "Independent review: running."
|
|
105
|
+
: review.status === "error"
|
|
106
|
+
? `Independent review: couldn't run (${detail}).`
|
|
107
|
+
: review.status === "pass"
|
|
108
|
+
? "Independent review: passed."
|
|
109
|
+
: `Independent review: blocking findings.\n${untrusted("reviewer", detail)}`;
|
|
110
|
+
return [ci, said].filter(Boolean).join(" ");
|
|
111
|
+
}
|
package/lib/orchestrator/asks.ts
CHANGED
|
@@ -397,3 +397,29 @@ export function moveAskHead(
|
|
|
397
397
|
.run(to, detail, id, from).changes === 1
|
|
398
398
|
);
|
|
399
399
|
}
|
|
400
|
+
|
|
401
|
+
// The gates' verdict on the commit an ask names (ask-verdict.ts), kept as
|
|
402
|
+
// the detail's last section and rewritten as it changes. Only while the ask
|
|
403
|
+
// still names `sha`. Returns whether it changed.
|
|
404
|
+
const VERDICT_AT = /\n?Gates at [0-9a-f]{7}: /;
|
|
405
|
+
export const VERDICT_MAX = 800;
|
|
406
|
+
|
|
407
|
+
export function setAskVerdict(id: number, sha: string, text: string): boolean {
|
|
408
|
+
const ask = db
|
|
409
|
+
.prepare(`SELECT detail FROM orchestrator_asks WHERE id = ?`)
|
|
410
|
+
.get(id) as { detail: string } | undefined;
|
|
411
|
+
if (!ask) return false;
|
|
412
|
+
const section = `Gates at ${sha.slice(0, 7)}: ${text}`.slice(0, VERDICT_MAX);
|
|
413
|
+
const rest = ask.detail.split(VERDICT_AT)[0];
|
|
414
|
+
const detail =
|
|
415
|
+
`${rest.slice(0, Math.max(0, MAX_DETAIL - section.length - 1))}\n${section}`.trim();
|
|
416
|
+
if (detail === ask.detail) return false;
|
|
417
|
+
return (
|
|
418
|
+
db
|
|
419
|
+
.prepare(
|
|
420
|
+
`UPDATE orchestrator_asks SET detail = ?
|
|
421
|
+
WHERE id = ? AND status = 'open' AND sha = ?`
|
|
422
|
+
)
|
|
423
|
+
.run(detail, id, sha).changes === 1
|
|
424
|
+
);
|
|
425
|
+
}
|
|
@@ -81,7 +81,7 @@ export async function runningCount(
|
|
|
81
81
|
// minutes, here or there: it holds its slot.
|
|
82
82
|
const moving = db
|
|
83
83
|
.prepare(
|
|
84
|
-
`SELECT s.id FROM
|
|
84
|
+
`SELECT s.id FROM session_rows s JOIN projects p ON p.id = s.project_id
|
|
85
85
|
WHERE p.workspace_id = ? AND s.task_status = 'moving' AND s.peer_mirror = 0`
|
|
86
86
|
)
|
|
87
87
|
.all(workspaceId) as { id: string }[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LOOP_GUARD_COMMITS } from "./loop-guard";
|
|
1
2
|
import { db, type Project, type Session } from "../db";
|
|
2
3
|
import { getDefaultBranch } from "../git";
|
|
3
4
|
import { getWorkspace } from "../workspaces";
|
|
@@ -78,7 +79,7 @@ When the base moves and a task's PR conflicts with it, AgentOS rebases it onto t
|
|
|
78
79
|
|
|
79
80
|
Tasks are woken by AgentOS itself when a check fails on their PR's head (with the failing step and the log around the error) and when a collaborator reviews or comments on their PR, and you get one "task X woken: ..." line for each. Don't message a task about the same failure or review; act only if it doesn't fix it.
|
|
80
81
|
|
|
81
|
-
"Not yet" (CI running or settling, no review of this commit yet) is not a failure: wait for the event and try again. A failure counts against the task once per commit, whichever path judges that commit and however often (a review that blocks counts when its verdict arrives); the same gate failing
|
|
82
|
+
"Not yet" (CI running or settling, no review of this commit yet) is not a failure: wait for the event and try again. A failure counts against the task once per commit, whichever path judges that commit and however often (a review that blocks counts when its verdict arrives). A failure is the task's to fix: have it fix the finding and push; every new head is judged afresh (CI, a new review, scope), and an armed task stays armed and merges by itself once a head passes. Only a loop goes to Saad (the loop guard): the same gate failing on ${LOOP_GUARD_COMMITS} commits in a row, or failing the same way (same CI job and failing tests, same blocking findings) on two commits in a row. Then it's with him, with a note in your chat: from then on only he merges or drops that task, and his ask shows CI and a fresh review of the commit he'd approve. Don't retry it; say so in your chat and move on. ${mergeApprovalsOn() ? APPROVALS_ON : APPROVALS_OFF} \`land\` merges a whole stack only if every open item passes the same gates, and judges each one again at its own head right before merging it.
|
|
82
83
|
|
|
83
84
|
${noApprovalsOn() ? NO_APPROVALS : ""}## Brakes
|
|
84
85
|
|
|
@@ -167,7 +167,7 @@ function tasksSection(
|
|
|
167
167
|
.prepare(
|
|
168
168
|
`SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_status, s.view,
|
|
169
169
|
s.merge_armed_at AS armed_at, s.pause_state, s.paused_until, s.pause_note, s.placed_note
|
|
170
|
-
FROM
|
|
170
|
+
FROM session_rows s JOIN projects p ON p.id = s.project_id
|
|
171
171
|
WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
|
|
172
172
|
AND s.task_status = 'running'
|
|
173
173
|
ORDER BY s.created_at, s.id`
|
|
@@ -223,7 +223,7 @@ function queueSection(workspaceId: string): Section {
|
|
|
223
223
|
AND b.held_queued = 1 AND b.delivered_at IS NULL AND b.read_at IS NULL) AS held,
|
|
224
224
|
p.name AS project, a.name AS after_name
|
|
225
225
|
FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
226
|
-
LEFT JOIN
|
|
226
|
+
LEFT JOIN session_rows a ON a.id = q.after_task
|
|
227
227
|
WHERE p.workspace_id = ? AND q.status IN ('queued', 'starting', 'failed')
|
|
228
228
|
ORDER BY q.position`
|
|
229
229
|
)
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { AskRefused, EXTERNAL_PREFIX, raiseAsk, workSubject } from "./asks";
|
|
6
6
|
import {
|
|
7
|
-
failedTwiceWhy,
|
|
8
7
|
failureOf,
|
|
9
8
|
holdAsk,
|
|
10
9
|
markEscalated,
|
|
11
10
|
recordFailure,
|
|
12
11
|
type GateOutcome,
|
|
13
12
|
} from "./gates";
|
|
13
|
+
import { LOOP_GUARD_COMMITS, stuckBy, stuckWhy } from "./loop-guard";
|
|
14
14
|
import { addNote } from "./notes";
|
|
15
15
|
|
|
16
16
|
// Puts the ask on Saad's list (or notes why it couldn't be).
|
|
@@ -61,7 +61,7 @@ export function escalate(
|
|
|
61
61
|
): string {
|
|
62
62
|
if (!failureOf(task.id, gate))
|
|
63
63
|
recordFailure(workspaceId, task.id, gate, why, sha);
|
|
64
|
-
markEscalated(task.id, gate);
|
|
64
|
+
markEscalated(task.id, gate, why);
|
|
65
65
|
if (opts.hold) {
|
|
66
66
|
holdAsk(task.id, gate);
|
|
67
67
|
return `Escalated to Saad: ${why}. His ask goes up once ${task.name}'s agent is idle and CI has settled on its latest head, so it names the commit he'd merge; his answer reaches you as an event. Don't retry; say so in your chat.`;
|
|
@@ -70,23 +70,36 @@ export function escalate(
|
|
|
70
70
|
return `Escalated to Saad: ${why}. It's on his asks list; his answer reaches you as an event. Don't retry; say so in your chat.`;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// Counts a gate's failure on this head against the task
|
|
74
|
-
//
|
|
75
|
-
// its agent is done
|
|
76
|
-
// again, from any path, isn't another
|
|
73
|
+
// Counts a gate's failure on this head against the task. A failure is the
|
|
74
|
+
// task's to fix, head after head; only a loop goes to Saad (loop-guard.ts):
|
|
75
|
+
// a task's ask held until its agent is done, a PR no task owns has no agent
|
|
76
|
+
// to wait for. The same head judged again, from any path, isn't another
|
|
77
|
+
// failure.
|
|
77
78
|
export function countFailure(
|
|
78
79
|
workspaceId: string,
|
|
79
80
|
task: { id: string; name: string },
|
|
80
81
|
o: GateOutcome,
|
|
81
82
|
url: string,
|
|
82
83
|
sha: string,
|
|
83
|
-
opts: { hold?: boolean } = {}
|
|
84
|
+
opts: { hold?: boolean; signature?: string | null } = {}
|
|
84
85
|
): { line: string; escalated: boolean } {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
recordFailure(
|
|
87
|
+
workspaceId,
|
|
88
|
+
task.id,
|
|
89
|
+
o.gate,
|
|
90
|
+
o.reason ?? "",
|
|
91
|
+
sha,
|
|
92
|
+
opts.signature ?? null
|
|
93
|
+
);
|
|
94
|
+
const row = failureOf(task.id, o.gate)!;
|
|
95
|
+
const nth =
|
|
96
|
+
row.streak <= 1
|
|
97
|
+
? "first"
|
|
98
|
+
: `again, ${row.streak} of ${LOOP_GUARD_COMMITS} in a row`;
|
|
99
|
+
const line = `- ${o.gate} failed (${nth}): ${o.reason}`;
|
|
100
|
+
const by = row.escalated_at ? null : stuckBy(row);
|
|
101
|
+
if (!by) return { line, escalated: false };
|
|
102
|
+
const why = stuckWhy(task.id, row, by);
|
|
90
103
|
return {
|
|
91
104
|
line: `${line}\n ${escalate(workspaceId, task, o.gate, why, url, sha, { hold: opts.hold ?? !task.id.startsWith(EXTERNAL_PREFIX) })}`,
|
|
92
105
|
escalated: true,
|
|
@@ -94,7 +94,7 @@ const unique = (repos: WorkspaceRepo[]) => [
|
|
|
94
94
|
function tasksWithPR(workspaceId: string, ref: PRRef): WorkspaceTask[] {
|
|
95
95
|
const rows = db
|
|
96
96
|
.prepare(
|
|
97
|
-
`SELECT s.*, p.name AS project_name FROM
|
|
97
|
+
`SELECT s.*, p.name AS project_name FROM session_rows s
|
|
98
98
|
JOIN projects p ON p.id = s.project_id
|
|
99
99
|
WHERE p.workspace_id = ? AND s.pr_number = ? AND s.task_status IS NOT NULL
|
|
100
100
|
ORDER BY s.created_at DESC`
|
|
@@ -125,7 +125,7 @@ async function ownerOf(
|
|
|
125
125
|
.prepare(
|
|
126
126
|
`SELECT s.*, p.name AS project_name, p.workspace_id AS owner_workspace,
|
|
127
127
|
p.working_directory AS project_dir, p.host_id AS project_host
|
|
128
|
-
FROM
|
|
128
|
+
FROM session_rows s JOIN projects p ON p.id = s.project_id
|
|
129
129
|
WHERE s.task_status IS NOT NULL
|
|
130
130
|
AND (s.pr_number = ? OR (s.branch_name = ? AND s.task_status IN ('running', 'moving', 'moved')))
|
|
131
131
|
ORDER BY s.created_at DESC`
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { db, type Session, type StackItemStatus } from "../db";
|
|
9
9
|
import { statusDetector } from "../status-detector";
|
|
10
|
-
import {
|
|
10
|
+
import { hostLink } from "../hosts/remote-api";
|
|
11
11
|
import { peerStatus } from "../hosts/peer-sessions";
|
|
12
12
|
import { chatCompacting, chatState } from "../chat/runner";
|
|
13
13
|
import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
|
|
@@ -18,14 +18,13 @@ import { sessionRowInfo } from "../session-meta";
|
|
|
18
18
|
import { taskView, type TaskPR, type TaskState } from "../tasks";
|
|
19
19
|
import { prFor, storedPR } from "../tasks/session";
|
|
20
20
|
import { lastActiveOf } from "../tasks/last-active";
|
|
21
|
-
import
|
|
21
|
+
import * as taskClient from "../tasks/contract/client";
|
|
22
22
|
import { deriveTaskState } from "../tasks";
|
|
23
23
|
import { waitingState } from "./task-state";
|
|
24
24
|
import { commitTime, repoOf } from "./repo";
|
|
25
25
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
26
26
|
import { repoHasCI } from "./repo-ci";
|
|
27
27
|
import { markStatusDirty } from "../status/dirty";
|
|
28
|
-
import { queueEvent } from "./events";
|
|
29
28
|
|
|
30
29
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
31
30
|
|
|
@@ -85,7 +84,7 @@ export function workspaceSessions(
|
|
|
85
84
|
): (Session & { project_name: string })[] {
|
|
86
85
|
return db
|
|
87
86
|
.prepare(
|
|
88
|
-
`SELECT s.*, p.name AS project_name FROM
|
|
87
|
+
`SELECT s.*, p.name AS project_name FROM session_rows s
|
|
89
88
|
JOIN projects p ON p.id = s.project_id
|
|
90
89
|
WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
|
|
91
90
|
-- A linked machine's own sessions are its orchestrator's to run.
|
|
@@ -271,7 +270,6 @@ async function remoteTaskFacts(
|
|
|
271
270
|
prFor(s).catch(() => null),
|
|
272
271
|
waitingState(s),
|
|
273
272
|
]);
|
|
274
|
-
await announceRemotePlan(workspaceId, s);
|
|
275
273
|
const status = peerStatus(s.host_id, s.id) ?? "running";
|
|
276
274
|
const state = deriveTaskState({
|
|
277
275
|
taskStatus: "running",
|
|
@@ -288,34 +286,12 @@ async function remoteTaskFacts(
|
|
|
288
286
|
};
|
|
289
287
|
}
|
|
290
288
|
|
|
291
|
-
// The plan-written line a held task stored on its machine (plan-step.ts),
|
|
292
|
-
// once per distinct plan: the mirror keeps the last one it told about.
|
|
293
|
-
async function announceRemotePlan(
|
|
294
|
-
workspaceId: string,
|
|
295
|
-
s: Session
|
|
296
|
-
): Promise<void> {
|
|
297
|
-
const link = hostLink(s.host_id);
|
|
298
|
-
if (!link) return;
|
|
299
|
-
const view = await hostTasks(link)
|
|
300
|
-
.then(({ tasks }) => tasks.find((t) => t?.id === s.id))
|
|
301
|
-
.catch(() => undefined);
|
|
302
|
-
const note =
|
|
303
|
-
typeof view?.planNote === "string"
|
|
304
|
-
? cleanRemoteText(view.planNote).slice(0, 2000)
|
|
305
|
-
: "";
|
|
306
|
-
if (!note || note === s.plan_note) return;
|
|
307
|
-
db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(note, s.id);
|
|
308
|
-
queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
289
|
// A task on another machine is as active as that machine says; one it can't
|
|
312
290
|
// reach is taken as active just now, so nothing reads it as abandoned.
|
|
313
291
|
async function lastActiveFor(s: Session): Promise<number> {
|
|
314
292
|
const link = hostLink(s.host_id);
|
|
315
293
|
if (!link) return lastActiveOf(s);
|
|
316
|
-
const view = await
|
|
317
|
-
.then(({ tasks }) => tasks.find((t) => t?.id === s.id))
|
|
318
|
-
.catch(() => undefined);
|
|
294
|
+
const view = await taskClient.view(link, s.id).catch(() => undefined);
|
|
319
295
|
return typeof view?.lastActiveAt === "number"
|
|
320
296
|
? view.lastActiveAt
|
|
321
297
|
: Date.now();
|
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
* The merge gates, ported from dispatch orchestrate. Each one passes, waits
|
|
3
3
|
* (not ready yet: CI running or settling, no review of this commit yet),
|
|
4
4
|
* fails, or goes straight to Saad (the repository has no CI at all, never
|
|
5
|
-
* just a head with no checks). Only a failure counts
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* head
|
|
9
|
-
*
|
|
10
|
-
* again is never another failure. A pass doesn't reset the count, so a gate
|
|
11
|
-
* that fails on A, passes on B and fails on C has failed twice.
|
|
5
|
+
* just a head with no checks). Only a failure counts against the task, once
|
|
6
|
+
* per task, gate and head commit, whichever path judged it (the armed
|
|
7
|
+
* watcher, CI finishing, sign_off, done, land, a review's verdict arriving):
|
|
8
|
+
* judging the same head again is never another failure. Every new head is
|
|
9
|
+
* judged afresh; only a task stuck in a loop goes to Saad (loop-guard.ts).
|
|
12
10
|
*/
|
|
13
11
|
|
|
14
12
|
import { db } from "../db";
|
|
@@ -38,6 +36,8 @@ export interface GateInput {
|
|
|
38
36
|
sha: string;
|
|
39
37
|
checks: ChecksVerdict;
|
|
40
38
|
failing?: string | null;
|
|
39
|
+
// The failing check's details page, for its log (loop-guard.ts).
|
|
40
|
+
failingUrl?: string | null;
|
|
41
41
|
// Seconds until CI on this commit counts as settled (tasks/task-state).
|
|
42
42
|
settleIn: number;
|
|
43
43
|
// GitHub's word on merging the PR into its base, and that base's name.
|
|
@@ -217,22 +217,31 @@ export interface FailureRow {
|
|
|
217
217
|
last_sha: string | null;
|
|
218
218
|
// Set while the escalation's ask waits for the agent to finish (held-asks.ts).
|
|
219
219
|
ask_held_at: string | null;
|
|
220
|
+
// Commits in a row this gate failed on, since it last passed.
|
|
221
|
+
streak: number;
|
|
222
|
+
last_signature: string | null;
|
|
223
|
+
// 1 when the last failure's signature equals the one before it.
|
|
224
|
+
repeated: number;
|
|
225
|
+
// Why it went to Saad, as his ask says it.
|
|
226
|
+
escalated_why: string | null;
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
// Counts one failure; returns the count now. With `sha`, a failure is
|
|
223
230
|
// counted once per commit: the (task, gate, sha) key in
|
|
224
231
|
// orchestrator_gate_failure_commits decides, so neither a restart nor two
|
|
225
|
-
// evaluations at once count the same head twice.
|
|
232
|
+
// evaluations at once count the same head twice. `signature` is what the
|
|
233
|
+
// loop guard compares between consecutive failures (loop-guard.ts).
|
|
226
234
|
export function recordFailure(
|
|
227
235
|
workspaceId: string,
|
|
228
236
|
sessionId: string,
|
|
229
237
|
gate: string,
|
|
230
238
|
reason: string,
|
|
231
|
-
sha: string | null = null
|
|
239
|
+
sha: string | null = null,
|
|
240
|
+
signature: string | null = null
|
|
232
241
|
): number {
|
|
233
242
|
return db
|
|
234
243
|
.transaction(countOnce)
|
|
235
|
-
.immediate(workspaceId, sessionId, gate, reason, sha);
|
|
244
|
+
.immediate(workspaceId, sessionId, gate, reason, sha, signature);
|
|
236
245
|
}
|
|
237
246
|
|
|
238
247
|
function countOnce(
|
|
@@ -240,28 +249,87 @@ function countOnce(
|
|
|
240
249
|
sessionId: string,
|
|
241
250
|
gate: string,
|
|
242
251
|
reason: string,
|
|
243
|
-
sha: string | null
|
|
252
|
+
sha: string | null,
|
|
253
|
+
signature: string | null
|
|
244
254
|
): number {
|
|
245
255
|
const fresh =
|
|
246
256
|
sha === null ||
|
|
247
257
|
db
|
|
248
258
|
.prepare(
|
|
249
|
-
`INSERT OR IGNORE INTO orchestrator_gate_failure_commits (session_id, gate, sha, workspace_id, reason)
|
|
250
|
-
VALUES (?, ?, ?, ?, ?)`
|
|
259
|
+
`INSERT OR IGNORE INTO orchestrator_gate_failure_commits (session_id, gate, sha, workspace_id, reason, signature)
|
|
260
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
251
261
|
)
|
|
252
|
-
.run(sessionId, gate, sha, workspaceId, reason).changes > 0;
|
|
262
|
+
.run(sessionId, gate, sha, workspaceId, reason, signature).changes > 0;
|
|
263
|
+
// Every SET reads the row as it was before this update.
|
|
253
264
|
db.prepare(
|
|
254
|
-
`INSERT INTO orchestrator_gate_failures (session_id, gate, workspace_id, count, last_reason, last_sha)
|
|
255
|
-
VALUES (
|
|
265
|
+
`INSERT INTO orchestrator_gate_failures (session_id, gate, workspace_id, count, last_reason, last_sha, streak, last_signature, repeated)
|
|
266
|
+
VALUES (@session, @gate, @workspace, 1, @reason, @sha, 1, @signature, 0)
|
|
256
267
|
ON CONFLICT(session_id, gate) DO UPDATE SET
|
|
257
|
-
count = count +
|
|
268
|
+
count = count + @fresh,
|
|
258
269
|
last_reason = excluded.last_reason,
|
|
259
|
-
last_sha = excluded.last_sha
|
|
260
|
-
|
|
270
|
+
last_sha = excluded.last_sha,
|
|
271
|
+
streak = streak + @fresh,
|
|
272
|
+
repeated = CASE WHEN @fresh = 1
|
|
273
|
+
THEN (streak > 0 AND excluded.last_signature IS NOT NULL AND last_signature IS excluded.last_signature)
|
|
274
|
+
ELSE repeated END,
|
|
275
|
+
last_signature = CASE WHEN @fresh = 1 THEN excluded.last_signature ELSE last_signature END`
|
|
276
|
+
).run({
|
|
277
|
+
session: sessionId,
|
|
278
|
+
gate,
|
|
279
|
+
workspace: workspaceId,
|
|
280
|
+
reason,
|
|
281
|
+
sha,
|
|
282
|
+
signature,
|
|
283
|
+
fresh: fresh ? 1 : 0,
|
|
284
|
+
});
|
|
261
285
|
markStatusDirty(sessionId);
|
|
262
286
|
return failureOf(sessionId, gate)!.count;
|
|
263
287
|
}
|
|
264
288
|
|
|
289
|
+
// The gate passed at a head: the next failure starts a new streak. An
|
|
290
|
+
// escalated gate keeps its record; Saad has it.
|
|
291
|
+
export function recordPass(sessionId: string, gate: string): void {
|
|
292
|
+
db.prepare(
|
|
293
|
+
`UPDATE orchestrator_gate_failures SET streak = 0, last_signature = NULL, repeated = 0
|
|
294
|
+
WHERE session_id = ? AND gate = ? AND escalated_at IS NULL AND streak > 0`
|
|
295
|
+
).run(sessionId, gate);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Whether this head's failure of the gate is already counted.
|
|
299
|
+
export function failureCounted(
|
|
300
|
+
sessionId: string,
|
|
301
|
+
gate: string,
|
|
302
|
+
sha: string
|
|
303
|
+
): boolean {
|
|
304
|
+
return !!db
|
|
305
|
+
.prepare(
|
|
306
|
+
`SELECT 1 FROM orchestrator_gate_failure_commits WHERE session_id = ? AND gate = ? AND sha = ?`
|
|
307
|
+
)
|
|
308
|
+
.get(sessionId, gate, sha);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface CommitFailure {
|
|
312
|
+
sha: string;
|
|
313
|
+
reason: string | null;
|
|
314
|
+
signature: string | null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The gate's latest `n` failed commits, oldest first.
|
|
318
|
+
export function lastFailures(
|
|
319
|
+
sessionId: string,
|
|
320
|
+
gate: string,
|
|
321
|
+
n: number
|
|
322
|
+
): CommitFailure[] {
|
|
323
|
+
return (
|
|
324
|
+
db
|
|
325
|
+
.prepare(
|
|
326
|
+
`SELECT sha, reason, signature FROM orchestrator_gate_failure_commits
|
|
327
|
+
WHERE session_id = ? AND gate = ? ORDER BY rowid DESC LIMIT ?`
|
|
328
|
+
)
|
|
329
|
+
.all(sessionId, gate, n) as CommitFailure[]
|
|
330
|
+
).reverse();
|
|
331
|
+
}
|
|
332
|
+
|
|
265
333
|
export function failureOf(sessionId: string, gate: string): FailureRow | null {
|
|
266
334
|
return (
|
|
267
335
|
(db
|
|
@@ -272,10 +340,14 @@ export function failureOf(sessionId: string, gate: string): FailureRow | null {
|
|
|
272
340
|
);
|
|
273
341
|
}
|
|
274
342
|
|
|
275
|
-
export function markEscalated(
|
|
343
|
+
export function markEscalated(
|
|
344
|
+
sessionId: string,
|
|
345
|
+
gate: string,
|
|
346
|
+
why: string | null = null
|
|
347
|
+
): void {
|
|
276
348
|
db.prepare(
|
|
277
|
-
`UPDATE orchestrator_gate_failures SET escalated_at = datetime('now') WHERE session_id = ? AND gate = ?`
|
|
278
|
-
).run(sessionId, gate);
|
|
349
|
+
`UPDATE orchestrator_gate_failures SET escalated_at = datetime('now'), escalated_why = ? WHERE session_id = ? AND gate = ?`
|
|
350
|
+
).run(why, sessionId, gate);
|
|
279
351
|
markStatusDirty(sessionId);
|
|
280
352
|
}
|
|
281
353
|
|
|
@@ -300,9 +372,6 @@ export function heldAskFailures(workspaceId: string): FailureRow[] {
|
|
|
300
372
|
.all(workspaceId) as FailureRow[];
|
|
301
373
|
}
|
|
302
374
|
|
|
303
|
-
export const failedTwiceWhy = (gate: string, reason: string) =>
|
|
304
|
-
`the ${gate} gate failed on two different commits: ${reason}`;
|
|
305
|
-
|
|
306
375
|
export function escalations(sessionId: string): FailureRow[] {
|
|
307
376
|
return db
|
|
308
377
|
.prepare(
|