@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
package/lib/chat/client-send.ts
CHANGED
|
@@ -3,20 +3,27 @@ import type { CompactFirst } from "./stale";
|
|
|
3
3
|
|
|
4
4
|
// What a browser's send may carry: its text and images, and whether the
|
|
5
5
|
// reader chose to compact a stale chat first. Who a message is from (`from`,
|
|
6
|
-
// `peer`, `origin`) is set by the server alone, never by a client.
|
|
6
|
+
// `peer`, `origin`) is set by the server alone, never by a client. Its own
|
|
7
|
+
// id for the message is kept only in a shape no server id takes.
|
|
7
8
|
export function clientSend(msg: unknown): {
|
|
8
9
|
text: string;
|
|
9
10
|
images?: ChatImage[];
|
|
10
11
|
compact?: CompactFirst;
|
|
12
|
+
id?: string;
|
|
11
13
|
} {
|
|
12
14
|
const m = (msg ?? {}) as {
|
|
13
15
|
text?: unknown;
|
|
14
16
|
images?: unknown;
|
|
15
17
|
compact?: unknown;
|
|
18
|
+
clientId?: unknown;
|
|
16
19
|
};
|
|
17
20
|
return {
|
|
18
21
|
text: typeof m.text === "string" ? m.text : "",
|
|
19
22
|
images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined,
|
|
20
23
|
...(m.compact === true && { compact: "yes" as const }),
|
|
24
|
+
...(isClientId(m.clientId) && { id: m.clientId }),
|
|
21
25
|
};
|
|
22
26
|
}
|
|
27
|
+
|
|
28
|
+
export const isClientId = (id: unknown): id is string =>
|
|
29
|
+
typeof id === "string" && /^user-c-[A-Za-z0-9-]{8,64}$/.test(id);
|
package/lib/chat/demo.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
|
+
import { clientSend } from "./client-send";
|
|
8
9
|
import type { ChatConversation } from "./driver";
|
|
9
10
|
import type { ChatClientMessage, ChatServerMessage } from "./events";
|
|
10
11
|
import { demoConversation } from "./drivers/demo";
|
|
@@ -37,7 +38,11 @@ function conversation(sessionId: string): ChatConversation {
|
|
|
37
38
|
return c;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
export function sendDemoChat(
|
|
41
|
+
export function sendDemoChat(
|
|
42
|
+
sessionId: string,
|
|
43
|
+
text: string,
|
|
44
|
+
id = `user-${Date.now()}-${randomUUID().slice(0, 5)}`
|
|
45
|
+
): void {
|
|
41
46
|
const trimmed = text.trim();
|
|
42
47
|
if (!trimmed) return;
|
|
43
48
|
getSession(sessionId);
|
|
@@ -45,7 +50,7 @@ export function sendDemoChat(sessionId: string, text: string): void {
|
|
|
45
50
|
if (count >= MAX_SENDS) throw new Error(DEMO_REFUSAL);
|
|
46
51
|
sends.set(sessionId, count + 1);
|
|
47
52
|
const item = {
|
|
48
|
-
id
|
|
53
|
+
id,
|
|
49
54
|
kind: "user" as const,
|
|
50
55
|
text: DEMO_VISITOR_TEXT,
|
|
51
56
|
tagged: true as const,
|
|
@@ -84,7 +89,21 @@ export function handleDemoChat(
|
|
|
84
89
|
msg: ChatClientMessage,
|
|
85
90
|
reply: (m: ChatServerMessage) => void
|
|
86
91
|
): void {
|
|
87
|
-
if (msg.type === "send")
|
|
92
|
+
if (msg.type === "send") {
|
|
93
|
+
const { id } = clientSend(msg);
|
|
94
|
+
if (!id) return sendDemoChat(sessionId, String(msg.text));
|
|
95
|
+
reply({ type: "accepted", id });
|
|
96
|
+
try {
|
|
97
|
+
return sendDemoChat(sessionId, String(msg.text), id);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
reply({
|
|
100
|
+
type: "send_failed",
|
|
101
|
+
id,
|
|
102
|
+
message: err instanceof Error ? err.message : String(err),
|
|
103
|
+
});
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
88
107
|
if (msg.type === "interrupt") {
|
|
89
108
|
void conversations.get(sessionId)?.interrupt();
|
|
90
109
|
return;
|
|
@@ -183,6 +183,19 @@ export const claudeDriver: ChatDriver = {
|
|
|
183
183
|
);
|
|
184
184
|
let access = options.access;
|
|
185
185
|
let plan = !options.permissionMode && !!options.plan;
|
|
186
|
+
// The mode the CLI is in. Asking again for the same one waits on the
|
|
187
|
+
// CLI starting up (seconds, on a new worker) for nothing.
|
|
188
|
+
let applied = sdkMode(access, plan);
|
|
189
|
+
const applyMode = async () => {
|
|
190
|
+
const mode = sdkMode(access, plan);
|
|
191
|
+
if (mode === applied) return;
|
|
192
|
+
const before = applied;
|
|
193
|
+
applied = mode;
|
|
194
|
+
await q.setPermissionMode(mode).catch((error: unknown) => {
|
|
195
|
+
if (applied === mode) applied = before;
|
|
196
|
+
throw error;
|
|
197
|
+
});
|
|
198
|
+
};
|
|
186
199
|
const q = query({
|
|
187
200
|
prompt: input,
|
|
188
201
|
options: {
|
|
@@ -344,12 +357,12 @@ export const claudeDriver: ChatDriver = {
|
|
|
344
357
|
async setAccess(next) {
|
|
345
358
|
access = next;
|
|
346
359
|
if (options.permissionMode || plan) return;
|
|
347
|
-
await
|
|
360
|
+
await applyMode();
|
|
348
361
|
},
|
|
349
362
|
async setPlan(next) {
|
|
350
363
|
if (options.permissionMode) return;
|
|
351
364
|
plan = next;
|
|
352
|
-
await
|
|
365
|
+
await applyMode();
|
|
353
366
|
},
|
|
354
367
|
respond(id, answer) {
|
|
355
368
|
approvals.respond(id, answer);
|
package/lib/chat/events.ts
CHANGED
|
@@ -381,11 +381,16 @@ export type ChatServerMessage =
|
|
|
381
381
|
ok: boolean;
|
|
382
382
|
error?: string;
|
|
383
383
|
}
|
|
384
|
-
| { type: "undone"; from: string; text: string }
|
|
384
|
+
| { type: "undone"; from: string; text: string }
|
|
385
|
+
// A send with a `clientId` was taken in (before any worker is up), or
|
|
386
|
+
// failed. The message itself still comes as an item or in the queue.
|
|
387
|
+
| { type: "accepted"; id: string }
|
|
388
|
+
| { type: "send_failed"; id: string; message: string };
|
|
385
389
|
|
|
386
390
|
// What a browser sends.
|
|
387
391
|
export type ChatClientMessage =
|
|
388
|
-
|
|
392
|
+
// `clientId` (`user-c-…`) becomes the message's id: a resend is a no-op.
|
|
393
|
+
| { type: "send"; text: string; images?: ChatImage[]; clientId?: string }
|
|
389
394
|
| { type: "queue_edit"; id: string; text: string }
|
|
390
395
|
| { type: "queue_move"; id: string; by: -1 | 1 }
|
|
391
396
|
| { type: "queue_delete"; id: string }
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ChatImage } from "./events";
|
|
2
|
+
|
|
3
|
+
// A message the composer sent that isn't in the chat or its queue yet. It
|
|
4
|
+
// shows as a bubble at once, and stays (as failed, with Retry) if it never
|
|
5
|
+
// arrives, so a long paste is never lost to a dead socket.
|
|
6
|
+
export interface PendingSend {
|
|
7
|
+
id: string;
|
|
8
|
+
text: string;
|
|
9
|
+
images?: ChatImage[];
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
// "sending": not taken in yet. "accepted": the server has it and is
|
|
12
|
+
// starting the agent.
|
|
13
|
+
status: "sending" | "accepted" | "failed";
|
|
14
|
+
error?: string;
|
|
15
|
+
// When it was last sent or accepted, for the timeouts below.
|
|
16
|
+
at: number;
|
|
17
|
+
// Sends so far; each reconnect resends once, up to MAX_SENDS.
|
|
18
|
+
sends: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PendingAction =
|
|
22
|
+
| {
|
|
23
|
+
type: "add";
|
|
24
|
+
send: Omit<PendingSend, "status" | "at" | "sends">;
|
|
25
|
+
now: number;
|
|
26
|
+
}
|
|
27
|
+
// Sent again on a new socket, or by Retry.
|
|
28
|
+
| { type: "resent"; id: string; now: number }
|
|
29
|
+
| { type: "accepted"; id: string; now: number }
|
|
30
|
+
| { type: "failed"; id: string; error: string }
|
|
31
|
+
// Ids now in the chat or its queue: those sends are done.
|
|
32
|
+
| { type: "resolved"; ids: Iterable<string> }
|
|
33
|
+
| { type: "drop"; id: string }
|
|
34
|
+
| { type: "tick"; now: number };
|
|
35
|
+
|
|
36
|
+
// No answer at all: the socket is likely dead though not yet closed.
|
|
37
|
+
export const SEND_TIMEOUT_MS = 10_000;
|
|
38
|
+
// Taken in but never shown: a new agent takes seconds, not a minute.
|
|
39
|
+
export const ACCEPTED_TIMEOUT_MS = 60_000;
|
|
40
|
+
export const MAX_SENDS = 3;
|
|
41
|
+
|
|
42
|
+
export const NOT_SENT = "connection lost";
|
|
43
|
+
const NO_ANSWER = "no answer from AgentOS";
|
|
44
|
+
const NEVER_ARRIVED = "the agent didn't take it";
|
|
45
|
+
|
|
46
|
+
export function pendingReducer(
|
|
47
|
+
state: PendingSend[],
|
|
48
|
+
action: PendingAction
|
|
49
|
+
): PendingSend[] {
|
|
50
|
+
const update = (id: string, fn: (p: PendingSend) => PendingSend | null) => {
|
|
51
|
+
const i = state.findIndex((p) => p.id === id);
|
|
52
|
+
if (i === -1) return state;
|
|
53
|
+
const next = fn(state[i]);
|
|
54
|
+
if (next === state[i]) return state;
|
|
55
|
+
const out = state.slice();
|
|
56
|
+
if (next) out[i] = next;
|
|
57
|
+
else out.splice(i, 1);
|
|
58
|
+
return out;
|
|
59
|
+
};
|
|
60
|
+
switch (action.type) {
|
|
61
|
+
case "add":
|
|
62
|
+
if (state.some((p) => p.id === action.send.id)) return state;
|
|
63
|
+
return [
|
|
64
|
+
...state,
|
|
65
|
+
{ ...action.send, status: "sending", at: action.now, sends: 1 },
|
|
66
|
+
];
|
|
67
|
+
case "resent":
|
|
68
|
+
return update(action.id, (p) => ({
|
|
69
|
+
...p,
|
|
70
|
+
status: "sending",
|
|
71
|
+
error: undefined,
|
|
72
|
+
at: action.now,
|
|
73
|
+
sends: p.sends + 1,
|
|
74
|
+
}));
|
|
75
|
+
case "accepted":
|
|
76
|
+
return update(action.id, (p) =>
|
|
77
|
+
p.status === "sending"
|
|
78
|
+
? { ...p, status: "accepted", at: action.now }
|
|
79
|
+
: p
|
|
80
|
+
);
|
|
81
|
+
case "failed":
|
|
82
|
+
return update(action.id, (p) => ({
|
|
83
|
+
...p,
|
|
84
|
+
status: "failed",
|
|
85
|
+
error: action.error,
|
|
86
|
+
}));
|
|
87
|
+
case "resolved": {
|
|
88
|
+
const ids = new Set(action.ids);
|
|
89
|
+
const kept = state.filter((p) => !ids.has(p.id));
|
|
90
|
+
return kept.length === state.length ? state : kept;
|
|
91
|
+
}
|
|
92
|
+
case "drop":
|
|
93
|
+
return update(action.id, () => null);
|
|
94
|
+
case "tick": {
|
|
95
|
+
let changed = false;
|
|
96
|
+
const next = state.map((p) => {
|
|
97
|
+
const limit =
|
|
98
|
+
p.status === "sending"
|
|
99
|
+
? SEND_TIMEOUT_MS
|
|
100
|
+
: p.status === "accepted"
|
|
101
|
+
? ACCEPTED_TIMEOUT_MS
|
|
102
|
+
: Infinity;
|
|
103
|
+
if (action.now - p.at < limit) return p;
|
|
104
|
+
changed = true;
|
|
105
|
+
return {
|
|
106
|
+
...p,
|
|
107
|
+
status: "failed" as const,
|
|
108
|
+
error: p.status === "sending" ? NO_ANSWER : NEVER_ARRIVED,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return changed ? next : state;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// What a new socket sends again: anything not failed and not over its sends.
|
|
117
|
+
// Safe to repeat: the server drops an id already in the chat or its queue.
|
|
118
|
+
export const toResend = (state: PendingSend[]) =>
|
|
119
|
+
state.filter((p) => p.status !== "failed" && p.sends < MAX_SENDS);
|
|
120
|
+
|
|
121
|
+
// A new socket that can't send again what's still pending marks it failed.
|
|
122
|
+
export const overSends = (state: PendingSend[]) =>
|
|
123
|
+
state.filter((p) => p.status !== "failed" && p.sends >= MAX_SENDS);
|
|
124
|
+
|
|
125
|
+
// getRandomValues, not randomUUID: a phone on plain http over the tailnet
|
|
126
|
+
// isn't a secure context.
|
|
127
|
+
export const clientId = () =>
|
|
128
|
+
`user-c-${Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) =>
|
|
129
|
+
b.toString(16).padStart(2, "0")
|
|
130
|
+
).join("")}`;
|
|
131
|
+
|
|
132
|
+
const keyFor = (sessionId: string) => `agentos:pending:${sessionId}`;
|
|
133
|
+
|
|
134
|
+
// Kept in this browser so a reload doesn't lose a failed article. Over
|
|
135
|
+
// quota, images go first and the words stay.
|
|
136
|
+
export function savePending(sessionId: string, state: PendingSend[]): void {
|
|
137
|
+
try {
|
|
138
|
+
const key = keyFor(sessionId);
|
|
139
|
+
if (!state.length) return localStorage.removeItem(key);
|
|
140
|
+
for (const kept of [
|
|
141
|
+
state,
|
|
142
|
+
state.map((p) => ({ ...p, images: undefined })),
|
|
143
|
+
]) {
|
|
144
|
+
try {
|
|
145
|
+
localStorage.setItem(key, JSON.stringify(kept));
|
|
146
|
+
return;
|
|
147
|
+
} catch {
|
|
148
|
+
// Too big; try without images.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Storage unavailable.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function loadPending(sessionId: string): PendingSend[] {
|
|
157
|
+
try {
|
|
158
|
+
const raw = localStorage.getItem(keyFor(sessionId));
|
|
159
|
+
const list = raw ? (JSON.parse(raw) as PendingSend[]) : [];
|
|
160
|
+
// From an earlier page: each is sent once more on connecting.
|
|
161
|
+
return Array.isArray(list)
|
|
162
|
+
? list.filter(
|
|
163
|
+
(p) => typeof p?.id === "string" && typeof p.text === "string"
|
|
164
|
+
)
|
|
165
|
+
: [];
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
package/lib/chat/runner.ts
CHANGED
|
@@ -384,6 +384,10 @@ const sentBy = ({ from, peer, origin }: SentBy): SentBy => ({
|
|
|
384
384
|
origin,
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
// Already a message in the chat or its queue.
|
|
388
|
+
const taken = (sessionId: string, id: string) =>
|
|
389
|
+
hasItem(sessionId, id) || listQueue(sessionId).some((q) => q.id === id);
|
|
390
|
+
|
|
387
391
|
export async function sendChat(
|
|
388
392
|
sessionId: string,
|
|
389
393
|
input: {
|
|
@@ -393,16 +397,20 @@ export async function sendChat(
|
|
|
393
397
|
queue?: boolean;
|
|
394
398
|
// Compact a stale chat first (lib/chat/stale).
|
|
395
399
|
compact?: CompactFirst;
|
|
400
|
+
// The client's own id for it, so a resend after a reconnect is a no-op.
|
|
401
|
+
id?: string;
|
|
396
402
|
} & SentBy
|
|
397
403
|
): Promise<void> {
|
|
398
404
|
const text = input.text.trim();
|
|
399
405
|
if (!text && !input.images?.length) return;
|
|
406
|
+
if (input.id && taken(sessionId, input.id)) return;
|
|
400
407
|
refuseAway(sessionId);
|
|
408
|
+
const id = input.id ?? `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
|
|
401
409
|
// Its worktree is still being set up, its task hasn't launched (held by
|
|
402
410
|
// Pause, say), or it's moving or landing: the message waits its turn.
|
|
403
411
|
if (settingUp(sessionId) || launchPending(sessionId) || chatHold(sessionId)) {
|
|
404
412
|
enqueue(sessionId, {
|
|
405
|
-
id
|
|
413
|
+
id,
|
|
406
414
|
text,
|
|
407
415
|
images: input.images,
|
|
408
416
|
...sentBy(input),
|
|
@@ -411,7 +419,8 @@ export async function sendChat(
|
|
|
411
419
|
return;
|
|
412
420
|
}
|
|
413
421
|
const live = await ensureLive(sessionId);
|
|
414
|
-
|
|
422
|
+
// Sent again while the worker was starting.
|
|
423
|
+
if (input.id && taken(sessionId, id)) return;
|
|
415
424
|
if (queuedIfHeld(sessionId, { ...input, id, text })) return;
|
|
416
425
|
live.worker.command({
|
|
417
426
|
type: "send",
|
package/lib/chat/socket.ts
CHANGED
|
@@ -145,7 +145,18 @@ function onChatMessage(
|
|
|
145
145
|
void Promise.resolve(p).catch(fail);
|
|
146
146
|
if (msg.type === "send") {
|
|
147
147
|
const send = clientSend(msg);
|
|
148
|
-
|
|
148
|
+
const id = send.id;
|
|
149
|
+
// Taken in now: a new worker can take seconds to start.
|
|
150
|
+
if (id) reply({ type: "accepted", id });
|
|
151
|
+
void sendChat(sessionId, { ...send, queue: true }).catch((err) => {
|
|
152
|
+
if (id)
|
|
153
|
+
reply({
|
|
154
|
+
type: "send_failed",
|
|
155
|
+
id,
|
|
156
|
+
message: err instanceof Error ? err.message : String(err),
|
|
157
|
+
});
|
|
158
|
+
fail(err);
|
|
159
|
+
});
|
|
149
160
|
// "Session 4" is named after its first message.
|
|
150
161
|
run(titleChatFromMessage(sessionId, send.text));
|
|
151
162
|
} else if (msg.type === "queue_edit" && typeof msg.text === "string")
|
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(
|