@saadnvd1/agent-os 0.3.517 → 0.3.519
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 +23 -3
- package/app/api/bus/peers/route.ts +8 -2
- package/app/api/tasks/[id]/arm/route.ts +5 -0
- package/app/api/tasks/[id]/chat-facts/route.ts +21 -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 +13 -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/held-replies/route.ts +31 -0
- package/app/api/tasks/import/route.ts +11 -1
- package/lib/bus/held-reply.ts +89 -0
- package/lib/bus/index.ts +101 -12
- package/lib/db/migrations.ts +50 -0
- package/lib/db/types.ts +9 -0
- package/lib/done/bulk.ts +8 -0
- package/lib/done/index.ts +15 -0
- package/lib/done/worktree.ts +24 -10
- package/lib/hosts/remote-api.ts +12 -0
- package/lib/models/plan-step.ts +13 -3
- package/lib/orchestrator/act.ts +41 -1
- package/lib/orchestrator/armed.ts +6 -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/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +15 -0
- package/lib/orchestrator/task-state.ts +40 -15
- 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/security/demo.ts +1 -0
- package/lib/sessions/stop.ts +15 -1
- package/lib/status-detector.ts +4 -0
- package/lib/tasks/branch-worktree.ts +16 -1
- package/lib/tasks/finish.ts +5 -1
- package/lib/tasks/held-remote.ts +109 -0
- package/lib/tasks/held-replies.ts +106 -0
- package/lib/tasks/held-routes.ts +33 -0
- package/lib/tasks/held.ts +28 -0
- package/lib/tasks/import.ts +65 -10
- package/lib/tasks/index.ts +11 -1
- package/lib/tasks/last-active.ts +25 -0
- package/lib/tasks/move-bundle.ts +72 -3
- package/lib/tasks/move-carry.ts +102 -0
- package/lib/tasks/move-flow.ts +55 -9
- package/lib/tasks/move-guard.ts +17 -13
- package/lib/tasks/move-recover.ts +2 -0
- package/lib/tasks/move.ts +49 -3
- package/lib/tasks/moved-away.ts +37 -0
- package/lib/tasks/remote.ts +6 -0
- package/lib/tasks/session.ts +1 -1
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ import { blockedReason } from "../tasks/state";
|
|
|
9
9
|
import { checkWaitingPatterns, statusDetector } from "../status-detector";
|
|
10
10
|
import { getCheck, putCheck } from "./checks";
|
|
11
11
|
import { cleanRemoteText, hostLink, type HostLink } from "../hosts/remote-api";
|
|
12
|
+
import { heldChatFacts } from "../tasks/held-remote";
|
|
12
13
|
import { hostTasks } from "../tasks/remote";
|
|
13
14
|
import { peerPane } from "../hosts/peer-sessions";
|
|
14
15
|
import { untrusted } from "./untrusted";
|
|
@@ -24,21 +25,7 @@ export async function waitingState(
|
|
|
24
25
|
// its own reading of the task is asked for, fresh.
|
|
25
26
|
const link = hostLink(task.host_id);
|
|
26
27
|
if (link && task.view === "chat") return peerChatWaiting(link, task);
|
|
27
|
-
if (task.view === "chat")
|
|
28
|
-
const reason = blockedReason(saidSinceLastMessage(task.id));
|
|
29
|
-
// Asked of a worker still running from before a restart, too; one
|
|
30
|
-
// that can't be reached can't be cleared.
|
|
31
|
-
const state = await chatStateNow(task.id).catch(() => "unknown");
|
|
32
|
-
return {
|
|
33
|
-
blocked: reason === null ? null : untrusted(task.name, reason),
|
|
34
|
-
waitingOn:
|
|
35
|
-
state === "waiting"
|
|
36
|
-
? "an approval or question in its chat"
|
|
37
|
-
: state === "unknown"
|
|
38
|
-
? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
|
|
39
|
-
: null,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
28
|
+
if (task.view === "chat") return localChatWaiting(task);
|
|
42
29
|
// A linked machine reads its own screens: asked, and an answer it can't
|
|
43
30
|
// give is a screen that can't be cleared.
|
|
44
31
|
let tail: string;
|
|
@@ -71,6 +58,26 @@ export async function waitingState(
|
|
|
71
58
|
};
|
|
72
59
|
}
|
|
73
60
|
|
|
61
|
+
// A chat task whose conversation is in this machine's store (or, for a held
|
|
62
|
+
// task, the one the holder asks about).
|
|
63
|
+
export async function localChatWaiting(
|
|
64
|
+
task: Pick<Session, "id" | "name">
|
|
65
|
+
): Promise<{ blocked: string | null; waitingOn: string | null }> {
|
|
66
|
+
const reason = blockedReason(saidSinceLastMessage(task.id));
|
|
67
|
+
// Asked of a worker still running from before a restart, too; one
|
|
68
|
+
// that can't be reached can't be cleared.
|
|
69
|
+
const state = await chatStateNow(task.id).catch(() => "unknown");
|
|
70
|
+
return {
|
|
71
|
+
blocked: reason === null ? null : untrusted(task.name, reason),
|
|
72
|
+
waitingOn:
|
|
73
|
+
state === "waiting"
|
|
74
|
+
? "an approval or question in its chat"
|
|
75
|
+
: state === "unknown"
|
|
76
|
+
? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
|
|
77
|
+
: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
async function peerChatWaiting(
|
|
75
82
|
link: HostLink,
|
|
76
83
|
task: Session
|
|
@@ -79,6 +86,24 @@ async function peerChatWaiting(
|
|
|
79
86
|
blocked: `its chat on ${link.hostName} couldn't be read, so a BLOCKED: line or open question can't be ruled out`,
|
|
80
87
|
waitingOn: null,
|
|
81
88
|
};
|
|
89
|
+
// A task its orchestrator moved is asked about directly: the whole task
|
|
90
|
+
// list is slow, and a miss there reads as unread.
|
|
91
|
+
const facts = task.started_by ? await heldChatFacts(task) : null;
|
|
92
|
+
if (facts) {
|
|
93
|
+
const { turn } = facts;
|
|
94
|
+
return {
|
|
95
|
+
blocked: facts.blocked ? untrusted(task.name, facts.blocked) : null,
|
|
96
|
+
waitingOn:
|
|
97
|
+
turn === "waiting"
|
|
98
|
+
? `an approval or question in its chat on ${link.hostName}`
|
|
99
|
+
: turn === null ||
|
|
100
|
+
turn === "running" ||
|
|
101
|
+
turn === "idle" ||
|
|
102
|
+
turn === "error"
|
|
103
|
+
? null
|
|
104
|
+
: `its chat on ${link.hostName} couldn't say whether a question is open`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
82
107
|
const view = await hostTasks(link, true)
|
|
83
108
|
.then(({ tasks, capabilities }) =>
|
|
84
109
|
// One that can't say its chat's turn can't rule out an open question.
|
|
@@ -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:
|
package/lib/security/demo.ts
CHANGED
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(
|
package/lib/status-detector.ts
CHANGED
|
@@ -537,6 +537,10 @@ class SessionStatusDetector {
|
|
|
537
537
|
if (this.peersAsked.has(hostId)) return;
|
|
538
538
|
this.peersAsked.add(hostId);
|
|
539
539
|
this.hostListedAt.set(hostId, Date.now());
|
|
540
|
+
// What its held tasks have said to their orchestrators here.
|
|
541
|
+
void import("./tasks/held-replies")
|
|
542
|
+
.then((m) => m.pullHeldReplies(link))
|
|
543
|
+
.catch(() => undefined);
|
|
540
544
|
void peerTmuxSessions(link)
|
|
541
545
|
.then(
|
|
542
546
|
(list) => {
|
|
@@ -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,6 +9,7 @@ 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";
|
|
@@ -111,11 +112,14 @@ async function finishMerge(
|
|
|
111
112
|
// machine merged it and didn't say).
|
|
112
113
|
export async function signOffTask(
|
|
113
114
|
id: string,
|
|
114
|
-
opts: { wait?: boolean; head?: string } = {}
|
|
115
|
+
opts: { wait?: boolean; head?: string; device?: string | null } = {}
|
|
115
116
|
): Promise<MergeMethod | null> {
|
|
116
117
|
const session = getTaskSession(id);
|
|
117
118
|
if (session.task_status !== "running")
|
|
118
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);
|
|
119
123
|
// Its own machine checks the gates and merges.
|
|
120
124
|
// That machine's settings chose the method; null if it didn't say.
|
|
121
125
|
if (isMirror(session)) return remoteTaskAction(session, "merge", opts.head);
|
|
@@ -0,0 +1,109 @@
|
|
|
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 {
|
|
9
|
+
cleanRemoteBlock,
|
|
10
|
+
cleanRemoteText,
|
|
11
|
+
hostApi,
|
|
12
|
+
requireHostLink,
|
|
13
|
+
} from "../hosts/remote-api";
|
|
14
|
+
import type { Delivery } from "../bus/delivery";
|
|
15
|
+
|
|
16
|
+
const path = (id: string, leaf: string) =>
|
|
17
|
+
`/api/tasks/${encodeURIComponent(id)}/${leaf}`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Delivered by that machine's AgentOS as a message from the orchestrator,
|
|
21
|
+
* never as the user's own. Keyed by the stored message, so a retry that
|
|
22
|
+
* already landed is not typed twice.
|
|
23
|
+
*/
|
|
24
|
+
export async function relayToHeldTask(
|
|
25
|
+
to: Session,
|
|
26
|
+
line: string,
|
|
27
|
+
fromName: string,
|
|
28
|
+
messageId?: number
|
|
29
|
+
): Promise<Delivery> {
|
|
30
|
+
try {
|
|
31
|
+
const link = requireHostLink(to.host_id);
|
|
32
|
+
const { state } = await hostApi<{ state?: unknown }>(
|
|
33
|
+
link,
|
|
34
|
+
path(to.id, "message"),
|
|
35
|
+
{
|
|
36
|
+
body: {
|
|
37
|
+
id: messageId ? `bus-${messageId}` : undefined,
|
|
38
|
+
line,
|
|
39
|
+
from: fromName,
|
|
40
|
+
},
|
|
41
|
+
timeout: 20000,
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
return { state: state === "queued" ? "queued" : "delivered" };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
47
|
+
return { state: "failed", why: why.slice(0, 300) };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A held chat task's last lines of conversation, as that machine reads them. */
|
|
52
|
+
export async function heldChatTail(
|
|
53
|
+
task: Session,
|
|
54
|
+
lines: number
|
|
55
|
+
): Promise<string | null> {
|
|
56
|
+
const link = requireHostLink(task.host_id);
|
|
57
|
+
const { text } = await hostApi<{ text?: unknown }>(
|
|
58
|
+
link,
|
|
59
|
+
`${path(task.id, "chat-tail")}?lines=${lines}`,
|
|
60
|
+
{ timeout: 10000 }
|
|
61
|
+
).catch(() => ({ text: undefined }));
|
|
62
|
+
// Whole lines, as a local read has them (read.ts caps and trims them).
|
|
63
|
+
return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What its chat says about being blocked or asking, as that machine reads it
|
|
68
|
+
* (the same reading a local chat task gets). Null when it can't be asked,
|
|
69
|
+
* which callers treat as unread, never as clear.
|
|
70
|
+
*/
|
|
71
|
+
export async function heldChatFacts(
|
|
72
|
+
task: Pick<Session, "id" | "host_id">
|
|
73
|
+
): Promise<{ blocked: string | null; turn: string | null } | null> {
|
|
74
|
+
const link = requireHostLink(task.host_id);
|
|
75
|
+
const res = await hostApi<{ blocked?: unknown; turn?: unknown }>(
|
|
76
|
+
link,
|
|
77
|
+
path(task.id, "chat-facts"),
|
|
78
|
+
{ timeout: 10000 }
|
|
79
|
+
).catch(() => null);
|
|
80
|
+
if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
|
|
81
|
+
return null;
|
|
82
|
+
if (res.turn !== null && typeof res.turn !== "string") return null;
|
|
83
|
+
return {
|
|
84
|
+
blocked: res.blocked ? cleanRemoteBlock(res.blocked, 2000) : null,
|
|
85
|
+
turn: res.turn,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Stops its agent there; its conversation and worktree are kept. */
|
|
90
|
+
export async function stopHeldTask(
|
|
91
|
+
task: Pick<Session, "id" | "name" | "host_id">
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
const link = requireHostLink(task.host_id);
|
|
94
|
+
const { text } = await hostApi<{ text?: unknown }>(
|
|
95
|
+
link,
|
|
96
|
+
path(task.id, "stop"),
|
|
97
|
+
{
|
|
98
|
+
body: {},
|
|
99
|
+
timeout: 30000,
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
return cleanRemoteText(text) || `Stopped ${task.name}.`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Done there: its agent stops and its worktree goes. Never a merge. */
|
|
106
|
+
export async function doneHeldTask(task: Session): Promise<void> {
|
|
107
|
+
const link = requireHostLink(task.host_id);
|
|
108
|
+
await hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 });
|
|
109
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the tasks this machine's orchestrators moved to a linked machine say
|
|
3
|
+
* to those orchestrators. That machine only stores the messages (bus/held-reply);
|
|
4
|
+
* this machine, which polls, pulls them and delivers each as that task's own
|
|
5
|
+
* message to the orchestrator that started it. Who receives it is this
|
|
6
|
+
* machine's own record (started_by), never anything the other machine names.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db, type Session } from "../db";
|
|
10
|
+
import { sendMessage } from "../bus";
|
|
11
|
+
import { hostApi, type HostLink } from "../hosts/remote-api";
|
|
12
|
+
|
|
13
|
+
const PULL_EVERY_MS = 5000;
|
|
14
|
+
const MISSING_FOR_MS = 10 * 60 * 1000;
|
|
15
|
+
const MAX_BODY = 8000;
|
|
16
|
+
|
|
17
|
+
const g = globalThis as unknown as {
|
|
18
|
+
__agentosRepliesAt?: Map<string, number>;
|
|
19
|
+
__agentosRepliesBusy?: Set<string>;
|
|
20
|
+
};
|
|
21
|
+
const lastAsked = (g.__agentosRepliesAt ??= new Map());
|
|
22
|
+
const busy = (g.__agentosRepliesBusy ??= new Set());
|
|
23
|
+
|
|
24
|
+
interface Pulled {
|
|
25
|
+
id: number;
|
|
26
|
+
fromId: string;
|
|
27
|
+
body: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const heldMirror = (hostId: string, id: string) =>
|
|
31
|
+
db
|
|
32
|
+
.prepare(
|
|
33
|
+
`SELECT * FROM sessions WHERE id = ? AND host_id = ?
|
|
34
|
+
AND started_by IS NOT NULL AND task_status IN ('running', 'moving')`
|
|
35
|
+
)
|
|
36
|
+
.get(id, hostId) as Session | undefined;
|
|
37
|
+
|
|
38
|
+
export function hasHeldMirrors(hostId: string): boolean {
|
|
39
|
+
return !!db
|
|
40
|
+
.prepare(
|
|
41
|
+
`SELECT 1 FROM sessions WHERE host_id = ? AND started_by IS NOT NULL
|
|
42
|
+
AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
|
|
43
|
+
)
|
|
44
|
+
.get(hostId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Delivers what the link's machine holds for this machine's orchestrators. */
|
|
48
|
+
export async function pullHeldReplies(link: HostLink): Promise<number> {
|
|
49
|
+
const hostId = link.hostId;
|
|
50
|
+
if (busy.has(hostId) || !hasHeldMirrors(hostId)) return 0;
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
if (now - (lastAsked.get(hostId) ?? 0) < PULL_EVERY_MS) return 0;
|
|
53
|
+
lastAsked.set(hostId, now);
|
|
54
|
+
busy.add(hostId);
|
|
55
|
+
try {
|
|
56
|
+
let messages: Pulled[];
|
|
57
|
+
try {
|
|
58
|
+
const res = await hostApi<{ messages?: unknown }>(
|
|
59
|
+
link,
|
|
60
|
+
"/api/tasks/held-replies",
|
|
61
|
+
{ timeout: 8000 }
|
|
62
|
+
);
|
|
63
|
+
messages = (Array.isArray(res.messages) ? res.messages : []).filter(
|
|
64
|
+
(m): m is Pulled =>
|
|
65
|
+
!!m &&
|
|
66
|
+
Number.isSafeInteger(m.id) &&
|
|
67
|
+
typeof m.fromId === "string" &&
|
|
68
|
+
typeof m.body === "string"
|
|
69
|
+
);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// A machine that predates replies: leave it alone for a while.
|
|
72
|
+
if ((err as { status?: number }).status === 404)
|
|
73
|
+
lastAsked.set(hostId, now + MISSING_FOR_MS);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const handled: number[] = [];
|
|
77
|
+
let delivered = 0;
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
const mirror = heldMirror(hostId, m.fromId);
|
|
80
|
+
if (!mirror) {
|
|
81
|
+
handled.push(m.id); // nothing here to deliver it from
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await sendMessage({
|
|
86
|
+
fromId: mirror.id,
|
|
87
|
+
to: mirror.started_by!,
|
|
88
|
+
body: m.body.slice(0, MAX_BODY),
|
|
89
|
+
relayKey: `${hostId}:${m.id}`,
|
|
90
|
+
});
|
|
91
|
+
handled.push(m.id);
|
|
92
|
+
delivered++;
|
|
93
|
+
} catch {
|
|
94
|
+
// Gone, or rate limited: offered again at the next pull.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (handled.length)
|
|
98
|
+
await hostApi(link, "/api/tasks/held-replies", {
|
|
99
|
+
body: { ids: handled },
|
|
100
|
+
timeout: 8000,
|
|
101
|
+
}).catch(() => undefined);
|
|
102
|
+
return delivered;
|
|
103
|
+
} finally {
|
|
104
|
+
busy.delete(hostId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -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,29 @@ 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
|
+
held_for = ?, held_for_name = ?,
|
|
178
|
+
model_provider = ?, model_overrides = ? WHERE id = ?`
|
|
179
|
+
).run(
|
|
180
|
+
holder,
|
|
181
|
+
orch.heldFrom,
|
|
182
|
+
orch.orchestrator?.id ?? null,
|
|
183
|
+
orch.orchestrator?.name ?? null,
|
|
184
|
+
orch.modelProvider,
|
|
185
|
+
orch.modelOverrides,
|
|
186
|
+
id
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
carryTaskState(bundle.moveId, id);
|
|
134
190
|
})();
|
|
135
191
|
|
|
136
192
|
stepProgress(bundle.moveId, "resume");
|
|
@@ -150,9 +206,8 @@ async function arrive(bundle: TaskBundle): Promise<Session> {
|
|
|
150
206
|
});
|
|
151
207
|
}, `setup-moved-${bundle.branch}`);
|
|
152
208
|
}
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
: `${bundle.prompt}\n\n${arrivalNote(bundle, worktreePath)}`;
|
|
209
|
+
const note = arrivalNote(bundle, worktreePath, os.hostname());
|
|
210
|
+
const prompt = claude ? note : `${bundle.prompt}\n\n${note}`;
|
|
156
211
|
if (bundle.chat) {
|
|
157
212
|
// Its worker starts on the carried conversation, takes the note
|
|
158
213
|
// first, then what was queued.
|