@saadnvd1/agent-os 0.3.578 → 0.3.580
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/app/api/tasks/[id]/hold/route.ts +28 -0
- package/app/api/tasks/route.ts +27 -2
- package/lib/bus/index.ts +82 -9
- package/lib/db/migrations.ts +55 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/index.ts +2 -2
- package/lib/orchestrator/act.ts +41 -1
- package/lib/orchestrator/digest.ts +7 -1
- package/lib/orchestrator/read.ts +2 -2
- package/lib/orchestrator/task-state.ts +2 -2
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/sessions/stop.ts +3 -3
- package/lib/sidebar/queued.ts +7 -1
- package/lib/tasks/held-remote.ts +140 -29
- package/lib/tasks/held.ts +106 -0
- package/lib/tasks/import.ts +6 -13
- package/lib/tasks/index.ts +21 -2
- package/lib/tasks/move-bundle.ts +3 -1
- package/lib/tasks/move-flow.ts +1 -20
- package/lib/tasks/placement.ts +2 -0
- package/lib/tasks/queue-held.ts +115 -0
- package/lib/tasks/queue.ts +49 -15
- package/lib/tasks/remote.ts +51 -3
- package/lib/tasks/start.ts +13 -1
- package/package.json +1 -1
package/lib/tasks/held-remote.ts
CHANGED
|
@@ -4,43 +4,152 @@
|
|
|
4
4
|
* orchestrator polls and calls; the other machine never reaches back.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { db, type Session } from "../db";
|
|
8
8
|
import {
|
|
9
|
+
HostApiError,
|
|
9
10
|
cleanRemoteBlock,
|
|
10
11
|
cleanRemoteText,
|
|
11
12
|
hostApi,
|
|
13
|
+
hostLink,
|
|
12
14
|
requireHostLink,
|
|
13
15
|
} from "../hosts/remote-api";
|
|
14
16
|
import type { Delivery } from "../bus/delivery";
|
|
17
|
+
import { wakeLine } from "../bus/format";
|
|
18
|
+
import { orchestratedStart } from "./held";
|
|
15
19
|
|
|
16
20
|
const path = (id: string, leaf: string) =>
|
|
17
21
|
`/api/tasks/${encodeURIComponent(id)}/${leaf}`;
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A task its orchestrator put on a linked machine, whether by moving it or
|
|
25
|
+
* by the queue placing it there: the one test every relayed call goes by.
|
|
26
|
+
* (held-replies.ts reads the same rule in SQL: started_by IS NOT NULL.)
|
|
27
|
+
*/
|
|
28
|
+
export const isHeldRemote = (
|
|
29
|
+
s: Pick<Session, "started_by" | "task_prompt" | "host_id">
|
|
30
|
+
): boolean => !!s.started_by && !!s.task_prompt && !!hostLink(s.host_id);
|
|
31
|
+
|
|
32
|
+
type Held = Pick<
|
|
33
|
+
Session,
|
|
34
|
+
"id" | "name" | "host_id" | "started_by" | "task_since" | "task_prompt"
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
const ADOPT_RETRY_MS = 5 * 60 * 1000;
|
|
38
|
+
const STALE_MS = 24 * 60 * 60 * 1000;
|
|
39
|
+
const g = globalThis as unknown as { __agentosAdopted?: Map<string, number> };
|
|
40
|
+
const adopted = (g.__agentosAdopted ??= new Map());
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A task the queue placed before machines held them is unheld there, so its
|
|
44
|
+
* calls are refused as "No such held task". Asks the machine to hold it for
|
|
45
|
+
* this one, then sends what was left undelivered. False when it can't.
|
|
46
|
+
*/
|
|
47
|
+
async function adoptHeld(task: Held): Promise<boolean> {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
// Only failures are remembered, and only for the retry window.
|
|
50
|
+
for (const [id, at] of adopted)
|
|
51
|
+
if (now - at >= ADOPT_RETRY_MS) adopted.delete(id);
|
|
52
|
+
if (adopted.has(task.id)) return false;
|
|
53
|
+
adopted.set(task.id, now);
|
|
54
|
+
try {
|
|
55
|
+
const link = requireHostLink(task.host_id);
|
|
56
|
+
await hostApi(link, path(task.id, "hold"), {
|
|
57
|
+
body: {
|
|
58
|
+
orchestrated: orchestratedStart(
|
|
59
|
+
task.started_by!,
|
|
60
|
+
task.task_since ?? null
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
timeout: 20000,
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
adopted.delete(task.id);
|
|
69
|
+
await flushUndelivered(task);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The call, held first if that machine says it isn't, once. */
|
|
74
|
+
async function withHold<T>(task: Held, call: () => Promise<T>): Promise<T> {
|
|
75
|
+
try {
|
|
76
|
+
return await call();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!(error instanceof HostApiError) || error.status !== 404) throw error;
|
|
79
|
+
if (!(await adoptHeld(task))) throw error;
|
|
80
|
+
return call();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Messages stored for the task while it couldn't be reached: sent now, each
|
|
86
|
+
* keyed by its stored id so one that did land isn't typed twice. Ones older
|
|
87
|
+
* than a day are marked read, not replayed into a later context.
|
|
88
|
+
*/
|
|
89
|
+
export async function flushUndelivered(
|
|
90
|
+
task: Pick<
|
|
91
|
+
Session,
|
|
92
|
+
"id" | "host_id" | "started_by" | "task_since" | "task_prompt" | "name"
|
|
93
|
+
>
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const rows = db
|
|
96
|
+
.prepare(
|
|
97
|
+
`SELECT id, from_id, from_name, body, created_at FROM bus_messages
|
|
98
|
+
WHERE to_id = ? AND delivered_at IS NULL AND read_at IS NULL
|
|
99
|
+
AND held_pause_id IS NULL ORDER BY id`
|
|
100
|
+
)
|
|
101
|
+
.all(task.id) as {
|
|
102
|
+
id: number;
|
|
103
|
+
from_id: string | null;
|
|
104
|
+
from_name: string;
|
|
105
|
+
body: string;
|
|
106
|
+
created_at: string;
|
|
107
|
+
}[];
|
|
108
|
+
for (const m of rows) {
|
|
109
|
+
const age = Date.now() - Date.parse(`${m.created_at.replace(" ", "T")}Z`);
|
|
110
|
+
if (age > STALE_MS) {
|
|
111
|
+
db.prepare(
|
|
112
|
+
`UPDATE bus_messages SET read_at = datetime('now') WHERE id = ?`
|
|
113
|
+
).run(m.id);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const line = wakeLine({
|
|
117
|
+
fromName: m.from_name,
|
|
118
|
+
fromId: m.from_id,
|
|
119
|
+
body: m.body,
|
|
120
|
+
});
|
|
121
|
+
const res = await relayToHeldTask(task, line, m.from_name, m.id, false);
|
|
122
|
+
if (res.state === "failed") return;
|
|
123
|
+
db.prepare(
|
|
124
|
+
`UPDATE bus_messages SET delivered_at = datetime('now') WHERE id = ?`
|
|
125
|
+
).run(m.id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
19
129
|
/**
|
|
20
130
|
* Delivered by that machine's AgentOS as a message from the orchestrator,
|
|
21
131
|
* never as the user's own. Keyed by the stored message, so a retry that
|
|
22
132
|
* already landed is not typed twice.
|
|
23
133
|
*/
|
|
24
134
|
export async function relayToHeldTask(
|
|
25
|
-
to:
|
|
135
|
+
to: Held,
|
|
26
136
|
line: string,
|
|
27
137
|
fromName: string,
|
|
28
|
-
messageId?: number
|
|
138
|
+
messageId?: number,
|
|
139
|
+
heal = true
|
|
29
140
|
): Promise<Delivery> {
|
|
30
141
|
try {
|
|
31
142
|
const link = requireHostLink(to.host_id);
|
|
32
|
-
const
|
|
33
|
-
link,
|
|
34
|
-
path(to.id, "message"),
|
|
35
|
-
{
|
|
143
|
+
const send = () =>
|
|
144
|
+
hostApi<{ state?: unknown }>(link, path(to.id, "message"), {
|
|
36
145
|
body: {
|
|
37
146
|
id: messageId ? `bus-${messageId}` : undefined,
|
|
38
147
|
line,
|
|
39
148
|
from: fromName,
|
|
40
149
|
},
|
|
41
150
|
timeout: 20000,
|
|
42
|
-
}
|
|
43
|
-
);
|
|
151
|
+
});
|
|
152
|
+
const { state } = await (heal ? withHold(to, send) : send());
|
|
44
153
|
return { state: state === "queued" ? "queued" : "delivered" };
|
|
45
154
|
} catch (error) {
|
|
46
155
|
const why = error instanceof Error ? error.message : String(error);
|
|
@@ -50,14 +159,16 @@ export async function relayToHeldTask(
|
|
|
50
159
|
|
|
51
160
|
/** A held chat task's last lines of conversation, as that machine reads them. */
|
|
52
161
|
export async function heldChatTail(
|
|
53
|
-
task:
|
|
162
|
+
task: Held,
|
|
54
163
|
lines: number
|
|
55
164
|
): Promise<string | null> {
|
|
56
165
|
const link = requireHostLink(task.host_id);
|
|
57
|
-
const { text } = await
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
166
|
+
const { text } = await withHold(task, () =>
|
|
167
|
+
hostApi<{ text?: unknown }>(
|
|
168
|
+
link,
|
|
169
|
+
`${path(task.id, "chat-tail")}?lines=${lines}`,
|
|
170
|
+
{ timeout: 10000 }
|
|
171
|
+
)
|
|
61
172
|
).catch(() => ({ text: undefined }));
|
|
62
173
|
// Whole lines, as a local read has them (read.ts caps and trims them).
|
|
63
174
|
return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
|
|
@@ -69,13 +180,15 @@ export async function heldChatTail(
|
|
|
69
180
|
* which callers treat as unread, never as clear.
|
|
70
181
|
*/
|
|
71
182
|
export async function heldChatFacts(
|
|
72
|
-
task:
|
|
183
|
+
task: Held
|
|
73
184
|
): Promise<{ blocked: string | null; turn: string | null } | null> {
|
|
74
185
|
const link = requireHostLink(task.host_id);
|
|
75
|
-
const res = await
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
186
|
+
const res = await withHold(task, () =>
|
|
187
|
+
hostApi<{ blocked?: unknown; turn?: unknown }>(
|
|
188
|
+
link,
|
|
189
|
+
path(task.id, "chat-facts"),
|
|
190
|
+
{ timeout: 10000 }
|
|
191
|
+
)
|
|
79
192
|
).catch(() => null);
|
|
80
193
|
if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
|
|
81
194
|
return null;
|
|
@@ -87,23 +200,21 @@ export async function heldChatFacts(
|
|
|
87
200
|
}
|
|
88
201
|
|
|
89
202
|
/** 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> {
|
|
203
|
+
export async function stopHeldTask(task: Held): Promise<string> {
|
|
93
204
|
const link = requireHostLink(task.host_id);
|
|
94
|
-
const { text } = await
|
|
95
|
-
link,
|
|
96
|
-
path(task.id, "stop"),
|
|
97
|
-
{
|
|
205
|
+
const { text } = await withHold(task, () =>
|
|
206
|
+
hostApi<{ text?: unknown }>(link, path(task.id, "stop"), {
|
|
98
207
|
body: {},
|
|
99
208
|
timeout: 30000,
|
|
100
|
-
}
|
|
209
|
+
})
|
|
101
210
|
);
|
|
102
211
|
return cleanRemoteText(text) || `Stopped ${task.name}.`;
|
|
103
212
|
}
|
|
104
213
|
|
|
105
214
|
/** Done there: its agent stops and its worktree goes. Never a merge. */
|
|
106
|
-
export async function doneHeldTask(task:
|
|
215
|
+
export async function doneHeldTask(task: Held): Promise<void> {
|
|
107
216
|
const link = requireHostLink(task.host_id);
|
|
108
|
-
await
|
|
217
|
+
await withHold(task, () =>
|
|
218
|
+
hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 })
|
|
219
|
+
);
|
|
109
220
|
}
|
package/lib/tasks/held.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* a green CI, past the gates its orchestrator judges it by.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import os from "os";
|
|
8
9
|
import { db, type Session } from "../db";
|
|
10
|
+
import type { OrchestratedBundle } from "./move-bundle";
|
|
9
11
|
|
|
10
12
|
export function holderRefusal(
|
|
11
13
|
session: Pick<Session, "held_by_device" | "held_from">,
|
|
@@ -26,3 +28,107 @@ export function heldRefusal(
|
|
|
26
28
|
.get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
|
|
27
29
|
return session ? holderRefusal(session, device) : null;
|
|
28
30
|
}
|
|
31
|
+
|
|
32
|
+
/** Holds a task here for the paired device that sent it, and the orchestrator there. */
|
|
33
|
+
export function holdFor(
|
|
34
|
+
id: string,
|
|
35
|
+
holder: string,
|
|
36
|
+
held: {
|
|
37
|
+
heldFrom: string;
|
|
38
|
+
orchestrator?: { id: string; name: string };
|
|
39
|
+
since?: string | null;
|
|
40
|
+
}
|
|
41
|
+
): void {
|
|
42
|
+
db.prepare(
|
|
43
|
+
`UPDATE sessions SET held_by_device = ?, held_from = ?, held_for = ?, held_for_name = ?,
|
|
44
|
+
task_since = COALESCE(?, task_since) WHERE id = ?`
|
|
45
|
+
).run(
|
|
46
|
+
holder,
|
|
47
|
+
held.heldFrom,
|
|
48
|
+
held.orchestrator?.id ?? null,
|
|
49
|
+
held.orchestrator?.name ?? null,
|
|
50
|
+
held.since ?? null,
|
|
51
|
+
id
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a linked machine needs to hold a task this machine's orchestrator
|
|
57
|
+
* starts there: the same description a move carries, minus work already done.
|
|
58
|
+
*/
|
|
59
|
+
export function orchestratedStart(
|
|
60
|
+
startedBy: string,
|
|
61
|
+
since: string | null = new Date().toISOString()
|
|
62
|
+
): OrchestratedBundle {
|
|
63
|
+
const o = db
|
|
64
|
+
.prepare(`SELECT name FROM sessions WHERE id = ?`)
|
|
65
|
+
.get(startedBy) as { name: string } | undefined;
|
|
66
|
+
return {
|
|
67
|
+
heldFrom: os.hostname(),
|
|
68
|
+
since,
|
|
69
|
+
modelProvider: null,
|
|
70
|
+
modelOverrides: null,
|
|
71
|
+
planPhase: null,
|
|
72
|
+
planFile: null,
|
|
73
|
+
orchestrator: o ? { id: startedBy, name: o.name } : undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Stands in for a starter nobody recorded: tasks already running when starters began to be recorded. */
|
|
78
|
+
const UNRECORDED_STARTER = "unrecorded";
|
|
79
|
+
|
|
80
|
+
/** Records the paired device that started a task here, the only one that may later hold it. */
|
|
81
|
+
export function recordStarter(id: string, device: string): void {
|
|
82
|
+
db.prepare(`UPDATE sessions SET started_by_device = ? WHERE id = ?`).run(
|
|
83
|
+
device,
|
|
84
|
+
id
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type Holdable = Pick<
|
|
89
|
+
Session,
|
|
90
|
+
| "held_by_device"
|
|
91
|
+
| "started_by"
|
|
92
|
+
| "started_by_device"
|
|
93
|
+
| "task_status"
|
|
94
|
+
| "host_id"
|
|
95
|
+
| "task_prompt"
|
|
96
|
+
>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether `device` may hold this task: a running task on this machine that
|
|
100
|
+
* nobody here (an orchestrator or a person) owns, which that device started.
|
|
101
|
+
*/
|
|
102
|
+
export function mayHold(s: Holdable, device: string): boolean {
|
|
103
|
+
if (!s.task_prompt || s.task_status !== "running") return false;
|
|
104
|
+
if (s.host_id && s.host_id !== "local") return false;
|
|
105
|
+
if (s.started_by || s.held_by_device) return false;
|
|
106
|
+
return (
|
|
107
|
+
s.started_by_device === device || s.started_by_device === UNRECORDED_STARTER
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Holds a task already running here for the device that started it, and the
|
|
113
|
+
* orchestrator there. False when there's no such running task; a task this
|
|
114
|
+
* device didn't start, one an orchestrator here owns, or one another device
|
|
115
|
+
* holds is refused. Holding the same task again is a no-op.
|
|
116
|
+
*/
|
|
117
|
+
export function holdExisting(
|
|
118
|
+
id: string,
|
|
119
|
+
device: string,
|
|
120
|
+
held: Parameters<typeof holdFor>[2]
|
|
121
|
+
): boolean {
|
|
122
|
+
const s = db
|
|
123
|
+
.prepare(
|
|
124
|
+
`SELECT held_by_device, started_by, started_by_device, task_status, host_id, task_prompt
|
|
125
|
+
FROM sessions WHERE id = ?`
|
|
126
|
+
)
|
|
127
|
+
.get(id) as Holdable | undefined;
|
|
128
|
+
if (!s?.task_prompt || s.task_status !== "running") return false;
|
|
129
|
+
if (s.held_by_device === device) return true;
|
|
130
|
+
if (!mayHold(s, device))
|
|
131
|
+
throw new Error("Only the machine that started this task can hold it");
|
|
132
|
+
holdFor(id, device, held);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
package/lib/tasks/import.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { expandHome } from "./session";
|
|
|
28
28
|
import { ensureProject } from "./project-ref";
|
|
29
29
|
import { checkoutBranchWorktree } from "./branch-worktree";
|
|
30
30
|
import { rewritePaths, writeTranscript } from "./transcript";
|
|
31
|
+
import { holdFor } from "./held";
|
|
31
32
|
import { stepProgress } from "./move-progress";
|
|
32
33
|
import { dropChat, unpackChat } from "./move-chat";
|
|
33
34
|
import { sendChat, stopChat } from "../chat/runner";
|
|
@@ -171,20 +172,12 @@ async function arrive(
|
|
|
171
172
|
// Held for the orchestrator's device. The orchestrator's own state
|
|
172
173
|
// (started_by, its gates) never comes from the bundle: it stays on its
|
|
173
174
|
// machine, and a task coming back takes it from the mirror's record.
|
|
174
|
-
if (holder)
|
|
175
|
+
if (holder) {
|
|
176
|
+
holdFor(id, holder, orch);
|
|
175
177
|
db.prepare(
|
|
176
|
-
`UPDATE sessions SET
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
);
|
|
178
|
+
`UPDATE sessions SET model_provider = ?, model_overrides = ? WHERE id = ?`
|
|
179
|
+
).run(orch.modelProvider, orch.modelOverrides, id);
|
|
180
|
+
}
|
|
188
181
|
}
|
|
189
182
|
carryTaskState(bundle.moveId, id);
|
|
190
183
|
})();
|
package/lib/tasks/index.ts
CHANGED
|
@@ -47,7 +47,8 @@ import {
|
|
|
47
47
|
type TaskState,
|
|
48
48
|
} from "./state";
|
|
49
49
|
import { expandHome, prFor, storedPR, taskSessions } from "./session";
|
|
50
|
-
import { InProgressError } from "./move-bundle";
|
|
50
|
+
import { InProgressError, type OrchestratedBundle } from "./move-bundle";
|
|
51
|
+
import { holdFor, mayHold, recordStarter } from "./held";
|
|
51
52
|
import { nameFor } from "../session-titles";
|
|
52
53
|
import { taskSetupOf, type TaskSetup } from "./setup";
|
|
53
54
|
import { taskPlan } from "./plan-view";
|
|
@@ -160,10 +161,24 @@ export async function createTask(opts: {
|
|
|
160
161
|
// How its agent runs: as a chat (the default, DEFAULT_START_VIEW) or in
|
|
161
162
|
// a terminal.
|
|
162
163
|
view?: StartView;
|
|
164
|
+
// Started by another machine's orchestrator, which this one holds it for
|
|
165
|
+
// (held.ts): that machine's paired device and its description of the task.
|
|
166
|
+
held?: { device: string; orchestrated: OrchestratedBundle };
|
|
167
|
+
// The paired device that sent this keyed start: the only one that may hold it later.
|
|
168
|
+
startedByDevice?: string;
|
|
163
169
|
}): Promise<Session> {
|
|
164
170
|
if (opts.id) {
|
|
165
171
|
const existing = queries.getSession(db).get(opts.id) as Session | undefined;
|
|
166
|
-
if (existing?.task_status)
|
|
172
|
+
if (existing?.task_status) {
|
|
173
|
+
// A retry of a start that wasn't held holds it now, for the device
|
|
174
|
+
// that started it and no other.
|
|
175
|
+
if (opts.held && existing.held_by_device !== opts.held.device) {
|
|
176
|
+
if (!mayHold(existing, opts.held.device))
|
|
177
|
+
throw new Error("That id is taken");
|
|
178
|
+
holdFor(existing.id, opts.held.device, opts.held.orchestrated);
|
|
179
|
+
}
|
|
180
|
+
return existing;
|
|
181
|
+
}
|
|
167
182
|
if (existing) throw new Error("That id is taken");
|
|
168
183
|
if (starting.has(opts.id))
|
|
169
184
|
throw new InProgressError("That task is starting already");
|
|
@@ -234,6 +249,7 @@ async function startTask(
|
|
|
234
249
|
model: typeof opts.model === "string" ? opts.model : undefined,
|
|
235
250
|
baseBranch: opts.baseBranch,
|
|
236
251
|
view,
|
|
252
|
+
startedBy: opts.startedBy,
|
|
237
253
|
});
|
|
238
254
|
opts.onCreated?.(session.id);
|
|
239
255
|
return session;
|
|
@@ -321,6 +337,9 @@ async function startTask(
|
|
|
321
337
|
opts.startedBy ?? null,
|
|
322
338
|
id
|
|
323
339
|
);
|
|
340
|
+
const starter = opts.held?.device ?? opts.startedByDevice;
|
|
341
|
+
if (starter) recordStarter(id, starter);
|
|
342
|
+
if (opts.held) holdFor(id, opts.held.device, opts.held.orchestrated);
|
|
324
343
|
opts.onCreated?.(id);
|
|
325
344
|
void naming.refine?.(id);
|
|
326
345
|
|
package/lib/tasks/move-bundle.ts
CHANGED
|
@@ -127,7 +127,9 @@ function validateChat(c: ChatBundle): ChatBundle {
|
|
|
127
127
|
|
|
128
128
|
const PHASES = ["planning", "implementing"];
|
|
129
129
|
|
|
130
|
-
function validateOrchestrated(
|
|
130
|
+
export function validateOrchestrated(
|
|
131
|
+
o: OrchestratedBundle
|
|
132
|
+
): OrchestratedBundle {
|
|
131
133
|
if (!o || typeof o !== "object") throw new Error("Bad orchestrated task");
|
|
132
134
|
if (
|
|
133
135
|
!isStrOrNull(o.since) ||
|
package/lib/tasks/move-flow.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { sessionWorkspace } from "../models/resolve";
|
|
|
18
18
|
import { arrived as arrivedHere, importTask } from "./import";
|
|
19
19
|
import {
|
|
20
20
|
forgetHostTasks,
|
|
21
|
-
|
|
21
|
+
requireCapability,
|
|
22
22
|
postIdempotent,
|
|
23
23
|
unknownOutcome,
|
|
24
24
|
} from "./remote";
|
|
@@ -88,25 +88,6 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// A machine on an older AgentOS would take a chat task as a terminal one,
|
|
92
|
-
// or an orchestrator's task as a plain one that anyone there could merge, or
|
|
93
|
-
// refuse to hand one over.
|
|
94
|
-
async function requireCapability(
|
|
95
|
-
link: HostLink,
|
|
96
|
-
capability: string,
|
|
97
|
-
what: string
|
|
98
|
-
): Promise<void> {
|
|
99
|
-
const { capabilities = [] } = await hostTasks(link, true).catch((err) => {
|
|
100
|
-
throw new Error(
|
|
101
|
-
`Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
|
|
102
|
-
);
|
|
103
|
-
});
|
|
104
|
-
if (!capabilities.includes(capability))
|
|
105
|
-
throw new Error(
|
|
106
|
-
`${link.hostName}'s AgentOS can't move ${what} yet; update it first`
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
91
|
const requireChatMoves = (link: HostLink) =>
|
|
111
92
|
requireCapability(link, "chat-move", "chat tasks");
|
|
112
93
|
|
package/lib/tasks/placement.ts
CHANGED
|
@@ -151,6 +151,8 @@ export interface PeerOption {
|
|
|
151
151
|
name: string;
|
|
152
152
|
view: MachineView;
|
|
153
153
|
capabilities: string[];
|
|
154
|
+
// It can hold an orchestrator's task (false: an older AgentOS). Unset: yes.
|
|
155
|
+
holds?: boolean;
|
|
154
156
|
// The project exists there (projectMachines said null).
|
|
155
157
|
hasProject: boolean;
|
|
156
158
|
loadPerCore: number;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages for a task still in the start queue. They're ordinary bus
|
|
3
|
+
* messages addressed to the queue id (the id the task starts as), flagged
|
|
4
|
+
* held_queued, and reach the task in its first prompt (tasks/start.ts).
|
|
5
|
+
* Nothing is deleted: a delivered or discarded message is marked, like the
|
|
6
|
+
* ones held for a paused task.
|
|
7
|
+
*
|
|
8
|
+
* Light on purpose: the message bus imports this, and must not pull in the
|
|
9
|
+
* queue's scheduler.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { db } from "../db";
|
|
13
|
+
import type { HeldMessage } from "./pause-policy";
|
|
14
|
+
|
|
15
|
+
export function queuedLabel(r: { name: string | null; prompt: string }) {
|
|
16
|
+
if (r.name) return r.name;
|
|
17
|
+
const line = r.prompt.split("\n")[0].trim();
|
|
18
|
+
return line.length > 60 ? `${line.slice(0, 57)}...` : line;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QueuedTarget {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
status: string;
|
|
25
|
+
error: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A task waiting to start (or failed to) by id, id prefix of 4+ characters
|
|
29
|
+
// or name; in one workspace when given.
|
|
30
|
+
export function findQueuedTarget(
|
|
31
|
+
ref: string,
|
|
32
|
+
opts: { workspaceId?: string } = {}
|
|
33
|
+
): QueuedTarget | null {
|
|
34
|
+
const want = ref.trim();
|
|
35
|
+
if (!want) return null;
|
|
36
|
+
const rows = db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT q.id, q.name, q.prompt, q.status, q.error, p.name AS project
|
|
39
|
+
FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
40
|
+
WHERE q.status IN ('queued', 'starting', 'failed')
|
|
41
|
+
${opts.workspaceId ? "AND p.workspace_id = @ws" : ""}`
|
|
42
|
+
)
|
|
43
|
+
.all(opts.workspaceId ? { ws: opts.workspaceId } : {}) as {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string | null;
|
|
46
|
+
prompt: string;
|
|
47
|
+
status: string;
|
|
48
|
+
error: string | null;
|
|
49
|
+
project: string;
|
|
50
|
+
}[];
|
|
51
|
+
const lower = want.toLowerCase();
|
|
52
|
+
const hits = rows.filter((r) => {
|
|
53
|
+
const name = queuedLabel(r).toLowerCase();
|
|
54
|
+
return (
|
|
55
|
+
r.id === want ||
|
|
56
|
+
(want.length >= 4 && r.id.startsWith(want)) ||
|
|
57
|
+
name === lower ||
|
|
58
|
+
`${r.project}/${name}`.toLowerCase() === lower
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
if (hits.length > 1)
|
|
62
|
+
throw new Error(
|
|
63
|
+
`"${ref}" matches ${hits.length} queued tasks: ${hits.map((h) => `${queuedLabel(h)} (${h.id.slice(0, 8)})`).join(", ")}`
|
|
64
|
+
);
|
|
65
|
+
const r = hits[0];
|
|
66
|
+
return r
|
|
67
|
+
? { id: r.id, name: queuedLabel(r), status: r.status, error: r.error }
|
|
68
|
+
: null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Refuses a task that failed to start: nothing will ever start it.
|
|
72
|
+
export function assertTakesMessages(q: QueuedTarget): void {
|
|
73
|
+
if (q.status === "failed")
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${q.name} failed to start${q.error ? ` (${q.error})` : ""}; it takes no messages. Remove it and queue it again.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const heldQueuedNote = (name: string) =>
|
|
80
|
+
`Queued for ${name}: it hasn't started; it gets this when it starts.`;
|
|
81
|
+
|
|
82
|
+
const UNDELIVERED = `held_queued = 1 AND delivered_at IS NULL AND read_at IS NULL`;
|
|
83
|
+
|
|
84
|
+
export function queuedHeldMessages(id: string): HeldMessage[] {
|
|
85
|
+
return (
|
|
86
|
+
db
|
|
87
|
+
.prepare(
|
|
88
|
+
`SELECT from_name, body FROM bus_messages
|
|
89
|
+
WHERE to_id = ? AND ${UNDELIVERED} ORDER BY id`
|
|
90
|
+
)
|
|
91
|
+
.all(id) as { from_name: string; body: string }[]
|
|
92
|
+
).map((r) => ({ from: r.from_name, text: r.body }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// They're in the first prompt: delivered, and not for `aos inbox` again.
|
|
96
|
+
export function markQueuedHeldDelivered(id: string): void {
|
|
97
|
+
db.prepare(
|
|
98
|
+
`UPDATE bus_messages
|
|
99
|
+
SET delivered_at = datetime('now'), read_at = COALESCE(read_at, datetime('now'))
|
|
100
|
+
WHERE to_id = ? AND ${UNDELIVERED}`
|
|
101
|
+
).run(id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The task left the queue without starting.
|
|
105
|
+
export function discardQueuedHeld(id: string): void {
|
|
106
|
+
db.prepare(
|
|
107
|
+
`UPDATE bus_messages SET read_at = datetime('now')
|
|
108
|
+
WHERE to_id = ? AND ${UNDELIVERED}`
|
|
109
|
+
).run(id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function queuedMessagesSection(held: HeldMessage[]): string {
|
|
113
|
+
if (!held.length) return "";
|
|
114
|
+
return `\n\n## Messages while queued\nThese arrived before you started, oldest first. They are part of your instructions:\n${held.map((m, i) => `${i + 1}. From ${m.from}: ${m.text}`).join("\n")}`;
|
|
115
|
+
}
|