@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/db/migrations.ts
CHANGED
|
@@ -1689,6 +1689,56 @@ const migrations: Migration[] = [
|
|
|
1689
1689
|
`);
|
|
1690
1690
|
},
|
|
1691
1691
|
},
|
|
1692
|
+
{
|
|
1693
|
+
id: 73,
|
|
1694
|
+
name: "orchestrated_task_moves",
|
|
1695
|
+
up: (db) => {
|
|
1696
|
+
// task_since: when the task first started, kept across moves (null:
|
|
1697
|
+
// created_at). held_by_device / held_from: the device and machine of
|
|
1698
|
+
// the orchestrator that moved this task here; only that device may
|
|
1699
|
+
// merge, arm, message or move it. plan_note: the "plan written" line
|
|
1700
|
+
// of a held task, for its orchestrator to read.
|
|
1701
|
+
const add = (col: string) => {
|
|
1702
|
+
const columns = db.prepare(`PRAGMA table_info(sessions)`).all() as {
|
|
1703
|
+
name: string;
|
|
1704
|
+
}[];
|
|
1705
|
+
if (!columns.some((c) => c.name === col))
|
|
1706
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`);
|
|
1707
|
+
};
|
|
1708
|
+
for (const col of [
|
|
1709
|
+
"task_since",
|
|
1710
|
+
"held_by_device",
|
|
1711
|
+
"held_from",
|
|
1712
|
+
"plan_note",
|
|
1713
|
+
])
|
|
1714
|
+
add(col);
|
|
1715
|
+
},
|
|
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
|
+
},
|
|
1692
1742
|
];
|
|
1693
1743
|
|
|
1694
1744
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -119,6 +119,15 @@ export interface Session {
|
|
|
119
119
|
rebase_asked_head?: string | null;
|
|
120
120
|
// When its PR's reviews and comments started being watched (pr-wake.ts).
|
|
121
121
|
pr_wake_since?: string | null;
|
|
122
|
+
// When the task first started, kept across moves (null: created_at).
|
|
123
|
+
task_since?: string | null;
|
|
124
|
+
// The device and machine of the orchestrator that moved this task here.
|
|
125
|
+
held_by_device?: string | null;
|
|
126
|
+
held_from?: string | null;
|
|
127
|
+
held_for?: string | null;
|
|
128
|
+
held_for_name?: string | null;
|
|
129
|
+
// A held task's "plan written" line, for its orchestrator to read.
|
|
130
|
+
plan_note?: string | null;
|
|
122
131
|
// Orchestration fields
|
|
123
132
|
conductor_session_id: string | null;
|
|
124
133
|
worker_task: string | null;
|
package/lib/done/bulk.ts
CHANGED
|
@@ -54,6 +54,8 @@ function scopeSessions(scope: DoneScope): Session[] {
|
|
|
54
54
|
AND role IS NULL AND archived_at IS NULL
|
|
55
55
|
-- A linked machine's own sessions are cleaned up there.
|
|
56
56
|
AND peer_mirror = 0
|
|
57
|
+
-- A task another machine's orchestrator moved here is its to finish.
|
|
58
|
+
AND held_by_device IS NULL
|
|
57
59
|
AND (task_status IS NULL OR task_status = 'running')
|
|
58
60
|
ORDER BY created_at`
|
|
59
61
|
)
|
|
@@ -75,6 +77,12 @@ export async function idleSessions(
|
|
|
75
77
|
|
|
76
78
|
// The caller's workspace when its project has one, else its project.
|
|
77
79
|
export function scopeOf(session: Session): DoneScope {
|
|
80
|
+
// An orchestrator acts through its tools, which Pause holds and which are
|
|
81
|
+
// logged; aos would reach the same sessions past both.
|
|
82
|
+
if (session.role === "orchestrator")
|
|
83
|
+
throw new Error(
|
|
84
|
+
`${session.name} is an orchestrator: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
|
|
85
|
+
);
|
|
78
86
|
const project = session.project_id ? getProject(session.project_id) : null;
|
|
79
87
|
if (!project || project.is_uncategorized)
|
|
80
88
|
throw new Error(`${session.name} isn't in a project`);
|
package/lib/done/index.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { sessionMergePolicy } from "../tasks/finish";
|
|
|
25
25
|
import { releasePorts } from "../ports";
|
|
26
26
|
import { dropSessionDatabase } from "../project-config/database";
|
|
27
27
|
import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
|
|
28
|
+
import { hostLink } from "../hosts/remote-api";
|
|
29
|
+
import { doneHeldTask } from "../tasks/held-remote";
|
|
28
30
|
import { captureMergedTask } from "../session-logs";
|
|
29
31
|
import { pushMerged } from "../notify/push/events";
|
|
30
32
|
|
|
@@ -161,6 +163,19 @@ export async function doneSession(
|
|
|
161
163
|
await stopAgent(s);
|
|
162
164
|
// The sign-off's cleanup has already settled it; this reads the result.
|
|
163
165
|
worktree = await settleWorktree(s, { prHead: m.sha });
|
|
166
|
+
} else if (s.started_by && s.task_prompt && hostLink(s.host_id)) {
|
|
167
|
+
// Moved to a linked machine by its orchestrator: that machine stops the
|
|
168
|
+
// agent and removes the worktree, which is where they are.
|
|
169
|
+
await doneHeldTask(s);
|
|
170
|
+
if (plan.mark) mark(s, plan.mark, plan.pr);
|
|
171
|
+
else
|
|
172
|
+
db.prepare(
|
|
173
|
+
`UPDATE sessions SET task_status = 'done' WHERE id = ? AND task_status = 'running'`
|
|
174
|
+
).run(s.id);
|
|
175
|
+
worktree = {
|
|
176
|
+
action: "removed",
|
|
177
|
+
why: `on ${hostLink(s.host_id)?.hostName ?? "its machine"}`,
|
|
178
|
+
};
|
|
164
179
|
} else {
|
|
165
180
|
if (plan.mark) mark(s, plan.mark, plan.pr);
|
|
166
181
|
if (
|
package/lib/done/worktree.ts
CHANGED
|
@@ -114,14 +114,15 @@ const gone = (merged: MergedAs | null, still = false) => ({
|
|
|
114
114
|
export async function worktreeFate(
|
|
115
115
|
session: Judged,
|
|
116
116
|
merged: MergedAs | null,
|
|
117
|
-
waitMs = 60_000
|
|
117
|
+
waitMs = 60_000,
|
|
118
|
+
movedAway = false
|
|
118
119
|
): Promise<FatePreview> {
|
|
119
120
|
const path = session.worktree_path;
|
|
120
121
|
if (!path) return { action: "none" };
|
|
121
122
|
for (;;) {
|
|
122
123
|
if (!(await removalSettled(path, waitMs))) return gone(merged, true);
|
|
123
124
|
if (!fs.existsSync(path)) return gone(merged);
|
|
124
|
-
const fate = await judge(session, merged);
|
|
125
|
+
const fate = await judge(session, merged, movedAway);
|
|
125
126
|
// A removal that started while it read: read what it leaves.
|
|
126
127
|
if (!removalRunning(path) && fs.existsSync(path)) return fate;
|
|
127
128
|
}
|
|
@@ -133,8 +134,17 @@ async function commitsKeep(
|
|
|
133
134
|
path: string,
|
|
134
135
|
repo: string,
|
|
135
136
|
session: Judged,
|
|
136
|
-
merged: MergedAs | null
|
|
137
|
+
merged: MergedAs | null,
|
|
138
|
+
movedAway = false
|
|
137
139
|
): Promise<string | null> {
|
|
140
|
+
// The task went on elsewhere: only commits no remote has would be lost.
|
|
141
|
+
if (movedAway) {
|
|
142
|
+
const stray = await strayCommits(path, {}).catch(() => null);
|
|
143
|
+
if (stray === null) return "its commits can't be checked";
|
|
144
|
+
return stray > 0
|
|
145
|
+
? `${stray} commit${stray === 1 ? " is" : "s are"} on it that no remote has`
|
|
146
|
+
: null;
|
|
147
|
+
}
|
|
138
148
|
if (merged) {
|
|
139
149
|
const stray = await strayCommits(path, merged).catch(() => null);
|
|
140
150
|
if (stray === null) return "its commits can't be checked";
|
|
@@ -163,7 +173,8 @@ async function commitsKeep(
|
|
|
163
173
|
// kept one names every reason, the commits as well as the changes.
|
|
164
174
|
async function judge(
|
|
165
175
|
session: Judged,
|
|
166
|
-
merged: MergedAs | null
|
|
176
|
+
merged: MergedAs | null,
|
|
177
|
+
movedAway = false
|
|
167
178
|
): Promise<FatePreview> {
|
|
168
179
|
const path = session.worktree_path!;
|
|
169
180
|
const keep = (why: string) => ({ action: "kept" as const, why, path });
|
|
@@ -183,14 +194,16 @@ async function judge(
|
|
|
183
194
|
unsaved.length
|
|
184
195
|
? `it has uncommitted changes (${describeChanges(unsaved)})`
|
|
185
196
|
: null,
|
|
186
|
-
await commitsKeep(path, repo, session, merged),
|
|
197
|
+
await commitsKeep(path, repo, session, merged, movedAway),
|
|
187
198
|
].filter((w) => w !== null);
|
|
188
199
|
if (why.length) return keep(why.join(", and "));
|
|
189
200
|
return {
|
|
190
201
|
action: "remove",
|
|
191
|
-
why:
|
|
192
|
-
? "its
|
|
193
|
-
:
|
|
202
|
+
why: movedAway
|
|
203
|
+
? "its work moved to another machine"
|
|
204
|
+
: merged
|
|
205
|
+
? "its branch is merged"
|
|
206
|
+
: "its branch has no commits of its own",
|
|
194
207
|
repo,
|
|
195
208
|
};
|
|
196
209
|
}
|
|
@@ -199,9 +212,10 @@ async function judge(
|
|
|
199
212
|
// and says so only once it's really gone.
|
|
200
213
|
export async function settleWorktree(
|
|
201
214
|
session: Judged,
|
|
202
|
-
merged: MergedAs | null
|
|
215
|
+
merged: MergedAs | null,
|
|
216
|
+
movedAway = false
|
|
203
217
|
): Promise<WorktreeFate> {
|
|
204
|
-
const fate = await worktreeFate(session, merged);
|
|
218
|
+
const fate = await worktreeFate(session, merged, undefined, movedAway);
|
|
205
219
|
if (fate.action !== "remove") return fate;
|
|
206
220
|
const path = session.worktree_path!;
|
|
207
221
|
await deleteWorktree(path, fate.repo, true).catch(() => {});
|
package/lib/hosts/remote-api.ts
CHANGED
|
@@ -70,6 +70,18 @@ export const cleanRemoteText = (s: unknown): string =>
|
|
|
70
70
|
.trim()
|
|
71
71
|
.slice(0, 300);
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Another machine's multi-line text (a chat's tail): control characters
|
|
75
|
+
* other than newline and tab become spaces, and the end is kept within `cap`.
|
|
76
|
+
*/
|
|
77
|
+
export const cleanRemoteBlock = (s: unknown, cap: number): string => {
|
|
78
|
+
const text = String(s ?? "")
|
|
79
|
+
.replace(/\r\n?/g, "\n")
|
|
80
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, " ")
|
|
81
|
+
.replace(/\s+$/, "");
|
|
82
|
+
return text.length > cap ? `…${text.slice(text.length - cap + 1)}` : text;
|
|
83
|
+
};
|
|
84
|
+
|
|
73
85
|
/**
|
|
74
86
|
* Why a call failed. `refused`: that AgentOS answered and said no, so nothing
|
|
75
87
|
* happened there. Otherwise the outcome is unknown (unreachable, timed out,
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -73,9 +73,12 @@ export function planSummary(text: string, max = 5): string[] {
|
|
|
73
73
|
|
|
74
74
|
// Tells the orchestrator that started a task its plan is written, once.
|
|
75
75
|
function announcePlan(s: Session, ref: string): void {
|
|
76
|
-
if (!s.started_by) return;
|
|
76
|
+
if (!s.started_by && !s.held_by_device) return;
|
|
77
77
|
const workspaceId = sessionWorkspace(s);
|
|
78
|
-
|
|
78
|
+
// A task held for another machine's orchestrator can't reach it: the line
|
|
79
|
+
// waits on the task for that orchestrator to read (orchestrator/facts).
|
|
80
|
+
if (!s.held_by_device && !workspaceId) return;
|
|
81
|
+
if (!s.worktree_path) return;
|
|
79
82
|
const file = path.join(s.worktree_path, PLAN_FILE);
|
|
80
83
|
let head = `Task "${s.name}": no plan file was written; implementing on ${ref} anyway.`;
|
|
81
84
|
try {
|
|
@@ -85,7 +88,14 @@ function announcePlan(s: Session, ref: string): void {
|
|
|
85
88
|
} catch {
|
|
86
89
|
// No plan file: the line says so.
|
|
87
90
|
}
|
|
88
|
-
|
|
91
|
+
if (s.held_by_device) {
|
|
92
|
+
db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(
|
|
93
|
+
head,
|
|
94
|
+
s.id
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
export const implementMessageId = (sessionId: string) =>
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
launchSession,
|
|
20
20
|
type StartView,
|
|
21
21
|
} from "../sessions/launch";
|
|
22
|
-
import { createTask, dropTask } from "../tasks";
|
|
22
|
+
import { assertMovable, createTask, dropTask, moveTask } from "../tasks";
|
|
23
|
+
import { getHost, hostIdNamed } from "../hosts";
|
|
24
|
+
import { queueEvent } from "./events";
|
|
23
25
|
import {
|
|
24
26
|
findQueued,
|
|
25
27
|
queuedLabel,
|
|
@@ -280,3 +282,41 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
|
|
|
280
282
|
export async function stop(workspaceId: string, ref: string): Promise<string> {
|
|
281
283
|
return stopSession(findWorkspaceSession(workspaceId, ref));
|
|
282
284
|
}
|
|
285
|
+
|
|
286
|
+
// Carries a task to a linked machine, or back ("here"): heavy test or build
|
|
287
|
+
// work can run on the box. The move takes minutes, so it runs in the
|
|
288
|
+
// background; the orchestrator is told when the task lands or doesn't.
|
|
289
|
+
// Everything about the task (gates, events, read/send/stop/drop) is the same
|
|
290
|
+
// wherever it runs; the machine it lands on holds it for this one.
|
|
291
|
+
export async function moveTaskTo(
|
|
292
|
+
workspaceId: string,
|
|
293
|
+
ref: string,
|
|
294
|
+
machine: string
|
|
295
|
+
): Promise<string> {
|
|
296
|
+
const task = workspaceTask(workspaceId, ref);
|
|
297
|
+
const hostId = hostIdNamed(machine);
|
|
298
|
+
assertMovable(task.id, hostId);
|
|
299
|
+
const where = hostId === "local" ? "this machine" : machineName(hostId);
|
|
300
|
+
void moveTask(task.id, hostId).then(
|
|
301
|
+
(moved) => {
|
|
302
|
+
addNote(workspaceId, `Moved task ${task.name} to ${where}.`);
|
|
303
|
+
queueEvent(
|
|
304
|
+
workspaceId,
|
|
305
|
+
`move:${moved.id}:${Date.now()}`,
|
|
306
|
+
moved.id,
|
|
307
|
+
`Task ${task.name} now runs on ${where}.`
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
(error) =>
|
|
311
|
+
queueEvent(
|
|
312
|
+
workspaceId,
|
|
313
|
+
`move-failed:${task.id}:${Date.now()}`,
|
|
314
|
+
task.id,
|
|
315
|
+
`Moving task ${task.name} to ${where} failed: ${error instanceof Error ? error.message : String(error)}. It carries on where it was unless it says it's paused as "moving".`
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
return `Moving ${task.name} to ${where}. It takes a few minutes; an event tells you when it runs there.`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const machineName = (hostId: string) =>
|
|
322
|
+
getHost(hostId)?.name ?? "the other machine";
|
|
@@ -58,7 +58,8 @@ function armedTasks(workspaceId: string): Session[] {
|
|
|
58
58
|
.prepare(
|
|
59
59
|
`SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
60
60
|
WHERE p.workspace_id = ? AND s.merge_armed_at IS NOT NULL
|
|
61
|
-
AND s.task_status = 'running' AND s.peer_mirror = 0
|
|
61
|
+
AND s.task_status = 'running' AND s.peer_mirror = 0
|
|
62
|
+
AND s.held_by_device IS NULL`
|
|
62
63
|
)
|
|
63
64
|
.all(workspaceId) as Session[];
|
|
64
65
|
}
|
|
@@ -100,6 +101,10 @@ export function armTask(task: Session): void {
|
|
|
100
101
|
);
|
|
101
102
|
if (task.peer_mirror)
|
|
102
103
|
throw new Error(`${task.name} runs on a linked machine; arm it there`);
|
|
104
|
+
if (task.held_by_device)
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${task.name} is held for ${task.held_from ?? "another machine"}'s orchestrator; it arms it there`
|
|
107
|
+
);
|
|
103
108
|
if (!armingWorkspace(task))
|
|
104
109
|
throw new Error(
|
|
105
110
|
`${task.name}'s project isn't in a workspace with an orchestrator, which an armed merge reports to`
|
|
@@ -74,6 +74,15 @@ export async function runningCount(
|
|
|
74
74
|
"running";
|
|
75
75
|
if (busy) running.add(s.id);
|
|
76
76
|
}
|
|
77
|
+
// A task mid-move is out of the list above, but it runs again within
|
|
78
|
+
// minutes, here or there: it holds its slot.
|
|
79
|
+
const moving = db
|
|
80
|
+
.prepare(
|
|
81
|
+
`SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
82
|
+
WHERE p.workspace_id = ? AND s.task_status = 'moving' AND s.peer_mirror = 0`
|
|
83
|
+
)
|
|
84
|
+
.all(workspaceId) as { id: string }[];
|
|
85
|
+
for (const { id } of moving) running.add(id);
|
|
77
86
|
for (const { target } of startsSince(workspaceId, now - WARMUP_MS))
|
|
78
87
|
if (target) running.add(target);
|
|
79
88
|
return running.size;
|
|
@@ -109,7 +109,8 @@ Acting:
|
|
|
109
109
|
- \`${TOOL_NAMES.quick_pr}\` (change, title, body): commit everything in a quick change, push its branch (never the default branch) and open its PR, or push to the PR it has; its review starts at once. Then it's gated like any task: CI, that review (also its code-review gate), scope, then sign_off or arm. It's in \`sessions\` as your quick change; drop it to throw it away, and it's cleaned up like a task when it merges or is dropped.
|
|
110
110
|
- \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
|
|
111
111
|
- \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
|
|
112
|
-
- \`${TOOL_NAMES.
|
|
112
|
+
- \`${TOOL_NAMES.move}\` (task, machine): carry a running task to a linked machine (heavy test or build work), or back with machine "here". It keeps its branch, conversation and plan, and everything you do with a task works the same there: its events come to you, read, send, stop, done and drop reach it, and the gates judge its PR at its head. The machine it lands on holds it for you and can't merge it. It runs in the background; an event says when it lands. Refused while paused, and for a task waiting on Saad.
|
|
113
|
+
- \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
|
|
113
114
|
- \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
|
|
114
115
|
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session is archived out of view. Not a rejection: use drop for that.
|
|
115
116
|
- \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { db, type Session, type StackItemStatus } from "../db";
|
|
9
9
|
import { statusDetector } from "../status-detector";
|
|
10
|
-
import { hostLink } from "../hosts/remote-api";
|
|
10
|
+
import { cleanRemoteText, hostLink } from "../hosts/remote-api";
|
|
11
11
|
import { peerStatus } from "../hosts/peer-sessions";
|
|
12
12
|
import { chatCompacting, chatState } from "../chat/runner";
|
|
13
13
|
import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
|
|
@@ -16,11 +16,16 @@ import { needsYou } from "../needs-you";
|
|
|
16
16
|
import { programSummary } from "../program-status/store";
|
|
17
17
|
import { sessionRowInfo } from "../session-meta";
|
|
18
18
|
import { taskView, type TaskPR, type TaskState } from "../tasks";
|
|
19
|
-
import { storedPR } from "../tasks/session";
|
|
19
|
+
import { prFor, storedPR } from "../tasks/session";
|
|
20
|
+
import { lastActiveOf } from "../tasks/last-active";
|
|
21
|
+
import { hostTasks } from "../tasks/remote";
|
|
22
|
+
import { deriveTaskState } from "../tasks";
|
|
23
|
+
import { waitingState } from "./task-state";
|
|
20
24
|
import { commitTime, repoOf } from "./repo";
|
|
21
25
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
22
26
|
import { repoHasCI } from "./repo-ci";
|
|
23
27
|
import { markStatusDirty } from "../status/dirty";
|
|
28
|
+
import { queueEvent } from "./events";
|
|
24
29
|
|
|
25
30
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
26
31
|
|
|
@@ -68,9 +73,6 @@ export interface SessionFacts {
|
|
|
68
73
|
} | null;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
const sqliteMs = (t: string | null | undefined) =>
|
|
72
|
-
t ? Date.parse(`${t.replace(" ", "T")}Z`) || 0 : 0;
|
|
73
|
-
|
|
74
76
|
// Live sessions in the workspace's projects, plus tasks merged in the last
|
|
75
77
|
// hour so their merge still reads. Never an orchestrator or an archived one.
|
|
76
78
|
export function workspaceSessions(
|
|
@@ -83,6 +85,8 @@ export function workspaceSessions(
|
|
|
83
85
|
WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
|
|
84
86
|
-- A linked machine's own sessions are its orchestrator's to run.
|
|
85
87
|
AND s.peer_mirror = 0
|
|
88
|
+
-- A task another machine's orchestrator moved here is held for it.
|
|
89
|
+
AND s.held_by_device IS NULL
|
|
86
90
|
AND (s.task_status IS NULL OR s.task_status = 'running'
|
|
87
91
|
OR (s.task_status = 'merged' AND s.updated_at > datetime('now', '-1 hour')))
|
|
88
92
|
ORDER BY s.created_at`
|
|
@@ -121,17 +125,6 @@ function stackOf(sessionId: string): SessionFacts["stack"] {
|
|
|
121
125
|
};
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
function latestChatAt(sessionId: string): number {
|
|
125
|
-
const row = db
|
|
126
|
-
.prepare(
|
|
127
|
-
`SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
|
|
128
|
-
)
|
|
129
|
-
.get(sessionId) as { data: string } | undefined;
|
|
130
|
-
return row
|
|
131
|
-
? ((JSON.parse(row.data) as { createdAt?: number }).createdAt ?? 0)
|
|
132
|
-
: 0;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
128
|
export async function statusOf(s: Session): Promise<{
|
|
136
129
|
status: FactStatus;
|
|
137
130
|
activity: string | null;
|
|
@@ -251,6 +244,7 @@ async function taskFacts(
|
|
|
251
244
|
const state = s.task_status === "moved" ? "done" : s.task_status;
|
|
252
245
|
return { state, pr, blocked: null };
|
|
253
246
|
}
|
|
247
|
+
if (hostLink(s.host_id)) return remoteTaskFacts(workspaceId, s);
|
|
254
248
|
const view = await taskView(s);
|
|
255
249
|
return {
|
|
256
250
|
state: view.state,
|
|
@@ -261,6 +255,67 @@ async function taskFacts(
|
|
|
261
255
|
};
|
|
262
256
|
}
|
|
263
257
|
|
|
258
|
+
// A task another machine runs: its state is that machine's word (the same
|
|
259
|
+
// reading the gates use), and the PR is read here, from GitHub, by branch.
|
|
260
|
+
// What it can't say reads as blocked, never as clear.
|
|
261
|
+
async function remoteTaskFacts(
|
|
262
|
+
workspaceId: string,
|
|
263
|
+
s: Session
|
|
264
|
+
): Promise<NonNullable<SessionFacts["task"]>> {
|
|
265
|
+
const [pr, waiting] = await Promise.all([
|
|
266
|
+
prFor(s).catch(() => null),
|
|
267
|
+
waitingState(s),
|
|
268
|
+
]);
|
|
269
|
+
await announceRemotePlan(workspaceId, s);
|
|
270
|
+
const status = peerStatus(s.host_id, s.id) ?? "running";
|
|
271
|
+
const state = deriveTaskState({
|
|
272
|
+
taskStatus: "running",
|
|
273
|
+
sessionStatus: status,
|
|
274
|
+
pr,
|
|
275
|
+
blocked: waiting.blocked !== null,
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
state,
|
|
279
|
+
pr,
|
|
280
|
+
blocked: waiting.blocked,
|
|
281
|
+
ciSettleIn: await settleIn(workspaceId, s, pr),
|
|
282
|
+
ciSilentMs: await silentFor(s, pr),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The plan-written line a held task stored on its machine (plan-step.ts),
|
|
287
|
+
// once per distinct plan: the mirror keeps the last one it told about.
|
|
288
|
+
async function announceRemotePlan(
|
|
289
|
+
workspaceId: string,
|
|
290
|
+
s: Session
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const link = hostLink(s.host_id);
|
|
293
|
+
if (!link) return;
|
|
294
|
+
const view = await hostTasks(link)
|
|
295
|
+
.then(({ tasks }) => tasks.find((t) => t?.id === s.id))
|
|
296
|
+
.catch(() => undefined);
|
|
297
|
+
const note =
|
|
298
|
+
typeof view?.planNote === "string"
|
|
299
|
+
? cleanRemoteText(view.planNote).slice(0, 2000)
|
|
300
|
+
: "";
|
|
301
|
+
if (!note || note === s.plan_note) return;
|
|
302
|
+
db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(note, s.id);
|
|
303
|
+
queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// A task on another machine is as active as that machine says; one it can't
|
|
307
|
+
// reach is taken as active just now, so nothing reads it as abandoned.
|
|
308
|
+
async function lastActiveFor(s: Session): Promise<number> {
|
|
309
|
+
const link = hostLink(s.host_id);
|
|
310
|
+
if (!link) return lastActiveOf(s);
|
|
311
|
+
const view = await hostTasks(link)
|
|
312
|
+
.then(({ tasks }) => tasks.find((t) => t?.id === s.id))
|
|
313
|
+
.catch(() => undefined);
|
|
314
|
+
return typeof view?.lastActiveAt === "number"
|
|
315
|
+
? view.lastActiveAt
|
|
316
|
+
: Date.now();
|
|
317
|
+
}
|
|
318
|
+
|
|
264
319
|
export async function sessionFacts(
|
|
265
320
|
workspaceId: string
|
|
266
321
|
): Promise<SessionFacts[]> {
|
|
@@ -283,11 +338,7 @@ export async function sessionFacts(
|
|
|
283
338
|
s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
|
|
284
339
|
compacting:
|
|
285
340
|
s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
|
|
286
|
-
lastActive:
|
|
287
|
-
sqliteMs(s.updated_at),
|
|
288
|
-
statusDetector.getTimestamp(s.tmux_name) * 1000,
|
|
289
|
-
latestChatAt(s.id)
|
|
290
|
-
),
|
|
341
|
+
lastActive: await lastActiveFor(s),
|
|
291
342
|
branch: s.branch_name,
|
|
292
343
|
quick: !!s.quick_of,
|
|
293
344
|
plan:
|
package/lib/orchestrator/read.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { hostLink } from "../hosts/remote-api";
|
|
|
10
10
|
import { peerPane } from "../hosts/peer-sessions";
|
|
11
11
|
import { workspaceSessions } from "./facts";
|
|
12
12
|
import { untrusted } from "./untrusted";
|
|
13
|
+
import { heldChatTail } from "../tasks/held-remote";
|
|
13
14
|
import {
|
|
14
15
|
findExternalSession,
|
|
15
16
|
knownExternalSession,
|
|
@@ -104,6 +105,12 @@ export function chatText(items: ChatItem[]): string {
|
|
|
104
105
|
.join("\n");
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// The last lines of a chat task's conversation, unwrapped.
|
|
109
|
+
export function chatTailText(sessionId: string, lines = DEFAULT_LINES): string {
|
|
110
|
+
const n = Math.max(1, Math.min(Math.floor(lines) || DEFAULT_LINES, 400));
|
|
111
|
+
return tail(chatText(lastItems(sessionId, n * 2)), n);
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
export async function readSession(
|
|
108
115
|
workspaceId: string,
|
|
109
116
|
ref: string,
|
|
@@ -121,8 +128,13 @@ export async function readSession(
|
|
|
121
128
|
return readExternal(external, n);
|
|
122
129
|
}
|
|
123
130
|
const link = hostLink(s.host_id);
|
|
124
|
-
if (s.view === "chat" && link)
|
|
125
|
-
|
|
131
|
+
if (s.view === "chat" && link) {
|
|
132
|
+
// Its orchestrator's task is read through the machine that holds it.
|
|
133
|
+
const text = s.started_by ? await heldChatTail(s, n) : null;
|
|
134
|
+
return text
|
|
135
|
+
? `${s.name} (chat on ${link.hostName}), last ${n} lines:\n${untrusted(s.name, text)}`
|
|
136
|
+
: `${s.name} (chat on ${link.hostName}): its messages are on that machine; open it to read them.`;
|
|
137
|
+
}
|
|
126
138
|
if (s.view === "chat") {
|
|
127
139
|
const text = chatText(lastItems(s.id, n * 2));
|
|
128
140
|
const body = text ? untrusted(s.name, tail(text, n)) : "(no messages yet)";
|
|
@@ -4,6 +4,7 @@ import { getWorkspace } from "../workspaces";
|
|
|
4
4
|
import {
|
|
5
5
|
done,
|
|
6
6
|
drop,
|
|
7
|
+
moveTaskTo,
|
|
7
8
|
send,
|
|
8
9
|
stack,
|
|
9
10
|
stackStatus,
|
|
@@ -46,6 +47,7 @@ const ACTING = new Set<ToolName>([
|
|
|
46
47
|
"stack",
|
|
47
48
|
"land",
|
|
48
49
|
"drop",
|
|
50
|
+
"move",
|
|
49
51
|
"stop",
|
|
50
52
|
"done",
|
|
51
53
|
"review",
|
|
@@ -156,6 +158,10 @@ export async function runTool(
|
|
|
156
158
|
const a = p(tool, raw);
|
|
157
159
|
return drop(w, a.task, a.reason);
|
|
158
160
|
}
|
|
161
|
+
case "move": {
|
|
162
|
+
const a = p(tool, raw);
|
|
163
|
+
return moveTaskTo(w, a.task, a.machine);
|
|
164
|
+
}
|
|
159
165
|
case "stop":
|
|
160
166
|
return stop(w, p(tool, raw).session);
|
|
161
167
|
case "done":
|
|
@@ -211,6 +211,20 @@ const PULL_MERGE = /(^|\/)pulls\/[^/?\s]+\/merge\/?(\?|$)/;
|
|
|
211
211
|
|
|
212
212
|
const MERGE_MSG = `Refused: a PR merges only through the gates: ${T.sign_off} (or ${T.arm}) for a task's or an external PR, ${T.land} for a stack. Nothing merges, closes or retargets a PR from the shell.`;
|
|
213
213
|
|
|
214
|
+
// aos move, done, stop and drop reach a session by name, past the tools'
|
|
215
|
+
// Pause check and logging; the tools do the same work.
|
|
216
|
+
function aosRefusal(args: string[]): string | null {
|
|
217
|
+
const [sub] = positionals(args);
|
|
218
|
+
const tool: Record<string, string> = {
|
|
219
|
+
move: T.move,
|
|
220
|
+
done: T.done,
|
|
221
|
+
stop: T.stop,
|
|
222
|
+
};
|
|
223
|
+
if (sub && tool[sub])
|
|
224
|
+
return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
214
228
|
// gh: pr merge, close and edit --base, and api calls that merge or change a
|
|
215
229
|
// pull request.
|
|
216
230
|
function ghRefusal(args: string[]): string | null {
|
|
@@ -522,6 +536,7 @@ export async function shellRefusal(
|
|
|
522
536
|
secretRefusal(inv, cwd) ??
|
|
523
537
|
(inv.name === "gh" ? ghRefusal(inv.args) : null) ??
|
|
524
538
|
(inv.name === "curl" ? curlRefusal(inv.args) : null) ??
|
|
539
|
+
(inv.name === "aos" ? aosRefusal(inv.args) : null) ??
|
|
525
540
|
(inv.name === "git" ? await gitRefusal(inv.args, ctx, cwd) : null) ??
|
|
526
541
|
(inv.name === "sqlite3"
|
|
527
542
|
? await sqliteRefusal(inv, ctx, cwd, command)
|