@saadnvd1/agent-os 0.3.516 → 0.3.518
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/README.md +47 -3
- package/app/api/devices/route.ts +2 -0
- package/app/api/push/test/route.test.ts +112 -0
- package/app/api/push/test/route.ts +38 -0
- package/app/api/tasks/[id]/arm/route.ts +5 -0
- package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
- package/app/api/tasks/[id]/done/route.ts +15 -0
- package/app/api/tasks/[id]/export/route.ts +6 -1
- package/app/api/tasks/[id]/merge/route.ts +9 -1
- package/app/api/tasks/[id]/message/route.ts +39 -0
- package/app/api/tasks/[id]/moved/route.ts +6 -1
- package/app/api/tasks/[id]/resume/route.ts +6 -1
- package/app/api/tasks/[id]/stop/route.ts +14 -0
- package/app/api/tasks/import/route.ts +11 -1
- package/components/Devices/DeviceRow.tsx +52 -3
- package/components/Devices/DevicesPanel.tsx +13 -0
- package/data/devices/queries.ts +30 -2
- package/lib/away/run.ts +15 -3
- package/lib/bus/index.ts +15 -2
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/connect/control-api.ts +38 -0
- package/lib/db/migrations.ts +56 -0
- package/lib/db/types.ts +7 -0
- package/lib/done/bulk.ts +8 -0
- package/lib/done/index.ts +20 -1
- package/lib/load/alert.ts +5 -1
- package/lib/models/plan-step.ts +13 -3
- package/lib/notify/index.ts +46 -7
- package/lib/notify/limiter.ts +6 -1
- package/lib/notify/push/encode.ts +42 -0
- package/lib/notify/push/events.ts +72 -0
- package/lib/notify/push/index.ts +183 -0
- package/lib/notify/push/outbox.ts +238 -0
- package/lib/notify/push/policy.ts +33 -0
- package/lib/notify/push/presence.ts +45 -0
- package/lib/notify/push/relay.ts +50 -0
- package/lib/notify/push/sender.ts +271 -0
- package/lib/notify/push/testing.ts +30 -0
- package/lib/notify/push/waiting.ts +29 -0
- package/lib/notify/push-types.ts +2 -0
- package/lib/notify/redact.ts +13 -2
- package/lib/orchestrator/act.ts +41 -1
- package/lib/orchestrator/armed.ts +6 -1
- package/lib/orchestrator/asks.ts +5 -1
- package/lib/orchestrator/brakes.ts +9 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/facts.ts +72 -21
- package/lib/orchestrator/read.ts +14 -2
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +15 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +11 -0
- package/lib/orchestrator/tools.ts +1 -0
- package/lib/program-status/tap.ts +11 -1
- package/lib/schedules/start.ts +11 -2
- package/lib/security/devices.ts +57 -4
- package/lib/sessions/stop.ts +15 -1
- package/lib/tasks/branch-worktree.ts +16 -1
- package/lib/tasks/finish.ts +7 -1
- package/lib/tasks/held-remote.ts +80 -0
- package/lib/tasks/held-routes.ts +33 -0
- package/lib/tasks/held.ts +28 -0
- package/lib/tasks/import.ts +60 -7
- package/lib/tasks/index.ts +11 -1
- package/lib/tasks/last-active.ts +25 -0
- package/lib/tasks/move-bundle.ts +53 -1
- package/lib/tasks/move-carry.ts +102 -0
- package/lib/tasks/move-flow.ts +34 -7
- package/lib/tasks/move-guard.ts +17 -13
- package/lib/tasks/move-recover.ts +2 -0
- package/lib/tasks/move.ts +34 -3
- package/lib/tasks/remote.ts +3 -0
- package/lib/tasks/session.ts +27 -3
- package/package.json +1 -1
- package/server.ts +2 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drains the push outbox into the Connect relay. Never throws at a caller and
|
|
3
|
+
* never blocks one: `kick()` starts a drain and returns. Retries back off;
|
|
4
|
+
* a relay that says push isn't set up is left alone for ten minutes instead
|
|
5
|
+
* of being asked per message, and what was waiting falls back to Telegram.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PushRequestBody } from "@/lib/connect/control-api";
|
|
9
|
+
import {
|
|
10
|
+
getDevicePush,
|
|
11
|
+
pruneDevicePush,
|
|
12
|
+
setDevicePushError,
|
|
13
|
+
} from "@/lib/security/devices";
|
|
14
|
+
import * as outbox from "./outbox";
|
|
15
|
+
import { relaySend, type RelayAnswer } from "./relay";
|
|
16
|
+
import { isTestKey, type OutboxRow } from "./outbox";
|
|
17
|
+
|
|
18
|
+
export const MAX_ATTEMPTS = 3;
|
|
19
|
+
const BACKOFF_MS = [30_000, 120_000];
|
|
20
|
+
const MAX_BACKOFF_MS = 600_000;
|
|
21
|
+
export const UNAVAILABLE_MS = 600_000;
|
|
22
|
+
const BATCH = 10;
|
|
23
|
+
/** Dead tokens are hex; anything else would make the relay refuse the batch. */
|
|
24
|
+
const APNS_TOKEN = /^[0-9a-fA-F]{64,200}$/;
|
|
25
|
+
|
|
26
|
+
export interface SenderDeps {
|
|
27
|
+
now: () => number;
|
|
28
|
+
relay: (body: PushRequestBody) => Promise<RelayAnswer>;
|
|
29
|
+
/** Sends a held message by the other channel. */
|
|
30
|
+
fallback: (source: string, text: string) => Promise<void>;
|
|
31
|
+
log: (line: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Sink = SenderDeps["fallback"];
|
|
35
|
+
// On globalThis like the sender: the app's bundles each have their own copy
|
|
36
|
+
// of this module, and the one that sets the sink may not be the one that built
|
|
37
|
+
// the sender.
|
|
38
|
+
const sinks = globalThis as unknown as { __agentosPushFallback?: Sink };
|
|
39
|
+
|
|
40
|
+
/** Where held text goes when push can't deliver it (Telegram). */
|
|
41
|
+
export function setFallbackSink(fn: Sink): void {
|
|
42
|
+
sinks.__agentosPushFallback = fn;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaults = (): SenderDeps => ({
|
|
46
|
+
now: () => Date.now(),
|
|
47
|
+
relay: relaySend,
|
|
48
|
+
fallback: async (source, text) => sinks.__agentosPushFallback?.(source, text),
|
|
49
|
+
log: (line) => console.error(`[push] ${line}`),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export class PushSender {
|
|
53
|
+
private deps: SenderDeps;
|
|
54
|
+
private running = false;
|
|
55
|
+
private again = false;
|
|
56
|
+
private current: Promise<void> = Promise.resolve();
|
|
57
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
58
|
+
private down: { until: number; reason: string } | null = null;
|
|
59
|
+
|
|
60
|
+
constructor(deps: Partial<SenderDeps> = {}) {
|
|
61
|
+
this.deps = { ...defaults(), ...deps };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Why pushes aren't being tried right now, or null. */
|
|
65
|
+
unavailable(): string | null {
|
|
66
|
+
if (this.down && this.deps.now() >= this.down.until) this.down = null;
|
|
67
|
+
return this.down?.reason ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
clearUnavailable(): void {
|
|
71
|
+
this.down = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stops the retry timer (a test discarding its sender). */
|
|
75
|
+
dispose(): void {
|
|
76
|
+
if (this.timer) clearTimeout(this.timer);
|
|
77
|
+
this.timer = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Starts a drain if none is running; resolves when it has caught up. */
|
|
81
|
+
kick(): Promise<void> {
|
|
82
|
+
if (this.running) {
|
|
83
|
+
this.again = true;
|
|
84
|
+
return this.current;
|
|
85
|
+
}
|
|
86
|
+
this.running = true;
|
|
87
|
+
this.current = this.drain().finally(() => {
|
|
88
|
+
this.running = false;
|
|
89
|
+
this.arm();
|
|
90
|
+
});
|
|
91
|
+
return this.current;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Re-arms after a restart. */
|
|
95
|
+
resume(): void {
|
|
96
|
+
outbox.resume(this.deps.now());
|
|
97
|
+
void this.kick();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async drain(): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
do {
|
|
103
|
+
this.again = false;
|
|
104
|
+
for (const key of outbox.expire(this.deps.now()))
|
|
105
|
+
await this.settle(key);
|
|
106
|
+
for (;;) {
|
|
107
|
+
const rows = outbox.claimDue(this.deps.now());
|
|
108
|
+
if (!rows.length) break;
|
|
109
|
+
const byEvent = new Map<string, OutboxRow[]>();
|
|
110
|
+
for (const r of rows)
|
|
111
|
+
byEvent.set(r.event_key, [...(byEvent.get(r.event_key) ?? []), r]);
|
|
112
|
+
for (const group of byEvent.values())
|
|
113
|
+
for (let i = 0; i < group.length; i += BATCH)
|
|
114
|
+
await this.sendGroup(group.slice(i, i + BATCH));
|
|
115
|
+
}
|
|
116
|
+
} while (this.again);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
this.deps.log(
|
|
119
|
+
`drain failed: ${error instanceof Error ? error.message : error}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private arm(): void {
|
|
125
|
+
if (this.timer) clearTimeout(this.timer);
|
|
126
|
+
this.timer = null;
|
|
127
|
+
const at = outbox.nextDueAt();
|
|
128
|
+
if (at === null) return;
|
|
129
|
+
this.timer = setTimeout(
|
|
130
|
+
() => void this.kick(),
|
|
131
|
+
Math.max(1000, at - this.deps.now())
|
|
132
|
+
);
|
|
133
|
+
this.timer.unref?.();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async sendGroup(rows: OutboxRow[]): Promise<void> {
|
|
137
|
+
// An earlier group in this drain found the relay unusable.
|
|
138
|
+
const down = this.unavailable();
|
|
139
|
+
if (down) {
|
|
140
|
+
for (const r of rows) outbox.markFailed(r.id, down);
|
|
141
|
+
for (const key of new Set(rows.map((r) => r.event_key)))
|
|
142
|
+
await this.settle(key);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const sendable: {
|
|
146
|
+
row: OutboxRow;
|
|
147
|
+
token: string;
|
|
148
|
+
env: "production" | "sandbox";
|
|
149
|
+
}[] = [];
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
const push = getDevicePush(row.device_id);
|
|
152
|
+
if (!push) outbox.markFailed(row.id, "no push token", true);
|
|
153
|
+
else if (!APNS_TOKEN.test(push.token)) {
|
|
154
|
+
outbox.markFailed(row.id, "not an APNs token");
|
|
155
|
+
setDevicePushError(
|
|
156
|
+
row.device_id,
|
|
157
|
+
"the registered token isn't an APNs token"
|
|
158
|
+
);
|
|
159
|
+
} else sendable.push({ row, token: push.token, env: push.environment });
|
|
160
|
+
}
|
|
161
|
+
// The relay folds a repeated token into one result, so send each once.
|
|
162
|
+
const seen = new Set<string>();
|
|
163
|
+
const unique = sendable.filter((s) => {
|
|
164
|
+
const k = `${s.token.toLowerCase()}:${s.env}`;
|
|
165
|
+
if (seen.has(k)) {
|
|
166
|
+
outbox.markFailed(s.row.id, "duplicate token");
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
seen.add(k);
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
if (unique.length) {
|
|
173
|
+
const first = unique[0].row;
|
|
174
|
+
const answer = await this.deps.relay({
|
|
175
|
+
v: 1,
|
|
176
|
+
devices: unique.map((s) => ({ token: s.token, env: s.env })),
|
|
177
|
+
push_type: "alert",
|
|
178
|
+
priority: first.level === "passive" ? 5 : 10,
|
|
179
|
+
level: first.level,
|
|
180
|
+
...(first.collapse_id && { collapse_id: first.collapse_id }),
|
|
181
|
+
thread_id: first.category,
|
|
182
|
+
payload: JSON.parse(first.payload),
|
|
183
|
+
});
|
|
184
|
+
await this.apply(
|
|
185
|
+
unique.map((s) => s.row),
|
|
186
|
+
answer
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
for (const key of new Set(rows.map((r) => r.event_key)))
|
|
190
|
+
await this.settle(key);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async apply(rows: OutboxRow[], answer: RelayAnswer): Promise<void> {
|
|
194
|
+
const now = this.deps.now();
|
|
195
|
+
if (answer.kind === "unavailable") {
|
|
196
|
+
this.down = { until: now + UNAVAILABLE_MS, reason: answer.reason };
|
|
197
|
+
this.deps.log(`push unavailable: ${answer.reason}`);
|
|
198
|
+
for (const r of rows) {
|
|
199
|
+
outbox.markFailed(r.id, answer.reason);
|
|
200
|
+
setDevicePushError(r.device_id, answer.reason);
|
|
201
|
+
}
|
|
202
|
+
for (const key of outbox.failPending(answer.reason))
|
|
203
|
+
await this.settle(key);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (answer.kind === "invalid") {
|
|
207
|
+
for (const r of rows) {
|
|
208
|
+
outbox.markFailed(r.id, answer.reason);
|
|
209
|
+
setDevicePushError(r.device_id, answer.reason);
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
rows.forEach((r, i) => {
|
|
214
|
+
if (answer.kind === "retry")
|
|
215
|
+
return this.retry(r, now, answer.reason, answer.afterMs);
|
|
216
|
+
const res = answer.results[i];
|
|
217
|
+
if (!res) return this.retry(r, now, "no answer for this device");
|
|
218
|
+
if (res.status === "sent") {
|
|
219
|
+
outbox.markSent(r.id, now);
|
|
220
|
+
setDevicePushError(r.device_id, null);
|
|
221
|
+
} else if (res.status === "gone" || res.prune) {
|
|
222
|
+
const why = `token no longer valid${res.reason ? ` (${res.reason})` : ""}`;
|
|
223
|
+
pruneDevicePush(r.device_id, why);
|
|
224
|
+
outbox.markFailed(r.id, why, true);
|
|
225
|
+
} else if (res.status === "retry") {
|
|
226
|
+
this.retry(
|
|
227
|
+
r,
|
|
228
|
+
now,
|
|
229
|
+
res.reason ?? "try again",
|
|
230
|
+
res.retry_after ? res.retry_after * 1000 : undefined
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
const why = res.reason ?? "rejected";
|
|
234
|
+
outbox.markFailed(r.id, why);
|
|
235
|
+
setDevicePushError(r.device_id, why);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private retry(
|
|
241
|
+
r: OutboxRow,
|
|
242
|
+
now: number,
|
|
243
|
+
why: string,
|
|
244
|
+
afterMs?: number
|
|
245
|
+
): void {
|
|
246
|
+
const max = isTestKey(r.event_key) ? 1 : MAX_ATTEMPTS;
|
|
247
|
+
if (r.attempts >= max) {
|
|
248
|
+
outbox.markFailed(r.id, why);
|
|
249
|
+
setDevicePushError(r.device_id, why);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const wait = Math.min(
|
|
253
|
+
Math.max(BACKOFF_MS[r.attempts - 1] ?? BACKOFF_MS.at(-1)!, afterMs ?? 0),
|
|
254
|
+
MAX_BACKOFF_MS
|
|
255
|
+
);
|
|
256
|
+
outbox.markRetry(r.id, now + wait, why);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Once an event has ended with no phone reached, the held text goes by Telegram. */
|
|
260
|
+
private async settle(eventKey: string): Promise<void> {
|
|
261
|
+
const held = outbox.claimFallback(eventKey);
|
|
262
|
+
if (!held) return;
|
|
263
|
+
try {
|
|
264
|
+
await this.deps.fallback(held.source, held.text);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.deps.log(
|
|
267
|
+
`fallback for ${held.source} didn't send: ${error instanceof Error ? error.message : error}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Test fixtures for the push sender: paired phones with push tokens.
|
|
2
|
+
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { db } from "../../db";
|
|
5
|
+
import { mintDevice, setDevicePush } from "@/lib/security/devices";
|
|
6
|
+
import type { PushCategory } from "../push-types";
|
|
7
|
+
|
|
8
|
+
export function pairPhone(categories?: PushCategory[]): string {
|
|
9
|
+
const { device } = mintDevice("phone");
|
|
10
|
+
setDevicePush(device.id, {
|
|
11
|
+
token: crypto.randomBytes(32).toString("hex"),
|
|
12
|
+
platform: "ios",
|
|
13
|
+
environment: "production",
|
|
14
|
+
categories,
|
|
15
|
+
});
|
|
16
|
+
return device.id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resetPush(): void {
|
|
20
|
+
db.prepare(`DELETE FROM push_outbox`).run();
|
|
21
|
+
db.prepare(`DELETE FROM devices`).run();
|
|
22
|
+
const g = globalThis as { __agentosPush?: { dispose(): void } };
|
|
23
|
+
g.__agentosPush?.dispose();
|
|
24
|
+
g.__agentosPush = undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const outboxRows = (where = "1=1") =>
|
|
28
|
+
db
|
|
29
|
+
.prepare(`SELECT * FROM push_outbox WHERE ${where} ORDER BY id`)
|
|
30
|
+
.all() as import("./outbox").OutboxRow[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Needs you" is a session that has waited on Saad for a minute. A timer
|
|
3
|
+
* starts when it begins waiting and is cancelled by any change, so a quick
|
|
4
|
+
* permission prompt that he answers never buzzes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { pushNeedsYou } from "./events";
|
|
8
|
+
import { WAIT_MS } from "./policy";
|
|
9
|
+
|
|
10
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
11
|
+
|
|
12
|
+
export function noteWaiting(sessionId: string, waiting: boolean): void {
|
|
13
|
+
const armed = timers.get(sessionId);
|
|
14
|
+
if (!waiting) {
|
|
15
|
+
if (armed) {
|
|
16
|
+
clearTimeout(armed);
|
|
17
|
+
timers.delete(sessionId);
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (armed) return;
|
|
22
|
+
const armedAt = Date.now();
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
timers.delete(sessionId);
|
|
25
|
+
pushNeedsYou(sessionId, armedAt);
|
|
26
|
+
}, WAIT_MS);
|
|
27
|
+
timer.unref?.();
|
|
28
|
+
timers.set(sessionId, timer);
|
|
29
|
+
}
|
package/lib/notify/push-types.ts
CHANGED
package/lib/notify/redact.ts
CHANGED
|
@@ -16,6 +16,17 @@ const SECRET_PATTERNS: RegExp[] = [
|
|
|
16
16
|
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g,
|
|
17
17
|
];
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* An id or key made by the app: no known token shape, and no single
|
|
21
|
+
* segment (split on - _ : .) that is a long hex or base64 run. A uuid or a
|
|
22
|
+
* timestamped id passes; a pasted key or a 40-character sha does not.
|
|
23
|
+
*/
|
|
24
|
+
function plainIdent(text: string): boolean {
|
|
25
|
+
if (SECRET_PATTERNS.some((re) => ((re.lastIndex = 0), re.test(text))))
|
|
26
|
+
return false;
|
|
27
|
+
return !text.split(/[-_:.]/).some((seg) => seg.length >= 24);
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/** True when the string looks like a token, key or JWT. */
|
|
20
31
|
export function looksSecret(text: string): boolean {
|
|
21
32
|
return [...SECRET_PATTERNS, LONG_RUN].some((re) => {
|
|
@@ -42,7 +53,7 @@ export function safeId(id: unknown): string | null {
|
|
|
42
53
|
if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID)
|
|
43
54
|
return null;
|
|
44
55
|
if (!SAFE_ID.test(id)) return null;
|
|
45
|
-
return
|
|
56
|
+
return plainIdent(id) ? id : null;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
/**
|
|
@@ -53,5 +64,5 @@ export function safeKey(key: unknown): string | null {
|
|
|
53
64
|
if (typeof key !== "string" || key.length === 0 || key.length > 128)
|
|
54
65
|
return null;
|
|
55
66
|
if (!/^[A-Za-z0-9_:.-]+$/.test(key)) return null;
|
|
56
|
-
return
|
|
67
|
+
return plainIdent(key) ? key : null;
|
|
57
68
|
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
launchSession,
|
|
20
20
|
type StartView,
|
|
21
21
|
} from "../sessions/launch";
|
|
22
|
-
import { createTask, dropTask } from "../tasks";
|
|
22
|
+
import { assertMovable, createTask, dropTask, moveTask } from "../tasks";
|
|
23
|
+
import { getHost, hostIdNamed } from "../hosts";
|
|
24
|
+
import { queueEvent } from "./events";
|
|
23
25
|
import {
|
|
24
26
|
findQueued,
|
|
25
27
|
queuedLabel,
|
|
@@ -280,3 +282,41 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
|
|
|
280
282
|
export async function stop(workspaceId: string, ref: string): Promise<string> {
|
|
281
283
|
return stopSession(findWorkspaceSession(workspaceId, ref));
|
|
282
284
|
}
|
|
285
|
+
|
|
286
|
+
// Carries a task to a linked machine, or back ("here"): heavy test or build
|
|
287
|
+
// work can run on the box. The move takes minutes, so it runs in the
|
|
288
|
+
// background; the orchestrator is told when the task lands or doesn't.
|
|
289
|
+
// Everything about the task (gates, events, read/send/stop/drop) is the same
|
|
290
|
+
// wherever it runs; the machine it lands on holds it for this one.
|
|
291
|
+
export async function moveTaskTo(
|
|
292
|
+
workspaceId: string,
|
|
293
|
+
ref: string,
|
|
294
|
+
machine: string
|
|
295
|
+
): Promise<string> {
|
|
296
|
+
const task = workspaceTask(workspaceId, ref);
|
|
297
|
+
const hostId = hostIdNamed(machine);
|
|
298
|
+
assertMovable(task.id, hostId);
|
|
299
|
+
const where = hostId === "local" ? "this machine" : machineName(hostId);
|
|
300
|
+
void moveTask(task.id, hostId).then(
|
|
301
|
+
(moved) => {
|
|
302
|
+
addNote(workspaceId, `Moved task ${task.name} to ${where}.`);
|
|
303
|
+
queueEvent(
|
|
304
|
+
workspaceId,
|
|
305
|
+
`move:${moved.id}:${Date.now()}`,
|
|
306
|
+
moved.id,
|
|
307
|
+
`Task ${task.name} now runs on ${where}.`
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
(error) =>
|
|
311
|
+
queueEvent(
|
|
312
|
+
workspaceId,
|
|
313
|
+
`move-failed:${task.id}:${Date.now()}`,
|
|
314
|
+
task.id,
|
|
315
|
+
`Moving task ${task.name} to ${where} failed: ${error instanceof Error ? error.message : String(error)}. It carries on where it was unless it says it's paused as "moving".`
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
return `Moving ${task.name} to ${where}. It takes a few minutes; an event tells you when it runs there.`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const machineName = (hostId: string) =>
|
|
322
|
+
getHost(hostId)?.name ?? "the other machine";
|
|
@@ -58,7 +58,8 @@ function armedTasks(workspaceId: string): Session[] {
|
|
|
58
58
|
.prepare(
|
|
59
59
|
`SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
60
60
|
WHERE p.workspace_id = ? AND s.merge_armed_at IS NOT NULL
|
|
61
|
-
AND s.task_status = 'running' AND s.peer_mirror = 0
|
|
61
|
+
AND s.task_status = 'running' AND s.peer_mirror = 0
|
|
62
|
+
AND s.held_by_device IS NULL`
|
|
62
63
|
)
|
|
63
64
|
.all(workspaceId) as Session[];
|
|
64
65
|
}
|
|
@@ -100,6 +101,10 @@ export function armTask(task: Session): void {
|
|
|
100
101
|
);
|
|
101
102
|
if (task.peer_mirror)
|
|
102
103
|
throw new Error(`${task.name} runs on a linked machine; arm it there`);
|
|
104
|
+
if (task.held_by_device)
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${task.name} is held for ${task.held_from ?? "another machine"}'s orchestrator; it arms it there`
|
|
107
|
+
);
|
|
103
108
|
if (!armingWorkspace(task))
|
|
104
109
|
throw new Error(
|
|
105
110
|
`${task.name}'s project isn't in a workspace with an orchestrator, which an armed merge reports to`
|
package/lib/orchestrator/asks.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* gate or brake ask is spent on that one item: never standing permission.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { pushAskRaised } from "../notify/push/events";
|
|
9
10
|
import { db } from "../db";
|
|
10
11
|
import { answerKey, queueEvent } from "./events";
|
|
11
12
|
import { addNote } from "./notes";
|
|
@@ -167,7 +168,7 @@ export function raiseAsk(input: {
|
|
|
167
168
|
const sha = input.sha ?? null;
|
|
168
169
|
const brakeKey = input.brakeKey ?? null;
|
|
169
170
|
const raisedBy = input.raisedBy ?? null;
|
|
170
|
-
|
|
171
|
+
const result = db.transaction(() => {
|
|
171
172
|
const open = openBySubject(input.workspaceId, input.subject);
|
|
172
173
|
if (open) {
|
|
173
174
|
db.prepare(
|
|
@@ -209,6 +210,9 @@ export function raiseAsk(input: {
|
|
|
209
210
|
created: true,
|
|
210
211
|
};
|
|
211
212
|
})();
|
|
213
|
+
// A new ask buzzes the phone; a refresh of an open one doesn't.
|
|
214
|
+
if (result.created) pushAskRaised(result.ask.id, input.workspaceId);
|
|
215
|
+
return result;
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
export function openAsks(workspaceId: string): AskRow[] {
|
|
@@ -74,6 +74,15 @@ export async function runningCount(
|
|
|
74
74
|
"running";
|
|
75
75
|
if (busy) running.add(s.id);
|
|
76
76
|
}
|
|
77
|
+
// A task mid-move is out of the list above, but it runs again within
|
|
78
|
+
// minutes, here or there: it holds its slot.
|
|
79
|
+
const moving = db
|
|
80
|
+
.prepare(
|
|
81
|
+
`SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
82
|
+
WHERE p.workspace_id = ? AND s.task_status = 'moving' AND s.peer_mirror = 0`
|
|
83
|
+
)
|
|
84
|
+
.all(workspaceId) as { id: string }[];
|
|
85
|
+
for (const { id } of moving) running.add(id);
|
|
77
86
|
for (const { target } of startsSince(workspaceId, now - WARMUP_MS))
|
|
78
87
|
if (target) running.add(target);
|
|
79
88
|
return running.size;
|
|
@@ -109,7 +109,8 @@ Acting:
|
|
|
109
109
|
- \`${TOOL_NAMES.quick_pr}\` (change, title, body): commit everything in a quick change, push its branch (never the default branch) and open its PR, or push to the PR it has; its review starts at once. Then it's gated like any task: CI, that review (also its code-review gate), scope, then sign_off or arm. It's in \`sessions\` as your quick change; drop it to throw it away, and it's cleaned up like a task when it merges or is dropped.
|
|
110
110
|
- \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
|
|
111
111
|
- \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
|
|
112
|
-
- \`${TOOL_NAMES.
|
|
112
|
+
- \`${TOOL_NAMES.move}\` (task, machine): carry a running task to a linked machine (heavy test or build work), or back with machine "here". It keeps its branch, conversation and plan, and everything you do with a task works the same there: its events come to you, read, send, stop, done and drop reach it, and the gates judge its PR at its head. The machine it lands on holds it for you and can't merge it. It runs in the background; an event says when it lands. Refused while paused, and for a task waiting on Saad.
|
|
113
|
+
- \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
|
|
113
114
|
- \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
|
|
114
115
|
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session is archived out of view. Not a rejection: use drop for that.
|
|
115
116
|
- \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { db, type Session, type StackItemStatus } from "../db";
|
|
9
9
|
import { statusDetector } from "../status-detector";
|
|
10
|
-
import { hostLink } from "../hosts/remote-api";
|
|
10
|
+
import { cleanRemoteText, hostLink } from "../hosts/remote-api";
|
|
11
11
|
import { peerStatus } from "../hosts/peer-sessions";
|
|
12
12
|
import { chatCompacting, chatState } from "../chat/runner";
|
|
13
13
|
import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
|
|
@@ -16,11 +16,16 @@ import { needsYou } from "../needs-you";
|
|
|
16
16
|
import { programSummary } from "../program-status/store";
|
|
17
17
|
import { sessionRowInfo } from "../session-meta";
|
|
18
18
|
import { taskView, type TaskPR, type TaskState } from "../tasks";
|
|
19
|
-
import { storedPR } from "../tasks/session";
|
|
19
|
+
import { prFor, storedPR } from "../tasks/session";
|
|
20
|
+
import { lastActiveOf } from "../tasks/last-active";
|
|
21
|
+
import { hostTasks } from "../tasks/remote";
|
|
22
|
+
import { deriveTaskState } from "../tasks";
|
|
23
|
+
import { waitingState } from "./task-state";
|
|
20
24
|
import { commitTime, repoOf } from "./repo";
|
|
21
25
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
22
26
|
import { repoHasCI } from "./repo-ci";
|
|
23
27
|
import { markStatusDirty } from "../status/dirty";
|
|
28
|
+
import { queueEvent } from "./events";
|
|
24
29
|
|
|
25
30
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
26
31
|
|
|
@@ -68,9 +73,6 @@ export interface SessionFacts {
|
|
|
68
73
|
} | null;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
const sqliteMs = (t: string | null | undefined) =>
|
|
72
|
-
t ? Date.parse(`${t.replace(" ", "T")}Z`) || 0 : 0;
|
|
73
|
-
|
|
74
76
|
// Live sessions in the workspace's projects, plus tasks merged in the last
|
|
75
77
|
// hour so their merge still reads. Never an orchestrator or an archived one.
|
|
76
78
|
export function workspaceSessions(
|
|
@@ -83,6 +85,8 @@ export function workspaceSessions(
|
|
|
83
85
|
WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
|
|
84
86
|
-- A linked machine's own sessions are its orchestrator's to run.
|
|
85
87
|
AND s.peer_mirror = 0
|
|
88
|
+
-- A task another machine's orchestrator moved here is held for it.
|
|
89
|
+
AND s.held_by_device IS NULL
|
|
86
90
|
AND (s.task_status IS NULL OR s.task_status = 'running'
|
|
87
91
|
OR (s.task_status = 'merged' AND s.updated_at > datetime('now', '-1 hour')))
|
|
88
92
|
ORDER BY s.created_at`
|
|
@@ -121,17 +125,6 @@ function stackOf(sessionId: string): SessionFacts["stack"] {
|
|
|
121
125
|
};
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
function latestChatAt(sessionId: string): number {
|
|
125
|
-
const row = db
|
|
126
|
-
.prepare(
|
|
127
|
-
`SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
|
|
128
|
-
)
|
|
129
|
-
.get(sessionId) as { data: string } | undefined;
|
|
130
|
-
return row
|
|
131
|
-
? ((JSON.parse(row.data) as { createdAt?: number }).createdAt ?? 0)
|
|
132
|
-
: 0;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
128
|
export async function statusOf(s: Session): Promise<{
|
|
136
129
|
status: FactStatus;
|
|
137
130
|
activity: string | null;
|
|
@@ -251,6 +244,7 @@ async function taskFacts(
|
|
|
251
244
|
const state = s.task_status === "moved" ? "done" : s.task_status;
|
|
252
245
|
return { state, pr, blocked: null };
|
|
253
246
|
}
|
|
247
|
+
if (hostLink(s.host_id)) return remoteTaskFacts(workspaceId, s);
|
|
254
248
|
const view = await taskView(s);
|
|
255
249
|
return {
|
|
256
250
|
state: view.state,
|
|
@@ -261,6 +255,67 @@ async function taskFacts(
|
|
|
261
255
|
};
|
|
262
256
|
}
|
|
263
257
|
|
|
258
|
+
// A task another machine runs: its state is that machine's word (the same
|
|
259
|
+
// reading the gates use), and the PR is read here, from GitHub, by branch.
|
|
260
|
+
// What it can't say reads as blocked, never as clear.
|
|
261
|
+
async function remoteTaskFacts(
|
|
262
|
+
workspaceId: string,
|
|
263
|
+
s: Session
|
|
264
|
+
): Promise<NonNullable<SessionFacts["task"]>> {
|
|
265
|
+
const [pr, waiting] = await Promise.all([
|
|
266
|
+
prFor(s).catch(() => null),
|
|
267
|
+
waitingState(s),
|
|
268
|
+
]);
|
|
269
|
+
await announceRemotePlan(workspaceId, s);
|
|
270
|
+
const status = peerStatus(s.host_id, s.id) ?? "running";
|
|
271
|
+
const state = deriveTaskState({
|
|
272
|
+
taskStatus: "running",
|
|
273
|
+
sessionStatus: status,
|
|
274
|
+
pr,
|
|
275
|
+
blocked: waiting.blocked !== null,
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
state,
|
|
279
|
+
pr,
|
|
280
|
+
blocked: waiting.blocked,
|
|
281
|
+
ciSettleIn: await settleIn(workspaceId, s, pr),
|
|
282
|
+
ciSilentMs: await silentFor(s, pr),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The plan-written line a held task stored on its machine (plan-step.ts),
|
|
287
|
+
// once per distinct plan: the mirror keeps the last one it told about.
|
|
288
|
+
async function announceRemotePlan(
|
|
289
|
+
workspaceId: string,
|
|
290
|
+
s: Session
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const link = hostLink(s.host_id);
|
|
293
|
+
if (!link) return;
|
|
294
|
+
const view = await hostTasks(link)
|
|
295
|
+
.then(({ tasks }) => tasks.find((t) => t?.id === s.id))
|
|
296
|
+
.catch(() => undefined);
|
|
297
|
+
const note =
|
|
298
|
+
typeof view?.planNote === "string"
|
|
299
|
+
? cleanRemoteText(view.planNote).slice(0, 2000)
|
|
300
|
+
: "";
|
|
301
|
+
if (!note || note === s.plan_note) return;
|
|
302
|
+
db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(note, s.id);
|
|
303
|
+
queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// A task on another machine is as active as that machine says; one it can't
|
|
307
|
+
// reach is taken as active just now, so nothing reads it as abandoned.
|
|
308
|
+
async function lastActiveFor(s: Session): Promise<number> {
|
|
309
|
+
const link = hostLink(s.host_id);
|
|
310
|
+
if (!link) return lastActiveOf(s);
|
|
311
|
+
const view = await hostTasks(link)
|
|
312
|
+
.then(({ tasks }) => tasks.find((t) => t?.id === s.id))
|
|
313
|
+
.catch(() => undefined);
|
|
314
|
+
return typeof view?.lastActiveAt === "number"
|
|
315
|
+
? view.lastActiveAt
|
|
316
|
+
: Date.now();
|
|
317
|
+
}
|
|
318
|
+
|
|
264
319
|
export async function sessionFacts(
|
|
265
320
|
workspaceId: string
|
|
266
321
|
): Promise<SessionFacts[]> {
|
|
@@ -283,11 +338,7 @@ export async function sessionFacts(
|
|
|
283
338
|
s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
|
|
284
339
|
compacting:
|
|
285
340
|
s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
|
|
286
|
-
lastActive:
|
|
287
|
-
sqliteMs(s.updated_at),
|
|
288
|
-
statusDetector.getTimestamp(s.tmux_name) * 1000,
|
|
289
|
-
latestChatAt(s.id)
|
|
290
|
-
),
|
|
341
|
+
lastActive: await lastActiveFor(s),
|
|
291
342
|
branch: s.branch_name,
|
|
292
343
|
quick: !!s.quick_of,
|
|
293
344
|
plan:
|