@saadnvd1/agent-os 0.3.518 → 0.3.520
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 +19 -1
- package/app/api/bus/peers/route.ts +8 -2
- package/app/api/bus/spawn/route.ts +4 -0
- package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
- package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
- package/app/api/tasks/[id]/moved/route.ts +7 -0
- package/app/api/tasks/held-replies/route.ts +31 -0
- package/app/api/tasks/route.ts +2 -0
- package/components/Tasks/TaskRow.tsx +46 -1
- package/components/Tasks/task-state-labels.ts +1 -0
- package/data/tasks/index.ts +12 -0
- package/lib/bus/held-reply.ts +89 -0
- package/lib/bus/index.ts +86 -10
- package/lib/db/migrations.ts +40 -0
- package/lib/db/types.ts +5 -1
- package/lib/done/worktree.ts +24 -10
- package/lib/hosts/remote-api.ts +12 -0
- package/lib/lumifyhub/lists.ts +1 -0
- package/lib/models/plan-step.ts +116 -32
- package/lib/models/roles.ts +3 -1
- package/lib/orchestrator/act.ts +24 -2
- package/lib/orchestrator/brief.ts +3 -1
- package/lib/orchestrator/conditions.ts +1 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +11 -2
- package/lib/orchestrator/serve.ts +8 -0
- package/lib/orchestrator/task-state.ts +40 -15
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +18 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/security/demo.ts +1 -0
- package/lib/status-detector.ts +4 -0
- package/lib/tasks/held-remote.ts +31 -2
- package/lib/tasks/held-replies.ts +106 -0
- package/lib/tasks/import.ts +5 -3
- package/lib/tasks/index.ts +28 -7
- package/lib/tasks/move-bundle.ts +19 -2
- package/lib/tasks/move-flow.ts +21 -2
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +20 -1
- package/lib/tasks/moved-away.ts +37 -0
- package/lib/tasks/plan-view.ts +6 -1
- package/lib/tasks/queue.ts +6 -2
- package/lib/tasks/remote.ts +3 -0
- package/lib/tasks/start.ts +1 -1
- package/lib/tasks/state.ts +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -539,6 +539,21 @@ and **Usage → Compare** splits cost into planned and not. When the plan step
|
|
|
539
539
|
finishes, the orchestrator gets one event with the plan file's path and a
|
|
540
540
|
3-5 line summary; the handoff to implementation stays automatic.
|
|
541
541
|
|
|
542
|
+
**Plan only.** A task started with `plan_only` (`start_task`'s `plan_only`,
|
|
543
|
+
`aos task --plan-only`, or `plan_only: true` on `POST /api/tasks`) writes its
|
|
544
|
+
plan on the planning model and then stops: no model switch, no implement
|
|
545
|
+
prompt, even when one model does every role. It sits in a **Plan ready**
|
|
546
|
+
state (`plan_phase` `awaiting`, stored on the task, so it survives a restart)
|
|
547
|
+
that isn't blocked, doesn't count as needing you, and holds no agent process
|
|
548
|
+
(its worker is released; the conversation resumes on approval). The
|
|
549
|
+
orchestrator gets one event with the plan's path, a summary and that the task
|
|
550
|
+
is waiting. **Approve** on the task row (plan viewable there), `POST
|
|
551
|
+
/api/tasks/<id>/approve-plan`, or the orchestrator's `approve_plan(task,
|
|
552
|
+
message?)` continues it on the implementation model, once; a message amends
|
|
553
|
+
the plan and is recorded on the task's brief as a scope change, so the review
|
|
554
|
+
judges against it. Reject with **Drop**. A chat task on this machine only; it
|
|
555
|
+
can't move until approved.
|
|
556
|
+
|
|
542
557
|
**Per task.** The task draft has a **Model** chip ("Routed by role", or one
|
|
543
558
|
model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
|
|
544
559
|
`"provider/model"` for the task's own roles or a map such as
|
|
@@ -1587,7 +1602,10 @@ Once linked:
|
|
|
1587
1602
|
judging the PR at its head, reads BLOCKED lines and open approvals from the
|
|
1588
1603
|
task's chat there, and gets PR, CI, idle and plan events by polling that
|
|
1589
1604
|
machine. Its messages arrive marked as from the orchestrator, never as
|
|
1590
|
-
yours.
|
|
1605
|
+
yours. The task can `aos send` its orchestrator back: the machine it runs
|
|
1606
|
+
on only stores the message, and the orchestrator's machine pulls it and
|
|
1607
|
+
delivers it as that task's own. Moving it back stops its agent there and
|
|
1608
|
+
removes its worktree when nothing in it would be lost. Guards (write guard, shell deny list, attribution settings) run in
|
|
1591
1609
|
the task's worker on either machine, and the task can't merge or push to
|
|
1592
1610
|
the default branch. Moving counts toward the brakes and Pause refuses the
|
|
1593
1611
|
tool.
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { listPeers, resolveTarget } from "@/lib/bus";
|
|
2
|
+
import { listPeers, resolveHeldOrchestrator, resolveTarget } from "@/lib/bus";
|
|
3
3
|
|
|
4
4
|
// ?ref=<name>: just the session that name reaches, the way aos send would.
|
|
5
5
|
export async function GET(request: NextRequest) {
|
|
6
6
|
const ref = request.nextUrl.searchParams.get("ref");
|
|
7
|
-
|
|
7
|
+
// from: the asking session, which a held task needs for its orchestrator.
|
|
8
|
+
const from = request.nextUrl.searchParams.get("from") ?? "";
|
|
9
|
+
if (ref === null)
|
|
10
|
+
return NextResponse.json({ peers: await listPeers(from || undefined) });
|
|
11
|
+
const held = from ? resolveHeldOrchestrator(from, ref) : null;
|
|
12
|
+
if (held)
|
|
13
|
+
return NextResponse.json({ peer: { id: held.id, name: held.name } });
|
|
8
14
|
try {
|
|
9
15
|
const { session, note } = resolveTarget(ref);
|
|
10
16
|
return NextResponse.json({
|
|
@@ -29,12 +29,15 @@ export async function POST(request: NextRequest) {
|
|
|
29
29
|
on,
|
|
30
30
|
after,
|
|
31
31
|
view,
|
|
32
|
+
planOnly,
|
|
32
33
|
from,
|
|
33
34
|
} = await request.json();
|
|
34
35
|
const startedBy = orchestratorFrom(from);
|
|
35
36
|
const given = typeof name === "string" ? name : undefined;
|
|
36
37
|
if (after !== undefined && mode !== "task")
|
|
37
38
|
throw new Error("--after works for tasks only");
|
|
39
|
+
if (planOnly !== undefined && mode !== "task")
|
|
40
|
+
throw new Error("--plan-only works for tasks only");
|
|
38
41
|
if (mode === "task") {
|
|
39
42
|
const task = {
|
|
40
43
|
projectId: findProject(String(project ?? "")).id,
|
|
@@ -43,6 +46,7 @@ export async function POST(request: NextRequest) {
|
|
|
43
46
|
model,
|
|
44
47
|
hostId: on ? hostIdNamed(String(on)) : undefined,
|
|
45
48
|
view: startView(view),
|
|
49
|
+
planOnly: planOnly === true ? true : undefined,
|
|
46
50
|
startedBy,
|
|
47
51
|
};
|
|
48
52
|
const out = await startOrQueue(
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { approvePlanNow } from "@/lib/models/plan-step";
|
|
3
|
+
|
|
4
|
+
// Approves a plan-only task's plan: it continues on the implementation
|
|
5
|
+
// model. An optional message amends the plan (recorded as a scope change).
|
|
6
|
+
export async function POST(
|
|
7
|
+
request: NextRequest,
|
|
8
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
9
|
+
) {
|
|
10
|
+
try {
|
|
11
|
+
const body = await request.json().catch(() => ({}));
|
|
12
|
+
const message =
|
|
13
|
+
typeof body?.message === "string" && body.message.trim()
|
|
14
|
+
? body.message.trim().slice(0, 8000)
|
|
15
|
+
: undefined;
|
|
16
|
+
const out = await approvePlanNow((await params).id, message);
|
|
17
|
+
return NextResponse.json({ success: true, model: out.ref });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { blockedReason } from "@/lib/tasks/state";
|
|
3
|
+
import { saidSinceLastMessage } from "@/lib/chat/store";
|
|
4
|
+
import { chatStateNow } from "@/lib/chat/runner";
|
|
5
|
+
import { heldRoute } from "@/lib/tasks/held-routes";
|
|
6
|
+
|
|
7
|
+
// GET /api/tasks/:id/chat-facts - what its chat says about being blocked
|
|
8
|
+
// and whether a question is open, for the orchestrator that moved it here.
|
|
9
|
+
// Cheap (no PR lookups), unlike the whole task list.
|
|
10
|
+
export async function GET(
|
|
11
|
+
request: NextRequest,
|
|
12
|
+
ctx: { params: Promise<{ id: string }> }
|
|
13
|
+
) {
|
|
14
|
+
return heldRoute(request, ctx, async (task) => {
|
|
15
|
+
if (task.view !== "chat") return { blocked: null, turn: null };
|
|
16
|
+
return {
|
|
17
|
+
blocked: blockedReason(saidSinceLastMessage(task.id)),
|
|
18
|
+
turn: await chatStateNow(task.id).catch(() => "unknown"),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { markMoved } from "@/lib/tasks/move";
|
|
3
3
|
import { callingDevice } from "@/lib/security/device-me";
|
|
4
4
|
import { heldRefusal } from "@/lib/tasks/held";
|
|
5
|
+
import { cleanUpMovedAway } from "@/lib/tasks/moved-away";
|
|
6
|
+
import { runInBackground } from "@/lib/async-operations";
|
|
5
7
|
|
|
6
8
|
// POST /api/tasks/:id/moved - the task now runs on the machine named `to`
|
|
7
9
|
export async function POST(
|
|
@@ -13,5 +15,10 @@ export async function POST(
|
|
|
13
15
|
if (held) return NextResponse.json({ error: held }, { status: 403 });
|
|
14
16
|
const { to } = await request.json().catch(() => ({}));
|
|
15
17
|
markMoved(id, String(to || "another machine").slice(0, 100));
|
|
18
|
+
// A task its orchestrator took back: its agent and worktree here go.
|
|
19
|
+
if (held === null)
|
|
20
|
+
runInBackground(async () => {
|
|
21
|
+
await cleanUpMovedAway(id);
|
|
22
|
+
}, `moved-away-${id}`);
|
|
16
23
|
return NextResponse.json({ success: true });
|
|
17
24
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { callingDevice } from "@/lib/security/device-me";
|
|
3
|
+
import { ackReplies, pendingReplies } from "@/lib/bus/held-reply";
|
|
4
|
+
|
|
5
|
+
// GET /api/tasks/held-replies - what the tasks this device moved here have
|
|
6
|
+
// said to their orchestrator, for it to deliver there. Only that device's.
|
|
7
|
+
export async function GET(request: NextRequest) {
|
|
8
|
+
const device = callingDevice(request);
|
|
9
|
+
if (!device)
|
|
10
|
+
return NextResponse.json(
|
|
11
|
+
{ error: "Needs a paired device" },
|
|
12
|
+
{ status: 403 }
|
|
13
|
+
);
|
|
14
|
+
return NextResponse.json({ messages: pendingReplies(device) });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// POST { ids } - those are delivered; they are not offered again.
|
|
18
|
+
export async function POST(request: NextRequest) {
|
|
19
|
+
const device = callingDevice(request);
|
|
20
|
+
if (!device)
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ error: "Needs a paired device" },
|
|
23
|
+
{ status: 403 }
|
|
24
|
+
);
|
|
25
|
+
const { ids } = await request.json().catch(() => ({}));
|
|
26
|
+
const list = (Array.isArray(ids) ? ids : []).filter((n): n is number =>
|
|
27
|
+
Number.isSafeInteger(n)
|
|
28
|
+
);
|
|
29
|
+
ackReplies(device, list.slice(0, 200));
|
|
30
|
+
return NextResponse.json({ success: true });
|
|
31
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -36,6 +36,7 @@ export async function POST(request: NextRequest) {
|
|
|
36
36
|
view,
|
|
37
37
|
plan,
|
|
38
38
|
plan_reason,
|
|
39
|
+
plan_only,
|
|
39
40
|
} = await request.json();
|
|
40
41
|
if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
|
|
41
42
|
const pid =
|
|
@@ -48,6 +49,7 @@ export async function POST(request: NextRequest) {
|
|
|
48
49
|
// Checked now, so a queued task can't fail on it later.
|
|
49
50
|
model: parseOverride(model) ?? undefined,
|
|
50
51
|
plan: typeof plan === "boolean" ? plan : undefined,
|
|
52
|
+
planOnly: plan_only === true ? true : undefined,
|
|
51
53
|
planReason:
|
|
52
54
|
typeof plan_reason === "string" && plan_reason.trim()
|
|
53
55
|
? plan_reason.trim().slice(0, 200)
|
|
@@ -10,7 +10,13 @@ import {
|
|
|
10
10
|
import { Button } from "@/components/ui/button";
|
|
11
11
|
import type { TaskView } from "@/lib/tasks";
|
|
12
12
|
import { isFinished } from "@/lib/tasks/state";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
useApprovePlan,
|
|
15
|
+
useArmTask,
|
|
16
|
+
useDropTask,
|
|
17
|
+
useSignOffTask,
|
|
18
|
+
} from "@/data/tasks";
|
|
19
|
+
import { Markdown } from "@/components/Chat/Markdown";
|
|
14
20
|
import { tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
15
21
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
16
22
|
import { cn } from "@/lib/utils";
|
|
@@ -23,6 +29,8 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
23
29
|
const signOff = useSignOffTask();
|
|
24
30
|
const drop = useDropTask();
|
|
25
31
|
const arm = useArmTask();
|
|
32
|
+
const approve = useApprovePlan();
|
|
33
|
+
const [showPlan, setShowPlan] = useState(false);
|
|
26
34
|
const [confirmDrop, setConfirmDrop] = useState(false);
|
|
27
35
|
const [moveError, setMoveError] = useState<string | null>(null);
|
|
28
36
|
const live = !isFinished(task.state);
|
|
@@ -30,6 +38,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
30
38
|
signOff.error?.message ||
|
|
31
39
|
drop.error?.message ||
|
|
32
40
|
arm.error?.message ||
|
|
41
|
+
approve.error?.message ||
|
|
33
42
|
moveError ||
|
|
34
43
|
task.hostError;
|
|
35
44
|
const settingUp = live && task.setup?.status === "running";
|
|
@@ -148,6 +157,26 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
148
157
|
{task.armed ? "Armed" : "Arm merge"}
|
|
149
158
|
</Button>
|
|
150
159
|
)}
|
|
160
|
+
{task.state === "plan-ready" && (
|
|
161
|
+
<>
|
|
162
|
+
<Button
|
|
163
|
+
size="sm"
|
|
164
|
+
variant="outline"
|
|
165
|
+
aria-expanded={showPlan}
|
|
166
|
+
onClick={() => setShowPlan((v) => !v)}
|
|
167
|
+
>
|
|
168
|
+
{showPlan ? "Hide plan" : "View plan"}
|
|
169
|
+
</Button>
|
|
170
|
+
<Button
|
|
171
|
+
size="sm"
|
|
172
|
+
className="h-11 md:h-8"
|
|
173
|
+
disabled={approve.isPending}
|
|
174
|
+
onClick={() => approve.mutate({ id: task.id })}
|
|
175
|
+
>
|
|
176
|
+
{approve.isPending ? "Approving..." : "Approve"}
|
|
177
|
+
</Button>
|
|
178
|
+
</>
|
|
179
|
+
)}
|
|
151
180
|
{task.state === "review" && (
|
|
152
181
|
<Button
|
|
153
182
|
size="sm"
|
|
@@ -159,6 +188,22 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
159
188
|
)}
|
|
160
189
|
</div>
|
|
161
190
|
)}
|
|
191
|
+
{live && task.state === "plan-ready" && showPlan && (
|
|
192
|
+
<div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
|
|
193
|
+
{task.plan?.text ? (
|
|
194
|
+
<Markdown text={task.plan.text} />
|
|
195
|
+
) : (
|
|
196
|
+
<p className="text-muted-foreground text-xs">
|
|
197
|
+
No plan file was written.
|
|
198
|
+
</p>
|
|
199
|
+
)}
|
|
200
|
+
{task.plan?.truncated && (
|
|
201
|
+
<p className="text-muted-foreground mt-2 font-mono text-[11px]">
|
|
202
|
+
Shortened; the whole plan is {task.plan.path} in the worktree.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
162
207
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
163
208
|
</div>
|
|
164
209
|
);
|
|
@@ -8,6 +8,7 @@ export const TASK_STATE: Record<TaskState, { label: string; tone: string }> = {
|
|
|
8
8
|
tone: "text-amber-600 dark:text-amber-400",
|
|
9
9
|
},
|
|
10
10
|
blocked: { label: "Blocked", tone: "text-amber-600 dark:text-amber-400" },
|
|
11
|
+
"plan-ready": { label: "Plan ready", tone: "text-primary" },
|
|
11
12
|
review: { label: "Ready for review", tone: "text-primary" },
|
|
12
13
|
"checks-failing": { label: "Checks failing", tone: "text-destructive" },
|
|
13
14
|
exited: { label: "Agent exited", tone: "text-amber-600 dark:text-amber-400" },
|
package/data/tasks/index.ts
CHANGED
|
@@ -85,6 +85,18 @@ export function useDropTask() {
|
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export function useApprovePlan() {
|
|
89
|
+
return useTaskMutation(async (input: { id: string; message?: string }) =>
|
|
90
|
+
json(
|
|
91
|
+
await fetch(`/api/tasks/${input.id}/approve-plan`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ message: input.message }),
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
export function useResumeTask() {
|
|
89
101
|
return useTaskMutation(async (input: { id: string; force?: boolean }) =>
|
|
90
102
|
json(
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task its orchestrator moved here has no orchestrator on this machine to
|
|
3
|
+
* message, and this machine never reaches back to the one that holds it. Its
|
|
4
|
+
* message to the orchestrator is only stored here (relay_state 'pending');
|
|
5
|
+
* the holder pulls it (lib/tasks/held-replies.ts) and delivers it there as
|
|
6
|
+
* that task's own. This machine never delivers or attributes it, and the
|
|
7
|
+
* holder, not the sender, says who receives it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { resolveRef } from "./resolve";
|
|
12
|
+
|
|
13
|
+
export interface HeldOrchestrator {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
heldFrom: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The orchestrator a running held task messages, or null. */
|
|
20
|
+
export function heldOrchestrator(
|
|
21
|
+
s: Session | undefined
|
|
22
|
+
): HeldOrchestrator | null {
|
|
23
|
+
if (
|
|
24
|
+
!s ||
|
|
25
|
+
!s.held_by_device ||
|
|
26
|
+
!s.held_for ||
|
|
27
|
+
!s.held_for_name ||
|
|
28
|
+
s.task_status !== "running"
|
|
29
|
+
)
|
|
30
|
+
return null;
|
|
31
|
+
return {
|
|
32
|
+
id: s.held_for,
|
|
33
|
+
name: s.held_for_name,
|
|
34
|
+
heldFrom: s.held_from || "another machine",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The orchestrator `ref` names for this held sender: its name, id prefix or "orchestrator". */
|
|
39
|
+
export function heldTarget(
|
|
40
|
+
from: Session | undefined,
|
|
41
|
+
ref: string
|
|
42
|
+
): HeldOrchestrator | null {
|
|
43
|
+
const o = from && heldOrchestrator(from);
|
|
44
|
+
if (!o) return null;
|
|
45
|
+
if (ref.trim().toLowerCase() === "orchestrator") return o;
|
|
46
|
+
const r = resolveRef(ref, [
|
|
47
|
+
{
|
|
48
|
+
id: o.id,
|
|
49
|
+
name: o.name,
|
|
50
|
+
projectName: null,
|
|
51
|
+
tmuxName: "",
|
|
52
|
+
previousNames: [],
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
return r.ok ? o : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PendingReply {
|
|
59
|
+
id: number;
|
|
60
|
+
fromId: string;
|
|
61
|
+
body: string;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the calling device's held tasks have said to their orchestrators. */
|
|
66
|
+
export function pendingReplies(device: string, limit = 50): PendingReply[] {
|
|
67
|
+
return (
|
|
68
|
+
db
|
|
69
|
+
.prepare(
|
|
70
|
+
`SELECT m.id, m.from_id AS fromId, m.body, m.created_at AS createdAt
|
|
71
|
+
FROM bus_messages m JOIN sessions s ON s.id = m.from_id
|
|
72
|
+
WHERE m.relay_state = 'pending' AND s.held_by_device = ?
|
|
73
|
+
ORDER BY m.id LIMIT ?`
|
|
74
|
+
)
|
|
75
|
+
.all(device, limit) as PendingReply[]
|
|
76
|
+
).map((r) => ({ ...r, id: Number(r.id) }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Marks them pulled; only the device's own are touched. */
|
|
80
|
+
export function ackReplies(device: string, ids: number[]): void {
|
|
81
|
+
const mark = db.prepare(
|
|
82
|
+
`UPDATE bus_messages SET relay_state = 'relayed', delivered_at = datetime('now')
|
|
83
|
+
WHERE id = ? AND relay_state = 'pending'
|
|
84
|
+
AND from_id IN (SELECT id FROM sessions WHERE held_by_device = ?)`
|
|
85
|
+
);
|
|
86
|
+
db.transaction(() => {
|
|
87
|
+
for (const id of ids) mark.run(id, device);
|
|
88
|
+
})();
|
|
89
|
+
}
|
package/lib/bus/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveRef, wasNames, type Candidate } from "./resolve";
|
|
|
16
16
|
import { tmuxPane } from "./tmux-pane";
|
|
17
17
|
import { hostLink } from "../hosts/remote-api";
|
|
18
18
|
import { relayToHeldTask } from "../tasks/held-remote";
|
|
19
|
+
import { heldOrchestrator, heldTarget } from "./held-reply";
|
|
19
20
|
import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
|
|
20
21
|
|
|
21
22
|
export * from "./format";
|
|
@@ -54,11 +55,21 @@ function candidates(sessions: Session[]): Candidate[] {
|
|
|
54
55
|
}));
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
const sessionById = (id: string | null | undefined) =>
|
|
59
|
+
id
|
|
60
|
+
? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
61
|
+
| Session
|
|
62
|
+
| undefined)
|
|
63
|
+
: undefined;
|
|
64
|
+
|
|
65
|
+
// `callerId`: the asking session. A task its orchestrator moved here also
|
|
66
|
+
// sees that orchestrator, which it can message though it runs elsewhere.
|
|
67
|
+
export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
58
68
|
await statusDetector.refreshCache();
|
|
59
69
|
const sessions = liveSessions();
|
|
60
70
|
const named = candidates(sessions);
|
|
61
|
-
|
|
71
|
+
const orchestrator = heldOrchestrator(sessionById(callerId));
|
|
72
|
+
const listed = await Promise.all(
|
|
62
73
|
sessions.map(async (s, i) => {
|
|
63
74
|
const status = statusDetector.sessionExists(s.tmux_name)
|
|
64
75
|
? await statusDetector.getStatus(s.tmux_name)
|
|
@@ -76,6 +87,25 @@ export async function listPeers(): Promise<Peer[]> {
|
|
|
76
87
|
};
|
|
77
88
|
})
|
|
78
89
|
);
|
|
90
|
+
if (!orchestrator) return listed;
|
|
91
|
+
return [
|
|
92
|
+
...listed,
|
|
93
|
+
{
|
|
94
|
+
id: orchestrator.id,
|
|
95
|
+
name: orchestrator.name,
|
|
96
|
+
was: [],
|
|
97
|
+
projectName: null,
|
|
98
|
+
tmuxName: "",
|
|
99
|
+
hostId: "remote",
|
|
100
|
+
running: true,
|
|
101
|
+
doing: `your orchestrator, on ${orchestrator.heldFrom}`,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The orchestrator a held caller's ref names, if it names it.
|
|
107
|
+
export function resolveHeldOrchestrator(callerId: string, ref: string) {
|
|
108
|
+
return heldTarget(sessionById(callerId), ref);
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
// A live session by name, old name, project/name, id or id prefix. The note
|
|
@@ -208,16 +238,18 @@ export async function sendMessage(opts: {
|
|
|
208
238
|
// It's never left unread for `aos inbox` to show in another form; a
|
|
209
239
|
// failed one is the sender's to retry.
|
|
210
240
|
line?: string;
|
|
241
|
+
// A message pulled from a linked machine, by its key there: one already
|
|
242
|
+
// stored is not stored or delivered again.
|
|
243
|
+
relayKey?: string;
|
|
211
244
|
}): Promise<{ message: BusMessageView; delivery: Delivery; note?: string }> {
|
|
212
245
|
const body = opts.body.trim();
|
|
213
246
|
if (!body) throw new Error("Message is empty");
|
|
214
|
-
const
|
|
215
|
-
const from = opts.fromId
|
|
216
|
-
? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(opts.fromId) as
|
|
217
|
-
| Session
|
|
218
|
-
| undefined)
|
|
219
|
-
: undefined;
|
|
247
|
+
const from = sessionById(opts.fromId);
|
|
220
248
|
if (opts.fromId && !from) throw new Error("Unknown sender session");
|
|
249
|
+
// A held task's message to its orchestrator is kept for the holder to pull.
|
|
250
|
+
const held = heldTarget(from, opts.to);
|
|
251
|
+
if (from && held) return storeForHolder(from, held, body);
|
|
252
|
+
const { session: to, note } = resolveTarget(opts.to);
|
|
221
253
|
if (from && from.id === to.id)
|
|
222
254
|
throw new Error("A session can't message itself");
|
|
223
255
|
if (from && overRateLimit(pairTimestamps(from.id, to.id))) {
|
|
@@ -225,13 +257,32 @@ export async function sendMessage(opts: {
|
|
|
225
257
|
`Too many messages between ${from.name} and ${to.name}; wait a few minutes`
|
|
226
258
|
);
|
|
227
259
|
}
|
|
260
|
+
if (opts.relayKey) {
|
|
261
|
+
const seen = db
|
|
262
|
+
.prepare(`SELECT id FROM bus_messages WHERE relay_key = ?`)
|
|
263
|
+
.get(opts.relayKey) as { id: number } | undefined;
|
|
264
|
+
if (seen)
|
|
265
|
+
return {
|
|
266
|
+
message: toView(
|
|
267
|
+
db.prepare(`${SELECT} WHERE m.id = ?`).get(seen.id) as Row
|
|
268
|
+
),
|
|
269
|
+
delivery: { state: "delivered" },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
228
272
|
|
|
229
273
|
const fromName = from?.name ?? opts.fromLabel ?? HUMAN;
|
|
230
274
|
const { lastInsertRowid } = db
|
|
231
275
|
.prepare(
|
|
232
|
-
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body) VALUES (?, ?, ?, ?, ?)`
|
|
276
|
+
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key) VALUES (?, ?, ?, ?, ?, ?)`
|
|
233
277
|
)
|
|
234
|
-
.run(
|
|
278
|
+
.run(
|
|
279
|
+
from?.id ?? null,
|
|
280
|
+
fromName,
|
|
281
|
+
to.id,
|
|
282
|
+
to.name,
|
|
283
|
+
body,
|
|
284
|
+
opts.relayKey ?? null
|
|
285
|
+
);
|
|
235
286
|
const id = Number(lastInsertRowid);
|
|
236
287
|
|
|
237
288
|
await statusDetector.refreshCache();
|
|
@@ -260,6 +311,31 @@ export async function sendMessage(opts: {
|
|
|
260
311
|
return { message, delivery, note };
|
|
261
312
|
}
|
|
262
313
|
|
|
314
|
+
function storeForHolder(
|
|
315
|
+
from: Session,
|
|
316
|
+
to: { id: string; name: string; heldFrom: string },
|
|
317
|
+
body: string
|
|
318
|
+
) {
|
|
319
|
+
if (overRateLimit(pairTimestamps(from.id, to.id)))
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Too many messages between ${from.name} and ${to.name}; wait a few minutes`
|
|
322
|
+
);
|
|
323
|
+
const { lastInsertRowid } = db
|
|
324
|
+
.prepare(
|
|
325
|
+
`INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_state)
|
|
326
|
+
VALUES (?, ?, ?, ?, ?, 'pending')`
|
|
327
|
+
)
|
|
328
|
+
.run(from.id, from.name, to.id, to.name, body);
|
|
329
|
+
const message = toView(
|
|
330
|
+
db.prepare(`${SELECT} WHERE m.id = ?`).get(Number(lastInsertRowid)) as Row
|
|
331
|
+
);
|
|
332
|
+
return {
|
|
333
|
+
message,
|
|
334
|
+
delivery: { state: "queued" as const },
|
|
335
|
+
note: `${to.name} runs on ${to.heldFrom}: it gets this when that machine next checks in, within a few seconds`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
263
339
|
// Unread messages for a session, marked read as they're returned.
|
|
264
340
|
export function readInbox(sessionId: string): BusMessageView[] {
|
|
265
341
|
const rows = db
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1714,6 +1714,46 @@ const migrations: Migration[] = [
|
|
|
1714
1714
|
add(col);
|
|
1715
1715
|
},
|
|
1716
1716
|
},
|
|
1717
|
+
{
|
|
1718
|
+
id: 74,
|
|
1719
|
+
name: "held_task_replies",
|
|
1720
|
+
up: (db) => {
|
|
1721
|
+
// held_for / held_for_name: the orchestrator (id, name there) a held
|
|
1722
|
+
// task messages. relay_state: a held task's message to it, waiting for
|
|
1723
|
+
// the holder to pull it (pending) or pulled (relayed). relay_key: a
|
|
1724
|
+
// pulled message's key on the orchestrator's machine, so a repeat pull
|
|
1725
|
+
// is not delivered twice.
|
|
1726
|
+
const has = (table: string, col: string) =>
|
|
1727
|
+
(
|
|
1728
|
+
db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
1729
|
+
).some((c) => c.name === col);
|
|
1730
|
+
for (const col of ["held_for", "held_for_name"])
|
|
1731
|
+
if (!has("sessions", col))
|
|
1732
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
|
|
1733
|
+
for (const col of ["relay_state", "relay_key"])
|
|
1734
|
+
if (!has("bus_messages", col))
|
|
1735
|
+
db.exec(`ALTER TABLE bus_messages ADD COLUMN ${col} TEXT`);
|
|
1736
|
+
db.exec(
|
|
1737
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_bus_relay_key ON bus_messages(relay_key) WHERE relay_key IS NOT NULL;
|
|
1738
|
+
CREATE INDEX IF NOT EXISTS idx_bus_relay_pending ON bus_messages(relay_state) WHERE relay_state = 'pending'`
|
|
1739
|
+
);
|
|
1740
|
+
},
|
|
1741
|
+
},
|
|
1742
|
+
{
|
|
1743
|
+
id: 75,
|
|
1744
|
+
name: "plan_only_tasks",
|
|
1745
|
+
up: (db) => {
|
|
1746
|
+
// plan_only: 1 for a task that stops after its plan and waits for
|
|
1747
|
+
// approval (plan_phase 'awaiting'); queued tasks keep it until they start.
|
|
1748
|
+
const has = (table: string) =>
|
|
1749
|
+
(
|
|
1750
|
+
db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
1751
|
+
).some((c) => c.name === "plan_only");
|
|
1752
|
+
for (const table of ["sessions", "task_queue"])
|
|
1753
|
+
if (!has(table))
|
|
1754
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN plan_only INTEGER`);
|
|
1755
|
+
},
|
|
1756
|
+
},
|
|
1717
1757
|
];
|
|
1718
1758
|
|
|
1719
1759
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -19,7 +19,9 @@ export interface Session {
|
|
|
19
19
|
// A task's own models: "provider/model", or JSON of role to one.
|
|
20
20
|
model_overrides?: string | null;
|
|
21
21
|
// A task's plan step: planning on the planning model, then implementing.
|
|
22
|
-
|
|
22
|
+
// 'awaiting': a plan-only task's plan is written; it waits for approval.
|
|
23
|
+
plan_phase?: "planning" | "awaiting" | "implementing" | null;
|
|
24
|
+
plan_only?: number | null;
|
|
23
25
|
// The orchestrator session that started this one; null for a person.
|
|
24
26
|
started_by?: string | null;
|
|
25
27
|
// 1 planned, 0 no plan, null not stated; why, in the starter's words.
|
|
@@ -124,6 +126,8 @@ export interface Session {
|
|
|
124
126
|
// The device and machine of the orchestrator that moved this task here.
|
|
125
127
|
held_by_device?: string | null;
|
|
126
128
|
held_from?: string | null;
|
|
129
|
+
held_for?: string | null;
|
|
130
|
+
held_for_name?: string | null;
|
|
127
131
|
// A held task's "plan written" line, for its orchestrator to read.
|
|
128
132
|
plan_note?: string | null;
|
|
129
133
|
// Orchestration fields
|