openmausbot 0.1.72 → 0.1.74
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/dist/assets/index-BRLHsQ5g.js +298 -0
- package/dist/assets/index-ByLQ7a3F.css +1 -0
- package/dist/assets/{index-Bpai86ua.js → index-CwoQdM0z.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +9 -0
- package/dist-server/drivers/agents-proxy.js +2 -2
- package/dist-server/index.js +721 -484
- package/dist-server/mcp-gate.js +58 -16
- package/dist-server/openmausbot.js +236 -80
- package/dist-server/pair-cli.js +236 -80
- package/dist-server/server/auto-approve.js +30 -206
- package/dist-server/server/channel-queue.js +20 -2
- package/dist-server/server/config.js +36 -1
- package/dist-server/server/drivers/acp/core.js +56 -3
- package/dist-server/server/drivers/acp/grok.js +3 -1
- package/dist-server/server/drivers/agents-proxy.js +2 -2
- package/dist-server/server/drivers/antigravity-acp.js +5 -4
- package/dist-server/server/drivers/antigravity.js +9 -4
- package/dist-server/server/drivers/claude.js +54 -11
- package/dist-server/server/drivers/codex.js +86 -27
- package/dist-server/server/engine-install.js +9 -5
- package/dist-server/server/index.js +210 -231
- package/dist-server/server/message-db.js +70 -3
- package/dist-server/server/procs.js +82 -23
- package/dist-server/server/routines.js +67 -8
- package/dist-server/server/steer-queue.js +27 -7
- package/dist-server/server/team-manifest.js +1 -1
- package/dist-server/server/vps-computer.js +81 -12
- package/dist-server/server/webhooks.js +4 -1
- package/dist-server/server/workspace-backup.js +24 -9
- package/dist-server/shared/approval-mode.js +11 -10
- package/dist-server/vps-container-mcp.js +9 -0
- package/package.json +1 -1
- package/dist/assets/index-BFPYw6P-.js +0 -296
- package/dist/assets/index-D1odUdTi.css +0 -1
- package/dist-server/server/auto-review.js +0 -79
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Legacy JSON thread files import lazily: the first read of a thread with
|
|
11
11
|
// no rows pulls the old file in, after which the DB is the source of
|
|
12
12
|
// truth (the JSON file is left behind as a one-time backup).
|
|
13
|
-
import { chmodSync, closeSync, existsSync, openSync, readFileSync, renameSync } from "node:fs";
|
|
13
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { DatabaseSync } from "node:sqlite";
|
|
16
16
|
import { DATA_DIR } from "./config.js";
|
|
@@ -19,6 +19,7 @@ const DB_FILE = () => join(DATA_DIR, "messages.db");
|
|
|
19
19
|
let handle = null;
|
|
20
20
|
let handlePath = null;
|
|
21
21
|
function open() {
|
|
22
|
+
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
22
23
|
const file = DB_FILE();
|
|
23
24
|
// Transcripts can contain private conversations and tool output. Create
|
|
24
25
|
// the database with owner-only permissions and also repair an existing
|
|
@@ -47,6 +48,16 @@ function open() {
|
|
|
47
48
|
thread_id TEXT PRIMARY KEY,
|
|
48
49
|
active_leaf_id TEXT
|
|
49
50
|
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS chat_followups (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
kind TEXT NOT NULL,
|
|
54
|
+
owner_id TEXT NOT NULL,
|
|
55
|
+
thread_id TEXT NOT NULL,
|
|
56
|
+
send_id TEXT,
|
|
57
|
+
status TEXT NOT NULL,
|
|
58
|
+
payload TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS chat_followups_receipt ON chat_followups(kind, owner_id, thread_id, send_id);
|
|
50
61
|
`);
|
|
51
62
|
ensureRecallIndex(db);
|
|
52
63
|
ensureMemoryIndex(db);
|
|
@@ -153,6 +164,59 @@ function db() {
|
|
|
153
164
|
handlePath = DB_FILE();
|
|
154
165
|
return handle;
|
|
155
166
|
}
|
|
167
|
+
/** A 202/cancel/dispatch claim must reach disk before publishing its result.
|
|
168
|
+
* Use the existing transcript DB (and backup path), with FULL sync for these
|
|
169
|
+
* small transactions only; ordinary transcript writes keep their policy. */
|
|
170
|
+
function writeFollowups(write) {
|
|
171
|
+
const connection = db();
|
|
172
|
+
connection.exec("PRAGMA synchronous = FULL");
|
|
173
|
+
try {
|
|
174
|
+
connection.exec("BEGIN IMMEDIATE");
|
|
175
|
+
try {
|
|
176
|
+
write(connection);
|
|
177
|
+
connection.exec("COMMIT");
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
connection.exec("ROLLBACK");
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
connection.exec("PRAGMA synchronous = NORMAL");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function saveChatFollowup(followup) {
|
|
189
|
+
writeFollowups((connection) => connection.prepare("INSERT INTO chat_followups(id, kind, owner_id, thread_id, send_id, status, payload) VALUES (?, ?, ?, ?, ?, 'pending', ?)").run(followup.id, followup.kind, followup.ownerId, followup.threadId, followup.payload.sendId ?? null, JSON.stringify(followup.payload)));
|
|
190
|
+
}
|
|
191
|
+
/** Null retires a dispatch whose canonical transcript is already durable.
|
|
192
|
+
* Cancellation tombstones remain so a retried sendId cannot resurrect it. */
|
|
193
|
+
export function settleChatFollowups(ids, status) {
|
|
194
|
+
if (!ids.length)
|
|
195
|
+
return;
|
|
196
|
+
writeFollowups((connection) => {
|
|
197
|
+
const statement = connection.prepare(status === null
|
|
198
|
+
? "DELETE FROM chat_followups WHERE id = ?"
|
|
199
|
+
: status === "cancelled"
|
|
200
|
+
? "UPDATE chat_followups SET status = ?, payload = '{\"text\":\"\"}' WHERE id = ?"
|
|
201
|
+
: "UPDATE chat_followups SET status = ? WHERE id = ?");
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
if (status === null)
|
|
204
|
+
statement.run(id);
|
|
205
|
+
else
|
|
206
|
+
statement.run(status, id);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
export function chatFollowups(kind) {
|
|
211
|
+
const rows = (kind
|
|
212
|
+
? db().prepare("SELECT * FROM chat_followups WHERE kind = ? ORDER BY rowid").all(kind)
|
|
213
|
+
: db().prepare("SELECT * FROM chat_followups ORDER BY rowid").all());
|
|
214
|
+
return rows.map((row) => ({ id: row.id, kind: row.kind, ownerId: row.owner_id, threadId: row.thread_id,
|
|
215
|
+
status: row.status, payload: JSON.parse(row.payload) }));
|
|
216
|
+
}
|
|
217
|
+
export function cancelledChatFollowup(kind, ownerId, threadId, sendId) {
|
|
218
|
+
return Boolean(db().prepare("SELECT 1 FROM chat_followups WHERE kind = ? AND owner_id = ? AND thread_id = ? AND send_id = ? AND status = 'cancelled'").get(kind, ownerId, threadId, sendId));
|
|
219
|
+
}
|
|
156
220
|
const rowToMessage = (row) => JSON.parse(row.json);
|
|
157
221
|
/** Read one thread, importing its legacy JSON file on first touch. */
|
|
158
222
|
export function readThread(threadId, legacyFile) {
|
|
@@ -268,8 +332,11 @@ export function setActiveLeaf(threadId, leafId) {
|
|
|
268
332
|
.run(threadId, leafId);
|
|
269
333
|
}
|
|
270
334
|
export function deleteThread(threadId) {
|
|
271
|
-
|
|
272
|
-
|
|
335
|
+
writeFollowups((connection) => {
|
|
336
|
+
connection.prepare("DELETE FROM chat_followups WHERE thread_id = ?").run(threadId);
|
|
337
|
+
connection.prepare("DELETE FROM messages WHERE thread_id = ?").run(threadId);
|
|
338
|
+
connection.prepare("DELETE FROM thread_state WHERE thread_id = ?").run(threadId);
|
|
339
|
+
});
|
|
273
340
|
}
|
|
274
341
|
/** Case-insensitive substring search over text messages, newest first.
|
|
275
342
|
* A LIKE scan, deliberately: local transcripts are megabytes at most, a
|
|
@@ -34,6 +34,10 @@ export function assertSafeCliArgv(resolved, platform = process.platform) {
|
|
|
34
34
|
error.code = "ENAMETOOLONG";
|
|
35
35
|
throw error;
|
|
36
36
|
}
|
|
37
|
+
// Only spawnCli establishes a private POSIX group. A plain spawned child
|
|
38
|
+
// (for example inside an MCP gate) may share our own group: never signal it.
|
|
39
|
+
const cliGroups = new WeakSet();
|
|
40
|
+
const stopping = new WeakMap();
|
|
37
41
|
export function spawnCli(cli, args, opts) {
|
|
38
42
|
const resolved = resolveCli(cli, args);
|
|
39
43
|
assertSafeCliArgv(resolved);
|
|
@@ -43,6 +47,8 @@ export function spawnCli(cli, args, opts) {
|
|
|
43
47
|
// win32: taskkill /T does the reaping instead (see killCliTree)
|
|
44
48
|
...(process.platform === "win32" ? { windowsHide: true } : { detached: true }),
|
|
45
49
|
}); // callers always pipe all three
|
|
50
|
+
if (process.platform !== "win32")
|
|
51
|
+
cliGroups.add(child);
|
|
46
52
|
// A write to a dying child's stdin fails differently per platform, and one
|
|
47
53
|
// of the ways is fatal. On POSIX the kill is synchronous, the stream is
|
|
48
54
|
// already destroyed by the time anything writes, and the write throws into
|
|
@@ -80,11 +86,78 @@ export function describeSpawnFailure(err, cli) {
|
|
|
80
86
|
};
|
|
81
87
|
return { message: `spawn failed: ${err.message}`, setup: false };
|
|
82
88
|
}
|
|
83
|
-
/** Stop
|
|
89
|
+
/** Stop an owned CLI group, including helpers after its root exits. timeoutMs
|
|
90
|
+
* is the TERM grace period; SIGKILL gets one further second to settle. Helpers
|
|
91
|
+
* that deliberately detach into another group are outside this ownership. */
|
|
84
92
|
export function killCliTree(child, timeoutMs = 5_000) {
|
|
93
|
+
const existing = stopping.get(child);
|
|
94
|
+
if (existing)
|
|
95
|
+
return existing;
|
|
85
96
|
const pid = child.pid;
|
|
86
|
-
if (!pid
|
|
97
|
+
if (!pid)
|
|
87
98
|
return Promise.resolve(true);
|
|
99
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid)
|
|
100
|
+
return Promise.resolve(false);
|
|
101
|
+
const run = stopCliTree(child, pid, Number.isFinite(timeoutMs) ? Math.max(0, timeoutMs) : 5_000);
|
|
102
|
+
stopping.set(child, run);
|
|
103
|
+
// A failed attempt may be retried. Remember success so a delayed caller
|
|
104
|
+
// cannot signal a reused PID/group after this child's group disappeared.
|
|
105
|
+
void run.then((stopped) => { if (!stopped)
|
|
106
|
+
stopping.delete(child); });
|
|
107
|
+
return run;
|
|
108
|
+
}
|
|
109
|
+
async function stopCliTree(child, pid, timeoutMs) {
|
|
110
|
+
const exited = () => child.exitCode !== null || child.signalCode !== null;
|
|
111
|
+
if (process.platform !== "win32") {
|
|
112
|
+
const group = cliGroups.has(child);
|
|
113
|
+
const settled = () => {
|
|
114
|
+
if (!exited())
|
|
115
|
+
return false;
|
|
116
|
+
if (!group)
|
|
117
|
+
return true;
|
|
118
|
+
try {
|
|
119
|
+
process.kill(-pid, 0);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
// EPERM and other failures do not prove that the group is gone.
|
|
124
|
+
return error.code === "ESRCH";
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const signal = (value) => {
|
|
128
|
+
try {
|
|
129
|
+
if (group)
|
|
130
|
+
process.kill(-pid, value);
|
|
131
|
+
else
|
|
132
|
+
child.kill(value);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
try {
|
|
136
|
+
child.kill(value);
|
|
137
|
+
}
|
|
138
|
+
catch { /* report uncertainty below */ }
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const wait = async (ms) => {
|
|
142
|
+
const deadline = Date.now() + ms;
|
|
143
|
+
while (!settled()) {
|
|
144
|
+
const remaining = deadline - Date.now();
|
|
145
|
+
if (remaining <= 0)
|
|
146
|
+
return false;
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(25, remaining)));
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
if (settled())
|
|
152
|
+
return true;
|
|
153
|
+
signal("SIGTERM");
|
|
154
|
+
if (await wait(timeoutMs))
|
|
155
|
+
return true;
|
|
156
|
+
signal("SIGKILL");
|
|
157
|
+
return wait(1_000);
|
|
158
|
+
}
|
|
159
|
+
if (exited())
|
|
160
|
+
return true;
|
|
88
161
|
return new Promise((resolve) => {
|
|
89
162
|
let timer;
|
|
90
163
|
const done = (stopped) => {
|
|
@@ -96,32 +169,18 @@ export function killCliTree(child, timeoutMs = 5_000) {
|
|
|
96
169
|
child.once("close", closed);
|
|
97
170
|
timer = setTimeout(() => done(false), timeoutMs);
|
|
98
171
|
timer.unref?.();
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return;
|
|
103
|
-
try {
|
|
104
|
-
// taskkill is unavailable or the tree lookup failed. At least stop
|
|
105
|
-
// the process we own instead of leaving the entire turn running.
|
|
106
|
-
child.kill();
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
/* already gone */
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
try {
|
|
115
|
-
process.kill(-pid, "SIGTERM");
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
172
|
+
execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (err) => {
|
|
173
|
+
if (!err)
|
|
174
|
+
return;
|
|
118
175
|
try {
|
|
119
|
-
|
|
176
|
+
// taskkill is unavailable or the tree lookup failed. At least stop
|
|
177
|
+
// the process we own instead of leaving the entire turn running.
|
|
178
|
+
child.kill();
|
|
120
179
|
}
|
|
121
180
|
catch {
|
|
122
181
|
/* already gone */
|
|
123
182
|
}
|
|
124
|
-
}
|
|
183
|
+
});
|
|
125
184
|
});
|
|
126
185
|
}
|
|
127
186
|
/** Per-turn broker channel: unix socket on POSIX, named pipe on Windows
|
|
@@ -6,6 +6,14 @@ import { DATA_DIR } from "./config.js";
|
|
|
6
6
|
import { writeFileAtomic } from "./atomic.js";
|
|
7
7
|
import { redactSecretsInText } from "./redact.js";
|
|
8
8
|
const persistedSourceThreadId = z.string().trim().min(1).optional().catch(undefined);
|
|
9
|
+
const webhookRunReceiptSchema = z.object({
|
|
10
|
+
webhookId: z.string().min(1).max(200),
|
|
11
|
+
deliveryId: z.string().min(1).max(200),
|
|
12
|
+
runId: z.string().min(1),
|
|
13
|
+
acceptedAt: z.number().finite().nonnegative(),
|
|
14
|
+
});
|
|
15
|
+
const WEBHOOK_RETRY_WINDOW_MS = 7 * 24 * 60 * 60_000;
|
|
16
|
+
const MAX_WEBHOOK_RECEIPTS = 20_000;
|
|
9
17
|
function routineRequestOwnerKey(owner) {
|
|
10
18
|
return JSON.stringify([owner.requestId, owner.messageId, owner.botId, owner.threadId]);
|
|
11
19
|
}
|
|
@@ -426,6 +434,7 @@ export class RoutineManager {
|
|
|
426
434
|
routines = [];
|
|
427
435
|
runs = [];
|
|
428
436
|
routineRequestReceipts = [];
|
|
437
|
+
webhookRunReceipts = [];
|
|
429
438
|
timer = null;
|
|
430
439
|
ticking = false;
|
|
431
440
|
constructor(options) {
|
|
@@ -486,11 +495,29 @@ export class RoutineManager {
|
|
|
486
495
|
typeof receipt?.resultId === "string" &&
|
|
487
496
|
Number.isFinite(receipt?.appliedAt))
|
|
488
497
|
: [];
|
|
498
|
+
this.webhookRunReceipts = Array.isArray(disk.webhookRunReceipts)
|
|
499
|
+
? disk.webhookRunReceipts.flatMap((receipt) => {
|
|
500
|
+
const parsed = webhookRunReceiptSchema.safeParse(receipt);
|
|
501
|
+
return parsed.success ? [parsed.data] : [];
|
|
502
|
+
})
|
|
503
|
+
: [];
|
|
504
|
+
// Upgrade old run logs before history pruning can discard their IDs.
|
|
505
|
+
const known = new Set(this.webhookRunReceipts.map((r) => JSON.stringify([r.webhookId, r.deliveryId])));
|
|
506
|
+
for (const run of this.runs) {
|
|
507
|
+
if (!run.webhookId || !run.deliveryId || run.createdAt < this.now() - WEBHOOK_RETRY_WINDOW_MS)
|
|
508
|
+
continue;
|
|
509
|
+
const key = JSON.stringify([run.webhookId, run.deliveryId]);
|
|
510
|
+
if (known.has(key))
|
|
511
|
+
continue;
|
|
512
|
+
this.webhookRunReceipts.push({ webhookId: run.webhookId, deliveryId: run.deliveryId, runId: run.id, acceptedAt: run.createdAt });
|
|
513
|
+
known.add(key);
|
|
514
|
+
}
|
|
489
515
|
}
|
|
490
516
|
catch {
|
|
491
517
|
this.routines = [];
|
|
492
518
|
this.runs = [];
|
|
493
519
|
this.routineRequestReceipts = [];
|
|
520
|
+
this.webhookRunReceipts = [];
|
|
494
521
|
}
|
|
495
522
|
// A local process cannot still own these turns after a full restart.
|
|
496
523
|
const recovered = [];
|
|
@@ -828,11 +855,22 @@ export class RoutineManager {
|
|
|
828
855
|
queueMicrotask(() => void this.tick());
|
|
829
856
|
return cloneRun(run);
|
|
830
857
|
}
|
|
831
|
-
/**
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
858
|
+
/** Look up an accepted delivery independently of run-log retention. */
|
|
859
|
+
webhookRunReceipt(webhookId, deliveryId) {
|
|
860
|
+
const receipt = this.webhookRunReceipts.find((candidate) => candidate.webhookId === webhookId && candidate.deliveryId === deliveryId &&
|
|
861
|
+
candidate.acceptedAt >= this.now() - WEBHOOK_RETRY_WINDOW_MS);
|
|
862
|
+
if (receipt)
|
|
863
|
+
return { id: receipt.runId };
|
|
864
|
+
// Never duplicate work that is still pending, even beyond the retry window.
|
|
865
|
+
const active = this.runs.find((run) => run.webhookId === webhookId && run.deliveryId === deliveryId &&
|
|
866
|
+
["queued", "running", "waiting"].includes(run.status));
|
|
867
|
+
return active ? { id: active.id } : null;
|
|
868
|
+
}
|
|
869
|
+
/** Queue webhook work through the same dispatcher as scheduled routines. */
|
|
835
870
|
enqueueWebhook(input) {
|
|
871
|
+
const existing = this.webhookRunReceipt(input.webhookId, input.deliveryId);
|
|
872
|
+
if (existing)
|
|
873
|
+
return existing;
|
|
836
874
|
if (this.options.botState(input.botId) === "missing") {
|
|
837
875
|
throw Object.assign(new Error("The assigned MAUS no longer exists"), { status: 410 });
|
|
838
876
|
}
|
|
@@ -853,8 +891,26 @@ export class RoutineManager {
|
|
|
853
891
|
attachments: [],
|
|
854
892
|
createdAt: this.now(),
|
|
855
893
|
};
|
|
894
|
+
const previousReceipts = this.webhookRunReceipts;
|
|
895
|
+
const receipts = previousReceipts.filter((receipt) => receipt.acceptedAt >= this.now() - WEBHOOK_RETRY_WINDOW_MS);
|
|
896
|
+
// Never evict a promised retry identity to admit fresh work.
|
|
897
|
+
if (receipts.length >= MAX_WEBHOOK_RECEIPTS) {
|
|
898
|
+
throw Object.assign(new Error("Webhook retry history is full; try again later"), { status: 429 });
|
|
899
|
+
}
|
|
900
|
+
receipts.push({ webhookId: input.webhookId, deliveryId: input.deliveryId, runId: run.id, acceptedAt: this.now() });
|
|
901
|
+
const previousRuns = this.runs.slice();
|
|
856
902
|
this.runs.push(run);
|
|
857
|
-
this.
|
|
903
|
+
this.webhookRunReceipts = receipts;
|
|
904
|
+
try {
|
|
905
|
+
this.save();
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
// Restore pruned history too, retaining the live run objects that tick()
|
|
909
|
+
// may be awaiting rather than replacing them with cloned snapshots.
|
|
910
|
+
this.runs = previousRuns;
|
|
911
|
+
this.webhookRunReceipts = previousReceipts;
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
858
914
|
this.emitRun(run);
|
|
859
915
|
queueMicrotask(() => void this.tick());
|
|
860
916
|
return cloneRun(run);
|
|
@@ -1044,9 +1100,11 @@ export class RoutineManager {
|
|
|
1044
1100
|
}
|
|
1045
1101
|
// Oldest queued requests have priority. New manual/webhook arrivals
|
|
1046
1102
|
// must not continually overtake work that has already waited. Snapshot
|
|
1047
|
-
//
|
|
1048
|
-
|
|
1049
|
-
|
|
1103
|
+
// IDs retain order across awaits without holding stale objects after
|
|
1104
|
+
// another request rolls back a failed routine-definition write.
|
|
1105
|
+
for (const id of this.runs.map((run) => run.id)) {
|
|
1106
|
+
const run = this.runs.find((candidate) => candidate.id === id);
|
|
1107
|
+
if (!run || run.status !== "queued")
|
|
1050
1108
|
continue;
|
|
1051
1109
|
// A queued interval represents the latest useful check, not a backlog
|
|
1052
1110
|
// item. If the bot stayed busy across later occurrences, align this
|
|
@@ -1430,6 +1488,7 @@ export class RoutineManager {
|
|
|
1430
1488
|
routines: this.routines,
|
|
1431
1489
|
runs: this.runs,
|
|
1432
1490
|
routineRequestReceipts: this.routineRequestReceipts,
|
|
1491
|
+
webhookRunReceipts: this.webhookRunReceipts,
|
|
1433
1492
|
}, null, 2), { mode: 0o600 });
|
|
1434
1493
|
}
|
|
1435
1494
|
}
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// here until the bot settles, then lands in the thread and runs as ONE
|
|
5
5
|
// follow-up turn whose prompt is the queued texts separated by a blank line.
|
|
6
6
|
//
|
|
7
|
-
// The queue is
|
|
7
|
+
// The durable queue is NOT in `messages[]` while the current
|
|
8
8
|
// turn is running: appending immediately would make the queued line the
|
|
9
9
|
// active leaf, so remaining tool/assistant events of *this* turn would
|
|
10
|
-
// hang off a user line the model has not seen. Restart
|
|
11
|
-
//
|
|
10
|
+
// hang off a user line the model has not seen. Restart restores only sends
|
|
11
|
+
// whose dispatch has not begun. The composer shows a pending chip
|
|
12
12
|
// until drain appends the words.
|
|
13
13
|
//
|
|
14
14
|
// Unlike the delegation drain, an interrupted or failed turn does NOT
|
|
@@ -17,7 +17,20 @@
|
|
|
17
17
|
// stop-then-steer (queue a correction, hit Stop, the correction runs) is
|
|
18
18
|
// the feature.
|
|
19
19
|
import { newId } from "./contracts.js";
|
|
20
|
+
import { chatFollowups, saveChatFollowup, settleChatFollowups } from "./message-db.js";
|
|
20
21
|
const queues = new Map(); // threadId → waiting sends
|
|
22
|
+
export function restoreSteeredMessages() {
|
|
23
|
+
queues.clear();
|
|
24
|
+
for (const row of chatFollowups("bot")) {
|
|
25
|
+
if (row.status !== "pending")
|
|
26
|
+
continue;
|
|
27
|
+
const entry = queues.get(row.threadId) ?? { botId: row.ownerId, items: [] };
|
|
28
|
+
if (entry.botId !== row.ownerId)
|
|
29
|
+
throw new Error("queued task belongs to another bot");
|
|
30
|
+
entry.items.push({ ...row.payload, messageId: row.id, prompt: row.payload.prompt ?? row.payload.text });
|
|
31
|
+
queues.set(row.threadId, entry);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
21
34
|
const listeners = new Set();
|
|
22
35
|
const changed = () => {
|
|
23
36
|
for (const listener of listeners) {
|
|
@@ -50,7 +63,7 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
|
|
|
50
63
|
// queues even if a corrupt caller reuses a thread id.
|
|
51
64
|
if (entry.botId !== botId)
|
|
52
65
|
throw new Error("queued task belongs to another bot");
|
|
53
|
-
|
|
66
|
+
const item = {
|
|
54
67
|
messageId: id,
|
|
55
68
|
text,
|
|
56
69
|
prompt: options.prompt ?? text,
|
|
@@ -58,7 +71,9 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
|
|
|
58
71
|
sendId: options.sendId,
|
|
59
72
|
reason: options.reason,
|
|
60
73
|
unattended: options.unattended,
|
|
61
|
-
}
|
|
74
|
+
};
|
|
75
|
+
saveChatFollowup({ id, kind: "bot", ownerId: botId, threadId, payload: item });
|
|
76
|
+
entry.items.push(item);
|
|
62
77
|
queues.set(threadId, entry);
|
|
63
78
|
changed();
|
|
64
79
|
return { id };
|
|
@@ -93,6 +108,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
|
|
|
93
108
|
: store.bot(entry.botId);
|
|
94
109
|
if (!bot) {
|
|
95
110
|
// the bot or task was deleted while messages waited
|
|
111
|
+
settleChatFollowups(entry.items.map((item) => item.messageId), "cancelled");
|
|
96
112
|
queues.delete(threadId);
|
|
97
113
|
changed();
|
|
98
114
|
continue;
|
|
@@ -101,6 +117,8 @@ export function drainSteeredMessages(store, run, isBlocked) {
|
|
|
101
117
|
continue;
|
|
102
118
|
// committed to draining: the entry leaves the map before anything runs,
|
|
103
119
|
// so a settle racing another settle can never fire the same queue twice
|
|
120
|
+
const ids = entry.items.map((item) => item.messageId);
|
|
121
|
+
settleChatFollowups(ids, "dispatching");
|
|
104
122
|
queues.delete(threadId);
|
|
105
123
|
changed();
|
|
106
124
|
const appended = [];
|
|
@@ -124,10 +142,11 @@ export function drainSteeredMessages(store, run, isBlocked) {
|
|
|
124
142
|
// message into one HTML block, which makes that attachment stop being a
|
|
125
143
|
// native image when the combined follow-up is dispatched.
|
|
126
144
|
const prompt = entry.items.map((item) => item.prompt).join("\n\n");
|
|
127
|
-
|
|
145
|
+
const running = run(entry.botId, threadId, prompt, last, appended.map((message) => message.id),
|
|
128
146
|
// one unattended line makes the whole drained turn unattended: a
|
|
129
147
|
// person's words in the same queue cannot re-attend a bot's own
|
|
130
148
|
entry.items.some((item) => item.unattended === true));
|
|
149
|
+
void Promise.resolve(running).then(() => settleChatFollowups(ids, null), () => settleChatFollowups(ids, "interrupted")).catch((error) => console.warn("steer-queue: could not settle durable follow-up", error));
|
|
131
150
|
}
|
|
132
151
|
}
|
|
133
152
|
/** Find the receipt for a retry whose message is still waiting to drain. */
|
|
@@ -141,7 +160,7 @@ export function queuedSteeredMessage(botId, threadId, sendId) {
|
|
|
141
160
|
/** Drop one waiting send owned by this bot so it never drains. The queue id
|
|
142
161
|
* is stable even if the bot switches away from the task while the request is
|
|
143
162
|
* in flight. Returns false when it was already drained, belongs to another
|
|
144
|
-
* bot, or
|
|
163
|
+
* bot, or dispatch has already started. */
|
|
145
164
|
export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
|
|
146
165
|
for (const [threadId, entry] of queues) {
|
|
147
166
|
if (entry.botId !== botId || (expectedThreadId !== undefined && threadId !== expectedThreadId))
|
|
@@ -149,6 +168,7 @@ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
|
|
|
149
168
|
const items = entry.items.filter((item) => item.messageId !== messageId);
|
|
150
169
|
if (items.length === entry.items.length)
|
|
151
170
|
continue;
|
|
171
|
+
settleChatFollowups([messageId], "cancelled");
|
|
152
172
|
if (items.length === 0)
|
|
153
173
|
queues.delete(threadId);
|
|
154
174
|
else
|
|
@@ -139,7 +139,7 @@ export function parseTeamManifest(value) {
|
|
|
139
139
|
*
|
|
140
140
|
* 1. Allowlist, not blocklist. The returned object is built field by field
|
|
141
141
|
* from the parsed member, so every privilege-bearing BotRecord field —
|
|
142
|
-
* approvalMode, autoApprove,
|
|
142
|
+
* approvalMode, autoApprove, alwaysAllow, chiefOfStaff, approvePeerComms, composio,
|
|
143
143
|
* computer, cloudBackend, cwd — is structurally absent, whatever the
|
|
144
144
|
* file claimed. parseTeamManifest already drops unknown member keys;
|
|
145
145
|
* this keeps the guarantee even if the schema grows a field later,
|
|
@@ -35,6 +35,17 @@ const MANAGED_VPS_CONTAINER_NAME = /^openmausbot-vps-[a-z0-9]{1,12}-[a-f0-9]{12}
|
|
|
35
35
|
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/i;
|
|
36
36
|
const PIDS_LIMIT = 512;
|
|
37
37
|
const SCREENSHOT_PATH = "/tmp/openmausbot-vps-preview.png";
|
|
38
|
+
// The Cua XFCE base includes Pillow in its existing Python environment. Keep
|
|
39
|
+
// this panel-only conversion in the transfer exec: no extra SSH round trip,
|
|
40
|
+
// image rebuild, driver settings change, or second temporary image. Older
|
|
41
|
+
// containers without Pillow can still return the original PNG.
|
|
42
|
+
const SCREENSHOT_TRANSFER = `/opt/venv/bin/python -I -c 'import base64, io, sys
|
|
43
|
+
from PIL import Image
|
|
44
|
+
with Image.open(sys.argv[1]) as image:
|
|
45
|
+
image.thumbnail((1280, 1280))
|
|
46
|
+
output = io.BytesIO()
|
|
47
|
+
image.convert("RGB").save(output, format="JPEG", quality=70)
|
|
48
|
+
sys.stdout.write(base64.b64encode(output.getvalue()).decode("ascii"))' "$1" 2>/dev/null || { base64 < "$1" | tr -d "\\n"; }`;
|
|
38
49
|
const INTERNAL_VIEWER_PORT = 6901;
|
|
39
50
|
const VIEWER_VERSION = "1";
|
|
40
51
|
const lifecycleLocks = new Map();
|
|
@@ -60,6 +71,9 @@ const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
|
|
|
60
71
|
// pattern as container-computer's screenshotStatusCache, and the same TTL.
|
|
61
72
|
const STATUS_CACHE_TTL_MS = 10_000;
|
|
62
73
|
const statusCache = new Map();
|
|
74
|
+
const SCREENSHOT_BUDGET_MS = 45_000;
|
|
75
|
+
const SCREENSHOT_CLEANUP_BUDGET_MS = 10_000;
|
|
76
|
+
const pendingScreenshots = new Map();
|
|
63
77
|
const viewerConnections = new Map();
|
|
64
78
|
const desktopTunnels = new Map();
|
|
65
79
|
function containerNamePart(botId) {
|
|
@@ -494,6 +508,9 @@ export async function vpsComputerStatus(cfg, botId, runner = defaultRunner) {
|
|
|
494
508
|
const key = vpsLockKey(cfg, botId);
|
|
495
509
|
const cacheable = runner === defaultRunner && key !== null;
|
|
496
510
|
if (cacheable) {
|
|
511
|
+
// A capture already checks this exact target. Let it finish instead of
|
|
512
|
+
// opening another six SSH connections while its readiness check is cold.
|
|
513
|
+
await pendingScreenshots.get(key)?.catch(() => { });
|
|
497
514
|
const cached = statusCache.get(key);
|
|
498
515
|
if (cached && cached.expiresAt > Date.now())
|
|
499
516
|
return cached.status;
|
|
@@ -1042,22 +1059,56 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
|
|
|
1042
1059
|
if (!alias)
|
|
1043
1060
|
throw Object.assign(new Error("VPS is not configured"), { status: 409 });
|
|
1044
1061
|
const key = `${alias}:${vpsContainerName(botId)}`;
|
|
1062
|
+
const pending = pendingScreenshots.get(key);
|
|
1063
|
+
if (pending)
|
|
1064
|
+
return pending;
|
|
1045
1065
|
const cacheable = runner === defaultRunner;
|
|
1046
|
-
|
|
1066
|
+
const deadline = Date.now() + SCREENSHOT_BUDGET_MS;
|
|
1067
|
+
const workDeadline = deadline - SCREENSHOT_CLEANUP_BUDGET_MS;
|
|
1068
|
+
let budgetExpired = false;
|
|
1069
|
+
const timeoutError = () => Object.assign(new Error("The VPS screen preview timed out. Retry the preview when the connection recovers."), { status: 504 });
|
|
1070
|
+
// Clamp each command and wait through the runner's termination grace,
|
|
1071
|
+
// rather than racing its promise and releasing the lock before cleanup.
|
|
1072
|
+
const boundedRunner = async (args, options = {}) => {
|
|
1073
|
+
const remaining = workDeadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS;
|
|
1074
|
+
if (remaining <= 0) {
|
|
1075
|
+
budgetExpired = true;
|
|
1076
|
+
throw timeoutError();
|
|
1077
|
+
}
|
|
1078
|
+
try {
|
|
1079
|
+
const result = await runner(args, { ...options, timeoutMs: Math.min(options.timeoutMs ?? 120_000, remaining) });
|
|
1080
|
+
if (Date.now() >= workDeadline) {
|
|
1081
|
+
budgetExpired = true;
|
|
1082
|
+
throw timeoutError();
|
|
1083
|
+
}
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
if (Date.now() >= workDeadline - COMMAND_TIMEOUT_KILL_GRACE_MS)
|
|
1088
|
+
budgetExpired = true;
|
|
1089
|
+
throw budgetExpired ? timeoutError() : error;
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
const capture = withVpsLifecycleLock(key, async () => {
|
|
1047
1093
|
// Same shape as containerComputerScreenshot's screenshotStatusCache: the
|
|
1048
1094
|
// poller runs every few seconds, and re-verifying the whole container
|
|
1049
1095
|
// between frames multiplied every frame's SSH cost.
|
|
1050
1096
|
const cached = cacheable ? statusCache.get(key) : undefined;
|
|
1051
1097
|
const status = cached && cached.expiresAt > Date.now()
|
|
1052
1098
|
? cached.status
|
|
1053
|
-
: await computeVpsComputerStatus(cfg, botId,
|
|
1099
|
+
: await computeVpsComputerStatus(cfg, botId, boundedRunner);
|
|
1100
|
+
// Status converts transport errors into displayable state. A deadline is
|
|
1101
|
+
// still a timeout, not evidence that this container became incompatible.
|
|
1102
|
+
if (budgetExpired) {
|
|
1103
|
+
if (cacheable)
|
|
1104
|
+
statusCache.delete(key);
|
|
1105
|
+
throw timeoutError();
|
|
1106
|
+
}
|
|
1054
1107
|
if (!status.ready) {
|
|
1055
1108
|
if (cacheable)
|
|
1056
1109
|
statusCache.delete(key);
|
|
1057
1110
|
throw Object.assign(new Error(status.problem ?? "The VPS computer is not ready"), { status: 409 });
|
|
1058
1111
|
}
|
|
1059
|
-
if (cacheable)
|
|
1060
|
-
statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
|
|
1061
1112
|
const containerRef = status.container_id ?? status.container_name;
|
|
1062
1113
|
// The ref goes straight into docker argv, and a cached status is one
|
|
1063
1114
|
// more step removed from the inspect that produced it — revalidate the
|
|
@@ -1065,23 +1116,26 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
|
|
|
1065
1116
|
if (!CONTAINER_ID.test(containerRef) && !CONTAINER_NAME.test(containerRef)) {
|
|
1066
1117
|
throw Object.assign(new Error("the VPS container reference is malformed"), { status: 409 });
|
|
1067
1118
|
}
|
|
1119
|
+
let frame;
|
|
1068
1120
|
try {
|
|
1069
|
-
await
|
|
1070
|
-
const encoded = (await
|
|
1121
|
+
await boundedRunner(vpsDockerArgs(alias, cuaExecArgs(["call", "get_desktop_state", "{}", "--socket", CUA_SOCKET, "--screenshot-out-file", SCREENSHOT_PATH], { container: containerRef })), { timeoutMs: 30_000 });
|
|
1122
|
+
const encoded = (await boundedRunner(vpsDockerArgs(alias, [
|
|
1071
1123
|
"exec",
|
|
1072
1124
|
"-u",
|
|
1073
1125
|
"cua",
|
|
1074
1126
|
"-e",
|
|
1075
1127
|
"HOME=/home/cua",
|
|
1076
1128
|
containerRef,
|
|
1077
|
-
"
|
|
1078
|
-
"-
|
|
1129
|
+
"sh",
|
|
1130
|
+
"-c",
|
|
1131
|
+
SCREENSHOT_TRANSFER,
|
|
1132
|
+
"openmausbot-preview",
|
|
1079
1133
|
SCREENSHOT_PATH,
|
|
1080
1134
|
]), { timeoutMs: 30_000 })).stdout.trim();
|
|
1081
1135
|
const checked = wholeScreenshot(Buffer.from(encoded, "base64"));
|
|
1082
1136
|
if (!checked.ok)
|
|
1083
1137
|
throw Object.assign(new Error("Cua Driver returned an incomplete VPS screenshot"), { status: 502 });
|
|
1084
|
-
|
|
1138
|
+
frame = { png: encoded, format: checked.mime === "image/jpeg" ? "jpeg" : "png" };
|
|
1085
1139
|
}
|
|
1086
1140
|
catch (error) {
|
|
1087
1141
|
// The failure may mean the world changed (container stopped, link
|
|
@@ -1091,9 +1145,24 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
|
|
|
1091
1145
|
throw error;
|
|
1092
1146
|
}
|
|
1093
1147
|
finally {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1148
|
+
const cleanupMs = Math.min(5_000, deadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS);
|
|
1149
|
+
if (cleanupMs > 0)
|
|
1150
|
+
await runner(vpsDockerArgs(alias, ["exec", "-u", "cua", containerRef, "rm", "-f", SCREENSHOT_PATH]), {
|
|
1151
|
+
timeoutMs: cleanupMs,
|
|
1152
|
+
}).catch(() => { });
|
|
1097
1153
|
}
|
|
1154
|
+
// Start the TTL after transfer and cleanup; a slow but healthy frame must
|
|
1155
|
+
// not return with its own readiness cache already expired.
|
|
1156
|
+
if (cacheable)
|
|
1157
|
+
statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
|
|
1158
|
+
return frame;
|
|
1098
1159
|
});
|
|
1160
|
+
pendingScreenshots.set(key, capture);
|
|
1161
|
+
try {
|
|
1162
|
+
return await capture;
|
|
1163
|
+
}
|
|
1164
|
+
finally {
|
|
1165
|
+
if (pendingScreenshots.get(key) === capture)
|
|
1166
|
+
pendingScreenshots.delete(key);
|
|
1167
|
+
}
|
|
1099
1168
|
}
|
|
@@ -370,7 +370,10 @@ export class WebhookManager {
|
|
|
370
370
|
const requestedDeliveryId = String(event.deliveryId ?? "").trim().slice(0, 200);
|
|
371
371
|
if (requestedDeliveryId) {
|
|
372
372
|
const key = `${trigger.endpointId}:${requestedDeliveryId}`;
|
|
373
|
-
const
|
|
373
|
+
const committed = this.options.findRun?.(trigger.id, requestedDeliveryId);
|
|
374
|
+
const duplicate = committed
|
|
375
|
+
? { runId: committed.id }
|
|
376
|
+
: this.deliveries.find((delivery) => delivery.key === key);
|
|
374
377
|
if (duplicate) {
|
|
375
378
|
this.appendAttempt(trigger, event, {
|
|
376
379
|
outcome: "duplicate",
|