@saadnvd1/agent-os 0.3.516 → 0.3.518
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/app/api/devices/route.ts +2 -0
- package/app/api/push/test/route.test.ts +112 -0
- package/app/api/push/test/route.ts +38 -0
- package/app/api/tasks/[id]/arm/route.ts +5 -0
- package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
- package/app/api/tasks/[id]/done/route.ts +15 -0
- package/app/api/tasks/[id]/export/route.ts +6 -1
- package/app/api/tasks/[id]/merge/route.ts +9 -1
- package/app/api/tasks/[id]/message/route.ts +39 -0
- package/app/api/tasks/[id]/moved/route.ts +6 -1
- package/app/api/tasks/[id]/resume/route.ts +6 -1
- package/app/api/tasks/[id]/stop/route.ts +14 -0
- package/app/api/tasks/import/route.ts +11 -1
- package/components/Devices/DeviceRow.tsx +52 -3
- package/components/Devices/DevicesPanel.tsx +13 -0
- package/data/devices/queries.ts +30 -2
- package/lib/away/run.ts +15 -3
- package/lib/bus/index.ts +15 -2
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/connect/control-api.ts +38 -0
- package/lib/db/migrations.ts +56 -0
- package/lib/db/types.ts +7 -0
- package/lib/done/bulk.ts +8 -0
- package/lib/done/index.ts +20 -1
- package/lib/load/alert.ts +5 -1
- package/lib/models/plan-step.ts +13 -3
- package/lib/notify/index.ts +46 -7
- package/lib/notify/limiter.ts +6 -1
- package/lib/notify/push/encode.ts +42 -0
- package/lib/notify/push/events.ts +72 -0
- package/lib/notify/push/index.ts +183 -0
- package/lib/notify/push/outbox.ts +238 -0
- package/lib/notify/push/policy.ts +33 -0
- package/lib/notify/push/presence.ts +45 -0
- package/lib/notify/push/relay.ts +50 -0
- package/lib/notify/push/sender.ts +271 -0
- package/lib/notify/push/testing.ts +30 -0
- package/lib/notify/push/waiting.ts +29 -0
- package/lib/notify/push-types.ts +2 -0
- package/lib/notify/redact.ts +13 -2
- package/lib/orchestrator/act.ts +41 -1
- package/lib/orchestrator/armed.ts +6 -1
- package/lib/orchestrator/asks.ts +5 -1
- package/lib/orchestrator/brakes.ts +9 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/facts.ts +72 -21
- package/lib/orchestrator/read.ts +14 -2
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +15 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +11 -0
- package/lib/orchestrator/tools.ts +1 -0
- package/lib/program-status/tap.ts +11 -1
- package/lib/schedules/start.ts +11 -2
- package/lib/security/devices.ts +57 -4
- package/lib/sessions/stop.ts +15 -1
- package/lib/tasks/branch-worktree.ts +16 -1
- package/lib/tasks/finish.ts +7 -1
- package/lib/tasks/held-remote.ts +80 -0
- package/lib/tasks/held-routes.ts +33 -0
- package/lib/tasks/held.ts +28 -0
- package/lib/tasks/import.ts +60 -7
- package/lib/tasks/index.ts +11 -1
- package/lib/tasks/last-active.ts +25 -0
- package/lib/tasks/move-bundle.ts +53 -1
- package/lib/tasks/move-carry.ts +102 -0
- package/lib/tasks/move-flow.ts +34 -7
- package/lib/tasks/move-guard.ts +17 -13
- package/lib/tasks/move-recover.ts +2 -0
- package/lib/tasks/move.ts +34 -3
- package/lib/tasks/remote.ts +3 -0
- package/lib/tasks/session.ts +27 -3
- package/package.json +1 -1
- package/server.ts +2 -0
package/lib/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,25 @@ 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;
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
export interface ChatBundle {
|
|
@@ -101,6 +122,34 @@ function validateChat(c: ChatBundle): ChatBundle {
|
|
|
101
122
|
return { ...c, plan: !!c.plan };
|
|
102
123
|
}
|
|
103
124
|
|
|
125
|
+
const PHASES = ["planning", "implementing"];
|
|
126
|
+
|
|
127
|
+
function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
|
|
128
|
+
if (!o || typeof o !== "object") throw new Error("Bad orchestrated task");
|
|
129
|
+
if (
|
|
130
|
+
!isStrOrNull(o.since) ||
|
|
131
|
+
!isStrOrNull(o.modelProvider) ||
|
|
132
|
+
!isStrOrNull(o.modelOverrides) ||
|
|
133
|
+
!isStrOrNull(o.planFile) ||
|
|
134
|
+
!(o.planPhase === null || PHASES.includes(o.planPhase as string))
|
|
135
|
+
)
|
|
136
|
+
throw new Error("Bad orchestrated task");
|
|
137
|
+
if (o.since !== null && Number.isNaN(Date.parse(o.since)))
|
|
138
|
+
throw new Error("Bad start time");
|
|
139
|
+
if ((o.planFile?.length ?? 0) > MAX_PLAN_BYTES)
|
|
140
|
+
throw new Error("The plan is too large to move");
|
|
141
|
+
return {
|
|
142
|
+
heldFrom: String(o.heldFrom || "another machine")
|
|
143
|
+
.replace(/[^\w.@ -]/g, "")
|
|
144
|
+
.slice(0, 100),
|
|
145
|
+
since: o.since,
|
|
146
|
+
modelProvider: o.modelProvider,
|
|
147
|
+
modelOverrides: o.modelOverrides,
|
|
148
|
+
planPhase: o.planPhase,
|
|
149
|
+
planFile: o.planFile,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
104
153
|
export function validateBundle(b: TaskBundle): TaskBundle {
|
|
105
154
|
if (!b || typeof b !== "object") throw new Error("No task to import");
|
|
106
155
|
if (!isSessionId(b.moveId)) throw new Error("Bad move id");
|
|
@@ -130,6 +179,9 @@ export function validateBundle(b: TaskBundle): TaskBundle {
|
|
|
130
179
|
}
|
|
131
180
|
return {
|
|
132
181
|
...b,
|
|
182
|
+
orchestrated: b.orchestrated
|
|
183
|
+
? validateOrchestrated(b.orchestrated)
|
|
184
|
+
: undefined,
|
|
133
185
|
chat: b.chat ? validateChat(b.chat) : undefined,
|
|
134
186
|
name: b.name.slice(0, 200),
|
|
135
187
|
from: String(b.from || "another machine")
|
|
@@ -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
|
@@ -11,7 +11,10 @@ import { isRemoteHost } from "../hosts";
|
|
|
11
11
|
import { requireHostLink, type HostLink } from "../hosts/remote-api";
|
|
12
12
|
import { getTaskSession } from "./session";
|
|
13
13
|
import { exportOrResume, markMoved, resumeTask, type TaskBundle } from "./move";
|
|
14
|
-
import { moveRefusal } from "./move-guard";
|
|
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,
|
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,28 @@ 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
|
+
// What an orchestrator's task carries besides its work (the orchestrator's
|
|
119
|
+
// own state is re-keyed here; see move-carry).
|
|
120
|
+
function orchestratedBundle(session: Session): OrchestratedBundle {
|
|
121
|
+
return {
|
|
122
|
+
heldFrom: session.held_from || os.hostname(),
|
|
123
|
+
since: isoUTC(session.task_since ?? session.created_at),
|
|
124
|
+
modelProvider: session.model_provider ?? null,
|
|
125
|
+
modelOverrides: session.model_overrides ?? null,
|
|
126
|
+
planPhase: session.plan_phase ?? null,
|
|
127
|
+
planFile: readPlanFile(session.working_directory),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
/**
|
|
102
132
|
* Stops the agent and pushes everything. On failure the task stays moving;
|
|
103
133
|
* the caller resumes it (exportOrResume) when it knows nothing arrived.
|
|
@@ -163,6 +193,7 @@ export async function exportTask(id: string, to: string): Promise<TaskBundle> {
|
|
|
163
193
|
? { sessionId: claudeId, cwd, home: os.homedir(), transcript }
|
|
164
194
|
: null,
|
|
165
195
|
chat: chatBundle,
|
|
196
|
+
orchestrated: isOrchestrated(now) ? orchestratedBundle(now) : undefined,
|
|
166
197
|
};
|
|
167
198
|
} finally {
|
|
168
199
|
packed();
|
package/lib/tasks/remote.ts
CHANGED
|
@@ -27,6 +27,9 @@ 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",
|
|
30
33
|
] as const;
|
|
31
34
|
|
|
32
35
|
export interface HostTasks {
|
package/lib/tasks/session.ts
CHANGED
|
@@ -16,6 +16,12 @@ import { findPRStrict } from "./gh";
|
|
|
16
16
|
import { currentBranch, git } from "../git-exec";
|
|
17
17
|
import { lookupPR } from "./pr-poll";
|
|
18
18
|
import { captureMergedTask } from "../session-logs";
|
|
19
|
+
import { getOrchestrator } from "../orchestrator/home";
|
|
20
|
+
import {
|
|
21
|
+
pushCiFailed,
|
|
22
|
+
pushMerged,
|
|
23
|
+
pushReviewReady,
|
|
24
|
+
} from "../notify/push/events";
|
|
19
25
|
import { notifyStatusChanged } from "../status/hub";
|
|
20
26
|
import type { TaskPR } from "./state";
|
|
21
27
|
import { markStatusDirty } from "../status/dirty";
|
|
@@ -164,7 +170,7 @@ export async function prFor(
|
|
|
164
170
|
const branch = renamed ?? session.branch_name;
|
|
165
171
|
if (!branch) return null;
|
|
166
172
|
// A PR older than the task is an earlier use of the branch's name.
|
|
167
|
-
const since = isoUTC(session.created_at);
|
|
173
|
+
const since = isoUTC(session.task_since ?? session.created_at);
|
|
168
174
|
let pr: TaskPR | null;
|
|
169
175
|
if (renamed) {
|
|
170
176
|
// Follow a rename only on gh's answer: a failure leaves the row put.
|
|
@@ -193,14 +199,19 @@ export async function prFor(
|
|
|
193
199
|
prCache.set(session.id, { at: Date.now(), pr });
|
|
194
200
|
markStatusDirty(session.id);
|
|
195
201
|
// A PR whose stage inputs moved is news for the sidebar's badge.
|
|
196
|
-
if (prSignature(before) !== prSignature(pr))
|
|
202
|
+
if (prSignature(before) !== prSignature(pr)) {
|
|
203
|
+
notifyStatusChanged();
|
|
204
|
+
if (pr) pushPrChange(session, pr);
|
|
205
|
+
}
|
|
197
206
|
if (pr) {
|
|
198
207
|
db.prepare(
|
|
199
208
|
`UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
|
|
200
209
|
).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
|
|
201
210
|
// Merged on GitHub rather than through AgentOS: its log is due too.
|
|
202
|
-
if (pr.state === "MERGED")
|
|
211
|
+
if (pr.state === "MERGED") {
|
|
212
|
+
if (session.pr_status !== "merged") pushMerged(session.id);
|
|
203
213
|
void captureMergedTask(session.id, { head: pr.head });
|
|
214
|
+
}
|
|
204
215
|
}
|
|
205
216
|
// Only an answer just read from GitHub: a cached one may predate a
|
|
206
217
|
// retarget AgentOS made itself (a restack).
|
|
@@ -283,3 +294,16 @@ export function forgetPR(sessionId: string): void {
|
|
|
283
294
|
prCache.delete(sessionId);
|
|
284
295
|
markStatusDirty(sessionId);
|
|
285
296
|
}
|
|
297
|
+
|
|
298
|
+
// CI failing is news for the phone in any workspace. Green is "ready for
|
|
299
|
+
// review" only where no orchestrator runs a review of its own (its verdict is
|
|
300
|
+
// the signal there). Each head is pushed once: the event key holds the sha.
|
|
301
|
+
export function pushPrChange(session: Session, pr: TaskPR): void {
|
|
302
|
+
if (pr.state !== "OPEN" || !pr.head) return;
|
|
303
|
+
if (pr.checks === "fail") pushCiFailed(session.id, pr.head);
|
|
304
|
+
else if (
|
|
305
|
+
pr.checks === "pass" &&
|
|
306
|
+
!(session.workspace_id && getOrchestrator(session.workspace_id))
|
|
307
|
+
)
|
|
308
|
+
pushReviewReady(session.id, pr.head);
|
|
309
|
+
}
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
|
|
|
15
15
|
import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
|
|
16
16
|
import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
|
|
17
17
|
import { resumePhoneOutbox } from "./lib/notify";
|
|
18
|
+
import { resumePushOutbox } from "./lib/notify/push";
|
|
18
19
|
import {
|
|
19
20
|
bindAddresses,
|
|
20
21
|
requestAllowed,
|
|
@@ -395,6 +396,7 @@ app.prepare().then(async () => {
|
|
|
395
396
|
// Schedules tick here, once a minute, and nowhere else.
|
|
396
397
|
if (schedulesEnabled(process.env)) startScheduler(realDeps);
|
|
397
398
|
resumePhoneOutbox();
|
|
399
|
+
resumePushOutbox();
|
|
398
400
|
// The usage ledger's daily rollup and its ratchet alerts.
|
|
399
401
|
if (usageRollupEnabled(process.env)) startUsageRollup();
|
|
400
402
|
// Machine load: a gauge, notes on heavy commands, one alert when red.
|