pi-extended-teams 2.1.16
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 +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as paths from "../utils/paths";
|
|
6
|
+
import * as teams from "../utils/teams";
|
|
7
|
+
import * as messaging from "../utils/messaging";
|
|
8
|
+
import * as runtime from "../utils/runtime";
|
|
9
|
+
import * as writeQueue from "../utils/write-queue";
|
|
10
|
+
import { appendTeamReportEvent, broadcastMessageOnce, ensureTeam, listTeamReportEvents, observeTeam, observeTeammate, sendMessageOnce, spawnTeammateOnce, spawnTeammatesOnce } from "./index";
|
|
11
|
+
import { roleForFavoriteModelSlot } from "../utils/settings";
|
|
12
|
+
import type { SpawnTeammateOnceRequest } from "./types";
|
|
13
|
+
|
|
14
|
+
let root: string;
|
|
15
|
+
|
|
16
|
+
function installPathSpies() {
|
|
17
|
+
vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName))));
|
|
18
|
+
vi.spyOn(paths, "taskDir").mockImplementation((teamName: unknown) => path.join(root, "tasks", paths.sanitizeName(String(teamName))));
|
|
19
|
+
vi.spyOn(paths, "configPath").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "config.json"));
|
|
20
|
+
vi.spyOn(paths, "inboxPath").mockImplementation((teamName: unknown, agentName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "inboxes", `${paths.sanitizeName(String(agentName))}.json`));
|
|
21
|
+
vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName: unknown, agentName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "runtime", `${paths.sanitizeName(String(agentName))}.json`));
|
|
22
|
+
vi.spyOn(paths, "claimsPath").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "claims.json"));
|
|
23
|
+
vi.spyOn(paths, "writeQueuePath").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "write-queue.json"));
|
|
24
|
+
vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName: unknown, agentName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "lifecycle", "quarantine", `${paths.sanitizeName(String(agentName))}.json`));
|
|
25
|
+
vi.spyOn(paths, "reportFilesDir").mockReturnValue(path.join(root, "agent", "reports"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeFavoriteLevels() {
|
|
29
|
+
const settingsPath = path.join(root, ".pi", "agent", "pi-extended-teams", "settings.json");
|
|
30
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
31
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
32
|
+
favoriteModels: {
|
|
33
|
+
"reading-default": { model: "provider/model", thinking: "high" },
|
|
34
|
+
"writing-hard": { model: "provider/model", thinking: "xhigh" },
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("orchestration primitives", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-orchestration-"));
|
|
42
|
+
vi.spyOn(os, "homedir").mockReturnValue(root);
|
|
43
|
+
installPathSpies();
|
|
44
|
+
writeFavoriteLevels();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("uses read-review as the canonical team default while loading its legacy settings alias", async () => {
|
|
53
|
+
const result = await ensureTeam({ teamName: "default-tier-team", description: "default tier" });
|
|
54
|
+
|
|
55
|
+
expect(result.created).toBe(true);
|
|
56
|
+
expect(result.config.defaultModel).toBe("provider/model");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("observes teammate state without marking inbox read or mutating runtime/team state", async () => {
|
|
60
|
+
teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
61
|
+
await teams.addMember("team", {
|
|
62
|
+
agentId: "writer@team",
|
|
63
|
+
name: "writer",
|
|
64
|
+
agentType: "teammate",
|
|
65
|
+
role: "write",
|
|
66
|
+
model: "provider/model",
|
|
67
|
+
joinedAt: Date.now(),
|
|
68
|
+
tmuxPaneId: "%1",
|
|
69
|
+
cwd: root,
|
|
70
|
+
subscriptions: [],
|
|
71
|
+
prompt: "work",
|
|
72
|
+
});
|
|
73
|
+
const writerRunId = (await teams.readConfig("team")).members.find(member => member.name === "writer")!.lifecycleRunId!;
|
|
74
|
+
await messaging.sendPlainMessage("team", "lead", "writer", "hello", "hello");
|
|
75
|
+
await runtime.writeRuntimeStatus("team", "writer", writerRunId, { ready: true, lastHeartbeatAt: Date.now() });
|
|
76
|
+
|
|
77
|
+
const observed = await observeTeammate("team", "writer", { terminal: { isAlive: () => false } });
|
|
78
|
+
expect(observed.health).toBe("dead");
|
|
79
|
+
expect(observed.unreadCount).toBe(1);
|
|
80
|
+
|
|
81
|
+
const inbox = await messaging.readInbox("team", "writer", false, false);
|
|
82
|
+
expect(inbox[0].read).toBe(false);
|
|
83
|
+
expect(await runtime.readRuntimeStatus("team", "writer")).not.toBeNull();
|
|
84
|
+
expect((await teams.readConfig("team")).members.some(member => member.name === "writer")).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("observes high-volume teams without rereading config per member", async () => {
|
|
88
|
+
const config = teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
89
|
+
config.members.push(...Array.from({ length: 150 }, (_, index) => ({
|
|
90
|
+
agentId: `reader-${index}@team`,
|
|
91
|
+
name: `reader-${index}`,
|
|
92
|
+
agentType: "teammate",
|
|
93
|
+
role: "read" as const,
|
|
94
|
+
model: "provider/model",
|
|
95
|
+
joinedAt: Date.now(),
|
|
96
|
+
tmuxPaneId: "",
|
|
97
|
+
cwd: root,
|
|
98
|
+
subscriptions: [],
|
|
99
|
+
prompt: "read",
|
|
100
|
+
})));
|
|
101
|
+
fs.writeFileSync(paths.configPath("team"), JSON.stringify(config, null, 2));
|
|
102
|
+
const readConfigSpy = vi.spyOn(teams, "readConfig");
|
|
103
|
+
|
|
104
|
+
const observation = await observeTeam("team");
|
|
105
|
+
|
|
106
|
+
expect(observation.members).toHaveLength(151);
|
|
107
|
+
expect(readConfigSpy).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("reuses existing members with canonical outward slots without rewriting persisted state", async () => {
|
|
111
|
+
teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
112
|
+
const joinedAt = Date.now();
|
|
113
|
+
const persistedMember = {
|
|
114
|
+
agentId: "writer@team",
|
|
115
|
+
name: "writer",
|
|
116
|
+
agentType: "teammate",
|
|
117
|
+
role: "write" as const,
|
|
118
|
+
model: "provider/model",
|
|
119
|
+
thinking: "xhigh" as const,
|
|
120
|
+
modelSlot: "writing-hard",
|
|
121
|
+
joinedAt,
|
|
122
|
+
tmuxPaneId: "%1",
|
|
123
|
+
cwd: root,
|
|
124
|
+
subscriptions: [],
|
|
125
|
+
prompt: "write",
|
|
126
|
+
metadata: { operationId: "op-1", workflowRunId: "run-1", modelSlot: "writing-hard" },
|
|
127
|
+
};
|
|
128
|
+
await teams.addMember("team", persistedMember);
|
|
129
|
+
const start = vi.fn();
|
|
130
|
+
|
|
131
|
+
const result = await spawnTeammateOnce({
|
|
132
|
+
teamName: "team",
|
|
133
|
+
name: "writer",
|
|
134
|
+
prompt: "write again",
|
|
135
|
+
cwd: root,
|
|
136
|
+
modelSlot: "writing-hard",
|
|
137
|
+
operationId: "op-1",
|
|
138
|
+
workflowRunId: "run-1",
|
|
139
|
+
}, { start });
|
|
140
|
+
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
status: "existing",
|
|
143
|
+
member: {
|
|
144
|
+
...persistedMember,
|
|
145
|
+
modelSlot: "write-system",
|
|
146
|
+
metadata: { operationId: "op-1", workflowRunId: "run-1", modelSlot: "write-system" },
|
|
147
|
+
},
|
|
148
|
+
details: {
|
|
149
|
+
agentId: "writer@team",
|
|
150
|
+
role: "write",
|
|
151
|
+
requestedRole: "write",
|
|
152
|
+
resolvedRole: "write",
|
|
153
|
+
requestedModelSlot: "write-system",
|
|
154
|
+
modelSlot: "write-system",
|
|
155
|
+
model: "provider/model",
|
|
156
|
+
thinking: "xhigh",
|
|
157
|
+
existing: true,
|
|
158
|
+
idempotent: true,
|
|
159
|
+
queued: false,
|
|
160
|
+
modelSource: "existing",
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
expect((await teams.readConfig("team")).members.find(member => member.name === "writer")).toMatchObject({
|
|
164
|
+
modelSlot: "writing-hard",
|
|
165
|
+
metadata: { modelSlot: "writing-hard" },
|
|
166
|
+
});
|
|
167
|
+
expect(start).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("bulk spawn once reuses one config and queue scan for high-volume requests", async () => {
|
|
171
|
+
const config = teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
172
|
+
config.members.push({
|
|
173
|
+
agentId: "existing-reader@team",
|
|
174
|
+
name: "existing-reader",
|
|
175
|
+
agentType: "teammate",
|
|
176
|
+
role: "read",
|
|
177
|
+
model: "provider/model",
|
|
178
|
+
modelSlot: "reading-default",
|
|
179
|
+
joinedAt: Date.now(),
|
|
180
|
+
tmuxPaneId: "",
|
|
181
|
+
cwd: root,
|
|
182
|
+
subscriptions: [],
|
|
183
|
+
prompt: "read",
|
|
184
|
+
metadata: { operationId: "op-existing", workflowRunId: "run-1" },
|
|
185
|
+
});
|
|
186
|
+
fs.writeFileSync(paths.configPath("team"), JSON.stringify(config, null, 2));
|
|
187
|
+
await writeQueue.enqueueWriteSpawn("team", {
|
|
188
|
+
name: "queued-reader",
|
|
189
|
+
prompt: "queued",
|
|
190
|
+
cwd: root,
|
|
191
|
+
modelSlot: "writing-hard",
|
|
192
|
+
operationId: "op-queued",
|
|
193
|
+
workflowRunId: "run-1",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const startRequests: SpawnTeammateOnceRequest[] = Array.from({ length: 120 }, (_, index) => ({
|
|
197
|
+
teamName: "team",
|
|
198
|
+
name: `reader-${index}`,
|
|
199
|
+
prompt: "read",
|
|
200
|
+
cwd: root,
|
|
201
|
+
modelSlot: "reading-default",
|
|
202
|
+
operationId: `op-start-${index}`,
|
|
203
|
+
workflowRunId: "run-1",
|
|
204
|
+
}));
|
|
205
|
+
const requests: SpawnTeammateOnceRequest[] = [
|
|
206
|
+
{ teamName: "team", name: "existing-reader", prompt: "read", cwd: root, modelSlot: "reading-default" },
|
|
207
|
+
{ teamName: "team", name: "operation-match", prompt: "read", cwd: root, modelSlot: "reading-default", operationId: "op-existing", workflowRunId: "run-1" },
|
|
208
|
+
{ teamName: "team", name: "queued-reader", prompt: "queued", cwd: root, modelSlot: "writing-hard", operationId: "op-queued", workflowRunId: "run-1" },
|
|
209
|
+
...startRequests,
|
|
210
|
+
{ teamName: "team", name: "reader-0", prompt: "duplicate", cwd: root, modelSlot: "reading-default", operationId: "op-duplicate-name", workflowRunId: "run-1" },
|
|
211
|
+
{ teamName: "team", name: "reader-via-op", prompt: "duplicate", cwd: root, modelSlot: "reading-default", operationId: "op-start-1", workflowRunId: "run-1" },
|
|
212
|
+
];
|
|
213
|
+
const start = vi.fn(async (request: SpawnTeammateOnceRequest) => ({
|
|
214
|
+
member: {
|
|
215
|
+
agentId: `${request.name}@${request.teamName}`,
|
|
216
|
+
name: request.name,
|
|
217
|
+
agentType: "teammate",
|
|
218
|
+
role: roleForFavoriteModelSlot(request.modelSlot),
|
|
219
|
+
model: "provider/model",
|
|
220
|
+
modelSlot: request.modelSlot,
|
|
221
|
+
joinedAt: Date.now(),
|
|
222
|
+
tmuxPaneId: "",
|
|
223
|
+
cwd: request.cwd,
|
|
224
|
+
subscriptions: [],
|
|
225
|
+
prompt: request.prompt,
|
|
226
|
+
metadata: request.metadata,
|
|
227
|
+
},
|
|
228
|
+
}));
|
|
229
|
+
const readConfigSpy = vi.spyOn(teams, "readConfig");
|
|
230
|
+
const listQueueSpy = vi.spyOn(writeQueue, "listWriteQueue");
|
|
231
|
+
|
|
232
|
+
const results = await spawnTeammatesOnce(requests, { start });
|
|
233
|
+
|
|
234
|
+
expect(results).toHaveLength(requests.length);
|
|
235
|
+
expect(results[0]).toMatchObject({ status: "existing", member: { name: "existing-reader" } });
|
|
236
|
+
expect(results[1]).toMatchObject({ status: "existing", member: { name: "existing-reader" } });
|
|
237
|
+
expect(results[2]).toMatchObject({ status: "queued", queued: { name: "queued-reader" } });
|
|
238
|
+
expect(results[3]).toMatchObject({ status: "started", member: { name: "reader-0" } });
|
|
239
|
+
expect(results.at(-2)).toMatchObject({ status: "existing", member: { name: "reader-0" } });
|
|
240
|
+
expect(results.at(-1)).toMatchObject({ status: "existing", member: { name: "reader-1" } });
|
|
241
|
+
expect(start).toHaveBeenCalledTimes(startRequests.length);
|
|
242
|
+
expect(start.mock.calls[0][0].metadata).toMatchObject({ operationId: "op-start-0", workflowRunId: "run-1" });
|
|
243
|
+
expect(readConfigSpy).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(listQueueSpy).toHaveBeenCalledTimes(1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("synthesizes effective spawn details from start callback results", async () => {
|
|
248
|
+
teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
249
|
+
const startedMember = {
|
|
250
|
+
agentId: "writer@team",
|
|
251
|
+
name: "writer",
|
|
252
|
+
agentType: "teammate",
|
|
253
|
+
role: "write" as const,
|
|
254
|
+
model: "provider/model",
|
|
255
|
+
thinking: "xhigh" as const,
|
|
256
|
+
modelSlot: "writing-hard",
|
|
257
|
+
joinedAt: Date.now(),
|
|
258
|
+
tmuxPaneId: "%1",
|
|
259
|
+
cwd: root,
|
|
260
|
+
subscriptions: [],
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const result = await spawnTeammateOnce({
|
|
264
|
+
teamName: "team",
|
|
265
|
+
name: "writer",
|
|
266
|
+
prompt: "write",
|
|
267
|
+
cwd: root,
|
|
268
|
+
modelSlot: "writing-hard",
|
|
269
|
+
operationId: "op-2",
|
|
270
|
+
workflowRunId: "run-1",
|
|
271
|
+
}, {
|
|
272
|
+
start: vi.fn(async () => ({ member: startedMember, details: { queued: false, terminalId: "%1", modelSource: "favorite-slot" } })),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(result.status).toBe("started");
|
|
276
|
+
expect(result.details).toMatchObject({
|
|
277
|
+
role: "write",
|
|
278
|
+
requestedRole: "write",
|
|
279
|
+
resolvedRole: "write",
|
|
280
|
+
requestedModelSlot: "write-system",
|
|
281
|
+
modelSlot: "write-system",
|
|
282
|
+
model: "provider/model",
|
|
283
|
+
thinking: "xhigh",
|
|
284
|
+
modelSource: "favorite-slot",
|
|
285
|
+
queued: false,
|
|
286
|
+
terminalId: "%1",
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("replays durable report events through observeTeam", async () => {
|
|
291
|
+
teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
292
|
+
await appendTeamReportEvent("team", {
|
|
293
|
+
agentName: "reader",
|
|
294
|
+
role: "read",
|
|
295
|
+
status: "completed",
|
|
296
|
+
report: "done",
|
|
297
|
+
summary: "done",
|
|
298
|
+
source: "read-agent",
|
|
299
|
+
operationId: "op-1",
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const replay = await listTeamReportEvents("team");
|
|
303
|
+
expect(replay).toHaveLength(1);
|
|
304
|
+
expect(replay[0]).toMatchObject({ agentName: "reader", report: "done", operationId: "op-1" });
|
|
305
|
+
|
|
306
|
+
const observation = await observeTeam("team");
|
|
307
|
+
expect(observation.reports).toHaveLength(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("records high-volume report events without lock retry backoff", async () => {
|
|
311
|
+
teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
312
|
+
const reportCount = 200;
|
|
313
|
+
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
314
|
+
|
|
315
|
+
const writes = Array.from({ length: reportCount }, (_, index) => appendTeamReportEvent("team", {
|
|
316
|
+
agentName: `reader-${index}`,
|
|
317
|
+
role: "read",
|
|
318
|
+
status: "completed",
|
|
319
|
+
report: `report-${index}`,
|
|
320
|
+
summary: `reader-${index} done`,
|
|
321
|
+
source: "read-agent",
|
|
322
|
+
operationId: `op-report-${index}`,
|
|
323
|
+
workflowRunId: "run-1",
|
|
324
|
+
createdAt: index + 1,
|
|
325
|
+
}));
|
|
326
|
+
|
|
327
|
+
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
|
328
|
+
const written = await Promise.all(writes);
|
|
329
|
+
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
|
330
|
+
expect(written).toHaveLength(reportCount);
|
|
331
|
+
expect(new Set(written.map((event) => event.id)).size).toBe(reportCount);
|
|
332
|
+
|
|
333
|
+
const replay = await listTeamReportEvents("team");
|
|
334
|
+
expect(replay).toHaveLength(reportCount);
|
|
335
|
+
expect(replay[0]).toMatchObject({ agentName: "reader-0", operationId: "op-report-0", createdAt: 1 });
|
|
336
|
+
expect(replay.at(-1)).toMatchObject({ agentName: "reader-199", operationId: "op-report-199", createdAt: 200 });
|
|
337
|
+
|
|
338
|
+
const observation = await observeTeam("team", { reportLimit: 5 });
|
|
339
|
+
expect(observation.reports.map((event) => event.operationId)).toEqual([
|
|
340
|
+
"op-report-195",
|
|
341
|
+
"op-report-196",
|
|
342
|
+
"op-report-197",
|
|
343
|
+
"op-report-198",
|
|
344
|
+
"op-report-199",
|
|
345
|
+
]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("sends and broadcasts orchestration messages once through active-recipient admission", async () => {
|
|
349
|
+
teams.createTeam("team", "session", "lead", "", "provider/model");
|
|
350
|
+
await teams.addMember("team", {
|
|
351
|
+
agentId: "reader@team",
|
|
352
|
+
name: "reader",
|
|
353
|
+
agentType: "teammate",
|
|
354
|
+
role: "read",
|
|
355
|
+
joinedAt: Date.now(),
|
|
356
|
+
tmuxPaneId: "",
|
|
357
|
+
cwd: root,
|
|
358
|
+
subscriptions: [],
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const first = await sendMessageOnce({ teamName: "team", fromName: "team-lead", toName: "reader", text: "one", summary: "one", operationId: "op-1" });
|
|
362
|
+
const second = await sendMessageOnce({ teamName: "team", fromName: "team-lead", toName: "reader", text: "two", summary: "two", operationId: "op-1" });
|
|
363
|
+
|
|
364
|
+
expect(first.delivered).toBe(true);
|
|
365
|
+
expect(second.delivered).toBe(false);
|
|
366
|
+
expect((await messaging.peekInbox("team", "reader", false)).map(message => message.text)).toEqual(["one"]);
|
|
367
|
+
|
|
368
|
+
await teams.addMember("team", {
|
|
369
|
+
agentId: "writer@team",
|
|
370
|
+
name: "writer",
|
|
371
|
+
agentType: "teammate",
|
|
372
|
+
role: "write",
|
|
373
|
+
joinedAt: Date.now(),
|
|
374
|
+
tmuxPaneId: "",
|
|
375
|
+
cwd: root,
|
|
376
|
+
subscriptions: [],
|
|
377
|
+
});
|
|
378
|
+
const firstBroadcast = await broadcastMessageOnce({
|
|
379
|
+
teamName: "team",
|
|
380
|
+
fromName: "team-lead",
|
|
381
|
+
text: "broadcast one",
|
|
382
|
+
summary: "broadcast",
|
|
383
|
+
operationId: "broadcast-op",
|
|
384
|
+
});
|
|
385
|
+
const secondBroadcast = await broadcastMessageOnce({
|
|
386
|
+
teamName: "team",
|
|
387
|
+
fromName: "team-lead",
|
|
388
|
+
text: "broadcast two",
|
|
389
|
+
summary: "broadcast",
|
|
390
|
+
operationId: "broadcast-op",
|
|
391
|
+
});
|
|
392
|
+
expect(firstBroadcast.every(result => result.delivered)).toBe(true);
|
|
393
|
+
expect(secondBroadcast.every(result => !result.delivered)).toBe(true);
|
|
394
|
+
expect((await messaging.peekInbox("team", "writer", false)).map(message => message.text)).toEqual(["broadcast one"]);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { AgentRuntimeStatus } from "../utils/runtime";
|
|
2
|
+
import type { QueuedWriteSpawn } from "../utils/write-queue";
|
|
3
|
+
import type { FileClaim } from "../utils/claims";
|
|
4
|
+
import type { InboxMessage, Member, TaskFile, TeamConfig, TeamReportEvent, ThinkingLevel } from "../utils/models";
|
|
5
|
+
import type { FavoriteModelSlot } from "../utils/settings";
|
|
6
|
+
|
|
7
|
+
export type { InboxMessage, Member, TaskFile, TeamConfig, TeamReportEvent, ThinkingLevel, FavoriteModelSlot };
|
|
8
|
+
|
|
9
|
+
export interface OrchestrationOperationMetadata {
|
|
10
|
+
operationId?: string;
|
|
11
|
+
workflowRunId?: string;
|
|
12
|
+
metadata?: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TeammateHealth = "lead" | "healthy" | "idle" | "starting" | "stalled" | "dead" | "unknown";
|
|
16
|
+
|
|
17
|
+
export interface TeammateObservation {
|
|
18
|
+
teamName: string;
|
|
19
|
+
agentName: string;
|
|
20
|
+
member: Member;
|
|
21
|
+
role: string;
|
|
22
|
+
alive: boolean | null;
|
|
23
|
+
health: TeammateHealth;
|
|
24
|
+
unreadCount: number;
|
|
25
|
+
agentLoopReady: boolean;
|
|
26
|
+
hasRecentHeartbeat: boolean;
|
|
27
|
+
startupStalled: boolean;
|
|
28
|
+
runtime: AgentRuntimeStatus | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TeamObservation {
|
|
32
|
+
teamName: string;
|
|
33
|
+
config: TeamConfig;
|
|
34
|
+
members: TeammateObservation[];
|
|
35
|
+
tasks: TaskFile[];
|
|
36
|
+
claims: FileClaim[];
|
|
37
|
+
writeQueue: QueuedWriteSpawn[];
|
|
38
|
+
reports: TeamReportEvent[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ObserveRuntimeOptions {
|
|
42
|
+
terminal?: { isAlive?(terminalId: string): boolean } | null;
|
|
43
|
+
runningReadAgents?: Map<string, unknown>;
|
|
44
|
+
readAgentKey?: (teamName: string, agentName: string) => string;
|
|
45
|
+
now?: number;
|
|
46
|
+
reportLimit?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EnsureTeamRequest extends OrchestrationOperationMetadata {
|
|
50
|
+
teamName: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
/** Intent tier used for the team's internal default model. Defaults to read-review. */
|
|
53
|
+
defaultModelSlot?: FavoriteModelSlot;
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
leadAgentId?: string;
|
|
56
|
+
separateWindows?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EnsureTeamResponse {
|
|
60
|
+
config: TeamConfig;
|
|
61
|
+
created: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SpawnTeammateOnceRequest extends OrchestrationOperationMetadata {
|
|
65
|
+
teamName: string;
|
|
66
|
+
name: string;
|
|
67
|
+
prompt: string;
|
|
68
|
+
cwd: string;
|
|
69
|
+
/** Required intent tier; selects read/write behavior, configured model, and thinking. */
|
|
70
|
+
modelSlot: FavoriteModelSlot;
|
|
71
|
+
planModeRequired?: boolean;
|
|
72
|
+
color?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type SpawnTeammateOnceStatus = "existing" | "queued" | "started" | "not_started";
|
|
76
|
+
export type TeammateModelSource = "favorite-slot" | "existing" | "queued";
|
|
77
|
+
|
|
78
|
+
export interface TeammateResolutionDetails {
|
|
79
|
+
requestedRole?: "read" | "write";
|
|
80
|
+
role?: "read" | "write" | string;
|
|
81
|
+
resolvedRole?: "read" | "write" | string;
|
|
82
|
+
requestedModelSlot?: FavoriteModelSlot | string | null;
|
|
83
|
+
modelSlot?: FavoriteModelSlot | string | null;
|
|
84
|
+
model?: string | null;
|
|
85
|
+
thinking?: ThinkingLevel | string | null;
|
|
86
|
+
modelSource?: TeammateModelSource | string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SpawnTeammateOnceStartResult {
|
|
90
|
+
member?: Member;
|
|
91
|
+
queued?: QueuedWriteSpawn;
|
|
92
|
+
details?: TeammateResolutionDetails & Record<string, any>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SpawnTeammateOnceOptions {
|
|
96
|
+
start?: (request: SpawnTeammateOnceRequest) => Promise<SpawnTeammateOnceStartResult>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SpawnTeammateOnceResponse {
|
|
100
|
+
status: SpawnTeammateOnceStatus;
|
|
101
|
+
member?: Member;
|
|
102
|
+
queued?: QueuedWriteSpawn;
|
|
103
|
+
details?: Record<string, any>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type SpawnTeammatesOnceResponse = SpawnTeammateOnceResponse[];
|
|
107
|
+
|
|
108
|
+
export interface SendMessageOnceRequest extends OrchestrationOperationMetadata {
|
|
109
|
+
teamName: string;
|
|
110
|
+
fromName: string;
|
|
111
|
+
toName: string;
|
|
112
|
+
text: string;
|
|
113
|
+
summary: string;
|
|
114
|
+
color?: string;
|
|
115
|
+
operationId: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface BroadcastMessageOnceRequest extends OrchestrationOperationMetadata {
|
|
119
|
+
teamName: string;
|
|
120
|
+
fromName: string;
|
|
121
|
+
text: string;
|
|
122
|
+
summary: string;
|
|
123
|
+
color?: string;
|
|
124
|
+
operationId: string;
|
|
125
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Stage JSON beside its destination, then atomically replace the live file. */
|
|
6
|
+
export function writeJsonAtomic(filePath: string, value: unknown): void {
|
|
7
|
+
const dir = path.dirname(filePath);
|
|
8
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
10
|
+
try {
|
|
11
|
+
fs.writeFileSync(tmpPath, JSON.stringify(value, null, 2));
|
|
12
|
+
fs.renameSync(tmpPath, filePath);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
16
|
+
} catch {
|
|
17
|
+
// Ignore cleanup errors; preserve the original write/rename failure.
|
|
18
|
+
}
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import * as paths from "./paths";
|
|
6
|
+
import {
|
|
7
|
+
claimFiles,
|
|
8
|
+
releaseFiles,
|
|
9
|
+
releaseAllForAgent,
|
|
10
|
+
listClaims,
|
|
11
|
+
normalizeClaimPath,
|
|
12
|
+
} from "./claims";
|
|
13
|
+
|
|
14
|
+
const testDir = path.join(os.tmpdir(), "pi-claims-test-" + Date.now());
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
18
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
19
|
+
vi.spyOn(paths, "claimsPath").mockReturnValue(path.join(testDir, "claims.json"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("normalizeClaimPath", () => {
|
|
28
|
+
it("collapses equivalent repository-relative paths", () => {
|
|
29
|
+
expect(normalizeClaimPath("./src/a.ts")).toBe("src/a.ts");
|
|
30
|
+
expect(normalizeClaimPath("src/a.ts/")).toBe("src/a.ts");
|
|
31
|
+
expect(normalizeClaimPath(" src/a.ts ")).toBe("src/a.ts");
|
|
32
|
+
expect(normalizeClaimPath("src\\a.ts")).toBe("src/a.ts");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects empty, root, absolute, and parent-traversal paths", () => {
|
|
36
|
+
expect(() => normalizeClaimPath("")).toThrow("must not be empty");
|
|
37
|
+
expect(() => normalizeClaimPath(" ")).toThrow("must not be empty");
|
|
38
|
+
expect(() => normalizeClaimPath(".")).toThrow("must not refer to the repository root");
|
|
39
|
+
expect(() => normalizeClaimPath("/")).toThrow("must be repository-relative");
|
|
40
|
+
expect(() => normalizeClaimPath("/tmp/a.ts")).toThrow("must be repository-relative");
|
|
41
|
+
expect(() => normalizeClaimPath("C:\\tmp\\a.ts")).toThrow("must be repository-relative");
|
|
42
|
+
expect(() => normalizeClaimPath("../a.ts")).toThrow("must not traverse outside");
|
|
43
|
+
expect(() => normalizeClaimPath("src/../../a.ts")).toThrow("must not traverse outside");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("claimFiles", () => {
|
|
48
|
+
it("grants free paths", async () => {
|
|
49
|
+
const r = await claimFiles("t", "alice", ["src/a.ts", "src/b.ts"]);
|
|
50
|
+
expect(r.granted.sort()).toEqual(["src/a.ts", "src/b.ts"]);
|
|
51
|
+
expect(r.conflicts).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("blocks a path already held by another agent (all-or-nothing)", async () => {
|
|
55
|
+
await claimFiles("t", "alice", ["src/a.ts"]);
|
|
56
|
+
const r = await claimFiles("t", "bob", ["src/a.ts", "src/b.ts"]);
|
|
57
|
+
expect(r.granted).toEqual([]);
|
|
58
|
+
expect(r.conflicts).toEqual([{ path: "src/a.ts", heldBy: "alice" }]);
|
|
59
|
+
// b.ts must NOT have been granted to bob since the batch failed
|
|
60
|
+
const claims = await listClaims("t");
|
|
61
|
+
expect(claims.find((c) => c.path === "src/b.ts")).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("treats normalized variants as the same file", async () => {
|
|
65
|
+
await claimFiles("t", "alice", ["src/a.ts"]);
|
|
66
|
+
const r = await claimFiles("t", "bob", ["./src/a.ts"]);
|
|
67
|
+
expect(r.conflicts[0]).toEqual({ path: "src/a.ts", heldBy: "alice" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects an invalid path before writing any claims", async () => {
|
|
71
|
+
await expect(claimFiles("t", "alice", ["src/a.ts", ""])).rejects.toThrow("must not be empty");
|
|
72
|
+
expect(await listClaims("t")).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("re-claiming your own path is idempotent and keeps original since", async () => {
|
|
76
|
+
const first = await claimFiles("t", "alice", ["src/a.ts"], 1000);
|
|
77
|
+
expect(first.granted).toEqual(["src/a.ts"]);
|
|
78
|
+
const again = await claimFiles("t", "alice", ["src/a.ts"], 2000);
|
|
79
|
+
expect(again.granted).toEqual(["src/a.ts"]);
|
|
80
|
+
const claims = await listClaims("t");
|
|
81
|
+
expect(claims[0].since).toBe(1000);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("releaseFiles", () => {
|
|
86
|
+
it("only releases paths held by the agent", async () => {
|
|
87
|
+
await claimFiles("t", "alice", ["src/a.ts"]);
|
|
88
|
+
await claimFiles("t", "bob", ["src/b.ts"]);
|
|
89
|
+
const released = await releaseFiles("t", "bob", ["src/a.ts", "src/b.ts"]);
|
|
90
|
+
expect(released).toEqual(["src/b.ts"]); // bob cannot release alice's claim
|
|
91
|
+
const remaining = await listClaims("t");
|
|
92
|
+
expect(remaining.map((c) => c.path).sort()).toEqual(["src/a.ts"]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("releaseAllForAgent", () => {
|
|
97
|
+
it("drops every claim for an agent", async () => {
|
|
98
|
+
await claimFiles("t", "alice", ["src/a.ts", "src/b.ts"]);
|
|
99
|
+
await claimFiles("t", "bob", ["src/c.ts"]);
|
|
100
|
+
const released = await releaseAllForAgent("t", "alice");
|
|
101
|
+
expect(released.sort()).toEqual(["src/a.ts", "src/b.ts"]);
|
|
102
|
+
const remaining = await listClaims("t");
|
|
103
|
+
expect(remaining.map((c) => c.path)).toEqual(["src/c.ts"]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("crash safety", () => {
|
|
108
|
+
it("never truncates the live registry in place", async () => {
|
|
109
|
+
await claimFiles("t", "alice", ["src/a.ts"]);
|
|
110
|
+
const registry = paths.claimsPath("t");
|
|
111
|
+
const writes: string[] = [];
|
|
112
|
+
const realWriteFileSync = fs.writeFileSync;
|
|
113
|
+
vi.spyOn(fs, "writeFileSync").mockImplementation(((target: any, data: any, options: any) => {
|
|
114
|
+
writes.push(String(target));
|
|
115
|
+
return realWriteFileSync(target, data, options);
|
|
116
|
+
}) as typeof fs.writeFileSync);
|
|
117
|
+
|
|
118
|
+
await claimFiles("t", "bob", ["src/b.ts"]);
|
|
119
|
+
|
|
120
|
+
expect(writes).not.toContain(registry);
|
|
121
|
+
expect((await listClaims("t")).map((claim) => claim.path).sort()).toEqual(["src/a.ts", "src/b.ts"]);
|
|
122
|
+
expect(fs.readdirSync(path.dirname(registry)).filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("concurrent contention", () => {
|
|
127
|
+
it("never grants the same path to two agents", async () => {
|
|
128
|
+
const results = await Promise.all([
|
|
129
|
+
claimFiles("t", "alice", ["shared.ts"]),
|
|
130
|
+
claimFiles("t", "bob", ["shared.ts"]),
|
|
131
|
+
]);
|
|
132
|
+
const granted = results.filter((r) => r.granted.includes("shared.ts"));
|
|
133
|
+
expect(granted.length).toBe(1); // exactly one winner
|
|
134
|
+
const claims = await listClaims("t");
|
|
135
|
+
expect(claims.length).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
});
|