@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,101 @@
|
|
|
1
|
+
// Each sync: tells a local session about a strong overlap it is in. An
|
|
2
|
+
// overlap must hold on two syncs in a row, and is told again only when the
|
|
3
|
+
// shared set grows. Both machines run this for their own sessions, so
|
|
4
|
+
// nothing is sent across machines. Never pauses or stops anything.
|
|
5
|
+
|
|
6
|
+
import { getDb } from "../db";
|
|
7
|
+
import { sendMessage } from "../bus";
|
|
8
|
+
import { machineId } from "./machine";
|
|
9
|
+
import { sessionOverlaps, type SessionOverlap } from "./mine";
|
|
10
|
+
import { sessionMessage } from "./overlapText";
|
|
11
|
+
import { liveClaims } from "./store";
|
|
12
|
+
|
|
13
|
+
export const HOLD_SYNCS = 2;
|
|
14
|
+
// A told pair that has not overlapped for this long is forgotten.
|
|
15
|
+
const FORGET_MS = 60 * 60_000;
|
|
16
|
+
|
|
17
|
+
export type Tell = (sessionId: string, body: string) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
export const tellSession: Tell = async (sessionId, body) => {
|
|
20
|
+
await sendMessage({
|
|
21
|
+
fromId: null,
|
|
22
|
+
to: sessionId,
|
|
23
|
+
body,
|
|
24
|
+
fromLabel: "AgentOS",
|
|
25
|
+
origin: { kind: "system", label: "Overlap", body },
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface Notice {
|
|
30
|
+
pair_key: string;
|
|
31
|
+
hits: number;
|
|
32
|
+
shared: string;
|
|
33
|
+
told_at: number | null;
|
|
34
|
+
seen_at: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const parse = (raw: string): string[] => {
|
|
38
|
+
try {
|
|
39
|
+
const v = JSON.parse(raw);
|
|
40
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export async function notifyOverlaps(
|
|
47
|
+
tell: Tell = tellSession,
|
|
48
|
+
now = Date.now()
|
|
49
|
+
): Promise<number> {
|
|
50
|
+
const db = getDb();
|
|
51
|
+
const byPair = new Map<string, SessionOverlap[]>();
|
|
52
|
+
for (const o of sessionOverlaps(machineId(), liveClaims({ now })))
|
|
53
|
+
byPair.set(o.pairKey, [...(byPair.get(o.pairKey) ?? []), o]);
|
|
54
|
+
const get = db.prepare(`SELECT * FROM claim_notices WHERE pair_key = ?`);
|
|
55
|
+
const save = db.prepare(
|
|
56
|
+
`INSERT INTO claim_notices (pair_key, hits, shared, told_at, seen_at)
|
|
57
|
+
VALUES (@key, @hits, @shared, @told, @now)
|
|
58
|
+
ON CONFLICT(pair_key) DO UPDATE SET hits = @hits, shared = @shared,
|
|
59
|
+
told_at = @told, seen_at = @now`
|
|
60
|
+
);
|
|
61
|
+
let told = 0;
|
|
62
|
+
for (const [key, sides] of byPair) {
|
|
63
|
+
const row = get.get(key) as Notice | undefined;
|
|
64
|
+
const hits = (row?.hits ?? 0) + 1;
|
|
65
|
+
const shared = sides[0].shared;
|
|
66
|
+
const known = new Set(parse(row?.shared ?? "[]"));
|
|
67
|
+
const grew = !!row?.told_at && shared.some((f) => !known.has(f));
|
|
68
|
+
const due = hits >= HOLD_SYNCS && (!row?.told_at || grew);
|
|
69
|
+
let delivered = false;
|
|
70
|
+
if (due)
|
|
71
|
+
for (const side of sides)
|
|
72
|
+
try {
|
|
73
|
+
await tell(side.sessionId, sessionMessage(side));
|
|
74
|
+
delivered = true;
|
|
75
|
+
} catch {
|
|
76
|
+
// Left untold when nobody got it: the next sync tries again.
|
|
77
|
+
}
|
|
78
|
+
if (delivered) told++;
|
|
79
|
+
save.run({
|
|
80
|
+
key,
|
|
81
|
+
hits,
|
|
82
|
+
shared: delivered ? JSON.stringify(shared) : (row?.shared ?? "[]"),
|
|
83
|
+
told: delivered ? now : (row?.told_at ?? null),
|
|
84
|
+
now,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// Consecutive means consecutive: a pair that dropped out starts again.
|
|
88
|
+
const rows = db.prepare(`SELECT * FROM claim_notices`).all() as Notice[];
|
|
89
|
+
for (const r of rows) {
|
|
90
|
+
if (byPair.has(r.pair_key)) continue;
|
|
91
|
+
if (!r.told_at || now - r.seen_at > FORGET_MS)
|
|
92
|
+
db.prepare(`DELETE FROM claim_notices WHERE pair_key = ?`).run(
|
|
93
|
+
r.pair_key
|
|
94
|
+
);
|
|
95
|
+
else if (r.hits)
|
|
96
|
+
db.prepare(`UPDATE claim_notices SET hits = 0 WHERE pair_key = ?`).run(
|
|
97
|
+
r.pair_key
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return told;
|
|
101
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A winner of an overlap merged: the base moved under the session that gave
|
|
2
|
+
// way. One with a PR is left to rebaseConflicting once GitHub flags a
|
|
3
|
+
// conflict. One without gets a single message, and a refreshBase when it is
|
|
4
|
+
// idle with nothing uncommitted (refreshBase never loses work). Nothing here
|
|
5
|
+
// pauses or stops a session.
|
|
6
|
+
|
|
7
|
+
import { getDb } from "../db";
|
|
8
|
+
import { git } from "../git-exec";
|
|
9
|
+
import { statusOf } from "../orchestrator/facts";
|
|
10
|
+
import { getSessionRow } from "../schedules/store";
|
|
11
|
+
import { refreshBase, type RefreshTarget } from "../tasks/refresh-base";
|
|
12
|
+
import { expandHome } from "../tasks/session";
|
|
13
|
+
import { machineId } from "./machine";
|
|
14
|
+
import { mergedMessage } from "./overlapText";
|
|
15
|
+
import { claimById } from "./store";
|
|
16
|
+
import { tellSession, type Tell } from "./notifyOverlaps";
|
|
17
|
+
import { winner } from "./yield";
|
|
18
|
+
|
|
19
|
+
export interface MergedDeps {
|
|
20
|
+
tell: Tell;
|
|
21
|
+
idle: (sessionId: string) => Promise<boolean>;
|
|
22
|
+
clean: (dir: string) => Promise<boolean>;
|
|
23
|
+
refresh: (t: RefreshTarget) => Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const realMerged: MergedDeps = {
|
|
27
|
+
tell: tellSession,
|
|
28
|
+
idle: async (id) => {
|
|
29
|
+
const s = getSessionRow(id);
|
|
30
|
+
return !!s && (await statusOf(s)).status === "idle";
|
|
31
|
+
},
|
|
32
|
+
clean: async (dir) =>
|
|
33
|
+
!(await git(dir, ["status", "--porcelain"], { timeout: 5000 })).trim(),
|
|
34
|
+
refresh: refreshBase,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
interface Row {
|
|
38
|
+
pair_key: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function handleMerged(
|
|
42
|
+
deps: MergedDeps = realMerged,
|
|
43
|
+
now = Date.now()
|
|
44
|
+
): Promise<number> {
|
|
45
|
+
const db = getDb();
|
|
46
|
+
const self = machineId();
|
|
47
|
+
const rows = db
|
|
48
|
+
.prepare(
|
|
49
|
+
`SELECT pair_key FROM claim_notices WHERE told_at IS NOT NULL AND merged_at IS NULL`
|
|
50
|
+
)
|
|
51
|
+
.all() as Row[];
|
|
52
|
+
const done = db.prepare(
|
|
53
|
+
`UPDATE claim_notices SET merged_at = ? WHERE pair_key = ?`
|
|
54
|
+
);
|
|
55
|
+
let handled = 0;
|
|
56
|
+
for (const { pair_key } of rows) {
|
|
57
|
+
const [a, b] = pair_key.split("|").map((id) => claimById(id));
|
|
58
|
+
if (!a || !b) continue;
|
|
59
|
+
const gone = [a, b].find((c) => c.released === "merged");
|
|
60
|
+
const mine = [a, b].find(
|
|
61
|
+
(c) => !c.released && c.origin === self && c.sessionId
|
|
62
|
+
);
|
|
63
|
+
if (!gone || !mine || winner(gone, mine) !== gone) continue;
|
|
64
|
+
try {
|
|
65
|
+
const s = getSessionRow(mine.sessionId!);
|
|
66
|
+
// A session with a PR is the rebase pipeline's.
|
|
67
|
+
if (s && !s.pr_url && s.pr_status !== "open") {
|
|
68
|
+
await deps.tell(s.id, mergedMessage(gone, s.base_branch ?? mine.base));
|
|
69
|
+
const dir = s.worktree_path ? expandHome(s.worktree_path) : null;
|
|
70
|
+
if (dir && (await deps.idle(s.id)) && (await deps.clean(dir)))
|
|
71
|
+
await deps.refresh({
|
|
72
|
+
name: s.name,
|
|
73
|
+
worktree: dir,
|
|
74
|
+
branch: s.branch_name,
|
|
75
|
+
base: s.base_branch,
|
|
76
|
+
});
|
|
77
|
+
handled++;
|
|
78
|
+
}
|
|
79
|
+
done.run(now, pair_key);
|
|
80
|
+
} catch {
|
|
81
|
+
// Tried again next sync.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return handled;
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Sessions AgentOS didn't start register a claim themselves, through
|
|
2
|
+
// `aos claim` or the hook. The CLI sends what git said in its own folder.
|
|
3
|
+
|
|
4
|
+
import { repoKeyOfRemote } from "../git-remote";
|
|
5
|
+
import { machineId, machineName } from "./machine";
|
|
6
|
+
import {
|
|
7
|
+
liveClaim,
|
|
8
|
+
newOutsideId,
|
|
9
|
+
releaseOutside,
|
|
10
|
+
upsertOutside,
|
|
11
|
+
} from "./store";
|
|
12
|
+
import type { Claim, OutsideBody } from "./types";
|
|
13
|
+
|
|
14
|
+
const self = () => ({ id: machineId(), name: machineName() });
|
|
15
|
+
|
|
16
|
+
// Null when the remote isn't one a claim can be keyed by, or the id is not
|
|
17
|
+
// this machine's outside claim.
|
|
18
|
+
export function registerOutside(b: OutsideBody): { claim: Claim } | null {
|
|
19
|
+
const repo = repoKeyOfRemote(b.remote);
|
|
20
|
+
if (!repo) return null;
|
|
21
|
+
const id = b.id ?? newOutsideId();
|
|
22
|
+
const prior = liveClaim(id);
|
|
23
|
+
const ok = upsertOutside(self(), {
|
|
24
|
+
id,
|
|
25
|
+
sessionName: b.name ?? null,
|
|
26
|
+
repo,
|
|
27
|
+
branch: b.branch ?? null,
|
|
28
|
+
base: b.base ?? null,
|
|
29
|
+
goal: b.goal || prior?.goal || "outside session",
|
|
30
|
+
files: b.files,
|
|
31
|
+
});
|
|
32
|
+
const claim = ok ? liveClaim(id) : null;
|
|
33
|
+
return claim ? { claim } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const endOutside = (id: string) => releaseOutside(machineId(), id);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Whether two live claims are working on the same thing. Pure: the same two
|
|
2
|
+
// claims give the same answer on every machine, which the yield rule needs.
|
|
3
|
+
|
|
4
|
+
import { goalsAlike } from "./goal";
|
|
5
|
+
import type { Claim, Overlap } from "./types";
|
|
6
|
+
|
|
7
|
+
const NOISE =
|
|
8
|
+
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Gemfile\.lock|Cargo\.lock|go\.sum|poetry\.lock|uv\.lock|composer\.lock|CHANGELOG[^/]*|__snapshots__\/.*|migrations\.generated\.ts)$/i;
|
|
9
|
+
|
|
10
|
+
export const isNoise = (path: string) =>
|
|
11
|
+
NOISE.test(path) || /(^|\/)__snapshots__\//.test(path);
|
|
12
|
+
|
|
13
|
+
// A path this many claims touch is a shared index or router, not a signal.
|
|
14
|
+
export const HUB_CLAIMS = 4;
|
|
15
|
+
|
|
16
|
+
const isBase = (b: string | null, c: Claim, o: Claim) =>
|
|
17
|
+
!b ||
|
|
18
|
+
b === "HEAD" ||
|
|
19
|
+
b === "main" ||
|
|
20
|
+
b === "master" ||
|
|
21
|
+
b === c.base ||
|
|
22
|
+
b === o.base;
|
|
23
|
+
|
|
24
|
+
function sameWork(a: Claim, b: Claim): boolean {
|
|
25
|
+
if (a.id === b.id) return true;
|
|
26
|
+
if (a.sessionId && a.sessionId === b.sessionId && a.origin === b.origin)
|
|
27
|
+
return true;
|
|
28
|
+
return !!a.branch && a.origin === b.origin && a.branch === b.branch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function overlapBetween(
|
|
32
|
+
a: Claim,
|
|
33
|
+
b: Claim,
|
|
34
|
+
hubs: ReadonlySet<string> = new Set()
|
|
35
|
+
): Overlap | null {
|
|
36
|
+
if (a.repo !== b.repo || sameWork(a, b)) return null;
|
|
37
|
+
const kinds: Overlap["kinds"] = [];
|
|
38
|
+
let strong = false;
|
|
39
|
+
|
|
40
|
+
if (a.branch && a.branch === b.branch && !isBase(a.branch, a, b)) {
|
|
41
|
+
kinds.push("branch");
|
|
42
|
+
strong = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fa = new Set(a.files.filter((f) => !isNoise(f)));
|
|
46
|
+
const fb = new Set(b.files.filter((f) => !isNoise(f)));
|
|
47
|
+
let shared = [...fa].filter((f) => fb.has(f));
|
|
48
|
+
if (shared.length && shared.every((f) => hubs.has(f))) shared = [];
|
|
49
|
+
const smaller = Math.min(fa.size, fb.size);
|
|
50
|
+
const ratio = smaller ? shared.length / smaller : 0;
|
|
51
|
+
const alike = goalsAlike(a.goal, b.goal);
|
|
52
|
+
if (shared.length && (shared.length >= 2 || ratio >= 0.3)) {
|
|
53
|
+
kinds.push("files");
|
|
54
|
+
if (shared.length >= 3 || ratio >= 0.5 || alike) strong = true;
|
|
55
|
+
}
|
|
56
|
+
if (alike) kinds.push("goal");
|
|
57
|
+
if (!kinds.length) return null;
|
|
58
|
+
return {
|
|
59
|
+
a,
|
|
60
|
+
b,
|
|
61
|
+
kinds,
|
|
62
|
+
shared: shared.sort(),
|
|
63
|
+
strength: strong ? "strong" : "weak",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function findOverlaps(claims: Claim[]): Overlap[] {
|
|
68
|
+
const live = claims.filter((c) => !c.released);
|
|
69
|
+
const byRepo = new Map<string, Claim[]>();
|
|
70
|
+
for (const c of live) byRepo.set(c.repo, [...(byRepo.get(c.repo) ?? []), c]);
|
|
71
|
+
const out: Overlap[] = [];
|
|
72
|
+
for (const group of byRepo.values()) {
|
|
73
|
+
const touched = new Map<string, number>();
|
|
74
|
+
for (const c of group)
|
|
75
|
+
for (const f of new Set(c.files))
|
|
76
|
+
touched.set(f, (touched.get(f) ?? 0) + 1);
|
|
77
|
+
const hubs = new Set(
|
|
78
|
+
[...touched].filter(([, n]) => n >= HUB_CLAIMS).map(([f]) => f)
|
|
79
|
+
);
|
|
80
|
+
for (let i = 0; i < group.length; i++)
|
|
81
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
82
|
+
const o = overlapBetween(group[i], group[j], hubs);
|
|
83
|
+
if (o) out.push(o);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The overlaps of this machine's sessions, worked out once a sync tick so
|
|
2
|
+
// /api/sessions needs no git and no pairwise pass per request.
|
|
3
|
+
|
|
4
|
+
import { findOverlaps } from "./overlap";
|
|
5
|
+
import { machineId } from "./machine";
|
|
6
|
+
import { liveClaims } from "./store";
|
|
7
|
+
import { overlapView, type OverlapView } from "./view";
|
|
8
|
+
import type { Claim } from "./types";
|
|
9
|
+
|
|
10
|
+
// A session row's pill: the strongest overlap it has.
|
|
11
|
+
export interface SessionOverlap {
|
|
12
|
+
strength: "strong" | "weak";
|
|
13
|
+
machine: string;
|
|
14
|
+
goal: string;
|
|
15
|
+
shared: number;
|
|
16
|
+
// This session's own claim and the other one: the pair a pill opens.
|
|
17
|
+
claimId: string;
|
|
18
|
+
mineId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A claim without its paths: enough to list on a phone.
|
|
22
|
+
export type ClaimSummary = Omit<Claim, "files"> & { fileCount: number };
|
|
23
|
+
|
|
24
|
+
export interface ClaimsSnapshot {
|
|
25
|
+
claims: ClaimSummary[];
|
|
26
|
+
overlaps: OverlapView[];
|
|
27
|
+
bySession: Map<string, SessionOverlap>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const EMPTY: ClaimsSnapshot = {
|
|
31
|
+
claims: [],
|
|
32
|
+
overlaps: [],
|
|
33
|
+
bySession: new Map(),
|
|
34
|
+
};
|
|
35
|
+
// server.ts (the loop) and the route bundles are separate module graphs, so
|
|
36
|
+
// the snapshot lives on globalThis to be one.
|
|
37
|
+
const g = globalThis as unknown as { __agentosClaimsSnapshot?: ClaimsSnapshot };
|
|
38
|
+
const get = () => g.__agentosClaimsSnapshot ?? EMPTY;
|
|
39
|
+
|
|
40
|
+
export const summarize = (c: Claim): ClaimSummary => {
|
|
41
|
+
const { files, ...rest } = c;
|
|
42
|
+
return { ...rest, fileCount: files.length + c.filesMore };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const rank = (o: SessionOverlap) =>
|
|
46
|
+
(o.strength === "strong" ? 1e6 : 0) + o.shared;
|
|
47
|
+
|
|
48
|
+
export function buildSnapshot(claims: Claim[], self: string): ClaimsSnapshot {
|
|
49
|
+
const bySession = new Map<string, SessionOverlap>();
|
|
50
|
+
const overlaps = findOverlaps(claims);
|
|
51
|
+
for (const o of overlaps)
|
|
52
|
+
for (const [mine, other] of [
|
|
53
|
+
[o.a, o.b],
|
|
54
|
+
[o.b, o.a],
|
|
55
|
+
] as const) {
|
|
56
|
+
if (mine.origin !== self || !mine.sessionId) continue;
|
|
57
|
+
const next: SessionOverlap = {
|
|
58
|
+
strength: o.strength,
|
|
59
|
+
machine: other.originName,
|
|
60
|
+
goal: other.goal,
|
|
61
|
+
shared: o.shared.length,
|
|
62
|
+
claimId: other.id,
|
|
63
|
+
mineId: mine.id,
|
|
64
|
+
};
|
|
65
|
+
const have = bySession.get(mine.sessionId);
|
|
66
|
+
if (!have || rank(next) > rank(have)) bySession.set(mine.sessionId, next);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
claims: claims.map(summarize),
|
|
70
|
+
overlaps: overlaps.map(overlapView),
|
|
71
|
+
bySession,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function refreshOverlapCache(now = Date.now()): void {
|
|
76
|
+
try {
|
|
77
|
+
g.__agentosClaimsSnapshot = buildSnapshot(liveClaims({ now }), machineId());
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error("[claims] overlap cache:", err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const claimsSnapshot = (): ClaimsSnapshot => get();
|
|
84
|
+
export const resetOverlapCache = () => {
|
|
85
|
+
g.__agentosClaimsSnapshot = EMPTY;
|
|
86
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// What a session or the orchestrator reads about an overlap. The other
|
|
2
|
+
// claim's goal, machine and paths are another machine's words: fenced.
|
|
3
|
+
|
|
4
|
+
import { untrusted } from "../orchestrator/untrusted";
|
|
5
|
+
import type { SessionOverlap } from "./mine";
|
|
6
|
+
|
|
7
|
+
const SHOWN = 5;
|
|
8
|
+
|
|
9
|
+
function where(o: SessionOverlap): string {
|
|
10
|
+
const files = o.shared.slice(0, SHOWN).join(", ");
|
|
11
|
+
const more =
|
|
12
|
+
o.shared.length > SHOWN ? ` and ${o.shared.length - SHOWN} more` : "";
|
|
13
|
+
const what = o.kinds.includes("branch")
|
|
14
|
+
? "the same branch"
|
|
15
|
+
: "the same files";
|
|
16
|
+
return `${untrusted("claim", `${o.other.goal} on ${o.other.originName}`)} is working on ${what}${files ? `: ${untrusted("claim", files)}${more}` : ""}.`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The message the local session gets, with the verdict.
|
|
20
|
+
export function sessionMessage(o: SessionOverlap): string {
|
|
21
|
+
const head = `Overlap: ${where(o)}`;
|
|
22
|
+
return o.yields
|
|
23
|
+
? `${head} The other work is closer to merging, so give way: finish your current step, commit, and don't restructure those files further. If your change depends on theirs, say so (BLOCKED: or tell the orchestrator) rather than copy it. Nothing is paused.`
|
|
24
|
+
: `${head} Yours is closer to merging, so keep going; just don't widen into those files. Nothing is paused.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function eventLine(o: SessionOverlap, name: string): string {
|
|
28
|
+
return `${name}: overlaps ${where(o)} ${o.yields ? "It yields." : "The other yields."}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mergedMessage(
|
|
32
|
+
winner: { goal: string; originName: string },
|
|
33
|
+
base: string | null
|
|
34
|
+
): string {
|
|
35
|
+
return `${untrusted("claim", `${winner.goal} on ${winner.originName}`)} merged, and it overlapped your work. Rebase onto origin/${base ?? "main"} at your next stopping point.`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getDb } from "../db";
|
|
2
|
+
import { hostLink, linkedHostIds, type HostLink } from "../hosts/remote-api";
|
|
3
|
+
|
|
4
|
+
export function rememberPeer(
|
|
5
|
+
hostId: string,
|
|
6
|
+
machineId: string,
|
|
7
|
+
now = Date.now()
|
|
8
|
+
): void {
|
|
9
|
+
getDb()
|
|
10
|
+
.prepare(
|
|
11
|
+
`INSERT INTO claim_peers (host_id, machine_id, seen_at) VALUES (?, ?, ?)
|
|
12
|
+
ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
|
|
13
|
+
seen_at = excluded.seen_at`
|
|
14
|
+
)
|
|
15
|
+
.run(hostId, machineId, now);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function peerMachineOf(hostId: string): string | null {
|
|
19
|
+
const r = getDb()
|
|
20
|
+
.prepare(`SELECT machine_id FROM claim_peers WHERE host_id = ?`)
|
|
21
|
+
.get(hostId) as { machine_id: string } | undefined;
|
|
22
|
+
return r?.machine_id ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One list per machine, best link first (token, then Connect). A Connect pin
|
|
26
|
+
// that isn't active is left out: peerCall would only refuse it. Links whose
|
|
27
|
+
// machine isn't known yet are each their own machine.
|
|
28
|
+
export function exchangeTargets(): HostLink[][] {
|
|
29
|
+
const groups = new Map<string, HostLink[]>();
|
|
30
|
+
for (const hostId of [...linkedHostIds()].sort()) {
|
|
31
|
+
const link = hostLink(hostId);
|
|
32
|
+
if (!link) continue;
|
|
33
|
+
if (link.connect && link.connect.state !== "active") continue;
|
|
34
|
+
const key = peerMachineOf(hostId) ?? `host:${hostId}`;
|
|
35
|
+
groups.set(key, [...(groups.get(key) ?? []), link]);
|
|
36
|
+
}
|
|
37
|
+
return [...groups.values()].map((g) =>
|
|
38
|
+
[...g].sort(
|
|
39
|
+
(a, b) =>
|
|
40
|
+
Number(a.transport === "connect") - Number(b.transport === "connect")
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Claims that arrived through a link that has since been suspended or
|
|
46
|
+
// unlinked stop counting now, not when their lifetime runs out.
|
|
47
|
+
export function dropViaInactive(): number {
|
|
48
|
+
return getDb()
|
|
49
|
+
.prepare(
|
|
50
|
+
`DELETE FROM work_claims WHERE via_host IS NOT NULL
|
|
51
|
+
AND via_host NOT IN (SELECT host_id FROM host_links
|
|
52
|
+
WHERE host_id NOT IN (SELECT id FROM hosts WHERE kind = 'connect'))
|
|
53
|
+
AND via_host NOT IN (SELECT host_id FROM peer_links WHERE state = 'active')`
|
|
54
|
+
)
|
|
55
|
+
.run().changes;
|
|
56
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Before a task starts: who else is working on the same thing. The files it
|
|
2
|
+
// will touch are guessed from the prompt (paths that exist in the repo).
|
|
3
|
+
// Any failure means no overlaps: this never blocks a start.
|
|
4
|
+
|
|
5
|
+
import { git } from "../git-exec";
|
|
6
|
+
import { repoKey } from "../git-remote";
|
|
7
|
+
import { getProject } from "../projects";
|
|
8
|
+
import { untrusted } from "../orchestrator/untrusted";
|
|
9
|
+
import { expandHome } from "../tasks/session";
|
|
10
|
+
import { claimsShared, machineId, machineName } from "./machine";
|
|
11
|
+
import { overlapBetween } from "./overlap";
|
|
12
|
+
import { liveClaims } from "./store";
|
|
13
|
+
import { MAX_FILES, type Claim, type Overlap } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface StartCheck {
|
|
16
|
+
// Overlaps of the new start (as `a`) with live claims (as `b`), strong first.
|
|
17
|
+
overlaps: Overlap[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PATH_LIKE = /[\w@.+-]+(?:\/[\w@.+-]+)*\.[A-Za-z0-9]{1,8}\b/g;
|
|
21
|
+
|
|
22
|
+
async function predictedFiles(dir: string, prompt: string): Promise<string[]> {
|
|
23
|
+
const tokens = [...new Set(prompt.match(PATH_LIKE) ?? [])].slice(0, 100);
|
|
24
|
+
if (!tokens.length) return [];
|
|
25
|
+
const tracked = new Set(
|
|
26
|
+
(await git(dir, ["ls-files", "-z"], { timeout: 5000 }))
|
|
27
|
+
.split("\0")
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
);
|
|
30
|
+
return tokens.filter((t) => tracked.has(t)).slice(0, MAX_FILES);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function checkStart(opts: {
|
|
34
|
+
projectId: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
prompt: string;
|
|
37
|
+
branch?: string | null;
|
|
38
|
+
now?: number;
|
|
39
|
+
}): Promise<StartCheck> {
|
|
40
|
+
const none: StartCheck = { overlaps: [] };
|
|
41
|
+
try {
|
|
42
|
+
if (!claimsShared()) return none;
|
|
43
|
+
const project = getProject(opts.projectId);
|
|
44
|
+
if (!project) return none;
|
|
45
|
+
const dir = expandHome(project.working_directory);
|
|
46
|
+
const repo = await repoKey(dir);
|
|
47
|
+
if (!repo) return none;
|
|
48
|
+
const live = liveClaims({ repo, now: opts.now });
|
|
49
|
+
if (!live.length) return none;
|
|
50
|
+
const now = opts.now ?? Date.now();
|
|
51
|
+
const goal = (opts.name?.trim() || opts.prompt.split("\n")[0]).slice(
|
|
52
|
+
0,
|
|
53
|
+
120
|
|
54
|
+
);
|
|
55
|
+
const mine: Claim = {
|
|
56
|
+
id: "start",
|
|
57
|
+
origin: machineId(),
|
|
58
|
+
originName: machineName(),
|
|
59
|
+
viaMachine: null,
|
|
60
|
+
hops: 0,
|
|
61
|
+
source: "session",
|
|
62
|
+
sessionId: null,
|
|
63
|
+
sessionName: null,
|
|
64
|
+
repo,
|
|
65
|
+
branch: opts.branch ?? null,
|
|
66
|
+
base: null,
|
|
67
|
+
goal,
|
|
68
|
+
files: await predictedFiles(dir, opts.prompt).catch(() => []),
|
|
69
|
+
filesMore: 0,
|
|
70
|
+
prUrl: null,
|
|
71
|
+
startedAt: now,
|
|
72
|
+
expiresAt: now,
|
|
73
|
+
released: null,
|
|
74
|
+
updatedAt: now,
|
|
75
|
+
};
|
|
76
|
+
const overlaps = live
|
|
77
|
+
.map((c) => overlapBetween(mine, c))
|
|
78
|
+
.filter((o): o is Overlap => !!o)
|
|
79
|
+
.sort(
|
|
80
|
+
(x, y) =>
|
|
81
|
+
Number(y.strength === "strong") - Number(x.strength === "strong")
|
|
82
|
+
);
|
|
83
|
+
return { overlaps };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error("[claims] start check:", error);
|
|
86
|
+
return none;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const where = (c: Claim) =>
|
|
91
|
+
c.origin === machineId() ? "here" : `on ${c.originName}`;
|
|
92
|
+
|
|
93
|
+
// "'<goal>' on box (branch x) touches a.ts, b.ts", fenced: another machine's words.
|
|
94
|
+
export function overlapText(o: Overlap): string {
|
|
95
|
+
const shared = o.shared.length
|
|
96
|
+
? ` touches ${o.shared.slice(0, 5).join(", ")}`
|
|
97
|
+
: "";
|
|
98
|
+
const branch = o.b.branch ? ` (branch ${o.b.branch})` : "";
|
|
99
|
+
return `${untrusted("claim", o.b.goal)} ${where(o.b)}${branch}${shared}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// An overlap of a new start with a live claim, as the start sheet shows it.
|
|
2
|
+
|
|
3
|
+
import type { Overlap } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface StartWarning {
|
|
6
|
+
strength: Overlap["strength"];
|
|
7
|
+
kinds: Overlap["kinds"];
|
|
8
|
+
shared: string[];
|
|
9
|
+
claim: {
|
|
10
|
+
id: string;
|
|
11
|
+
goal: string;
|
|
12
|
+
machine: string;
|
|
13
|
+
branch: string | null;
|
|
14
|
+
sessionName: string | null;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const startWarning = (o: Overlap): StartWarning => ({
|
|
19
|
+
strength: o.strength,
|
|
20
|
+
kinds: o.kinds,
|
|
21
|
+
shared: o.shared.slice(0, 20),
|
|
22
|
+
claim: {
|
|
23
|
+
id: o.b.id,
|
|
24
|
+
goal: o.b.goal,
|
|
25
|
+
machine: o.b.originName,
|
|
26
|
+
branch: o.b.branch,
|
|
27
|
+
sessionName: o.b.sessionName,
|
|
28
|
+
},
|
|
29
|
+
});
|