@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
package/lib/tasks/index.ts
CHANGED
|
@@ -58,11 +58,12 @@ import { remoteTaskViews } from "./remote-views";
|
|
|
58
58
|
import { chatStateNow } from "../chat/runner";
|
|
59
59
|
import { saidSinceLastMessage } from "../chat/store";
|
|
60
60
|
|
|
61
|
+
import { lastActiveOf } from "./last-active";
|
|
61
62
|
export * from "./state";
|
|
62
63
|
export { codeReviewGate, codeReviewRefusal } from "./code-review";
|
|
63
64
|
export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
|
|
64
65
|
export { prFor as taskPR } from "./session";
|
|
65
|
-
export { moveTask } from "./move-flow";
|
|
66
|
+
export { assertMovable, moveTask } from "./move-flow";
|
|
66
67
|
|
|
67
68
|
export interface TaskView {
|
|
68
69
|
id: string;
|
|
@@ -106,6 +107,12 @@ export interface TaskView {
|
|
|
106
107
|
quickOf?: string | null;
|
|
107
108
|
// What it has cost so far in weighted tokens (lib/usage/weights).
|
|
108
109
|
weighted?: number;
|
|
110
|
+
// Its last sign of life, in ms, for an orchestrator on another machine.
|
|
111
|
+
lastActiveAt?: number;
|
|
112
|
+
// Held for another machine's orchestrator (held.ts): which machine, and
|
|
113
|
+
// the "plan written" line it would have been told.
|
|
114
|
+
heldFrom?: string | null;
|
|
115
|
+
planNote?: string | null;
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
export async function createTask(opts: {
|
|
@@ -394,6 +401,9 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
394
401
|
planning: session.plan_phase === "planning",
|
|
395
402
|
plan: taskPlan(session),
|
|
396
403
|
quickOf: session.quick_of ?? null,
|
|
404
|
+
lastActiveAt: session.held_by_device ? lastActiveOf(session) : undefined,
|
|
405
|
+
heldFrom: session.held_by_device ? (session.held_from ?? null) : null,
|
|
406
|
+
planNote: session.held_by_device ? (session.plan_note ?? null) : null,
|
|
397
407
|
};
|
|
398
408
|
}
|
|
399
409
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A session's last sign of life, in ms: its row, its tmux pane, its chat.
|
|
2
|
+
|
|
3
|
+
import { db, type Session } from "../db";
|
|
4
|
+
import { statusDetector } from "../status-detector";
|
|
5
|
+
|
|
6
|
+
export const sqliteMs = (t: string | null | undefined) =>
|
|
7
|
+
t ? Date.parse(`${t.replace(" ", "T")}Z`) || 0 : 0;
|
|
8
|
+
|
|
9
|
+
export function latestChatAt(sessionId: string): number {
|
|
10
|
+
const row = db
|
|
11
|
+
.prepare(
|
|
12
|
+
`SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
|
|
13
|
+
)
|
|
14
|
+
.get(sessionId) as { data: string } | undefined;
|
|
15
|
+
return row
|
|
16
|
+
? ((JSON.parse(row.data) as { createdAt?: number }).createdAt ?? 0)
|
|
17
|
+
: 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const lastActiveOf = (s: Session): number =>
|
|
21
|
+
Math.max(
|
|
22
|
+
sqliteMs(s.updated_at),
|
|
23
|
+
statusDetector.getTimestamp(s.tmux_name) * 1000,
|
|
24
|
+
latestChatAt(s.id)
|
|
25
|
+
);
|
package/lib/tasks/move-bundle.ts
CHANGED
|
@@ -14,8 +14,10 @@ export const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
|
14
14
|
// whole bundle stays under what a linked machine reads of an answer
|
|
15
15
|
// (lib/hosts/remote-api), since a move back here reads it as one.
|
|
16
16
|
export const MAX_CHAT_BYTES = 12 * 1024 * 1024;
|
|
17
|
+
// A task's plan file is a page or two; this is far past that.
|
|
18
|
+
export const MAX_PLAN_BYTES = 1024 * 1024;
|
|
17
19
|
export const MAX_BUNDLE_BYTES =
|
|
18
|
-
MAX_TRANSCRIPT_BYTES + MAX_CHAT_BYTES + 1024 * 1024;
|
|
20
|
+
MAX_TRANSCRIPT_BYTES + MAX_CHAT_BYTES + MAX_PLAN_BYTES + 1024 * 1024;
|
|
19
21
|
|
|
20
22
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
21
23
|
export const isSessionId = (id: unknown): id is string =>
|
|
@@ -50,6 +52,28 @@ export interface TaskBundle {
|
|
|
50
52
|
// A chat task's own state; absent for a terminal task, or from a machine
|
|
51
53
|
// that predates chat moves.
|
|
52
54
|
chat?: ChatBundle;
|
|
55
|
+
// What a task started by an orchestrator carries besides its work; absent
|
|
56
|
+
// for any other task, or from a machine that predates these moves.
|
|
57
|
+
orchestrated?: OrchestratedBundle;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OrchestratedBundle {
|
|
61
|
+
// The machine whose orchestrator holds the task, for the other machine's
|
|
62
|
+
// screens and refusals.
|
|
63
|
+
heldFrom: string;
|
|
64
|
+
// When the task first started (an ISO time), so a PR opened before the
|
|
65
|
+
// move still counts as the task's.
|
|
66
|
+
since: string | null;
|
|
67
|
+
// The model's provider and a task's own models, as they were; an arriving
|
|
68
|
+
// machine without that provider refuses the task rather than change it.
|
|
69
|
+
modelProvider: string | null;
|
|
70
|
+
modelOverrides: string | null;
|
|
71
|
+
planPhase: "planning" | "implementing" | null;
|
|
72
|
+
// .agentos/plan.md, which is never in git.
|
|
73
|
+
planFile: string | null;
|
|
74
|
+
// The orchestrator that holds the task (its id and name on the machine
|
|
75
|
+
// that runs it), so the task can message it from where it runs.
|
|
76
|
+
orchestrator?: { id: string; name: string };
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
export interface ChatBundle {
|
|
@@ -101,6 +125,47 @@ function validateChat(c: ChatBundle): ChatBundle {
|
|
|
101
125
|
return { ...c, plan: !!c.plan };
|
|
102
126
|
}
|
|
103
127
|
|
|
128
|
+
const PHASES = ["planning", "implementing"];
|
|
129
|
+
|
|
130
|
+
function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
|
|
131
|
+
if (!o || typeof o !== "object") throw new Error("Bad orchestrated task");
|
|
132
|
+
if (
|
|
133
|
+
!isStrOrNull(o.since) ||
|
|
134
|
+
!isStrOrNull(o.modelProvider) ||
|
|
135
|
+
!isStrOrNull(o.modelOverrides) ||
|
|
136
|
+
!isStrOrNull(o.planFile) ||
|
|
137
|
+
!(o.planPhase === null || PHASES.includes(o.planPhase as string))
|
|
138
|
+
)
|
|
139
|
+
throw new Error("Bad orchestrated task");
|
|
140
|
+
if (o.since !== null && Number.isNaN(Date.parse(o.since)))
|
|
141
|
+
throw new Error("Bad start time");
|
|
142
|
+
if ((o.planFile?.length ?? 0) > MAX_PLAN_BYTES)
|
|
143
|
+
throw new Error("The plan is too large to move");
|
|
144
|
+
return {
|
|
145
|
+
heldFrom: String(o.heldFrom || "another machine")
|
|
146
|
+
.replace(/[^\w.@ -]/g, "")
|
|
147
|
+
.slice(0, 100),
|
|
148
|
+
since: o.since,
|
|
149
|
+
modelProvider: o.modelProvider,
|
|
150
|
+
modelOverrides: o.modelOverrides,
|
|
151
|
+
planPhase: o.planPhase,
|
|
152
|
+
planFile: o.planFile,
|
|
153
|
+
orchestrator:
|
|
154
|
+
o.orchestrator &&
|
|
155
|
+
isSessionId(o.orchestrator.id) &&
|
|
156
|
+
typeof o.orchestrator.name === "string" &&
|
|
157
|
+
o.orchestrator.name.trim()
|
|
158
|
+
? {
|
|
159
|
+
id: o.orchestrator.id,
|
|
160
|
+
name: o.orchestrator.name
|
|
161
|
+
.replace(/[\u0000-\u001f]/g, " ")
|
|
162
|
+
.trim()
|
|
163
|
+
.slice(0, 200),
|
|
164
|
+
}
|
|
165
|
+
: undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
104
169
|
export function validateBundle(b: TaskBundle): TaskBundle {
|
|
105
170
|
if (!b || typeof b !== "object") throw new Error("No task to import");
|
|
106
171
|
if (!isSessionId(b.moveId)) throw new Error("Bad move id");
|
|
@@ -130,6 +195,9 @@ export function validateBundle(b: TaskBundle): TaskBundle {
|
|
|
130
195
|
}
|
|
131
196
|
return {
|
|
132
197
|
...b,
|
|
198
|
+
orchestrated: b.orchestrated
|
|
199
|
+
? validateOrchestrated(b.orchestrated)
|
|
200
|
+
: undefined,
|
|
133
201
|
chat: b.chat ? validateChat(b.chat) : undefined,
|
|
134
202
|
name: b.name.slice(0, 200),
|
|
135
203
|
from: String(b.from || "another machine")
|
|
@@ -140,10 +208,11 @@ export function validateBundle(b: TaskBundle): TaskBundle {
|
|
|
140
208
|
|
|
141
209
|
export function arrivalNote(
|
|
142
210
|
b: { from: string; branch: string },
|
|
143
|
-
cwd: string
|
|
211
|
+
cwd: string,
|
|
212
|
+
here: string
|
|
144
213
|
): string {
|
|
145
214
|
return [
|
|
146
|
-
`This task just moved
|
|
215
|
+
`This task just moved from ${b.from} to ${here}. It now runs on ${here}: every command you run executes there, not on ${b.from}, so name ${here} when you report where something ran. Your worktree is now ${cwd}, on branch ${b.branch}.`,
|
|
147
216
|
`Anything that was uncommitted came over as a commit starting "wip: moving to"; fold it into your own commits as you see fit.`,
|
|
148
217
|
`Continue the task where you left off.`,
|
|
149
218
|
].join(" ");
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An orchestrator's state about a task is keyed by the task's session id, and
|
|
3
|
+
* a move gives the task a new one on each machine. Carrying re-keys that state
|
|
4
|
+
* from the old id to the new, inside the transaction that makes the new row,
|
|
5
|
+
* so the gates, failure counts, asks, scope changes and events judge the task
|
|
6
|
+
* the same after a move. move-carry.test.ts fails on any table that keys by a
|
|
7
|
+
* session and is in neither list, so a new one gets decided.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type Database from "better-sqlite3";
|
|
11
|
+
import { db } from "../db";
|
|
12
|
+
|
|
13
|
+
// table, column, and how the id sits in it: the whole value, `task:<id>`, or
|
|
14
|
+
// the tail of a `<condition>:<id>` key.
|
|
15
|
+
export const REKEYED = [
|
|
16
|
+
["orchestrator_starts", "target", "id"],
|
|
17
|
+
["orchestrator_checks", "session_id", "id"],
|
|
18
|
+
["orchestrator_gate_failures", "session_id", "id"],
|
|
19
|
+
["orchestrator_gate_failure_commits", "session_id", "id"],
|
|
20
|
+
["orchestrator_asks", "subject", "task"],
|
|
21
|
+
["brief_amendments", "session_id", "id"],
|
|
22
|
+
["task_pr_wakes", "session_id", "id"],
|
|
23
|
+
["orchestrator_events", "subject", "id"],
|
|
24
|
+
["orchestrator_events", "key", "tail"],
|
|
25
|
+
["check_runs", "session_id", "id"],
|
|
26
|
+
["away_actions", "subject", "task"],
|
|
27
|
+
["task_queue", "after_task", "id"],
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
// Session-keyed columns that stay on the old id, and why.
|
|
31
|
+
export const KEPT = [
|
|
32
|
+
["orchestrator_event_log", "subject", "history is never rewritten"],
|
|
33
|
+
["stack_items", "session_id", "stacked tasks don't move"],
|
|
34
|
+
[
|
|
35
|
+
"task_queue",
|
|
36
|
+
"after_any",
|
|
37
|
+
"a JSON list; a queued task waits on any of them",
|
|
38
|
+
],
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
// Copied onto the new row where it has no value.
|
|
42
|
+
export const CARRIED_COLUMNS = [
|
|
43
|
+
"started_by",
|
|
44
|
+
"plan_decided",
|
|
45
|
+
"plan_reason",
|
|
46
|
+
"plan_phase",
|
|
47
|
+
"model_provider",
|
|
48
|
+
"model_overrides",
|
|
49
|
+
"merge_armed_at",
|
|
50
|
+
"merge_armed_seen",
|
|
51
|
+
"rebased_from",
|
|
52
|
+
"rebased_to",
|
|
53
|
+
"rebase_asked_head",
|
|
54
|
+
"pr_wake_since",
|
|
55
|
+
"pr_url",
|
|
56
|
+
"pr_number",
|
|
57
|
+
"pr_status",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/** The orchestration columns that make a session "an orchestrator's task". */
|
|
61
|
+
export const orchestrationOf = (session: Record<string, unknown>) =>
|
|
62
|
+
Object.fromEntries(
|
|
63
|
+
CARRIED_COLUMNS.map((c) => [c, session[c] ?? null] as const)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export function carryTaskState(
|
|
67
|
+
fromId: string,
|
|
68
|
+
toId: string,
|
|
69
|
+
handle: Database.Database = db
|
|
70
|
+
): void {
|
|
71
|
+
if (fromId === toId) return;
|
|
72
|
+
for (const [table, col, shape] of REKEYED) {
|
|
73
|
+
if (shape === "id")
|
|
74
|
+
handle
|
|
75
|
+
.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`)
|
|
76
|
+
.run(toId, fromId);
|
|
77
|
+
else if (shape === "task")
|
|
78
|
+
handle
|
|
79
|
+
.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`)
|
|
80
|
+
.run(`task:${toId}`, `task:${fromId}`);
|
|
81
|
+
else
|
|
82
|
+
handle
|
|
83
|
+
.prepare(
|
|
84
|
+
`UPDATE OR IGNORE ${table}
|
|
85
|
+
SET ${col} = substr(${col}, 1, length(${col}) - ?) || ?
|
|
86
|
+
WHERE ${col} LIKE ?`
|
|
87
|
+
)
|
|
88
|
+
.run(fromId.length, toId, `%:${fromId}`);
|
|
89
|
+
}
|
|
90
|
+
const from = handle
|
|
91
|
+
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
92
|
+
.get(fromId) as Record<string, unknown> | undefined;
|
|
93
|
+
if (!from) return;
|
|
94
|
+
const sets = [...CARRIED_COLUMNS, "task_since"]
|
|
95
|
+
.map((c) => `${c} = COALESCE(${c}, @${c})`)
|
|
96
|
+
.join(", ");
|
|
97
|
+
handle.prepare(`UPDATE sessions SET ${sets} WHERE id = @id`).run({
|
|
98
|
+
...orchestrationOf(from),
|
|
99
|
+
task_since: (from.task_since as string | null) ?? from.created_at ?? null,
|
|
100
|
+
id: toId,
|
|
101
|
+
});
|
|
102
|
+
}
|
package/lib/tasks/move-flow.ts
CHANGED
|
@@ -10,8 +10,11 @@ import { db, type Session } from "../db";
|
|
|
10
10
|
import { isRemoteHost } from "../hosts";
|
|
11
11
|
import { requireHostLink, type HostLink } from "../hosts/remote-api";
|
|
12
12
|
import { getTaskSession } from "./session";
|
|
13
|
-
import { exportOrResume,
|
|
14
|
-
import { moveRefusal } from "./move-guard";
|
|
13
|
+
import { exportOrResume, resumeTask, type TaskBundle } from "./move";
|
|
14
|
+
import { isOrchestrated, moveRefusal } from "./move-guard";
|
|
15
|
+
import { holderRefusal } from "./held";
|
|
16
|
+
import { isPaused } from "../orchestrator/pause";
|
|
17
|
+
import { sessionWorkspace } from "../models/resolve";
|
|
15
18
|
import { arrived as arrivedHere, importTask } from "./import";
|
|
16
19
|
import {
|
|
17
20
|
forgetHostTasks,
|
|
@@ -34,7 +37,8 @@ const LIVE = new Set(["running", "moving"]);
|
|
|
34
37
|
const g = globalThis as unknown as { __agentosMovesInFlight?: Set<string> };
|
|
35
38
|
const inFlight = (g.__agentosMovesInFlight ??= new Set());
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
/** Why this task can't move to that machine now, as an error; or nothing. */
|
|
41
|
+
export function assertMovable(id: string, toHostId: string): Session {
|
|
38
42
|
const session = getTaskSession(id);
|
|
39
43
|
if (!LIVE.has(session.task_status ?? ""))
|
|
40
44
|
throw new Error(`Task is already ${session.task_status}`);
|
|
@@ -47,9 +51,22 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
|
|
|
47
51
|
: "It's already on this machine"
|
|
48
52
|
);
|
|
49
53
|
}
|
|
54
|
+
// A person here can't move what another machine's orchestrator holds.
|
|
55
|
+
const held = holderRefusal(session, null);
|
|
56
|
+
if (held) throw new Error(held);
|
|
50
57
|
const refusal = moveRefusal(session);
|
|
51
58
|
if (refusal) throw new Error(refusal);
|
|
59
|
+
// Pause stops the orchestrator's new work, and a move is some.
|
|
60
|
+
const workspace = sessionWorkspace(session);
|
|
61
|
+
if (workspace && isOrchestrated(session) && isPaused(workspace))
|
|
62
|
+
throw new Error("The orchestrator is paused; moves wait for Resume");
|
|
52
63
|
if (inFlight.has(id)) throw new Error("It's already moving");
|
|
64
|
+
return session;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function moveTask(id: string, toHostId: string): Promise<Session> {
|
|
68
|
+
const session = assertMovable(id, toHostId);
|
|
69
|
+
const toRemote = isRemoteHost(toHostId);
|
|
53
70
|
inFlight.add(id);
|
|
54
71
|
try {
|
|
55
72
|
const moved = toRemote
|
|
@@ -66,19 +83,27 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
|
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
// A machine on an older AgentOS would take a chat task as a terminal one,
|
|
69
|
-
// or
|
|
70
|
-
|
|
86
|
+
// or an orchestrator's task as a plain one that anyone there could merge, or
|
|
87
|
+
// refuse to hand one over.
|
|
88
|
+
async function requireCapability(
|
|
89
|
+
link: HostLink,
|
|
90
|
+
capability: string,
|
|
91
|
+
what: string
|
|
92
|
+
): Promise<void> {
|
|
71
93
|
const { capabilities = [] } = await hostTasks(link, true).catch((err) => {
|
|
72
94
|
throw new Error(
|
|
73
|
-
`Can't ask ${link.hostName} whether it takes
|
|
95
|
+
`Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
|
|
74
96
|
);
|
|
75
97
|
});
|
|
76
|
-
if (!capabilities.includes(
|
|
98
|
+
if (!capabilities.includes(capability))
|
|
77
99
|
throw new Error(
|
|
78
|
-
`${link.hostName}'s AgentOS can't move
|
|
100
|
+
`${link.hostName}'s AgentOS can't move ${what} yet; update it first`
|
|
79
101
|
);
|
|
80
102
|
}
|
|
81
103
|
|
|
104
|
+
const requireChatMoves = (link: HostLink) =>
|
|
105
|
+
requireCapability(link, "chat-move", "chat tasks");
|
|
106
|
+
|
|
82
107
|
const unconfirmed = (where: string, err: unknown) =>
|
|
83
108
|
new Error(
|
|
84
109
|
`Couldn't confirm the move with ${where} (${(err as Error).message}). It's paused as "moving": press Move again to finish it.`
|
|
@@ -88,6 +113,8 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
|
|
|
88
113
|
const link = requireHostLink(hostId);
|
|
89
114
|
const chat = session.view === "chat";
|
|
90
115
|
if (chat) await requireChatMoves(link);
|
|
116
|
+
if (isOrchestrated(session))
|
|
117
|
+
await requireCapability(link, "orchestrated-move", "orchestrator tasks");
|
|
91
118
|
startProgress(
|
|
92
119
|
session.id,
|
|
93
120
|
link.hostName,
|
|
@@ -119,6 +146,13 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
|
|
|
119
146
|
return settleMovedOut(session, hostId, link.hostName, there);
|
|
120
147
|
}
|
|
121
148
|
|
|
149
|
+
function mirrorRunning(id: string): void {
|
|
150
|
+
db.prepare(
|
|
151
|
+
`UPDATE sessions SET task_status = 'running', moved_to = NULL
|
|
152
|
+
WHERE id = ? AND task_status = 'moving'`
|
|
153
|
+
).run(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
122
156
|
async function moveIn(mirror: Session): Promise<Session> {
|
|
123
157
|
const link = requireHostLink(mirror.host_id);
|
|
124
158
|
if (mirror.view === "chat") await requireChatMoves(link);
|
|
@@ -127,6 +161,12 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
127
161
|
// A retry after it arrived here but that machine didn't hear: tidy up.
|
|
128
162
|
let here = arrivedHere(mirror.id);
|
|
129
163
|
if (!here) {
|
|
164
|
+
// Moving, so nothing reads the mirror as a task to judge, or sign off,
|
|
165
|
+
// while it leaves (a refusal below puts it back).
|
|
166
|
+
db.prepare(
|
|
167
|
+
`UPDATE sessions SET task_status = 'moving', moved_to = 'this machine'
|
|
168
|
+
WHERE id = ? AND task_status = 'running'`
|
|
169
|
+
).run(mirror.id);
|
|
130
170
|
// That machine resumes the agent itself if its half fails there.
|
|
131
171
|
stepProgress(mirror.id, "export");
|
|
132
172
|
let bundle: TaskBundle;
|
|
@@ -138,6 +178,7 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
138
178
|
300000
|
|
139
179
|
));
|
|
140
180
|
} catch (err) {
|
|
181
|
+
mirrorRunning(mirror.id);
|
|
141
182
|
if (unknownOutcome(err)) throw unconfirmed(link.hostName, err);
|
|
142
183
|
throw err;
|
|
143
184
|
}
|
|
@@ -149,6 +190,7 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
149
190
|
`${(err as Error).message}; and ${link.hostName} didn't resume it: ${e.message}`
|
|
150
191
|
);
|
|
151
192
|
});
|
|
193
|
+
mirrorRunning(mirror.id);
|
|
152
194
|
throw err;
|
|
153
195
|
}
|
|
154
196
|
}
|
|
@@ -163,7 +205,11 @@ async function moveIn(mirror: Session): Promise<Session> {
|
|
|
163
205
|
`It's running here now, but ${link.hostName} didn't confirm (${(err as Error).message}). Press Move here again to tidy up.`
|
|
164
206
|
);
|
|
165
207
|
}
|
|
166
|
-
|
|
208
|
+
// The mirror was only how this machine showed it: the task is the new row.
|
|
209
|
+
db.prepare(`DELETE FROM sessions WHERE id = ? AND host_id = ?`).run(
|
|
210
|
+
mirror.id,
|
|
211
|
+
mirror.host_id
|
|
212
|
+
);
|
|
167
213
|
forgetHostTasks(link.hostId);
|
|
168
214
|
return db
|
|
169
215
|
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
package/lib/tasks/move-guard.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tasks that can't move: one waiting on a person (an escalated gate or an
|
|
3
|
-
* open ask), on a LumifyHub card, in a stack
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* open ask), on a LumifyHub card, or in a stack. A task an orchestrator
|
|
4
|
+
* started does move: its state is re-keyed to the new session (move-carry),
|
|
5
|
+
* and the machine it lands on holds it for that orchestrator's device
|
|
6
|
+
* (held.ts), so nothing there merges it.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { db, type Session } from "../db";
|
|
10
10
|
import { escalations } from "../orchestrator/gates";
|
|
11
|
+
import { taskSubject } from "../orchestrator/asks";
|
|
12
|
+
|
|
13
|
+
/** Started by an orchestrator, or held for one by the machine it left. */
|
|
14
|
+
export function isOrchestrated(session: Session): boolean {
|
|
15
|
+
if (session.started_by || session.held_by_device) return true;
|
|
16
|
+
return !!db
|
|
17
|
+
.prepare(
|
|
18
|
+
`SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
|
|
19
|
+
)
|
|
20
|
+
.get(session.id);
|
|
21
|
+
}
|
|
11
22
|
|
|
12
23
|
export function moveRefusal(session: Session): string | null {
|
|
13
24
|
if (session.quick_of)
|
|
@@ -16,9 +27,9 @@ export function moveRefusal(session: Session): string | null {
|
|
|
16
27
|
return "It's waiting on you (an escalated gate), so it stays here";
|
|
17
28
|
const ask = db
|
|
18
29
|
.prepare(
|
|
19
|
-
`SELECT 1 FROM orchestrator_asks WHERE subject
|
|
30
|
+
`SELECT 1 FROM orchestrator_asks WHERE subject IN (?, ?) AND status = 'open' LIMIT 1`
|
|
20
31
|
)
|
|
21
|
-
.get(session.id);
|
|
32
|
+
.get(session.id, taskSubject(session.id));
|
|
22
33
|
if (ask) return "It has an open ask waiting on you, so it stays here";
|
|
23
34
|
const row = db
|
|
24
35
|
.prepare(
|
|
@@ -31,12 +42,5 @@ export function moveRefusal(session: Session): string | null {
|
|
|
31
42
|
| undefined;
|
|
32
43
|
if (row?.lh_card_id) return "Card tasks run on this machine only for now";
|
|
33
44
|
if (row?.stacked) return "Stacked tasks run on this machine only for now";
|
|
34
|
-
const orchestrated = db
|
|
35
|
-
.prepare(
|
|
36
|
-
`SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
|
|
37
|
-
)
|
|
38
|
-
.get(session.id);
|
|
39
|
-
if (orchestrated)
|
|
40
|
-
return "An orchestrator's tasks run on this machine only for now";
|
|
41
45
|
return null;
|
|
42
46
|
}
|
|
@@ -12,6 +12,7 @@ import { hostApi, hostLink, type HostLink } from "../hosts/remote-api";
|
|
|
12
12
|
import { getTaskSession } from "./session";
|
|
13
13
|
import { markMoved, resumeTask } from "./move";
|
|
14
14
|
import { forgetHostTasks, mirrorTask, unknownOutcome } from "./remote";
|
|
15
|
+
import { carryTaskState } from "./move-carry";
|
|
15
16
|
|
|
16
17
|
/** POST, repeated when the outcome is unknown (the calls are idempotent). */
|
|
17
18
|
export async function tell(
|
|
@@ -50,6 +51,7 @@ export function settleMovedOut(
|
|
|
50
51
|
db.transaction(() => {
|
|
51
52
|
markMoved(session.id, hostName);
|
|
52
53
|
mirrorTask(hostId, session.project_id, there);
|
|
54
|
+
carryTaskState(session.id, there.id);
|
|
53
55
|
})();
|
|
54
56
|
forgetHostTasks(hostId);
|
|
55
57
|
return db
|
package/lib/tasks/move.ts
CHANGED
|
@@ -6,18 +6,26 @@
|
|
|
6
6
|
* it off and a second move of it is refused.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import fs from "fs";
|
|
9
10
|
import os from "os";
|
|
11
|
+
import path from "path";
|
|
10
12
|
import { db, type Session } from "../db";
|
|
11
13
|
import { getProject } from "../projects";
|
|
12
14
|
import { endTmuxSession } from "../tmux/end";
|
|
13
15
|
import { launchClaude } from "../agents/launch";
|
|
14
16
|
import { buildTaskBrief } from "./brief";
|
|
15
17
|
import { git as runGit } from "../git-exec";
|
|
16
|
-
import { getTaskSession } from "./session";
|
|
18
|
+
import { getTaskSession, isoUTC } from "./session";
|
|
17
19
|
import { projectRef } from "./project-ref";
|
|
18
|
-
import { moveRefusal } from "./move-guard";
|
|
19
|
-
import {
|
|
20
|
+
import { isOrchestrated, moveRefusal } from "./move-guard";
|
|
21
|
+
import {
|
|
22
|
+
MAX_PLAN_BYTES,
|
|
23
|
+
MAX_TRANSCRIPT_BYTES,
|
|
24
|
+
type OrchestratedBundle,
|
|
25
|
+
type TaskBundle,
|
|
26
|
+
} from "./move-bundle";
|
|
20
27
|
import { stepProgress } from "./move-progress";
|
|
28
|
+
import { PLAN_FILE } from "../models/plan-step";
|
|
21
29
|
import { dropSessionDatabase } from "../project-config/database";
|
|
22
30
|
import { releaseChat } from "../chat/runner";
|
|
23
31
|
import { collectWhileMoving } from "../chat/hold";
|
|
@@ -98,6 +106,43 @@ function claimMoving(id: string, to: string): { fresh: boolean } {
|
|
|
98
106
|
// Set by exportTask: did this call stop a running agent, or retry a move?
|
|
99
107
|
const freshClaims = new Set<string>();
|
|
100
108
|
|
|
109
|
+
const readPlanFile = (cwd: string): string | null => {
|
|
110
|
+
try {
|
|
111
|
+
const text = fs.readFileSync(path.join(cwd, PLAN_FILE), "utf8");
|
|
112
|
+
return text.length > MAX_PLAN_BYTES ? null : text;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// The orchestrator that holds the task: its own, or the one on the machine
|
|
119
|
+
// that moved it here.
|
|
120
|
+
function orchestratorOf(
|
|
121
|
+
session: Session
|
|
122
|
+
): { id: string; name: string } | undefined {
|
|
123
|
+
if (session.held_for && session.held_for_name)
|
|
124
|
+
return { id: session.held_for, name: session.held_for_name };
|
|
125
|
+
if (!session.started_by) return undefined;
|
|
126
|
+
const o = db
|
|
127
|
+
.prepare(`SELECT name FROM sessions WHERE id = ?`)
|
|
128
|
+
.get(session.started_by) as { name: string } | undefined;
|
|
129
|
+
return o ? { id: session.started_by, name: o.name } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// What an orchestrator's task carries besides its work (the orchestrator's
|
|
133
|
+
// own state is re-keyed here; see move-carry).
|
|
134
|
+
function orchestratedBundle(session: Session): OrchestratedBundle {
|
|
135
|
+
return {
|
|
136
|
+
heldFrom: session.held_from || os.hostname(),
|
|
137
|
+
since: isoUTC(session.task_since ?? session.created_at),
|
|
138
|
+
modelProvider: session.model_provider ?? null,
|
|
139
|
+
modelOverrides: session.model_overrides ?? null,
|
|
140
|
+
planPhase: session.plan_phase ?? null,
|
|
141
|
+
planFile: readPlanFile(session.working_directory),
|
|
142
|
+
orchestrator: orchestratorOf(session),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
101
146
|
/**
|
|
102
147
|
* Stops the agent and pushes everything. On failure the task stays moving;
|
|
103
148
|
* the caller resumes it (exportOrResume) when it knows nothing arrived.
|
|
@@ -163,6 +208,7 @@ export async function exportTask(id: string, to: string): Promise<TaskBundle> {
|
|
|
163
208
|
? { sessionId: claudeId, cwd, home: os.homedir(), transcript }
|
|
164
209
|
: null,
|
|
165
210
|
chat: chatBundle,
|
|
211
|
+
orchestrated: isOrchestrated(now) ? orchestratedBundle(now) : undefined,
|
|
166
212
|
};
|
|
167
213
|
} finally {
|
|
168
214
|
packed();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a machine tidies up after a task it held went back to the machine
|
|
3
|
+
* that sent it: its agent and chat worker stop, its ports are released, and
|
|
4
|
+
* its worktree goes when nothing in it would be lost (no uncommitted change,
|
|
5
|
+
* no commit a remote lacks). The row stays, archived, as history.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import { endSessionAgent } from "../sessions/end";
|
|
10
|
+
import { releasePorts } from "../ports";
|
|
11
|
+
import { settleWorktree, type WorktreeFate } from "../done/worktree";
|
|
12
|
+
import { archiveSession } from "../done/archive";
|
|
13
|
+
|
|
14
|
+
export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
|
|
15
|
+
const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
16
|
+
| Session
|
|
17
|
+
| undefined;
|
|
18
|
+
if (!s || s.task_status !== "moved" || !s.held_by_device)
|
|
19
|
+
return { action: "none" };
|
|
20
|
+
await endSessionAgent(s).catch((err) =>
|
|
21
|
+
console.error(`[moved-away] ${id}: agent not stopped:`, err)
|
|
22
|
+
);
|
|
23
|
+
releasePorts(id);
|
|
24
|
+
const fate = await settleWorktree(s, null, true).catch(
|
|
25
|
+
(err): WorktreeFate => ({
|
|
26
|
+
action: "kept",
|
|
27
|
+
why: `it couldn't be checked: ${err instanceof Error ? err.message : err}`,
|
|
28
|
+
path: s.worktree_path ?? "",
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
archiveSession(id);
|
|
32
|
+
if (fate.action === "kept")
|
|
33
|
+
console.warn(
|
|
34
|
+
`[moved-away] ${id}: worktree kept at ${fate.path}: ${fate.why}`
|
|
35
|
+
);
|
|
36
|
+
return fate;
|
|
37
|
+
}
|
package/lib/tasks/remote.ts
CHANGED
|
@@ -27,6 +27,12 @@ export const TASK_CAPABILITIES = [
|
|
|
27
27
|
"chat-turn",
|
|
28
28
|
"keyed-start",
|
|
29
29
|
"chat-move",
|
|
30
|
+
// Holds an orchestrator's task for the machine that sent it, and answers
|
|
31
|
+
// its messages, reads, stops and done (lib/tasks/held.ts).
|
|
32
|
+
"orchestrated-move",
|
|
33
|
+
// A held task's chat facts and its messages for its orchestrator.
|
|
34
|
+
"held-chat-facts",
|
|
35
|
+
"held-replies",
|
|
30
36
|
] as const;
|
|
31
37
|
|
|
32
38
|
export interface HostTasks {
|
package/lib/tasks/session.ts
CHANGED
|
@@ -170,7 +170,7 @@ export async function prFor(
|
|
|
170
170
|
const branch = renamed ?? session.branch_name;
|
|
171
171
|
if (!branch) return null;
|
|
172
172
|
// A PR older than the task is an earlier use of the branch's name.
|
|
173
|
-
const since = isoUTC(session.created_at);
|
|
173
|
+
const since = isoUTC(session.task_since ?? session.created_at);
|
|
174
174
|
let pr: TaskPR | null;
|
|
175
175
|
if (renamed) {
|
|
176
176
|
// Follow a rename only on gh's answer: a failure leaves the row put.
|