@saadnvd1/agent-os 0.3.700 → 0.3.715
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 +1 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/claims/[id]/route.ts +20 -0
- package/app/api/claims/check/route.ts +23 -0
- package/app/api/claims/check.test.ts +113 -0
- package/app/api/claims/exchange/route.ts +42 -0
- package/app/api/claims/outside.test.ts +90 -0
- package/app/api/claims/route.test.ts +193 -0
- package/app/api/claims/route.ts +73 -0
- package/app/api/hosts/[id]/route.ts +9 -1
- package/app/api/peer/unlink/route.ts +17 -0
- package/app/api/peers/[hostId]/audit/route.ts +14 -0
- package/app/api/peers/[hostId]/route.ts +20 -0
- package/app/api/peers/gate.test.ts +64 -1
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +11 -4
- package/app/api/session-groups/[id]/restore/route.ts +11 -0
- package/app/api/session-groups/[id]/route.ts +25 -0
- package/app/api/session-groups/order/route.ts +15 -0
- package/app/api/session-groups/route.ts +21 -0
- package/app/api/sessions/[id]/fork/route.ts +3 -0
- package/app/api/sessions/[id]/group/route.ts +18 -0
- package/app/api/sessions/[id]/route.ts +14 -4
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/sessions/claims.test.ts +122 -0
- package/app/api/sessions/group/route.ts +17 -0
- package/app/api/sessions/route.ts +15 -14
- package/app/page.tsx +5 -0
- package/components/Claims/ClaimFiles.tsx +38 -0
- package/components/Claims/ClaimRow.tsx +82 -0
- package/components/Claims/ClaimsSheet.tsx +176 -0
- package/components/Claims/OverlapPill.tsx +31 -0
- package/components/Claims/StartClaimsNotice.tsx +70 -0
- package/components/Claims/claims-ui.test.ts +161 -0
- package/components/Claims/format.ts +57 -0
- package/components/Claims/index.ts +3 -0
- package/components/Draft/DraftPanel.tsx +40 -2
- package/components/Hosts/AccountMachines.tsx +32 -18
- package/components/Hosts/HostRow.tsx +155 -14
- package/components/Hosts/IncomingLinkSheet.tsx +28 -10
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +7 -1
- package/components/SessionList/SelectionToolbar.tsx +52 -1
- package/components/SessionList/SessionRow.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +17 -0
- package/components/SessionList/SessionShelves.tsx +5 -0
- package/components/SessionList/SidebarBody.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +104 -0
- package/components/SessionList/SidebarContextMenu.tsx +59 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
- package/components/SessionList/index.tsx +18 -14
- package/components/SessionList/useSidebarData.ts +10 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +4 -0
- package/data/session-groups/index.ts +156 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/pin.ts +2 -6
- package/data/sessions/queries.ts +10 -29
- package/data/sessions/shelf.ts +2 -6
- package/data/tasks/index.ts +2 -0
- package/hooks/useSessions.ts +0 -12
- package/lib/__fixtures__/peer-rpc.ts +65 -33
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/index.ts +8 -0
- package/lib/claims/collectLocal.ts +121 -0
- package/lib/claims/describe.ts +41 -0
- package/lib/claims/exchange.ts +91 -0
- package/lib/claims/goal.ts +45 -0
- package/lib/claims/index.ts +10 -0
- package/lib/claims/loop.ts +51 -0
- package/lib/claims/machine.ts +21 -0
- package/lib/claims/mine.ts +43 -0
- package/lib/claims/notifyOverlaps.ts +101 -0
- package/lib/claims/onMerged.ts +85 -0
- package/lib/claims/outside.ts +36 -0
- package/lib/claims/overlap.ts +87 -0
- package/lib/claims/overlapCache.ts +86 -0
- package/lib/claims/overlapText.ts +36 -0
- package/lib/claims/peers.ts +56 -0
- package/lib/claims/startCheck.ts +100 -0
- package/lib/claims/startWarning.ts +29 -0
- package/lib/claims/store.ts +366 -0
- package/lib/claims/types.ts +102 -0
- package/lib/claims/view.ts +80 -0
- package/lib/claims/yield.ts +24 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
- package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
- package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
- package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
- package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
- package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
- package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
- package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +1 -0
- package/lib/db/types.ts +16 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/orchestration.ts +2 -0
- package/lib/orchestrator/act.ts +54 -4
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claim-wait.ts +71 -0
- package/lib/orchestrator/conditions.ts +28 -1
- package/lib/orchestrator/describe.ts +6 -1
- package/lib/orchestrator/facts.ts +5 -0
- package/lib/orchestrator/serve.ts +37 -2
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +31 -1
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/orchestrator/watcher.ts +19 -1
- package/lib/peer/account.ts +74 -2
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/client.ts +26 -1
- package/lib/peer/link-cli.ts +19 -4
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +14 -2
- package/lib/peer/name.ts +32 -0
- package/lib/peer/pairing.ts +34 -5
- package/lib/peer/pin.ts +10 -3
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-server.ts +53 -5
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-groups/assignSessions.ts +81 -0
- package/lib/session-groups/cli.ts +61 -0
- package/lib/session-groups/createGroup.ts +28 -0
- package/lib/session-groups/index.ts +21 -0
- package/lib/session-groups/removeGroup.ts +36 -0
- package/lib/session-groups/reorderGroups.ts +31 -0
- package/lib/session-groups/resolveGroup.ts +19 -0
- package/lib/session-groups/route.ts +32 -0
- package/lib/session-groups/store.ts +85 -0
- package/lib/session-groups/types.ts +25 -0
- package/lib/session-groups/updateGroup.ts +35 -0
- package/lib/session-groups/workspace.ts +21 -0
- package/lib/sessions/launch.ts +5 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/lib/sidebar/shelves.ts +90 -15
- package/lib/sidebar/visible-order.ts +5 -0
- package/lib/tasks/hold-checks.ts +3 -1
- package/lib/tasks/holds.ts +3 -0
- package/lib/tasks/index.ts +5 -0
- package/lib/tasks/queue-rows.ts +8 -1
- package/lib/tasks/queue-start.ts +1 -0
- package/lib/tasks/queue-types.ts +9 -1
- package/lib/tasks/queue.ts +15 -5
- package/lib/tasks/schedule-types.ts +1 -0
- package/lib/test-temp.ts +19 -3
- package/lib/workspaces.ts +11 -3
- package/lib/worktree-sweep/testTemp.ts +23 -106
- package/package.json +1 -1
- package/scripts/agent-os +1 -1
- package/scripts/autodeploy +2 -0
- package/scripts/autodeploy.test.ts +31 -1
- package/scripts/claim-hook.sh +31 -0
- package/scripts/claim-hook.test.ts +230 -0
- package/scripts/lib/cli-links.sh +36 -0
- package/scripts/lib/commands.sh +3 -0
- package/scripts/lib/machine.sh +27 -2
- package/scripts/lib/machine.test.ts +25 -3
- package/scripts/sync-cli-links +7 -0
- package/scripts/sync-cli-links.test.ts +69 -0
- package/server.ts +3 -0
- package/stores/claimsUi.ts +22 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import {
|
|
3
|
+
installChangeTriggers,
|
|
4
|
+
TABLES_WATCHED_SESSION_GROUPS,
|
|
5
|
+
} from "../changes";
|
|
6
|
+
import { createSessionRowsView } from "../session-parts/view";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
key: "20261020T1800_session_groups",
|
|
10
|
+
up: (db) => {
|
|
11
|
+
// Named shelves a user files sessions into (lib/session-groups). Not the
|
|
12
|
+
// old `groups` table, which nothing reads. Additive.
|
|
13
|
+
db.exec(`CREATE TABLE IF NOT EXISTS session_groups (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
|
|
16
|
+
name TEXT NOT NULL,
|
|
17
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
collapsed INTEGER NOT NULL DEFAULT 0,
|
|
19
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
21
|
+
deleted_at TEXT
|
|
22
|
+
)`);
|
|
23
|
+
db.exec(
|
|
24
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS session_groups_name
|
|
25
|
+
ON session_groups(COALESCE(workspace_id, ''), lower(name))
|
|
26
|
+
WHERE deleted_at IS NULL`
|
|
27
|
+
);
|
|
28
|
+
const has = (
|
|
29
|
+
db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
|
|
30
|
+
).some((c) => c.name === "session_group_id");
|
|
31
|
+
if (!has)
|
|
32
|
+
db.exec(
|
|
33
|
+
`ALTER TABLE sessions ADD COLUMN session_group_id TEXT
|
|
34
|
+
REFERENCES session_groups(id) ON DELETE SET NULL`
|
|
35
|
+
);
|
|
36
|
+
db.exec(
|
|
37
|
+
`CREATE INDEX IF NOT EXISTS sessions_session_group
|
|
38
|
+
ON sessions(session_group_id)`
|
|
39
|
+
);
|
|
40
|
+
// A queued task remembers the group it starts in.
|
|
41
|
+
const queueHas = (
|
|
42
|
+
db.prepare(`PRAGMA table_info(task_queue)`).all() as { name: string }[]
|
|
43
|
+
).some((c) => c.name === "session_group_id");
|
|
44
|
+
if (!queueHas)
|
|
45
|
+
db.exec(`ALTER TABLE task_queue ADD COLUMN session_group_id TEXT`);
|
|
46
|
+
createSessionRowsView(db);
|
|
47
|
+
installChangeTriggers(db, TABLES_WATCHED_SESSION_GROUPS);
|
|
48
|
+
},
|
|
49
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T1900_task_queue_after_claim",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// The work claim (lib/claims) a queued task waits on, by claim id.
|
|
7
|
+
const cols = db.prepare(`PRAGMA table_info(task_queue)`).all() as {
|
|
8
|
+
name: string;
|
|
9
|
+
}[];
|
|
10
|
+
if (!cols.some((c) => c.name === "after_claim"))
|
|
11
|
+
db.exec(`ALTER TABLE task_queue ADD COLUMN after_claim TEXT`);
|
|
12
|
+
},
|
|
13
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T2000_claim_notices",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// What a mid-work overlap between two claims has told the local session:
|
|
7
|
+
// how many syncs it has held, the shared paths last told, and whether the
|
|
8
|
+
// winner's merge has been passed on. Times are this machine's clock in ms.
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS claim_notices (
|
|
11
|
+
pair_key TEXT PRIMARY KEY,
|
|
12
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
shared TEXT NOT NULL DEFAULT '[]',
|
|
14
|
+
told_at INTEGER,
|
|
15
|
+
merged_at INTEGER,
|
|
16
|
+
seen_at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
`);
|
|
19
|
+
},
|
|
20
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T2100_claims_via_host",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// The link (hosts.id) a remote claim arrived through, and which claims
|
|
7
|
+
// machine each link talks to, so a machine linked twice is asked once.
|
|
8
|
+
const cols = db.prepare(`PRAGMA table_info(work_claims)`).all() as {
|
|
9
|
+
name: string;
|
|
10
|
+
}[];
|
|
11
|
+
if (!cols.some((c) => c.name === "via_host"))
|
|
12
|
+
db.exec(`ALTER TABLE work_claims ADD COLUMN via_host TEXT`);
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS claim_peers (
|
|
15
|
+
host_id TEXT PRIMARY KEY,
|
|
16
|
+
machine_id TEXT NOT NULL,
|
|
17
|
+
seen_at INTEGER NOT NULL
|
|
18
|
+
);
|
|
19
|
+
`);
|
|
20
|
+
},
|
|
21
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import { installChangeTriggers, TABLES_WATCHED_WORK_CLAIMS } from "../changes";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
key: "20261020T2200_work_claims_watch",
|
|
6
|
+
up: (db) => {
|
|
7
|
+
// The claims sheet and the overlap pills refetch when a claim changes.
|
|
8
|
+
installChangeTriggers(db, TABLES_WATCHED_WORK_CLAIMS);
|
|
9
|
+
},
|
|
10
|
+
} satisfies Migration;
|
package/lib/db/queries.ts
CHANGED
|
@@ -94,24 +94,6 @@ export const queries = {
|
|
|
94
94
|
`UPDATE sessions SET worktree_path = ?, branch_name = ?, base_branch = ?, dev_server_port = ?, updated_at = datetime('now') WHERE id = ?`
|
|
95
95
|
),
|
|
96
96
|
|
|
97
|
-
updateSessionGroup: (db: Database.Database) =>
|
|
98
|
-
getStmt(
|
|
99
|
-
db,
|
|
100
|
-
`UPDATE sessions SET group_path = ?, updated_at = datetime('now') WHERE id = ?`
|
|
101
|
-
),
|
|
102
|
-
|
|
103
|
-
getSessionsByGroup: (db: Database.Database) =>
|
|
104
|
-
getStmt(
|
|
105
|
-
db,
|
|
106
|
-
`SELECT * FROM session_rows WHERE group_path = ? ORDER BY updated_at DESC`
|
|
107
|
-
),
|
|
108
|
-
|
|
109
|
-
moveSessionsToGroup: (db: Database.Database) =>
|
|
110
|
-
getStmt(
|
|
111
|
-
db,
|
|
112
|
-
`UPDATE sessions SET group_path = ?, updated_at = datetime('now') WHERE group_path = ?`
|
|
113
|
-
),
|
|
114
|
-
|
|
115
97
|
updateSessionProject: (db: Database.Database) =>
|
|
116
98
|
getStmt(
|
|
117
99
|
db,
|
|
@@ -196,28 +178,6 @@ export const queries = {
|
|
|
196
178
|
`SELECT * FROM tool_calls WHERE message_id = ? ORDER BY timestamp ASC`
|
|
197
179
|
),
|
|
198
180
|
|
|
199
|
-
// Groups
|
|
200
|
-
getAllGroups: (db: Database.Database) =>
|
|
201
|
-
getStmt(db, `SELECT * FROM groups ORDER BY sort_order ASC, name ASC`),
|
|
202
|
-
|
|
203
|
-
getGroup: (db: Database.Database) =>
|
|
204
|
-
getStmt(db, `SELECT * FROM groups WHERE path = ?`),
|
|
205
|
-
|
|
206
|
-
createGroup: (db: Database.Database) =>
|
|
207
|
-
getStmt(db, `INSERT INTO groups (path, name, sort_order) VALUES (?, ?, ?)`),
|
|
208
|
-
|
|
209
|
-
updateGroupName: (db: Database.Database) =>
|
|
210
|
-
getStmt(db, `UPDATE groups SET name = ? WHERE path = ?`),
|
|
211
|
-
|
|
212
|
-
updateGroupExpanded: (db: Database.Database) =>
|
|
213
|
-
getStmt(db, `UPDATE groups SET expanded = ? WHERE path = ?`),
|
|
214
|
-
|
|
215
|
-
updateGroupOrder: (db: Database.Database) =>
|
|
216
|
-
getStmt(db, `UPDATE groups SET sort_order = ? WHERE path = ?`),
|
|
217
|
-
|
|
218
|
-
deleteGroup: (db: Database.Database) =>
|
|
219
|
-
getStmt(db, `DELETE FROM groups WHERE path = ?`),
|
|
220
|
-
|
|
221
181
|
// Projects
|
|
222
182
|
createProject: (db: Database.Database) =>
|
|
223
183
|
getStmt(
|
package/lib/db/types.ts
CHANGED
|
@@ -94,11 +94,23 @@ export interface Session {
|
|
|
94
94
|
// it, as a short line (lib/bus/waits/store).
|
|
95
95
|
waiting_on?: string | null;
|
|
96
96
|
waited_by?: string | null;
|
|
97
|
+
// Set only by GET /api/sessions: the strongest overlap of this session's
|
|
98
|
+
// work with a claim here or on a linked machine (lib/claims/overlapCache).
|
|
99
|
+
overlap?: {
|
|
100
|
+
strength: "strong" | "weak";
|
|
101
|
+
machine: string;
|
|
102
|
+
goal: string;
|
|
103
|
+
shared: number;
|
|
104
|
+
claimId: string;
|
|
105
|
+
mineId: string;
|
|
106
|
+
} | null;
|
|
97
107
|
placed_note?: string | null;
|
|
98
108
|
// Set by done: hidden from the sidebar and the orchestrator, never deleted.
|
|
99
109
|
archived_at: string | null;
|
|
100
110
|
// On the sidebar's Pinned shelf (0/1 from SQLite).
|
|
101
111
|
pinned: boolean;
|
|
112
|
+
// The user's group it sits in (session_groups), or null.
|
|
113
|
+
session_group_id?: string | null;
|
|
102
114
|
// A workspace's standing orchestrator chat (and that workspace), or null.
|
|
103
115
|
role: "orchestrator" | null;
|
|
104
116
|
// An orchestrator's tool-call secret.
|
|
@@ -165,14 +177,6 @@ export interface Session {
|
|
|
165
177
|
worker_status: "pending" | "running" | "completed" | "failed" | null;
|
|
166
178
|
}
|
|
167
179
|
|
|
168
|
-
export interface Group {
|
|
169
|
-
path: string;
|
|
170
|
-
name: string;
|
|
171
|
-
expanded: boolean;
|
|
172
|
-
sort_order: number;
|
|
173
|
-
created_at: string;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
180
|
// production: every merge gate applies. fast: a task's PR merges when it opens.
|
|
177
181
|
export type ProjectTier = "production" | "fast";
|
|
178
182
|
|
|
@@ -314,4 +318,8 @@ export interface Host {
|
|
|
314
318
|
kind?: "ssh" | "connect";
|
|
315
319
|
// Paired with that machine's own AgentOS (lib/hosts/link.ts); 0/1 from SQLite.
|
|
316
320
|
linked?: boolean | number;
|
|
321
|
+
// Connect hosts (lib/peer): the link's state, and why it's suspended or
|
|
322
|
+
// who unlinked it.
|
|
323
|
+
peer_state?: "active" | "suspended" | "unlinked" | null;
|
|
324
|
+
peer_note?: string | null;
|
|
317
325
|
}
|
package/lib/doctor/checks.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFile } from "child_process";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
8
11
|
import { promisify } from "util";
|
|
12
|
+
import { agentOsHome, liveCheckout } from "../dev-instance/liveServer";
|
|
9
13
|
import { probeAgents, type AgentProbe } from "../agents/probe";
|
|
10
14
|
import { getAllProviderDefinitions } from "../providers/registry";
|
|
11
15
|
|
|
@@ -37,12 +41,14 @@ export interface DoctorDeps {
|
|
|
37
41
|
exec: ExecFn;
|
|
38
42
|
probe: (fresh: boolean) => Promise<Record<string, AgentProbe>>;
|
|
39
43
|
nodeVersion: string;
|
|
44
|
+
cliLinks: () => DoctorCheck | null;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
const defaultDeps: DoctorDeps = {
|
|
43
48
|
exec: promisify(execFile) as ExecFn,
|
|
44
49
|
probe: probeAgents,
|
|
45
50
|
nodeVersion: process.versions.node,
|
|
51
|
+
cliLinks: () => checkCliLinks(),
|
|
46
52
|
};
|
|
47
53
|
|
|
48
54
|
const INSTALL = {
|
|
@@ -190,6 +196,62 @@ export function checkAgents(probes: Record<string, AgentProbe>): DoctorCheck {
|
|
|
190
196
|
};
|
|
191
197
|
}
|
|
192
198
|
|
|
199
|
+
const CLI_LINKS: Array<[string, string]> = [
|
|
200
|
+
["agent-os", "scripts/agent-os"],
|
|
201
|
+
["aos", "bin/aos"],
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const real = (p: string) => {
|
|
205
|
+
try {
|
|
206
|
+
return fs.realpathSync(p);
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Do the CLI links in ~/.local/bin run the checkout the live server runs from?
|
|
214
|
+
* A link into an older clone runs an old `aos`. Null when there is no live
|
|
215
|
+
* server record or no link to judge.
|
|
216
|
+
*/
|
|
217
|
+
export function checkCliLinks(
|
|
218
|
+
binDir = process.env.AOS_BIN_DIR || path.join(os.homedir(), ".local/bin"),
|
|
219
|
+
home = agentOsHome()
|
|
220
|
+
): DoctorCheck | null {
|
|
221
|
+
const live = liveCheckout(home);
|
|
222
|
+
if (!live) return null;
|
|
223
|
+
const stale: string[] = [];
|
|
224
|
+
let seen = 0;
|
|
225
|
+
for (const [name, rel] of CLI_LINKS) {
|
|
226
|
+
const link = path.join(binDir, name);
|
|
227
|
+
let target: string;
|
|
228
|
+
try {
|
|
229
|
+
target = fs.readlinkSync(link);
|
|
230
|
+
} catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
seen++;
|
|
234
|
+
const want = real(path.join(live, rel));
|
|
235
|
+
if (!want || real(path.resolve(binDir, target)) !== want)
|
|
236
|
+
stale.push(`${name} -> ${target}`);
|
|
237
|
+
}
|
|
238
|
+
if (seen === 0) return null;
|
|
239
|
+
if (stale.length === 0)
|
|
240
|
+
return {
|
|
241
|
+
id: "cli-links",
|
|
242
|
+
label: "CLI links",
|
|
243
|
+
status: "ok",
|
|
244
|
+
detail: `Point at ${live}`,
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
id: "cli-links",
|
|
248
|
+
label: "CLI links",
|
|
249
|
+
status: "warn",
|
|
250
|
+
detail: `${stale.join(", ")}; the live server runs from ${live}`,
|
|
251
|
+
fix: `${live}/scripts/sync-cli-links`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
193
255
|
let cache: { at: number; checks: Promise<DoctorCheck[]> } | null = null;
|
|
194
256
|
|
|
195
257
|
/** Every check, in parallel, cached for a minute. `fresh` runs them again. */
|
|
@@ -214,7 +276,8 @@ export function runDoctor(
|
|
|
214
276
|
detail: "Couldn't check the agent CLIs",
|
|
215
277
|
fix: "Check again",
|
|
216
278
|
})),
|
|
217
|
-
|
|
279
|
+
Promise.resolve(d.cliLinks()),
|
|
280
|
+
]).then((all) => all.filter((c): c is DoctorCheck => c !== null));
|
|
218
281
|
if (!custom) cache = { at: Date.now(), checks };
|
|
219
282
|
return checks;
|
|
220
283
|
}
|
package/lib/git-remote.ts
CHANGED
|
@@ -12,6 +12,36 @@ export function slugOfRemote(url: string | null | undefined): string | null {
|
|
|
12
12
|
return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// A repository's key across clones and URL forms: ssh and https of the same
|
|
16
|
+
// remote give the same key, with no scheme, credentials, port or ".git".
|
|
17
|
+
// "host/path", lower case; null for anything that isn't a remote URL.
|
|
18
|
+
export function repoKeyOfRemote(url: string | null | undefined): string | null {
|
|
19
|
+
const raw = (url ?? "").trim();
|
|
20
|
+
if (!raw) return null;
|
|
21
|
+
let host = "";
|
|
22
|
+
let path = "";
|
|
23
|
+
const scp = /^(?:[^@/:\s]+@)?([^/:\s]+):(?!\/\/)(.+)$/.exec(raw);
|
|
24
|
+
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
|
25
|
+
host = scp[1];
|
|
26
|
+
path = scp[2];
|
|
27
|
+
} else {
|
|
28
|
+
try {
|
|
29
|
+
const u = new URL(raw);
|
|
30
|
+
if (!/^(https?|ssh|git):$/.test(u.protocol)) return null;
|
|
31
|
+
host = u.hostname;
|
|
32
|
+
path = u.pathname;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
path = path
|
|
38
|
+
.replace(/^\/+|\/+$/g, "")
|
|
39
|
+
.replace(/\.git$/i, "")
|
|
40
|
+
.toLowerCase();
|
|
41
|
+
host = host.toLowerCase();
|
|
42
|
+
return host && path.includes("/") ? `${host}/${path}` : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
// A remote's URL doesn't change under a running server often enough to ask
|
|
16
46
|
// git on every lookup.
|
|
17
47
|
const SLUG_TTL_MS = 5 * 60 * 1000;
|
|
@@ -45,3 +75,16 @@ export function repoSlugSync(dir: string): string | null {
|
|
|
45
75
|
slugs.set(dir, { at: Date.now(), slug });
|
|
46
76
|
return slug;
|
|
47
77
|
}
|
|
78
|
+
|
|
79
|
+
const keys = new Map<string, { at: number; key: string | null }>();
|
|
80
|
+
|
|
81
|
+
export async function repoKey(dir: string): Promise<string | null> {
|
|
82
|
+
const hit = keys.get(dir);
|
|
83
|
+
if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.key;
|
|
84
|
+
const url = await git(dir, ["config", "--get", "remote.origin.url"], {
|
|
85
|
+
timeout: 10000,
|
|
86
|
+
}).catch(() => "");
|
|
87
|
+
const key = repoKeyOfRemote(url);
|
|
88
|
+
keys.set(dir, { at: Date.now(), key });
|
|
89
|
+
return key;
|
|
90
|
+
}
|
package/lib/hosts/index.ts
CHANGED
|
@@ -29,7 +29,16 @@ export function listRemoteHosts(): Host[] {
|
|
|
29
29
|
`SELECT h.*, CASE WHEN h.kind = 'connect'
|
|
30
30
|
THEN EXISTS (SELECT 1 FROM peer_links p WHERE p.host_id = h.id)
|
|
31
31
|
ELSE EXISTS (SELECT 1 FROM host_links l WHERE l.host_id = h.id)
|
|
32
|
-
END AS linked
|
|
32
|
+
END AS linked,
|
|
33
|
+
CASE WHEN h.kind = 'connect' THEN COALESCE(
|
|
34
|
+
(SELECT p.state FROM peer_links p WHERE p.host_id = h.id),
|
|
35
|
+
(SELECT 'unlinked' FROM peer_unlinked u WHERE u.host_id = h.id LIMIT 1))
|
|
36
|
+
END AS peer_state,
|
|
37
|
+
CASE WHEN h.kind = 'connect' THEN COALESCE(
|
|
38
|
+
(SELECT p.suspended_reason FROM peer_links p WHERE p.host_id = h.id),
|
|
39
|
+
(SELECT u.reason FROM peer_unlinked u WHERE u.host_id = h.id
|
|
40
|
+
ORDER BY u.at DESC LIMIT 1))
|
|
41
|
+
END AS peer_note
|
|
33
42
|
FROM hosts h ORDER BY sort_order, created_at`
|
|
34
43
|
)
|
|
35
44
|
.all() as Host[];
|
package/lib/orchestration.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { UNSET_SERVER_ENV } from "./agents/server-env";
|
|
|
19
19
|
import { runInBackground } from "./async-operations";
|
|
20
20
|
import { tmux } from "./tmux/exec";
|
|
21
21
|
import { endTmuxSession } from "./tmux/end";
|
|
22
|
+
import { inheritGroup } from "@/lib/session-groups/assignSessions";
|
|
22
23
|
|
|
23
24
|
export interface SpawnWorkerOptions {
|
|
24
25
|
conductorSessionId: string;
|
|
@@ -146,6 +147,7 @@ export async function spawnWorker(
|
|
|
146
147
|
agentType,
|
|
147
148
|
"uncategorized" // project_id
|
|
148
149
|
);
|
|
150
|
+
inheritGroup(conductorSessionId, sessionId);
|
|
149
151
|
|
|
150
152
|
// Update worktree info if created
|
|
151
153
|
if (worktreePath) {
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { assertMovable, createTask, dropTask, moveTask } from "../tasks";
|
|
23
23
|
import { getHost, hostIdNamed } from "../hosts";
|
|
24
24
|
import { queueEvent } from "./events";
|
|
25
|
+
import { claimQueuedEvent, claimWait } from "./claim-wait";
|
|
25
26
|
import {
|
|
26
27
|
findQueued,
|
|
27
28
|
queuedLabel,
|
|
@@ -45,6 +46,7 @@ import { braked } from "./brakes";
|
|
|
45
46
|
import { getOrchestrator } from "./home";
|
|
46
47
|
import { addNote } from "./notes";
|
|
47
48
|
import { findWorkspaceSession } from "./read";
|
|
49
|
+
import { assignSessions, resolveGroup } from "../session-groups";
|
|
48
50
|
import {
|
|
49
51
|
assertTakesMessages,
|
|
50
52
|
findQueuedTarget,
|
|
@@ -162,7 +164,9 @@ export async function startTask(
|
|
|
162
164
|
machine?: "mac" | "any";
|
|
163
165
|
now?: boolean;
|
|
164
166
|
nowReason?: string;
|
|
167
|
+
ignoreClaims?: boolean;
|
|
165
168
|
piece?: string;
|
|
169
|
+
group?: string;
|
|
166
170
|
} = {}
|
|
167
171
|
): Promise<string> {
|
|
168
172
|
if (opts.planOnly && opts.plan === false)
|
|
@@ -170,6 +174,7 @@ export async function startTask(
|
|
|
170
174
|
if (opts.now && !opts.nowReason)
|
|
171
175
|
throw new Error("now needs now_reason: say why it can't wait for a slot");
|
|
172
176
|
const project = workspaceProject(workspaceId, projectRef);
|
|
177
|
+
const groupId = groupIdOf(workspaceId, opts.group);
|
|
173
178
|
const model = parseOverride(opts.model) ?? undefined;
|
|
174
179
|
const startedBy = orchestratorId(workspaceId);
|
|
175
180
|
const pieceId = opts.piece
|
|
@@ -182,6 +187,19 @@ export async function startTask(
|
|
|
182
187
|
);
|
|
183
188
|
const view = opts.view ?? DEFAULT_START_VIEW;
|
|
184
189
|
const slots = slotState(project.id);
|
|
190
|
+
// Work claimed elsewhere: a strong overlap queues this start after it,
|
|
191
|
+
// unless it starts now or the caller said to ignore claims.
|
|
192
|
+
const claims = opts.ignoreClaims
|
|
193
|
+
? null
|
|
194
|
+
: await claimWait(
|
|
195
|
+
{ projectId: project.id, name: opts.name, prompt, base: opts.base },
|
|
196
|
+
{
|
|
197
|
+
workspaceId,
|
|
198
|
+
queue: !opts.now && !opts.after,
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
const after = opts.after ?? claims?.after;
|
|
202
|
+
const headsUp = claims?.headsUp ?? "";
|
|
185
203
|
// Held for a slot or its `after`, it starts by itself through the brakes;
|
|
186
204
|
// you get an event when it does.
|
|
187
205
|
const out = await startOrQueue(
|
|
@@ -199,11 +217,12 @@ export async function startTask(
|
|
|
199
217
|
startedVia: "orchestrator",
|
|
200
218
|
pieceId,
|
|
201
219
|
baseBranch: opts.base || undefined,
|
|
202
|
-
after
|
|
220
|
+
after,
|
|
203
221
|
originWorkspaceId: workspaceId,
|
|
204
222
|
priority: opts.priority,
|
|
205
223
|
machine: opts.machine,
|
|
206
224
|
now: opts.now,
|
|
225
|
+
groupId,
|
|
207
226
|
},
|
|
208
227
|
() =>
|
|
209
228
|
braked(
|
|
@@ -224,14 +243,17 @@ export async function startTask(
|
|
|
224
243
|
parentSessionId: startedBy,
|
|
225
244
|
startedVia: "orchestrator",
|
|
226
245
|
pieceId,
|
|
246
|
+
groupId,
|
|
227
247
|
}),
|
|
228
248
|
(s) => s.id
|
|
229
249
|
)
|
|
230
250
|
);
|
|
231
251
|
if ("queued" in out) {
|
|
232
252
|
const { queued } = out;
|
|
253
|
+
if (claims?.strong && !opts.after)
|
|
254
|
+
claimQueuedEvent(workspaceId, queued, claims.strong);
|
|
233
255
|
const builds = pieceId ? ` It builds ${pieceLabel(pieceId)}.` : "";
|
|
234
|
-
return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}.${builds} It starts by itself; you'll get an event when it does
|
|
256
|
+
return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}.${builds} It starts by itself; you'll get an event when it does.${headsUp}`;
|
|
235
257
|
}
|
|
236
258
|
const task = out.started;
|
|
237
259
|
if (opts.priority) stampPriority(task.id, opts.priority);
|
|
@@ -248,7 +270,7 @@ export async function startTask(
|
|
|
248
270
|
opts.plan && !opts.planOnly && task.plan_phase !== "planning"
|
|
249
271
|
? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
|
|
250
272
|
: "";
|
|
251
|
-
return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${pieceId ? ` It builds ${pieceLabel(pieceId)}.` : ""}${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}`;
|
|
273
|
+
return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${pieceId ? ` It builds ${pieceLabel(pieceId)}.` : ""}${skipped}${planOnlyNote}${past ? " Started past the running-task limit." : ""}${headsUp}`;
|
|
252
274
|
}
|
|
253
275
|
|
|
254
276
|
// Starts a queued task now, past the running-task limit and its `after`.
|
|
@@ -299,9 +321,15 @@ export async function startSession(
|
|
|
299
321
|
workspaceId: string,
|
|
300
322
|
projectRef: string,
|
|
301
323
|
prompt: string,
|
|
302
|
-
opts: {
|
|
324
|
+
opts: {
|
|
325
|
+
name?: string;
|
|
326
|
+
view?: StartView;
|
|
327
|
+
model?: string;
|
|
328
|
+
group?: string;
|
|
329
|
+
} = {}
|
|
303
330
|
): Promise<string> {
|
|
304
331
|
const project = workspaceProject(workspaceId, projectRef);
|
|
332
|
+
const groupId = groupIdOf(workspaceId, opts.group);
|
|
305
333
|
const startedBy = orchestratorId(workspaceId);
|
|
306
334
|
const model = parseOverride(opts.model);
|
|
307
335
|
if (model && typeof model !== "string")
|
|
@@ -326,6 +354,7 @@ export async function startSession(
|
|
|
326
354
|
startedBy,
|
|
327
355
|
parentSessionId: startedBy,
|
|
328
356
|
startedVia: "orchestrator",
|
|
357
|
+
groupId,
|
|
329
358
|
})
|
|
330
359
|
).session
|
|
331
360
|
: spawnSession({
|
|
@@ -335,6 +364,7 @@ export async function startSession(
|
|
|
335
364
|
model,
|
|
336
365
|
startedBy,
|
|
337
366
|
parentSessionId: startedBy,
|
|
367
|
+
groupId,
|
|
338
368
|
}),
|
|
339
369
|
(s) => s.id
|
|
340
370
|
);
|
|
@@ -408,6 +438,26 @@ export async function drop(
|
|
|
408
438
|
return `Dropped ${task.name}: its PR is closed and its worktree removed.`;
|
|
409
439
|
}
|
|
410
440
|
|
|
441
|
+
// One of Saad's groups in the workspace by name; the error lists the ones
|
|
442
|
+
// there are. The orchestrator files into groups, it never creates them.
|
|
443
|
+
function groupIdOf(workspaceId: string, name?: string): string | undefined {
|
|
444
|
+
return name ? resolveGroup(workspaceId, name).id : undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Files a session of this workspace in a group, or out of its group (null).
|
|
448
|
+
export async function setGroup(
|
|
449
|
+
workspaceId: string,
|
|
450
|
+
ref: string,
|
|
451
|
+
group: string | null
|
|
452
|
+
): Promise<string> {
|
|
453
|
+
const s = findWorkspaceSession(workspaceId, ref);
|
|
454
|
+
const target = group === null ? null : resolveGroup(workspaceId, group);
|
|
455
|
+
assignSessions([s.id], target?.id ?? null);
|
|
456
|
+
return target
|
|
457
|
+
? `Filed ${s.name} in "${target.name}".`
|
|
458
|
+
: `Took ${s.name} out of its group.`;
|
|
459
|
+
}
|
|
460
|
+
|
|
411
461
|
// Finished work: merged through the gates if its PR is open, then
|
|
412
462
|
// stopped and cleaned up, then left on the Done shelf (lib/done).
|
|
413
463
|
export async function done(workspaceId: string, ref: string): Promise<string> {
|
|
@@ -122,11 +122,12 @@ Reading:
|
|
|
122
122
|
Acting:
|
|
123
123
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
124
124
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
125
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task in a production project (see "Triage every job"); a fast project skips the plan unless you pass plan true. Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
125
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, ignore_claims?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. If another session (here or on a linked machine) has claimed the same branch or files, it is queued after that work and you get an event saying so; pass ignore_claims only when the overlap is wrong. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task in a production project (see "Triage every job"); a fast project skips the plan unless you pass plan true. Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
126
126
|
- \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
|
|
127
127
|
- \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
|
|
128
128
|
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
129
129
|
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?, model?): a session without a worktree or PR, a chat unless view is terminal. It runs on the implementation model (Sonnet) unless you pass model; use it for work outside a repository, with the steps in the prompt.
|
|
130
|
+
- \`${TOOL_NAMES.set_group}\` (session, group): file a session in one of Saad's groups by name, or out of its group with null. Saad's groups organise his sidebar: put a task in one when he asks, or when it continues work that's already in that group. \`start_task\` and \`start_session\` take a group too. A group has to exist: you can't create, rename or delete one, so if he asks for one that doesn't, say so.
|
|
130
131
|
- \`${TOOL_NAMES.recipes}\` (name?): the workspace's recipes with their last runs; with a name, one in full.
|
|
131
132
|
- \`${TOOL_NAMES.run_recipe}\` (name, note?): run a recipe now: a fresh task or session from its saved brief. See "Recipes".
|
|
132
133
|
- \`${TOOL_NAMES.recipe_save}\` (name, project, kind, brief, model?, view?): save a recipe (kind task ends in a PR; session has none).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// What the orchestrator's start_task does about work claimed elsewhere: a
|
|
2
|
+
// strong overlap queues the start after that claim, any other overlap is a
|
|
3
|
+
// heads-up. A person's start never comes through here.
|
|
4
|
+
|
|
5
|
+
import { checkStart, overlapText } from "../claims/startCheck";
|
|
6
|
+
import { machineId } from "../claims/machine";
|
|
7
|
+
import { IN_FLIGHT, resolveAfter, sessionOf } from "../tasks/queue-rows";
|
|
8
|
+
import { queueEvent } from "./events";
|
|
9
|
+
import type { Overlap } from "../claims/types";
|
|
10
|
+
|
|
11
|
+
export interface ClaimWait {
|
|
12
|
+
// The `after` to queue with: a local task's id, or "claim:<id>".
|
|
13
|
+
after?: string;
|
|
14
|
+
// The overlap that set it, for the event once the start is queued.
|
|
15
|
+
strong: Overlap | null;
|
|
16
|
+
headsUp: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isLocalTask = (sessionId: string) => {
|
|
20
|
+
const s = sessionOf(sessionId);
|
|
21
|
+
return !!s?.task_status && IN_FLIGHT.includes(s.task_status);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// A local task is waited on as a task (the existing `after`); anything else
|
|
25
|
+
// as a claim.
|
|
26
|
+
function afterOf(o: Overlap, workspaceId: string): string | undefined {
|
|
27
|
+
const c = o.b;
|
|
28
|
+
if (c.origin !== machineId() || !c.sessionId) return `claim:${c.id}`;
|
|
29
|
+
if (!isLocalTask(c.sessionId)) return `claim:${c.id}`;
|
|
30
|
+
try {
|
|
31
|
+
return resolveAfter(c.sessionId, workspaceId).id;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function claimWait(
|
|
38
|
+
start: { projectId: string; name?: string; prompt: string; base?: string },
|
|
39
|
+
opts: { workspaceId: string; queue: boolean }
|
|
40
|
+
): Promise<ClaimWait> {
|
|
41
|
+
const check = await checkStart(start);
|
|
42
|
+
if (!check.overlaps.length) return { strong: null, headsUp: "" };
|
|
43
|
+
const strong =
|
|
44
|
+
(opts.queue && check.overlaps.find((o) => o.strength === "strong")) || null;
|
|
45
|
+
const after = strong ? afterOf(strong, opts.workspaceId) : undefined;
|
|
46
|
+
const lines = check.overlaps
|
|
47
|
+
.slice(0, 3)
|
|
48
|
+
.map((o) => `- ${o.strength}: ${overlapText(o)}`);
|
|
49
|
+
return {
|
|
50
|
+
after,
|
|
51
|
+
strong: after ? strong : null,
|
|
52
|
+
headsUp: `\nHeads-up, work already claimed:\n${lines.join("\n")}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Tells the orchestrator it was queued behind a claim, and how to override.
|
|
57
|
+
export function claimQueuedEvent(
|
|
58
|
+
workspaceId: string,
|
|
59
|
+
queued: { id: string; name: string },
|
|
60
|
+
o: Overlap
|
|
61
|
+
): void {
|
|
62
|
+
const shared = o.shared.length
|
|
63
|
+
? ` (shared: ${o.shared.slice(0, 5).join(", ")})`
|
|
64
|
+
: "";
|
|
65
|
+
queueEvent(
|
|
66
|
+
workspaceId,
|
|
67
|
+
`claim-queued:${queued.id}`,
|
|
68
|
+
queued.id,
|
|
69
|
+
`Queued "${queued.name}" (id ${queued.id.slice(0, 8)}) after ${overlapText(o)}${shared}. It starts when that work ends; start_now overrides, or drop it.`
|
|
70
|
+
);
|
|
71
|
+
}
|