@saadnvd1/agent-os 0.3.588 → 0.3.592
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/globals.css +11 -0
- package/components/Chat/ChatPanel.tsx +45 -11
- package/components/Chat/Messages.test.ts +40 -1
- package/components/Chat/Messages.tsx +96 -26
- package/components/Chat/PlanCard.tsx +4 -2
- package/components/Chat/Undo.tsx +3 -0
- package/components/Chat/idle.ts +9 -0
- package/data/chat/useChat.ts +83 -13
- package/data/chat/usePending.ts +70 -0
- package/lib/bus/format.ts +16 -8
- package/lib/bus/index.ts +6 -1
- package/lib/chat/client-send.ts +8 -1
- package/lib/chat/demo.ts +22 -3
- package/lib/chat/drivers/claude.ts +15 -2
- package/lib/chat/drivers/opencode.ts +1 -0
- package/lib/chat/events.ts +7 -2
- package/lib/chat/pending.ts +169 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/socket.ts +12 -1
- package/lib/db/migrations.ts +16 -0
- package/lib/orchestrator/ask-heads.ts +14 -2
- package/lib/orchestrator/escalate.ts +34 -12
- package/lib/orchestrator/gates.ts +26 -0
- package/lib/orchestrator/held-asks.ts +117 -0
- package/lib/orchestrator/held.ts +4 -1
- package/lib/orchestrator/home.ts +4 -1
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/tasks/held-remote.ts +4 -5
- package/lib/test-home-guard.ts +23 -0
- package/lib/worktrees.ts +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// A gate that failed twice holds the task at once, but Saad's ask waits
|
|
2
|
+
// until the task's agent is done: idle, nothing sent to it lately, and CI
|
|
3
|
+
// settled on its latest head. Then the ask goes up at that head, the commit
|
|
4
|
+
// he'd merge; if the head moves after that, followAskHeads moves the same ask.
|
|
5
|
+
|
|
6
|
+
import { db, type Session } from "../db";
|
|
7
|
+
import { prFor } from "../tasks/session";
|
|
8
|
+
import { latestApproval } from "./ask-approvals";
|
|
9
|
+
import { askSaadAbout } from "./escalate";
|
|
10
|
+
import { getCheck } from "./checks";
|
|
11
|
+
import { openBySubject, workSubject } from "./asks";
|
|
12
|
+
import { recentlyMessaged } from "./conditions";
|
|
13
|
+
import type { SessionFacts } from "./facts";
|
|
14
|
+
import {
|
|
15
|
+
failedTwiceWhy,
|
|
16
|
+
heldAskFailures,
|
|
17
|
+
releaseHeldAsks,
|
|
18
|
+
type FailureRow,
|
|
19
|
+
} from "./gates";
|
|
20
|
+
import { getOrchestrator } from "./home";
|
|
21
|
+
import { commitTime, fetchRefs, repoOf } from "./repo";
|
|
22
|
+
import { ciSettleIn, SETTLE_S } from "./task-state";
|
|
23
|
+
|
|
24
|
+
// Long enough for the orchestrator to send the findings that caused the
|
|
25
|
+
// escalation; the hour is the most a stuck agent can hide an escalation.
|
|
26
|
+
export const HOLD_MIN_MS = SETTLE_S * 1000;
|
|
27
|
+
export const HOLD_MAX_MS = 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
const short = (sha: string) => sha.slice(0, 7);
|
|
30
|
+
const sqliteMs = (t: string) => Date.parse(`${t.replace(" ", "T")}Z`);
|
|
31
|
+
|
|
32
|
+
export async function raiseHeldAsks(
|
|
33
|
+
workspaceId: string,
|
|
34
|
+
facts: SessionFacts[],
|
|
35
|
+
now = Date.now()
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const byTask = new Map<string, FailureRow[]>();
|
|
38
|
+
for (const row of heldAskFailures(workspaceId))
|
|
39
|
+
byTask.set(row.session_id, [...(byTask.get(row.session_id) ?? []), row]);
|
|
40
|
+
if (!byTask.size) return;
|
|
41
|
+
const orch = getOrchestrator(workspaceId);
|
|
42
|
+
const messaged = orch ? recentlyMessaged(orch.id, now) : new Set<string>();
|
|
43
|
+
for (const [taskId, rows] of byTask) {
|
|
44
|
+
try {
|
|
45
|
+
await raiseFor(workspaceId, taskId, rows, facts, messaged, now);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`Raising ${taskId}'s held ask:`, error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function raiseFor(
|
|
53
|
+
workspaceId: string,
|
|
54
|
+
taskId: string,
|
|
55
|
+
rows: FailureRow[],
|
|
56
|
+
facts: SessionFacts[],
|
|
57
|
+
messaged: Set<string>,
|
|
58
|
+
now: number
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
const f = facts.find((x) => x.id === taskId);
|
|
61
|
+
const task = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(taskId) as
|
|
62
|
+
| Session
|
|
63
|
+
| undefined;
|
|
64
|
+
// Gone, merged or dropped: nothing left to ask. An open ask or an
|
|
65
|
+
// unspent approval already covers it (followAskHeads, heldVerdict).
|
|
66
|
+
if (
|
|
67
|
+
!task ||
|
|
68
|
+
task.task_status !== "running" ||
|
|
69
|
+
openBySubject(workspaceId, workSubject(taskId)) ||
|
|
70
|
+
latestApproval(workspaceId, workSubject(taskId))
|
|
71
|
+
)
|
|
72
|
+
return releaseHeldAsks(taskId);
|
|
73
|
+
const heldFor =
|
|
74
|
+
now - Math.min(...rows.map((r) => sqliteMs(r.ask_held_at as string)));
|
|
75
|
+
const overdue = heldFor >= HOLD_MAX_MS;
|
|
76
|
+
// No facts yet is a wait, but never past the hour.
|
|
77
|
+
if (!f && !overdue) return;
|
|
78
|
+
// A gh failure throws and keeps the hold: only GitHub saying the PR isn't
|
|
79
|
+
// open releases it.
|
|
80
|
+
const pr = await prFor(task, true, true);
|
|
81
|
+
if (pr?.state !== "OPEN" || !pr.head) return releaseHeldAsks(taskId);
|
|
82
|
+
if (!overdue && f) {
|
|
83
|
+
if (f.status === "running" || f.compacting) return;
|
|
84
|
+
if (messaged.has(taskId) || heldFor < HOLD_MIN_MS) return;
|
|
85
|
+
if (getCheck(taskId, pr.head, "review")?.status === "running") return;
|
|
86
|
+
const repo = repoOf(task);
|
|
87
|
+
// The head may be a commit this clone hasn't fetched yet.
|
|
88
|
+
await fetchRefs(repo, task);
|
|
89
|
+
const committedAt = await commitTime(repo, pr.head);
|
|
90
|
+
if (pr.checks === "pending") return;
|
|
91
|
+
if (pr.checks === "pass") {
|
|
92
|
+
const left = ciSettleIn({
|
|
93
|
+
workspaceId,
|
|
94
|
+
taskId,
|
|
95
|
+
sha: pr.head,
|
|
96
|
+
checkCount: pr.checkCount ?? 0,
|
|
97
|
+
committedAt,
|
|
98
|
+
now,
|
|
99
|
+
});
|
|
100
|
+
if (left > 0) return;
|
|
101
|
+
} else if (now / 1000 - committedAt < SETTLE_S) return;
|
|
102
|
+
}
|
|
103
|
+
const last = rows.find((r) => r.last_sha)?.last_sha;
|
|
104
|
+
const why = [
|
|
105
|
+
...rows.map((r) => failedTwiceWhy(r.gate, r.last_reason ?? "")),
|
|
106
|
+
...(last && last !== pr.head
|
|
107
|
+
? [
|
|
108
|
+
`Asked at ${short(pr.head)}, its head after the fixes since ${short(last)}.`,
|
|
109
|
+
]
|
|
110
|
+
: []),
|
|
111
|
+
...(overdue
|
|
112
|
+
? [`${task.name} was still working after an hour; this is its head now.`]
|
|
113
|
+
: []),
|
|
114
|
+
].join("\n");
|
|
115
|
+
askSaadAbout(workspaceId, task, why, pr.url, pr.head);
|
|
116
|
+
releaseHeldAsks(taskId);
|
|
117
|
+
}
|
package/lib/orchestrator/held.ts
CHANGED
|
@@ -40,9 +40,12 @@ export function heldVerdict(
|
|
|
40
40
|
text: `${task.name}: ${why}. Asked him again; don't retry.`,
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
const waiting = held.some((h) => h.ask_held_at)
|
|
44
|
+
? " His ask goes up once its agent is idle and CI has settled on its latest head."
|
|
45
|
+
: "";
|
|
43
46
|
return {
|
|
44
47
|
ok: false,
|
|
45
48
|
wait: false,
|
|
46
|
-
text: `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}: ${held[0].last_reason}). Only he can merge it now, or approve it on his asks list
|
|
49
|
+
text: `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}: ${held[0].last_reason}). Only he can merge it now, or approve it on his asks list.${waiting}`,
|
|
47
50
|
};
|
|
48
51
|
}
|
package/lib/orchestrator/home.ts
CHANGED
|
@@ -12,10 +12,13 @@ import path from "path";
|
|
|
12
12
|
import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
13
13
|
import { db, type Session } from "../db";
|
|
14
14
|
import { getWorkspace } from "../workspaces";
|
|
15
|
+
import { refuseRealStateUnderTest } from "../test-home-guard";
|
|
15
16
|
|
|
16
17
|
// It works through tools, not in a repo: a scratch folder of its own.
|
|
17
18
|
export const orchestratorDir = (workspaceId: string) =>
|
|
18
|
-
|
|
19
|
+
refuseRealStateUnderTest(
|
|
20
|
+
path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId)
|
|
21
|
+
);
|
|
19
22
|
|
|
20
23
|
export function getOrchestrator(workspaceId: string): Session | null {
|
|
21
24
|
return (
|
|
@@ -17,6 +17,7 @@ import { settleStaleAsks } from "./ask-settle";
|
|
|
17
17
|
import { brakesOn, liftBrake } from "./brakes";
|
|
18
18
|
import { anyArmed, mergeArmed } from "./armed";
|
|
19
19
|
import { followAskHeads } from "./ask-heads";
|
|
20
|
+
import { raiseHeldAsks } from "./held-asks";
|
|
20
21
|
import { rebaseConflicting } from "./rebase";
|
|
21
22
|
import { wakeTasks } from "./pr-wake";
|
|
22
23
|
import { awayTick } from "../away/run";
|
|
@@ -79,6 +80,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
|
|
|
79
80
|
await followAskHeads(workspaceId, facts).catch((error) =>
|
|
80
81
|
console.error(`Following ask heads in ${workspaceId}:`, error)
|
|
81
82
|
);
|
|
83
|
+
await raiseHeldAsks(workspaceId, facts).catch((error) =>
|
|
84
|
+
console.error(`Raising held asks in ${workspaceId}:`, error)
|
|
85
|
+
);
|
|
82
86
|
await rebaseConflicting(workspaceId, facts);
|
|
83
87
|
await mergeArmed(workspaceId, facts);
|
|
84
88
|
})()
|
package/lib/tasks/held-remote.ts
CHANGED
|
@@ -113,11 +113,10 @@ export async function flushUndelivered(
|
|
|
113
113
|
).run(m.id);
|
|
114
114
|
continue;
|
|
115
115
|
}
|
|
116
|
-
const line = wakeLine(
|
|
117
|
-
fromName: m.from_name,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
});
|
|
116
|
+
const line = wakeLine(
|
|
117
|
+
{ fromName: m.from_name, fromId: m.from_id, body: m.body },
|
|
118
|
+
{ inline: true }
|
|
119
|
+
);
|
|
121
120
|
const res = await relayToHeldTask(task, line, m.from_name, m.id, false);
|
|
122
121
|
if (res.state === "failed") return;
|
|
123
122
|
db.prepare(
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
// Under the test runner, refuse a path inside the real ~/.agent-os: a test
|
|
5
|
+
// that resolves there would leave folders in the owner's state. The real
|
|
6
|
+
// home comes from the account, not $HOME, which the test setup replaces.
|
|
7
|
+
export function refuseRealStateUnderTest(p: string): string {
|
|
8
|
+
if (!process.env.VITEST) return p;
|
|
9
|
+
let real: string;
|
|
10
|
+
try {
|
|
11
|
+
real = os.userInfo().homedir;
|
|
12
|
+
} catch {
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
const state = path.join(real, ".agent-os");
|
|
16
|
+
const resolved = path.resolve(p);
|
|
17
|
+
if (resolved === state || resolved.startsWith(state + path.sep)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Refusing to use ${state} under the test runner (${resolved}): point HOME at a temp folder`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return p;
|
|
23
|
+
}
|
package/lib/worktrees.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
slugify,
|
|
13
13
|
generateBranchName,
|
|
14
14
|
} from "./git";
|
|
15
|
+
import { refuseRealStateUnderTest } from "./test-home-guard";
|
|
15
16
|
import { installWorktreeHooks } from "./worktree-hooks";
|
|
16
17
|
import { gitResult, isGitError, worktreeList } from "./git-exec";
|
|
17
18
|
import { addWorktree } from "./worktree-add";
|
|
@@ -43,8 +44,13 @@ export async function mainCheckoutOf(worktreePath: string): Promise<string> {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** Where task worktrees live: ~/.agent-os/worktrees. */
|
|
48
|
+
export function worktreesRoot(home = os.homedir()): string {
|
|
49
|
+
return refuseRealStateUnderTest(path.join(home, ".agent-os", "worktrees"));
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
// Base directory for all worktrees
|
|
47
|
-
export const WORKTREES_DIR =
|
|
53
|
+
export const WORKTREES_DIR = worktreesRoot();
|
|
48
54
|
|
|
49
55
|
export interface WorktreeInfo {
|
|
50
56
|
worktreePath: string;
|