@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.
Files changed (211) hide show
  1. package/app/api/bus/branches/route.ts +25 -0
  2. package/app/api/bus/resurrect/route.test.ts +3 -6
  3. package/app/api/sessions/[id]/pr/route.ts +3 -3
  4. package/app/api/sessions/[id]/setup/route.ts +3 -3
  5. package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
  6. package/app/api/tasks/[id]/arm/route.ts +6 -5
  7. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  8. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  9. package/app/api/tasks/[id]/checks/route.ts +1 -1
  10. package/app/api/tasks/[id]/diff/route.ts +1 -1
  11. package/app/api/tasks/[id]/done/route.ts +2 -2
  12. package/app/api/tasks/[id]/drop/route.ts +6 -2
  13. package/app/api/tasks/[id]/export/route.ts +3 -5
  14. package/app/api/tasks/[id]/merge/route.ts +3 -3
  15. package/app/api/tasks/[id]/message/route.ts +2 -2
  16. package/app/api/tasks/[id]/model/route.ts +5 -1
  17. package/app/api/tasks/[id]/move/route.ts +5 -1
  18. package/app/api/tasks/[id]/moved/route.ts +6 -8
  19. package/app/api/tasks/[id]/plan/route.ts +4 -6
  20. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  21. package/app/api/tasks/[id]/priority/route.ts +4 -1
  22. package/app/api/tasks/[id]/resume/route.ts +3 -4
  23. package/app/api/tasks/[id]/stop/route.ts +2 -2
  24. package/app/api/tasks/route.ts +1 -1
  25. package/app/api/worktree-sweep/route.test.ts +40 -0
  26. package/app/api/worktree-sweep/route.ts +31 -0
  27. package/app/api/worktree-sweep/run/route.ts +26 -0
  28. package/components/Health/HealthPanel.tsx +4 -1
  29. package/components/Health/SweepRuns.tsx +125 -0
  30. package/components/Merge/GlobalMergeSection.tsx +2 -0
  31. package/components/Merge/ProjectMergeSection.tsx +2 -0
  32. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  33. package/components/Settings/NoApprovalsSection.tsx +1 -1
  34. package/components/Settings/SettingsDialog.tsx +1 -1
  35. package/data/worktree-sweep/index.ts +56 -0
  36. package/lib/approvals/enable.ts +1 -1
  37. package/lib/away/report.ts +5 -5
  38. package/lib/away/run.ts +2 -2
  39. package/lib/bookmarks/store.ts +3 -3
  40. package/lib/bus/held-reply.ts +2 -2
  41. package/lib/bus/index.ts +3 -3
  42. package/lib/chat/compact-context.ts +1 -1
  43. package/lib/chat/hold.ts +3 -3
  44. package/lib/chat/refresh.ts +1 -1
  45. package/lib/chat/registry.ts +3 -3
  46. package/lib/chat/runner.ts +1 -1
  47. package/lib/chat/worker/main.ts +1 -1
  48. package/lib/db/backup.ts +49 -0
  49. package/lib/db/index.ts +5 -1
  50. package/lib/db/lumifyhub.ts +8 -9
  51. package/lib/db/machine-scope.ts +12 -0
  52. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  53. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  54. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  55. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  56. package/lib/db/migrations/README.md +11 -0
  57. package/lib/db/queries.ts +16 -31
  58. package/lib/db/session-parts/columns.ts +186 -0
  59. package/lib/db/session-parts/fill.ts +33 -0
  60. package/lib/db/session-parts/index.ts +10 -0
  61. package/lib/db/session-parts/pauses.ts +11 -0
  62. package/lib/db/session-parts/placement.ts +11 -0
  63. package/lib/db/session-parts/plans.ts +31 -0
  64. package/lib/db/session-parts/prs.ts +31 -0
  65. package/lib/db/session-parts/rows.ts +69 -0
  66. package/lib/db/session-parts/tasks.ts +27 -0
  67. package/lib/db/session-parts/view.ts +50 -0
  68. package/lib/db/session-parts/write.ts +61 -0
  69. package/lib/done/archive.ts +2 -2
  70. package/lib/done/auto-archive.ts +1 -1
  71. package/lib/done/bulk.ts +1 -1
  72. package/lib/done/index.ts +8 -14
  73. package/lib/done/remote.ts +9 -20
  74. package/lib/health/evidence.ts +1 -1
  75. package/lib/health/facts.ts +1 -1
  76. package/lib/health/pieces.ts +1 -1
  77. package/lib/hosts/peer-actions.ts +2 -2
  78. package/lib/hosts/peer-mirror.ts +2 -2
  79. package/lib/hosts/peer-sync.ts +69 -32
  80. package/lib/learning-cards/input.ts +1 -1
  81. package/lib/learning-cards/store.ts +1 -1
  82. package/lib/models/plan-step.ts +10 -24
  83. package/lib/models/resume.ts +5 -7
  84. package/lib/notify/index.ts +1 -1
  85. package/lib/notify/push/events.ts +1 -1
  86. package/lib/notify/push/index.ts +1 -1
  87. package/lib/orchestrator/act.ts +1 -1
  88. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  89. package/lib/orchestrator/armed.ts +18 -16
  90. package/lib/orchestrator/ask-approvals.ts +1 -1
  91. package/lib/orchestrator/ask-heads.ts +1 -1
  92. package/lib/orchestrator/ask-settle.ts +1 -1
  93. package/lib/orchestrator/ask-verdict.ts +111 -0
  94. package/lib/orchestrator/asks.ts +26 -0
  95. package/lib/orchestrator/brakes.ts +1 -1
  96. package/lib/orchestrator/brief.ts +2 -1
  97. package/lib/orchestrator/digest.ts +2 -2
  98. package/lib/orchestrator/escalate.ts +25 -12
  99. package/lib/orchestrator/external-gates.ts +1 -0
  100. package/lib/orchestrator/external-pr.ts +2 -2
  101. package/lib/orchestrator/facts.ts +4 -28
  102. package/lib/orchestrator/gates.ts +94 -25
  103. package/lib/orchestrator/held-asks.ts +8 -11
  104. package/lib/orchestrator/held-review.ts +8 -4
  105. package/lib/orchestrator/home.ts +2 -2
  106. package/lib/orchestrator/loop-guard.ts +145 -0
  107. package/lib/orchestrator/merge-approvals.ts +2 -2
  108. package/lib/orchestrator/overview.ts +1 -1
  109. package/lib/orchestrator/pr-wake.ts +3 -5
  110. package/lib/orchestrator/questions.ts +1 -1
  111. package/lib/orchestrator/quick-store.ts +1 -1
  112. package/lib/orchestrator/quick.ts +11 -8
  113. package/lib/orchestrator/read.ts +1 -1
  114. package/lib/orchestrator/rebase.ts +8 -11
  115. package/lib/orchestrator/reconcile.ts +2 -2
  116. package/lib/orchestrator/review-prompt.ts +2 -0
  117. package/lib/orchestrator/review.ts +14 -4
  118. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  119. package/lib/orchestrator/signoff.ts +49 -11
  120. package/lib/orchestrator/targets.ts +1 -1
  121. package/lib/orchestrator/task-state.ts +4 -2
  122. package/lib/orchestrator/testing.ts +11 -14
  123. package/lib/orchestrator/watcher.ts +4 -0
  124. package/lib/plans/features.ts +1 -1
  125. package/lib/plans/release.ts +1 -1
  126. package/lib/ports.ts +1 -1
  127. package/lib/recipes/test-fixtures.ts +2 -3
  128. package/lib/resurrect/find.ts +1 -1
  129. package/lib/resurrect/index.ts +44 -20
  130. package/lib/resurrect/swept.ts +61 -0
  131. package/lib/schedules/store.ts +1 -1
  132. package/lib/session-audit/sources.ts +3 -3
  133. package/lib/session-logs/index.ts +1 -1
  134. package/lib/session-titles.ts +1 -1
  135. package/lib/sessions/setup-progress.ts +1 -1
  136. package/lib/sessions/stop.ts +1 -1
  137. package/lib/sessions/worktree-setup.ts +2 -1
  138. package/lib/simulator/index.ts +1 -1
  139. package/lib/simulator/task-devices.ts +1 -1
  140. package/lib/stacks/board.ts +1 -1
  141. package/lib/stacks/land.ts +1 -1
  142. package/lib/stacks/restack.ts +1 -1
  143. package/lib/stacks/start.ts +2 -2
  144. package/lib/stacks/testing.ts +17 -16
  145. package/lib/stacks/tick.ts +1 -1
  146. package/lib/stacks/view.ts +1 -1
  147. package/lib/status/incremental.ts +1 -1
  148. package/lib/tasks/contract/actions.ts +35 -0
  149. package/lib/tasks/contract/authorize.ts +82 -0
  150. package/lib/tasks/contract/client.ts +296 -0
  151. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  152. package/lib/tasks/contract/index.ts +28 -0
  153. package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
  154. package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
  155. package/lib/tasks/contract/routes.ts +26 -0
  156. package/lib/tasks/contract/start.ts +122 -0
  157. package/lib/tasks/contract/sync.ts +126 -0
  158. package/lib/tasks/contract/types.ts +41 -0
  159. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  160. package/lib/tasks/finish.ts +30 -23
  161. package/lib/tasks/held-replies.ts +7 -13
  162. package/lib/tasks/held.ts +14 -18
  163. package/lib/tasks/import.ts +19 -15
  164. package/lib/tasks/index.ts +34 -22
  165. package/lib/tasks/launch-gate.ts +1 -1
  166. package/lib/tasks/move-carry.ts +15 -7
  167. package/lib/tasks/move-flow.ts +29 -36
  168. package/lib/tasks/move-guard.ts +1 -1
  169. package/lib/tasks/move-recover.ts +6 -25
  170. package/lib/tasks/move-testing.ts +16 -7
  171. package/lib/tasks/move.ts +23 -23
  172. package/lib/tasks/moved-away.ts +1 -1
  173. package/lib/tasks/pause-hold.ts +13 -5
  174. package/lib/tasks/pause.ts +19 -23
  175. package/lib/tasks/plan-view.ts +1 -1
  176. package/lib/tasks/pr-activity.ts +11 -2
  177. package/lib/tasks/prune-branches.ts +193 -0
  178. package/lib/tasks/prune-scope.ts +22 -0
  179. package/lib/tasks/queue-placement.ts +1 -1
  180. package/lib/tasks/queue-rows.ts +4 -4
  181. package/lib/tasks/queue-start.ts +5 -3
  182. package/lib/tasks/queue-tick.ts +2 -2
  183. package/lib/tasks/queue.ts +14 -6
  184. package/lib/tasks/remote-branch.ts +170 -0
  185. package/lib/tasks/session.ts +7 -4
  186. package/lib/tasks/start.ts +4 -4
  187. package/lib/tasks/starter.ts +8 -11
  188. package/lib/tasks/testing.ts +13 -7
  189. package/lib/usage/calls.ts +1 -1
  190. package/lib/usage/compare.ts +1 -1
  191. package/lib/usage/quality.ts +1 -1
  192. package/lib/usage/turns.ts +1 -1
  193. package/lib/voice/vocab.ts +1 -1
  194. package/lib/worktree-sweep/commits.ts +65 -0
  195. package/lib/worktree-sweep/findOrphans.ts +139 -0
  196. package/lib/worktree-sweep/findSettled.ts +112 -0
  197. package/lib/worktree-sweep/index.ts +10 -0
  198. package/lib/worktree-sweep/judge.ts +31 -0
  199. package/lib/worktree-sweep/panes.ts +18 -0
  200. package/lib/worktree-sweep/runSweep.ts +243 -0
  201. package/lib/worktree-sweep/settings.ts +83 -0
  202. package/lib/worktree-sweep/start.ts +64 -0
  203. package/lib/worktree-sweep/state.ts +36 -0
  204. package/lib/worktree-sweep/store.ts +96 -0
  205. package/lib/worktree-sweep/types.ts +53 -0
  206. package/package.json +1 -1
  207. package/scripts/backfill-session-logs.ts +1 -1
  208. package/scripts/screenshots/seed.ts +19 -18
  209. package/server.ts +3 -0
  210. package/lib/tasks/held-routes.ts +0 -33
  211. 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.prepare(
39
- `UPDATE sessions SET merge_armed_at = ${armed ? "COALESCE(merge_armed_at, datetime('now'))" : "NULL"},
40
- merge_armed_seen = NULL WHERE id = ?`
41
- ).run(sessionId);
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 sessions s JOIN projects p ON p.id = s.project_id
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 sessions WHERE id = ?`)
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 gate failing on a second commit goes to Saad.
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.prepare(`UPDATE sessions SET merge_armed_seen = ? WHERE id = ?`).run(
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 sessions WHERE task_status = 'running')
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 sessions WHERE id = ?`)
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 sessions s JOIN projects p ON p.id = s.project_id
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
+ }
@@ -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 sessions s JOIN projects p ON p.id = s.project_id
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 again on a different commit goes to Saad, with a note in your chat, and from then on only he merges or drops that task. 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
+ "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 sessions s JOIN projects p ON p.id = s.project_id
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 sessions a ON a.id = q.after_task
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; the same gate
74
- // failing on a second, different head goes to Saad (a task's ask held until
75
- // its agent is done; a PR no task owns has no agent to wait for). The same head judged
76
- // again, from any path, isn't another failure.
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
- const n = recordFailure(workspaceId, task.id, o.gate, o.reason ?? "", sha);
86
- const line = `- ${o.gate} failed (${n === 1 ? "first" : "again"}): ${o.reason}`;
87
- if (n < 2 || failureOf(task.id, o.gate)?.escalated_at)
88
- return { line, escalated: false };
89
- const why = failedTwiceWhy(o.gate, o.reason ?? "");
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,
@@ -69,6 +69,7 @@ export async function judgeExternal(
69
69
  return {
70
70
  checks: pr.checks,
71
71
  failing: pr.failing,
72
+ failingUrl: pr.failingUrl,
72
73
  settleIn: ciSettleIn({
73
74
  workspaceId,
74
75
  taskId: ext.id,
@@ -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 sessions s
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 sessions s JOIN projects p ON p.id = s.project_id
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 { cleanRemoteText, hostLink } from "../hosts/remote-api";
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 { hostTasks } from "../tasks/remote";
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 sessions s
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 hostTasks(link)
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
- * against the task, and a gate failing again on a different commit goes to
7
- * Saad instead of being retried. A failure counts once per task, gate and
8
- * head commit, whichever path judged it (the armed watcher, CI finishing,
9
- * sign_off, done, land, a review's verdict arriving): judging the same head
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 (?, ?, ?, 1, ?, ?)
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
- ).run(sessionId, gate, workspaceId, reason, sha, fresh ? 1 : 0);
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(sessionId: string, gate: string): void {
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(