@saadnvd1/agent-os 0.3.587 → 0.3.591
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/components/Chat/composer/ComposerBody.tsx +13 -5
- package/lib/bus/format.ts +16 -8
- package/lib/bus/index.ts +6 -1
- package/lib/chat/commands.ts +9 -5
- 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
|
@@ -163,7 +163,8 @@ export function ComposerBody({
|
|
|
163
163
|
insertMarkdown(editor, content, insert.append);
|
|
164
164
|
}, [insert, editor]);
|
|
165
165
|
|
|
166
|
-
const
|
|
166
|
+
const slash = slashQuery(caret.before);
|
|
167
|
+
const query = slash?.query ?? null;
|
|
167
168
|
const mentions = useMentions(editor, caret, query === null, requestFiles);
|
|
168
169
|
const matches = useMemo(() => {
|
|
169
170
|
if (query === null) return [];
|
|
@@ -173,8 +174,9 @@ export function ComposerBody({
|
|
|
173
174
|
];
|
|
174
175
|
return rankCommands(query, all);
|
|
175
176
|
}, [query, commands, onSetModel]);
|
|
176
|
-
// One menu at a time: "/" commands
|
|
177
|
-
const menuKey =
|
|
177
|
+
// One menu at a time: "/" commands or "@" files at the caret.
|
|
178
|
+
const menuKey =
|
|
179
|
+
query !== null ? `/${caret.pos}:${caret.before}` : mentions.key;
|
|
178
180
|
const menuOpen = menuKey !== null && dismissed !== menuKey;
|
|
179
181
|
const options: MenuOption[] =
|
|
180
182
|
query !== null
|
|
@@ -205,12 +207,18 @@ export function ComposerBody({
|
|
|
205
207
|
);
|
|
206
208
|
|
|
207
209
|
const pickCommand = (command: ChatCommand) => {
|
|
210
|
+
if (!editor || !slash) return;
|
|
211
|
+
// The "/" and what follows it, up to the caret.
|
|
212
|
+
const from = caret.pos - (caret.before.length - slash.start);
|
|
213
|
+
const chain = editor.chain().focus().deleteRange({ from, to: caret.pos });
|
|
208
214
|
if (command === MODEL_COMMAND) {
|
|
209
|
-
|
|
215
|
+
chain.run();
|
|
210
216
|
setModelOpen(true);
|
|
211
217
|
return;
|
|
212
218
|
}
|
|
213
|
-
|
|
219
|
+
chain
|
|
220
|
+
.insertContent({ type: "text", text: insertCommand(command.name) })
|
|
221
|
+
.run();
|
|
214
222
|
};
|
|
215
223
|
const pick = (index: number) => {
|
|
216
224
|
if (query !== null) {
|
package/lib/bus/format.ts
CHANGED
|
@@ -15,16 +15,19 @@ export interface BusMessageView {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const INLINE_LIMIT = 600;
|
|
18
|
+
// A relayed line stays under the linked machine's own cap (tasks/[id]/message).
|
|
19
|
+
export const RELAY_LIMIT = 30_000;
|
|
18
20
|
|
|
19
21
|
// Enough of a session id to address it (aos send takes a unique prefix).
|
|
20
22
|
export const shortId = (id: string) => id.slice(0, 8);
|
|
21
23
|
|
|
22
|
-
// Typed into the recipient's terminal. Long bodies are left for `aos inbox
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
body: string
|
|
27
|
-
|
|
24
|
+
// Typed into the recipient's terminal. Long bodies are left for `aos inbox`,
|
|
25
|
+
// unless `inline`: the recipient's AgentOS is another machine's, whose inbox
|
|
26
|
+
// never held the message, so the whole body goes in the line.
|
|
27
|
+
export function wakeLine(
|
|
28
|
+
m: { fromName: string; fromId: string | null; body: string },
|
|
29
|
+
opts: { inline?: boolean } = {}
|
|
30
|
+
): string {
|
|
28
31
|
const short = m.fromId ? shortId(m.fromId) : null;
|
|
29
32
|
const who = short
|
|
30
33
|
? `"${m.fromName}" (${short})`
|
|
@@ -32,8 +35,13 @@ export function wakeLine(m: {
|
|
|
32
35
|
? "the user (via AgentOS)"
|
|
33
36
|
: `${m.fromName} (via AgentOS)`;
|
|
34
37
|
const oneLine = m.body.replace(/\s*\n\s*/g, " ").trim();
|
|
35
|
-
const text =
|
|
36
|
-
|
|
38
|
+
const text = opts.inline
|
|
39
|
+
? `: ${
|
|
40
|
+
oneLine.length <= RELAY_LIMIT
|
|
41
|
+
? oneLine
|
|
42
|
+
: `${oneLine.slice(0, RELAY_LIMIT)} [cut: ${oneLine.length - RELAY_LIMIT} more chars; ask the sender to resend the rest]`
|
|
43
|
+
}`
|
|
44
|
+
: oneLine.length <= INLINE_LIMIT
|
|
37
45
|
? `: ${oneLine}`
|
|
38
46
|
: ` (${m.body.length} chars). Read it with: aos inbox`;
|
|
39
47
|
// By id: a reply can't break when either side is renamed.
|
package/lib/bus/index.ts
CHANGED
|
@@ -333,7 +333,12 @@ export async function sendMessage(opts: {
|
|
|
333
333
|
await statusDetector.refreshCache();
|
|
334
334
|
const delivery = await deliver(
|
|
335
335
|
to,
|
|
336
|
-
opts.line ??
|
|
336
|
+
opts.line ??
|
|
337
|
+
wakeLine(
|
|
338
|
+
{ fromName, fromId: from?.id ?? null, body },
|
|
339
|
+
// The linked machine's inbox never held a long body.
|
|
340
|
+
{ inline: isHeldRemote(to) }
|
|
341
|
+
),
|
|
337
342
|
{
|
|
338
343
|
name: fromName,
|
|
339
344
|
messageId: id,
|
package/lib/chat/commands.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ChatCommand } from "./events";
|
|
2
2
|
|
|
3
|
-
// The command being typed
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
// The /command being typed: a "/" at the start of a line or after whitespace,
|
|
4
|
+
// then the partial name, right up to the caret. Not "a/b" or "http://x".
|
|
5
|
+
// `start` is where the "/" sits in the text before the caret.
|
|
6
|
+
export function slashQuery(
|
|
7
|
+
beforeCaret: string
|
|
8
|
+
): { query: string; start: number } | null {
|
|
9
|
+
const m = /(^|\s)\/([^\s/]*)$/.exec(beforeCaret);
|
|
10
|
+
if (!m) return null;
|
|
11
|
+
return { query: m[2].toLowerCase(), start: m.index + m[1].length };
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
function isSubsequence(q: string, s: string): boolean {
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2325,6 +2325,22 @@ export const migrations: Migration[] = [
|
|
|
2325
2325
|
installChangeTriggers(db, TABLES_WATCHED_CHAT_BOOKMARKS);
|
|
2326
2326
|
},
|
|
2327
2327
|
},
|
|
2328
|
+
{
|
|
2329
|
+
key: "20261014T2100_gate_failure_ask_held",
|
|
2330
|
+
up: (db) => {
|
|
2331
|
+
// When a failed-twice escalation started holding its ask until the
|
|
2332
|
+
// agent is idle and CI has settled; null is not held. Additive.
|
|
2333
|
+
const has = (
|
|
2334
|
+
db.prepare(`PRAGMA table_info(orchestrator_gate_failures)`).all() as {
|
|
2335
|
+
name: string;
|
|
2336
|
+
}[]
|
|
2337
|
+
).some((c) => c.name === "ask_held_at");
|
|
2338
|
+
if (!has)
|
|
2339
|
+
db.exec(
|
|
2340
|
+
`ALTER TABLE orchestrator_gate_failures ADD COLUMN ask_held_at TEXT`
|
|
2341
|
+
);
|
|
2342
|
+
},
|
|
2343
|
+
},
|
|
2328
2344
|
];
|
|
2329
2345
|
|
|
2330
2346
|
// `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// gone; the ask is moved to the head it would merge, with a line on whether
|
|
4
4
|
// the PR's patch changed. The approve button is bound to the commit shown,
|
|
5
5
|
// so an answer from a screen that predates the move is refused (asks.ts).
|
|
6
|
+
// The head is read from GitHub, never taken from the facts (which can be
|
|
7
|
+
// older than the ask), and an ask never moves back to a commit it contains.
|
|
6
8
|
|
|
7
9
|
import { db, type Session } from "../db";
|
|
10
|
+
import { isAncestor } from "../git-exec";
|
|
8
11
|
import { hostLink } from "../hosts/remote-api";
|
|
12
|
+
import { prFor } from "../tasks/session";
|
|
9
13
|
import { moveAskHead, openAsks, taskSubject } from "./asks";
|
|
10
14
|
import type { SessionFacts } from "./facts";
|
|
11
15
|
import { addNote } from "./notes";
|
|
@@ -21,16 +25,24 @@ export async function followAskHeads(
|
|
|
21
25
|
): Promise<void> {
|
|
22
26
|
for (const ask of openAsks(workspaceId)) {
|
|
23
27
|
const f = facts.find((x) => taskSubject(x.id) === ask.subject);
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
28
|
+
const seen = f?.task?.pr;
|
|
29
|
+
if (
|
|
30
|
+
!ask.sha ||
|
|
31
|
+
seen?.state !== "OPEN" ||
|
|
32
|
+
!seen.head ||
|
|
33
|
+
seen.head === ask.sha
|
|
34
|
+
)
|
|
26
35
|
continue;
|
|
27
36
|
const task = db
|
|
28
37
|
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
29
38
|
.get(f!.id) as Session | undefined;
|
|
30
39
|
if (!task || hostLink(task.host_id)) continue;
|
|
31
40
|
try {
|
|
41
|
+
const pr = await prFor(task, true);
|
|
42
|
+
if (pr?.state !== "OPEN" || !pr.head || pr.head === ask.sha) continue;
|
|
32
43
|
const repo = repoOf(task);
|
|
33
44
|
const base = await fetchRefs(repo, task);
|
|
45
|
+
if (await isAncestor(repo, pr.head, ask.sha)) continue;
|
|
34
46
|
const { same, note } = await comparePatches(repo, base, ask.sha, pr.head);
|
|
35
47
|
const what = same ? `same patch (rebased onto ${base})` : note;
|
|
36
48
|
const line = `Head moved from ${short(ask.sha)} to ${short(pr.head)}: ${what}. Approving merges ${short(pr.head)}.`;
|
|
@@ -2,26 +2,25 @@
|
|
|
2
2
|
// it escalates), one escalation note in the orchestrator's chat, and the
|
|
3
3
|
// task held so the orchestrator doesn't merge it.
|
|
4
4
|
|
|
5
|
-
import { AskRefused, raiseAsk, workSubject } from "./asks";
|
|
5
|
+
import { AskRefused, EXTERNAL_PREFIX, raiseAsk, workSubject } from "./asks";
|
|
6
6
|
import {
|
|
7
|
+
failedTwiceWhy,
|
|
7
8
|
failureOf,
|
|
9
|
+
holdAsk,
|
|
8
10
|
markEscalated,
|
|
9
11
|
recordFailure,
|
|
10
12
|
type GateOutcome,
|
|
11
13
|
} from "./gates";
|
|
12
14
|
import { addNote } from "./notes";
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
// Puts the ask on Saad's list (or notes why it couldn't be).
|
|
17
|
+
export function askSaadAbout(
|
|
15
18
|
workspaceId: string,
|
|
16
19
|
task: { id: string; name: string },
|
|
17
|
-
gate: string,
|
|
18
20
|
why: string,
|
|
19
21
|
url: string,
|
|
20
|
-
sha: string | null
|
|
21
|
-
):
|
|
22
|
-
if (!failureOf(task.id, gate))
|
|
23
|
-
recordFailure(workspaceId, task.id, gate, why, sha);
|
|
24
|
-
markEscalated(task.id, gate);
|
|
22
|
+
sha: string | null
|
|
23
|
+
): void {
|
|
25
24
|
let created = false;
|
|
26
25
|
try {
|
|
27
26
|
created = raiseAsk({
|
|
@@ -47,26 +46,49 @@ export function escalate(
|
|
|
47
46
|
`Saad must decide on ${task.name} (${url}): ${why}. The orchestrator won't merge it; it's on his asks list to approve, decline, or sign off or drop himself.`,
|
|
48
47
|
"escalation"
|
|
49
48
|
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `hold`: the task is held at once, but Saad's ask waits until its agent is
|
|
52
|
+
// idle and CI has settled, so it names the commit he'd merge (held-asks.ts).
|
|
53
|
+
export function escalate(
|
|
54
|
+
workspaceId: string,
|
|
55
|
+
task: { id: string; name: string },
|
|
56
|
+
gate: string,
|
|
57
|
+
why: string,
|
|
58
|
+
url: string,
|
|
59
|
+
sha: string | null = null,
|
|
60
|
+
opts: { hold?: boolean } = {}
|
|
61
|
+
): string {
|
|
62
|
+
if (!failureOf(task.id, gate))
|
|
63
|
+
recordFailure(workspaceId, task.id, gate, why, sha);
|
|
64
|
+
markEscalated(task.id, gate);
|
|
65
|
+
if (opts.hold) {
|
|
66
|
+
holdAsk(task.id, gate);
|
|
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.`;
|
|
68
|
+
}
|
|
69
|
+
askSaadAbout(workspaceId, task, why, url, sha);
|
|
50
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.`;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
// Counts a gate's failure on this head against the task; the same gate
|
|
54
|
-
// failing on a second, different head goes to Saad
|
|
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
|
|
55
76
|
// again, from any path, isn't another failure.
|
|
56
77
|
export function countFailure(
|
|
57
78
|
workspaceId: string,
|
|
58
79
|
task: { id: string; name: string },
|
|
59
80
|
o: GateOutcome,
|
|
60
81
|
url: string,
|
|
61
|
-
sha: string
|
|
82
|
+
sha: string,
|
|
83
|
+
opts: { hold?: boolean } = {}
|
|
62
84
|
): { line: string; escalated: boolean } {
|
|
63
85
|
const n = recordFailure(workspaceId, task.id, o.gate, o.reason ?? "", sha);
|
|
64
86
|
const line = `- ${o.gate} failed (${n === 1 ? "first" : "again"}): ${o.reason}`;
|
|
65
87
|
if (n < 2 || failureOf(task.id, o.gate)?.escalated_at)
|
|
66
88
|
return { line, escalated: false };
|
|
67
|
-
const why =
|
|
89
|
+
const why = failedTwiceWhy(o.gate, o.reason ?? "");
|
|
68
90
|
return {
|
|
69
|
-
line: `${line}\n ${escalate(workspaceId, task, o.gate, why, url, sha)}`,
|
|
91
|
+
line: `${line}\n ${escalate(workspaceId, task, o.gate, why, url, sha, { hold: opts.hold ?? !task.id.startsWith(EXTERNAL_PREFIX) })}`,
|
|
70
92
|
escalated: true,
|
|
71
93
|
};
|
|
72
94
|
}
|
|
@@ -215,6 +215,8 @@ export interface FailureRow {
|
|
|
215
215
|
last_reason: string | null;
|
|
216
216
|
escalated_at: string | null;
|
|
217
217
|
last_sha: string | null;
|
|
218
|
+
// Set while the escalation's ask waits for the agent to finish (held-asks.ts).
|
|
219
|
+
ask_held_at: string | null;
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
// Counts one failure; returns the count now. With `sha`, a failure is
|
|
@@ -277,6 +279,30 @@ export function markEscalated(sessionId: string, gate: string): void {
|
|
|
277
279
|
markStatusDirty(sessionId);
|
|
278
280
|
}
|
|
279
281
|
|
|
282
|
+
export function holdAsk(sessionId: string, gate: string): void {
|
|
283
|
+
db.prepare(
|
|
284
|
+
`UPDATE orchestrator_gate_failures SET ask_held_at = COALESCE(ask_held_at, datetime('now')) WHERE session_id = ? AND gate = ?`
|
|
285
|
+
).run(sessionId, gate);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function releaseHeldAsks(sessionId: string): void {
|
|
289
|
+
db.prepare(
|
|
290
|
+
`UPDATE orchestrator_gate_failures SET ask_held_at = NULL WHERE session_id = ?`
|
|
291
|
+
).run(sessionId);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function heldAskFailures(workspaceId: string): FailureRow[] {
|
|
295
|
+
return db
|
|
296
|
+
.prepare(
|
|
297
|
+
`SELECT * FROM orchestrator_gate_failures
|
|
298
|
+
WHERE workspace_id = ? AND escalated_at IS NOT NULL AND ask_held_at IS NOT NULL`
|
|
299
|
+
)
|
|
300
|
+
.all(workspaceId) as FailureRow[];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export const failedTwiceWhy = (gate: string, reason: string) =>
|
|
304
|
+
`the ${gate} gate failed on two different commits: ${reason}`;
|
|
305
|
+
|
|
280
306
|
export function escalations(sessionId: string): FailureRow[] {
|
|
281
307
|
return db
|
|
282
308
|
.prepare(
|
|
@@ -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;
|