@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
package/lib/orchestrator/read.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { hostLink } from "../hosts/remote-api";
|
|
|
10
10
|
import { peerPane } from "../hosts/peer-sessions";
|
|
11
11
|
import { workspaceSessions } from "./facts";
|
|
12
12
|
import { untrusted } from "./untrusted";
|
|
13
|
+
import { heldChatTail } from "../tasks/held-remote";
|
|
13
14
|
import {
|
|
14
15
|
findExternalSession,
|
|
15
16
|
knownExternalSession,
|
|
@@ -104,6 +105,12 @@ export function chatText(items: ChatItem[]): string {
|
|
|
104
105
|
.join("\n");
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// The last lines of a chat task's conversation, unwrapped.
|
|
109
|
+
export function chatTailText(sessionId: string, lines = DEFAULT_LINES): string {
|
|
110
|
+
const n = Math.max(1, Math.min(Math.floor(lines) || DEFAULT_LINES, 400));
|
|
111
|
+
return tail(chatText(lastItems(sessionId, n * 2)), n);
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
export async function readSession(
|
|
108
115
|
workspaceId: string,
|
|
109
116
|
ref: string,
|
|
@@ -121,8 +128,13 @@ export async function readSession(
|
|
|
121
128
|
return readExternal(external, n);
|
|
122
129
|
}
|
|
123
130
|
const link = hostLink(s.host_id);
|
|
124
|
-
if (s.view === "chat" && link)
|
|
125
|
-
|
|
131
|
+
if (s.view === "chat" && link) {
|
|
132
|
+
// Its orchestrator's task is read through the machine that holds it.
|
|
133
|
+
const text = s.started_by ? await heldChatTail(s, n) : null;
|
|
134
|
+
return text
|
|
135
|
+
? `${s.name} (chat on ${link.hostName}), last ${n} lines:\n${untrusted(s.name, text)}`
|
|
136
|
+
: `${s.name} (chat on ${link.hostName}): its messages are on that machine; open it to read them.`;
|
|
137
|
+
}
|
|
126
138
|
if (s.view === "chat") {
|
|
127
139
|
const text = chatText(lastItems(s.id, n * 2));
|
|
128
140
|
const body = text ? untrusted(s.name, tail(text, n)) : "(no messages yet)";
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* the orchestrator hears the verdict as an event.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { pushReviewBlocked, pushReviewReady } from "../notify/push/events";
|
|
13
14
|
import { db, type Session } from "../db";
|
|
14
15
|
import { amendmentsOf } from "./amendments";
|
|
15
16
|
import type { TaskPR } from "../tasks/state";
|
|
@@ -275,6 +276,8 @@ export async function reviewAt(
|
|
|
275
276
|
.then((done) => {
|
|
276
277
|
if (done.status === "pass" && target.task)
|
|
277
278
|
noteQuickReview(target.task, sha);
|
|
279
|
+
if (done.status === "pass") pushReviewReady(target.id, sha);
|
|
280
|
+
else if (done.status === "block") pushReviewBlocked(target.id, sha);
|
|
278
281
|
const said =
|
|
279
282
|
done.status === "pass"
|
|
280
283
|
? "passed"
|
|
@@ -4,6 +4,7 @@ import { getWorkspace } from "../workspaces";
|
|
|
4
4
|
import {
|
|
5
5
|
done,
|
|
6
6
|
drop,
|
|
7
|
+
moveTaskTo,
|
|
7
8
|
send,
|
|
8
9
|
stack,
|
|
9
10
|
stackStatus,
|
|
@@ -46,6 +47,7 @@ const ACTING = new Set<ToolName>([
|
|
|
46
47
|
"stack",
|
|
47
48
|
"land",
|
|
48
49
|
"drop",
|
|
50
|
+
"move",
|
|
49
51
|
"stop",
|
|
50
52
|
"done",
|
|
51
53
|
"review",
|
|
@@ -156,6 +158,10 @@ export async function runTool(
|
|
|
156
158
|
const a = p(tool, raw);
|
|
157
159
|
return drop(w, a.task, a.reason);
|
|
158
160
|
}
|
|
161
|
+
case "move": {
|
|
162
|
+
const a = p(tool, raw);
|
|
163
|
+
return moveTaskTo(w, a.task, a.machine);
|
|
164
|
+
}
|
|
159
165
|
case "stop":
|
|
160
166
|
return stop(w, p(tool, raw).session);
|
|
161
167
|
case "done":
|
|
@@ -211,6 +211,20 @@ const PULL_MERGE = /(^|\/)pulls\/[^/?\s]+\/merge\/?(\?|$)/;
|
|
|
211
211
|
|
|
212
212
|
const MERGE_MSG = `Refused: a PR merges only through the gates: ${T.sign_off} (or ${T.arm}) for a task's or an external PR, ${T.land} for a stack. Nothing merges, closes or retargets a PR from the shell.`;
|
|
213
213
|
|
|
214
|
+
// aos move, done, stop and drop reach a session by name, past the tools'
|
|
215
|
+
// Pause check and logging; the tools do the same work.
|
|
216
|
+
function aosRefusal(args: string[]): string | null {
|
|
217
|
+
const [sub] = positionals(args);
|
|
218
|
+
const tool: Record<string, string> = {
|
|
219
|
+
move: T.move,
|
|
220
|
+
done: T.done,
|
|
221
|
+
stop: T.stop,
|
|
222
|
+
};
|
|
223
|
+
if (sub && tool[sub])
|
|
224
|
+
return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
214
228
|
// gh: pr merge, close and edit --base, and api calls that merge or change a
|
|
215
229
|
// pull request.
|
|
216
230
|
function ghRefusal(args: string[]): string | null {
|
|
@@ -522,6 +536,7 @@ export async function shellRefusal(
|
|
|
522
536
|
secretRefusal(inv, cwd) ??
|
|
523
537
|
(inv.name === "gh" ? ghRefusal(inv.args) : null) ??
|
|
524
538
|
(inv.name === "curl" ? curlRefusal(inv.args) : null) ??
|
|
539
|
+
(inv.name === "aos" ? aosRefusal(inv.args) : null) ??
|
|
525
540
|
(inv.name === "git" ? await gitRefusal(inv.args, ctx, cwd) : null) ??
|
|
526
541
|
(inv.name === "sqlite3"
|
|
527
542
|
? await sqliteRefusal(inv, ctx, cwd, command)
|
|
@@ -193,6 +193,17 @@ export const TOOL_SHAPES = {
|
|
|
193
193
|
stack_status: { id: stackId },
|
|
194
194
|
land: { id: stackId },
|
|
195
195
|
drop: { task, reason: z.string().trim().min(1).max(2000) },
|
|
196
|
+
move: {
|
|
197
|
+
task,
|
|
198
|
+
machine: z
|
|
199
|
+
.string()
|
|
200
|
+
.trim()
|
|
201
|
+
.min(1)
|
|
202
|
+
.max(200)
|
|
203
|
+
.describe(
|
|
204
|
+
'A linked machine\'s name (see Machines), or "here" to bring the task back'
|
|
205
|
+
),
|
|
206
|
+
},
|
|
196
207
|
stop: { session },
|
|
197
208
|
done: { session },
|
|
198
209
|
add_project: {
|
|
@@ -76,6 +76,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
76
76
|
stack_status: "One stack's items, PRs and progress.",
|
|
77
77
|
land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
|
|
78
78
|
drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
|
|
79
|
+
move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
|
|
79
80
|
stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
|
|
80
81
|
done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
|
|
81
82
|
add_project:
|
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
applyProgramReport,
|
|
25
25
|
dropProgramTransient,
|
|
26
26
|
programSessions,
|
|
27
|
+
programSummary,
|
|
27
28
|
} from "./store";
|
|
29
|
+
import { noteWaiting } from "../notify/push/waiting";
|
|
28
30
|
|
|
29
31
|
const execFileAsync = promisify(execFile);
|
|
30
32
|
const tmux = (args: string[]) =>
|
|
@@ -85,7 +87,15 @@ async function handle(name: string, event: OscEvent): Promise<void> {
|
|
|
85
87
|
? await foreground(name)
|
|
86
88
|
: undefined
|
|
87
89
|
);
|
|
88
|
-
if (changed)
|
|
90
|
+
if (changed) {
|
|
91
|
+
notifyStatusChanged();
|
|
92
|
+
const id = (
|
|
93
|
+
getDb()
|
|
94
|
+
.prepare(`SELECT id FROM sessions WHERE tmux_name = ?`)
|
|
95
|
+
.get(name) as { id: string } | undefined
|
|
96
|
+
)?.id;
|
|
97
|
+
if (id) noteWaiting(id, programSummary(name)?.state === "blocked");
|
|
98
|
+
}
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
// What one pane has said but isn't applied yet: the latest report per
|
package/lib/schedules/start.ts
CHANGED
|
@@ -237,7 +237,11 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
|
|
|
237
237
|
const text = `Schedule "${schedule.name}" is paused: ${why}. Point it at another session or delete it.`;
|
|
238
238
|
console.error(`[schedules] ${text}`);
|
|
239
239
|
addNote(schedule.workspace_id, text, "pause");
|
|
240
|
-
notifyPhone(`schedule:${schedule.id}`, text
|
|
240
|
+
notifyPhone(`schedule:${schedule.id}`, text, {
|
|
241
|
+
category: "system",
|
|
242
|
+
key: `system:schedule:${schedule.id}:${Date.now()}`,
|
|
243
|
+
collapse: `system:schedule:${schedule.id}`,
|
|
244
|
+
});
|
|
241
245
|
return;
|
|
242
246
|
}
|
|
243
247
|
console.error(`[schedules] ${schedule.name} failed: ${why}`);
|
|
@@ -248,7 +252,12 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
|
|
|
248
252
|
);
|
|
249
253
|
notifyPhone(
|
|
250
254
|
`schedule:${schedule.id}`,
|
|
251
|
-
`Schedule "${schedule.name}" failed: ${why}
|
|
255
|
+
`Schedule "${schedule.name}" failed: ${why}`,
|
|
256
|
+
{
|
|
257
|
+
category: "system",
|
|
258
|
+
key: `system:schedule:${schedule.id}:${Date.now()}`,
|
|
259
|
+
collapse: `system:schedule:${schedule.id}`,
|
|
260
|
+
}
|
|
252
261
|
);
|
|
253
262
|
}
|
|
254
263
|
|
package/lib/security/devices.ts
CHANGED
|
@@ -86,13 +86,52 @@ export function touchDevice(id: string, address?: string): boolean {
|
|
|
86
86
|
return true;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
/** What the Devices page shows of a device's push: never the token. */
|
|
90
|
+
export interface DevicePushView {
|
|
91
|
+
enabled: boolean;
|
|
92
|
+
categories: PushCategory[];
|
|
93
|
+
lastError: string | null;
|
|
94
|
+
updatedAt: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ListedDevice = Device & { push: DevicePushView };
|
|
98
|
+
|
|
99
|
+
export function listDevices(): ListedDevice[] {
|
|
100
|
+
const rows = getDb()
|
|
91
101
|
.prepare(
|
|
92
|
-
`SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve
|
|
102
|
+
`SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve,
|
|
103
|
+
${PUSH_COLS}
|
|
93
104
|
FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC`
|
|
94
105
|
)
|
|
95
|
-
.all() as Device[];
|
|
106
|
+
.all() as (Device & PushRow)[];
|
|
107
|
+
return rows.map((r) => {
|
|
108
|
+
const {
|
|
109
|
+
push_token,
|
|
110
|
+
push_platform,
|
|
111
|
+
push_environment,
|
|
112
|
+
push_categories,
|
|
113
|
+
push_updated_at,
|
|
114
|
+
push_last_error,
|
|
115
|
+
...device
|
|
116
|
+
} = r;
|
|
117
|
+
const push = pushOf({
|
|
118
|
+
push_token,
|
|
119
|
+
push_platform,
|
|
120
|
+
push_environment,
|
|
121
|
+
push_categories,
|
|
122
|
+
push_updated_at,
|
|
123
|
+
push_last_error,
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
...device,
|
|
127
|
+
push: {
|
|
128
|
+
enabled: !!push,
|
|
129
|
+
categories: push?.categories ?? [],
|
|
130
|
+
lastError: push_last_error,
|
|
131
|
+
updatedAt: push?.updatedAt ?? null,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
});
|
|
96
135
|
}
|
|
97
136
|
|
|
98
137
|
export function renameDevice(id: string, name: string): boolean {
|
|
@@ -301,3 +340,17 @@ function dropDeviceSockets(id: string): void {
|
|
|
301
340
|
for (const s of sockets.get(id) ?? []) s.destroy();
|
|
302
341
|
sockets.delete(id);
|
|
303
342
|
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The relay said this token is dead: forget it, keep the chosen categories,
|
|
346
|
+
* say why. The app registers a fresh token on its next launch.
|
|
347
|
+
*/
|
|
348
|
+
export function pruneDevicePush(id: string, reason: string): void {
|
|
349
|
+
getDb()
|
|
350
|
+
.prepare(
|
|
351
|
+
`UPDATE devices SET push_token = NULL, push_platform = NULL,
|
|
352
|
+
push_environment = NULL, push_last_error = ?
|
|
353
|
+
WHERE id = ?`
|
|
354
|
+
)
|
|
355
|
+
.run(reason.slice(0, 200), id);
|
|
356
|
+
}
|
package/lib/sessions/stop.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { endTmuxSession } from "../tmux/end";
|
|
|
2
2
|
import { hostLink } from "../hosts/remote-api";
|
|
3
3
|
import { stopChatTurn } from "../chat/stop";
|
|
4
4
|
import type { Session } from "../db";
|
|
5
|
+
import { stopHeldTask } from "../tasks/held-remote";
|
|
5
6
|
|
|
6
7
|
// Stops a session's agent and keeps everything else, the same for every
|
|
7
8
|
// caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
|
|
@@ -9,8 +10,21 @@ import type { Session } from "../db";
|
|
|
9
10
|
// and conversation stay, and the next message runs as the next turn. Throws
|
|
10
11
|
// when the chat can't be shown to have stopped.
|
|
11
12
|
export async function stopSession(
|
|
12
|
-
s: Pick<
|
|
13
|
+
s: Pick<
|
|
14
|
+
Session,
|
|
15
|
+
| "id"
|
|
16
|
+
| "name"
|
|
17
|
+
| "view"
|
|
18
|
+
| "host_id"
|
|
19
|
+
| "tmux_name"
|
|
20
|
+
| "started_by"
|
|
21
|
+
| "task_prompt"
|
|
22
|
+
>
|
|
13
23
|
): Promise<string> {
|
|
24
|
+
// A task its orchestrator moved to a linked machine stops through that
|
|
25
|
+
// machine's AgentOS, chat or terminal.
|
|
26
|
+
if (s.started_by && s.task_prompt && hostLink(s.host_id))
|
|
27
|
+
return stopHeldTask(s);
|
|
14
28
|
if (s.view === "chat") {
|
|
15
29
|
if (hostLink(s.host_id))
|
|
16
30
|
throw new Error(
|
|
@@ -8,6 +8,7 @@ import fs from "fs";
|
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { getRepoName, slugify } from "../git";
|
|
10
10
|
import { WORKTREES_DIR } from "../worktrees";
|
|
11
|
+
import { installWorktreeHooks } from "../worktree-hooks";
|
|
11
12
|
import { git as runGit } from "../git-exec";
|
|
12
13
|
import { pushedRef } from "./move";
|
|
13
14
|
|
|
@@ -39,7 +40,21 @@ export function worktreeDirFor(
|
|
|
39
40
|
return dir;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
// The hook that strips attribution from commit messages, as every worktree
|
|
44
|
+
// AgentOS makes has (best effort: a worktree without it still works).
|
|
45
|
+
const withHooks = async (r: { worktreePath: string; reused: boolean }) => {
|
|
46
|
+
await installWorktreeHooks(r.worktreePath).catch((err) =>
|
|
47
|
+
console.error(`[worktrees] hooks not installed in ${r.worktreePath}:`, err)
|
|
48
|
+
);
|
|
49
|
+
return r;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const checkoutBranchWorktree = async (
|
|
53
|
+
projectPath: string,
|
|
54
|
+
branch: string
|
|
55
|
+
) => withHooks(await checkoutBranch(projectPath, branch));
|
|
56
|
+
|
|
57
|
+
async function checkoutBranch(
|
|
43
58
|
projectPath: string,
|
|
44
59
|
branch: string
|
|
45
60
|
): Promise<{ worktreePath: string; reused: boolean }> {
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
import { republishAfterMerge } from "../lumifyhub/publish";
|
|
10
10
|
import { restackAfterMerge } from "../stacks/restack";
|
|
11
11
|
import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
|
|
12
|
+
import { holderRefusal } from "./held";
|
|
12
13
|
import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
|
|
13
14
|
import { endSessionAgent } from "../sessions/end";
|
|
14
15
|
import { run } from "./gh";
|
|
16
|
+
import { pushMerged } from "../notify/push/events";
|
|
15
17
|
import { gitFetch, gitPush } from "../git-exec";
|
|
16
18
|
import {
|
|
17
19
|
mergePolicy,
|
|
@@ -110,11 +112,14 @@ async function finishMerge(
|
|
|
110
112
|
// machine merged it and didn't say).
|
|
111
113
|
export async function signOffTask(
|
|
112
114
|
id: string,
|
|
113
|
-
opts: { wait?: boolean; head?: string } = {}
|
|
115
|
+
opts: { wait?: boolean; head?: string; device?: string | null } = {}
|
|
114
116
|
): Promise<MergeMethod | null> {
|
|
115
117
|
const session = getTaskSession(id);
|
|
116
118
|
if (session.task_status !== "running")
|
|
117
119
|
throw new Error(`Task is already ${session.task_status}`);
|
|
120
|
+
// A task another machine's orchestrator moved here merges only from there.
|
|
121
|
+
const held = holderRefusal(session, opts.device);
|
|
122
|
+
if (held) throw new Error(held);
|
|
118
123
|
// Its own machine checks the gates and merges.
|
|
119
124
|
// That machine's settings chose the method; null if it didn't say.
|
|
120
125
|
if (isMirror(session)) return remoteTaskAction(session, "merge", opts.head);
|
|
@@ -155,6 +160,7 @@ export async function signOffTask(
|
|
|
155
160
|
signingOff.delete(id);
|
|
156
161
|
}
|
|
157
162
|
syncTaskCardInBackground(session, "merged", pr);
|
|
163
|
+
pushMerged(id);
|
|
158
164
|
// Reads the plan file now: cleanup below removes the worktree.
|
|
159
165
|
void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
|
|
160
166
|
const head = opts.head ?? pr!.head;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An orchestrator's calls on a task it moved to a linked machine, which that
|
|
3
|
+
* machine's AgentOS answers for the device that sent the task (held.ts). The
|
|
4
|
+
* orchestrator polls and calls; the other machine never reaches back.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Session } from "../db";
|
|
8
|
+
import { cleanRemoteText, hostApi, requireHostLink } from "../hosts/remote-api";
|
|
9
|
+
import type { Delivery } from "../bus/delivery";
|
|
10
|
+
|
|
11
|
+
const path = (id: string, leaf: string) =>
|
|
12
|
+
`/api/tasks/${encodeURIComponent(id)}/${leaf}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Delivered by that machine's AgentOS as a message from the orchestrator,
|
|
16
|
+
* never as the user's own. Keyed by the stored message, so a retry that
|
|
17
|
+
* already landed is not typed twice.
|
|
18
|
+
*/
|
|
19
|
+
export async function relayToHeldTask(
|
|
20
|
+
to: Session,
|
|
21
|
+
line: string,
|
|
22
|
+
fromName: string,
|
|
23
|
+
messageId?: number
|
|
24
|
+
): Promise<Delivery> {
|
|
25
|
+
try {
|
|
26
|
+
const link = requireHostLink(to.host_id);
|
|
27
|
+
const { state } = await hostApi<{ state?: unknown }>(
|
|
28
|
+
link,
|
|
29
|
+
path(to.id, "message"),
|
|
30
|
+
{
|
|
31
|
+
body: {
|
|
32
|
+
id: messageId ? `bus-${messageId}` : undefined,
|
|
33
|
+
line,
|
|
34
|
+
from: fromName,
|
|
35
|
+
},
|
|
36
|
+
timeout: 20000,
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
return { state: state === "queued" ? "queued" : "delivered" };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
42
|
+
return { state: "failed", why: why.slice(0, 300) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A held chat task's last lines of conversation, as that machine reads them. */
|
|
47
|
+
export async function heldChatTail(
|
|
48
|
+
task: Session,
|
|
49
|
+
lines: number
|
|
50
|
+
): Promise<string | null> {
|
|
51
|
+
const link = requireHostLink(task.host_id);
|
|
52
|
+
const { text } = await hostApi<{ text?: unknown }>(
|
|
53
|
+
link,
|
|
54
|
+
`${path(task.id, "chat-tail")}?lines=${lines}`,
|
|
55
|
+
{ timeout: 10000 }
|
|
56
|
+
).catch(() => ({ text: undefined }));
|
|
57
|
+
return typeof text === "string" ? cleanRemoteText(text) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stops its agent there; its conversation and worktree are kept. */
|
|
61
|
+
export async function stopHeldTask(
|
|
62
|
+
task: Pick<Session, "id" | "name" | "host_id">
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
const link = requireHostLink(task.host_id);
|
|
65
|
+
const { text } = await hostApi<{ text?: unknown }>(
|
|
66
|
+
link,
|
|
67
|
+
path(task.id, "stop"),
|
|
68
|
+
{
|
|
69
|
+
body: {},
|
|
70
|
+
timeout: 30000,
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
return cleanRemoteText(text) || `Stopped ${task.name}.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Done there: its agent stops and its worktree goes. Never a merge. */
|
|
77
|
+
export async function doneHeldTask(task: Session): Promise<void> {
|
|
78
|
+
const link = requireHostLink(task.host_id);
|
|
79
|
+
await hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 });
|
|
80
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routes only the device that moved a task here may call on it
|
|
3
|
+
* (held.ts): message, stop, done and read. Each is refused for any other
|
|
4
|
+
* device, and for a task that isn't held.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import { callingDevice } from "../security/device-me";
|
|
10
|
+
import { holderRefusal } from "./held";
|
|
11
|
+
|
|
12
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
13
|
+
|
|
14
|
+
export async function heldRoute(
|
|
15
|
+
request: NextRequest,
|
|
16
|
+
{ params }: Ctx,
|
|
17
|
+
run: (task: Session) => Promise<object>
|
|
18
|
+
): Promise<NextResponse> {
|
|
19
|
+
const id = (await params).id;
|
|
20
|
+
const task = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
21
|
+
| Session
|
|
22
|
+
| undefined;
|
|
23
|
+
if (!task?.held_by_device)
|
|
24
|
+
return NextResponse.json({ error: "No such held task" }, { status: 404 });
|
|
25
|
+
const refusal = holderRefusal(task, callingDevice(request));
|
|
26
|
+
if (refusal) return NextResponse.json({ error: refusal }, { status: 403 });
|
|
27
|
+
try {
|
|
28
|
+
return NextResponse.json(await run(task));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task an orchestrator moved here belongs to that orchestrator's machine:
|
|
3
|
+
* only the device that sent it may merge, arm, move, message or finish it.
|
|
4
|
+
* Without this a moved task would be a plain task here, merged by anyone with
|
|
5
|
+
* a green CI, past the gates its orchestrator judges it by.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
|
|
10
|
+
export function holderRefusal(
|
|
11
|
+
session: Pick<Session, "held_by_device" | "held_from">,
|
|
12
|
+
device: string | null | undefined
|
|
13
|
+
): string | null {
|
|
14
|
+
if (!session.held_by_device) return null;
|
|
15
|
+
if (device && device === session.held_by_device) return null;
|
|
16
|
+
return `Its orchestrator on ${session.held_from || "another machine"} decides this; do it from there`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The refusal for the task with this id, or null (also when it's unknown). */
|
|
20
|
+
export function heldRefusal(
|
|
21
|
+
id: string,
|
|
22
|
+
device: string | null | undefined
|
|
23
|
+
): string | null {
|
|
24
|
+
const session = db
|
|
25
|
+
.prepare(`SELECT held_by_device, held_from FROM sessions WHERE id = ?`)
|
|
26
|
+
.get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
|
|
27
|
+
return session ? holderRefusal(session, device) : null;
|
|
28
|
+
}
|
package/lib/tasks/import.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* again returns the task it made the first time.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import fs from "fs";
|
|
8
9
|
import os from "os";
|
|
10
|
+
import path from "path";
|
|
9
11
|
import { randomUUID } from "crypto";
|
|
10
12
|
import { db, queries, type Session } from "../db";
|
|
11
13
|
import { setupWorktree } from "../env-setup";
|
|
@@ -18,6 +20,9 @@ import {
|
|
|
18
20
|
import { runInBackground } from "../async-operations";
|
|
19
21
|
import { launchClaude } from "../agents/launch";
|
|
20
22
|
import { resolveModelForAgent } from "../model-catalog";
|
|
23
|
+
import { providerFor } from "../models/resolve";
|
|
24
|
+
import { preparePlanDir, PLAN_FILE } from "../models/plan-step";
|
|
25
|
+
import { carryTaskState } from "./move-carry";
|
|
21
26
|
import { buildTaskBrief } from "./brief";
|
|
22
27
|
import { expandHome } from "./session";
|
|
23
28
|
import { ensureProject } from "./project-ref";
|
|
@@ -57,7 +62,16 @@ function refuseBusyBranch(projectId: string, branch: string): void {
|
|
|
57
62
|
if (busy) throw new Error(`${branch} already has a task running here`);
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
export
|
|
65
|
+
export interface ImportOptions {
|
|
66
|
+
// The paired device that sent an orchestrator's task: it holds the task
|
|
67
|
+
// here (held.ts). Absent for this machine taking its own task back.
|
|
68
|
+
holder?: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function importTask(
|
|
72
|
+
input: TaskBundle,
|
|
73
|
+
opts: ImportOptions = {}
|
|
74
|
+
): Promise<Session> {
|
|
61
75
|
const bundle = validateBundle(input);
|
|
62
76
|
const done = arrived(bundle.moveId);
|
|
63
77
|
if (done) return done;
|
|
@@ -65,13 +79,16 @@ export async function importTask(input: TaskBundle): Promise<Session> {
|
|
|
65
79
|
throw new InProgressError("It's arriving already");
|
|
66
80
|
importing.add(bundle.moveId);
|
|
67
81
|
try {
|
|
68
|
-
return await arrive(bundle);
|
|
82
|
+
return await arrive(bundle, opts);
|
|
69
83
|
} finally {
|
|
70
84
|
importing.delete(bundle.moveId);
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
async function arrive(
|
|
88
|
+
async function arrive(
|
|
89
|
+
bundle: TaskBundle,
|
|
90
|
+
opts: ImportOptions
|
|
91
|
+
): Promise<Session> {
|
|
75
92
|
const project = await ensureProject(bundle.project);
|
|
76
93
|
const projectPath = expandHome(project.working_directory);
|
|
77
94
|
refuseBusyBranch(project.id, bundle.branch);
|
|
@@ -81,12 +98,29 @@ async function arrive(bundle: TaskBundle): Promise<Session> {
|
|
|
81
98
|
projectPath,
|
|
82
99
|
bundle.branch
|
|
83
100
|
);
|
|
101
|
+
const orch = bundle.orchestrated;
|
|
102
|
+
const holder = orch ? (opts.holder ?? null) : null;
|
|
103
|
+
// Its provider as it was, or this machine says no and the source resumes
|
|
104
|
+
// it: a task never silently changes provider.
|
|
105
|
+
const provider = holder
|
|
106
|
+
? (orch?.modelProvider ?? null)
|
|
107
|
+
: ((
|
|
108
|
+
db
|
|
109
|
+
.prepare(`SELECT model_provider FROM sessions WHERE id = ?`)
|
|
110
|
+
.get(bundle.moveId) as { model_provider: string | null } | undefined
|
|
111
|
+
)?.model_provider ?? null);
|
|
112
|
+
if (provider) providerFor(provider);
|
|
84
113
|
const claude = bundle.claude;
|
|
85
114
|
const paths = claude && {
|
|
86
115
|
from: { cwd: claude.cwd, home: claude.home },
|
|
87
116
|
to: { cwd: worktreePath, home: os.homedir() },
|
|
88
117
|
};
|
|
89
118
|
stepProgress(bundle.moveId, "conversation");
|
|
119
|
+
// The plan is never in git: it comes in the bundle.
|
|
120
|
+
if (orch?.planFile != null) {
|
|
121
|
+
preparePlanDir(worktreePath);
|
|
122
|
+
fs.writeFileSync(path.join(worktreePath, PLAN_FILE), orch.planFile);
|
|
123
|
+
}
|
|
90
124
|
if (claude && paths) {
|
|
91
125
|
await writeTranscript(
|
|
92
126
|
worktreePath,
|
|
@@ -97,10 +131,9 @@ async function arrive(bundle: TaskBundle): Promise<Session> {
|
|
|
97
131
|
|
|
98
132
|
const id = randomUUID();
|
|
99
133
|
const tmuxName = `claude-${id}`;
|
|
100
|
-
const model =
|
|
101
|
-
|
|
102
|
-
bundle.model || project.default_model
|
|
103
|
-
);
|
|
134
|
+
const model = provider
|
|
135
|
+
? bundle.model
|
|
136
|
+
: resolveModelForAgent("claude", bundle.model || project.default_model);
|
|
104
137
|
const baseBranch = bundle.baseBranch ?? "main";
|
|
105
138
|
const brief = buildTaskBrief({ branch: bundle.branch, baseBranch });
|
|
106
139
|
// Checked and written with no await between, so two can't both pass.
|
|
@@ -131,6 +164,26 @@ async function arrive(bundle: TaskBundle): Promise<Session> {
|
|
|
131
164
|
claude_session_id = ?, moved_from = ? WHERE id = ?`
|
|
132
165
|
).run(bundle.prompt, brief, claude?.sessionId ?? null, bundle.moveId, id);
|
|
133
166
|
if (bundle.chat) unpackChat(id, bundle.chat, paths);
|
|
167
|
+
if (orch) {
|
|
168
|
+
db.prepare(
|
|
169
|
+
`UPDATE sessions SET plan_phase = ?, task_since = ? WHERE id = ?`
|
|
170
|
+
).run(orch.planPhase, orch.since, id);
|
|
171
|
+
// Held for the orchestrator's device. The orchestrator's own state
|
|
172
|
+
// (started_by, its gates) never comes from the bundle: it stays on its
|
|
173
|
+
// machine, and a task coming back takes it from the mirror's record.
|
|
174
|
+
if (holder)
|
|
175
|
+
db.prepare(
|
|
176
|
+
`UPDATE sessions SET held_by_device = ?, held_from = ?,
|
|
177
|
+
model_provider = ?, model_overrides = ? WHERE id = ?`
|
|
178
|
+
).run(
|
|
179
|
+
holder,
|
|
180
|
+
orch.heldFrom,
|
|
181
|
+
orch.modelProvider,
|
|
182
|
+
orch.modelOverrides,
|
|
183
|
+
id
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
carryTaskState(bundle.moveId, id);
|
|
134
187
|
})();
|
|
135
188
|
|
|
136
189
|
stepProgress(bundle.moveId, "resume");
|