indus-swarms 0.1.0
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/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- package/tsconfig.strict.json +22 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "indus-swarms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code agent teams style workflow for Pi.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Varun Israni",
|
|
7
|
+
"private": false,
|
|
8
|
+
"type": "module",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi-package",
|
|
11
|
+
"indus",
|
|
12
|
+
"agent",
|
|
13
|
+
"teams",
|
|
14
|
+
"indus-swarms"
|
|
15
|
+
],
|
|
16
|
+
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./extensions"
|
|
23
|
+
],
|
|
24
|
+
"skills": [
|
|
25
|
+
"./skills"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"lint": "eslint 'extensions/**/*.ts' 'scripts/**/*.ts' 'scripts/**/*.mts'",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.strict.json",
|
|
31
|
+
"check": "npm run typecheck && npm run lint",
|
|
32
|
+
"smoke-test": "tsx scripts/smoke-test.mts",
|
|
33
|
+
"integration-claim-test": "tsx scripts/integration-claim-test.mts",
|
|
34
|
+
"integration-spawn-overrides-test": "tsx scripts/integration-spawn-overrides-test.mts",
|
|
35
|
+
"integration-hooks-remediation-test": "tsx scripts/integration-hooks-remediation-test.mts",
|
|
36
|
+
"integration-todo-test": "tsx scripts/integration-todo-test.mts"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^9.39.2",
|
|
40
|
+
"indusagi": "^0.12.7",
|
|
41
|
+
"indusagi-coding-agent": "^0.1.23",
|
|
42
|
+
"@sinclair/typebox": "^0.34.48",
|
|
43
|
+
"eslint": "^9.39.2",
|
|
44
|
+
"tsx": "^4.20.5",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"typescript-eslint": "^8.54.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"indusagi": "*",
|
|
50
|
+
"indusagi-coding-agent": "*",
|
|
51
|
+
"@sinclair/typebox": "*"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as readline from "node:readline";
|
|
6
|
+
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function waitFor(fn, { timeoutMs = 120_000, pollMs = 200, label = "condition" } = {}) {
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
// eslint-disable-next-line no-constant-condition
|
|
14
|
+
while (true) {
|
|
15
|
+
if (await fn()) return;
|
|
16
|
+
if (Date.now() - start > timeoutMs) throw new Error(`Timeout waiting for ${label}`);
|
|
17
|
+
await sleep(pollMs);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeJsonParse(line) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(line);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mkTempTeamsRootDir() {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-teams-root-"));
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function run() {
|
|
35
|
+
const repoRoot = process.cwd();
|
|
36
|
+
const extensionPath = path.join(repoRoot, "extensions/teams/index.ts");
|
|
37
|
+
if (!fs.existsSync(extensionPath)) throw new Error(`Extension not found: ${extensionPath}`);
|
|
38
|
+
|
|
39
|
+
const teamsRootDir = mkTempTeamsRootDir();
|
|
40
|
+
console.log("teamsRootDir:", teamsRootDir);
|
|
41
|
+
|
|
42
|
+
const env = {
|
|
43
|
+
...process.env,
|
|
44
|
+
// Keep the real PI agent dir for credentials/settings, but isolate Teams artifacts.
|
|
45
|
+
PI_TEAMS_ROOT_DIR: teamsRootDir,
|
|
46
|
+
// Ensure the leader runs in leader mode even when this script is executed from inside a worker.
|
|
47
|
+
PI_TEAMS_WORKER: "0",
|
|
48
|
+
PI_TEAMS_TEAM_ID: "",
|
|
49
|
+
PI_TEAMS_AGENT_NAME: "",
|
|
50
|
+
PI_TEAMS_TASK_LIST_ID: "",
|
|
51
|
+
PI_TEAMS_LEAD_NAME: "",
|
|
52
|
+
PI_TEAMS_AUTO_CLAIM: "",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const args = [
|
|
56
|
+
"--mode",
|
|
57
|
+
"rpc",
|
|
58
|
+
"--no-session",
|
|
59
|
+
"--no-tools",
|
|
60
|
+
"--provider",
|
|
61
|
+
"openai-codex",
|
|
62
|
+
"--model",
|
|
63
|
+
"gpt-5.1-codex-mini",
|
|
64
|
+
"--thinking",
|
|
65
|
+
"minimal",
|
|
66
|
+
"--no-extensions",
|
|
67
|
+
"-e",
|
|
68
|
+
extensionPath,
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const proc = spawn("indusagi", args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
72
|
+
|
|
73
|
+
let stderr = "";
|
|
74
|
+
proc.stderr.on("data", (d) => {
|
|
75
|
+
stderr += d.toString();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const pending = new Map();
|
|
79
|
+
let nextId = 1;
|
|
80
|
+
|
|
81
|
+
const rl = readline.createInterface({ input: proc.stdout, crlfDelay: Infinity });
|
|
82
|
+
let leaderSessionId = null;
|
|
83
|
+
|
|
84
|
+
rl.on("line", (line) => {
|
|
85
|
+
const obj = safeJsonParse(line);
|
|
86
|
+
if (!obj) return;
|
|
87
|
+
|
|
88
|
+
if (obj.type === "response") {
|
|
89
|
+
if (obj.id && pending.has(obj.id)) {
|
|
90
|
+
pending.get(obj.id).resolve(obj);
|
|
91
|
+
pending.delete(obj.id);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (obj.type === "extension_ui_request") {
|
|
97
|
+
// Useful visibility in CI/logs
|
|
98
|
+
if (obj.method === "notify") {
|
|
99
|
+
console.log(`[notify:${obj.notifyType ?? "info"}] ${obj.message}`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Other agent events can be noisy; keep minimal.
|
|
105
|
+
if (obj.type === "extension_error") {
|
|
106
|
+
console.log(`[extension_error] ${obj.error}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const send = (command) => {
|
|
111
|
+
const id = command.id ?? `req-${nextId++}`;
|
|
112
|
+
const full = { ...command, id };
|
|
113
|
+
proc.stdin.write(JSON.stringify(full) + "\n");
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
pending.set(id, { resolve, reject });
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
if (!pending.has(id)) return;
|
|
118
|
+
pending.delete(id);
|
|
119
|
+
reject(new Error(`Timeout waiting for response: ${id} (${full.type})`));
|
|
120
|
+
}, 30_000);
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// ---------------------------------------------------------------------
|
|
126
|
+
// Get session id (used as teamId/taskListId)
|
|
127
|
+
// ---------------------------------------------------------------------
|
|
128
|
+
const stateResp = await send({ type: "get_state" });
|
|
129
|
+
leaderSessionId = stateResp.data?.sessionId;
|
|
130
|
+
if (!leaderSessionId) throw new Error(`No sessionId in get_state response: ${JSON.stringify(stateResp)}`);
|
|
131
|
+
console.log("leaderSessionId:", leaderSessionId);
|
|
132
|
+
|
|
133
|
+
let teamDir;
|
|
134
|
+
let cfgPath;
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------
|
|
137
|
+
// Spawn worker
|
|
138
|
+
// ---------------------------------------------------------------------
|
|
139
|
+
await send({ type: "prompt", message: "/team spawn alice fresh" });
|
|
140
|
+
|
|
141
|
+
// Discover the teamId directory (leader uses its sessionId as teamId, but RPC get_state may differ).
|
|
142
|
+
await waitFor(
|
|
143
|
+
() => {
|
|
144
|
+
try {
|
|
145
|
+
const dirs = fs
|
|
146
|
+
.readdirSync(teamsRootDir, { withFileTypes: true })
|
|
147
|
+
.filter((e) => e.isDirectory())
|
|
148
|
+
.map((e) => e.name);
|
|
149
|
+
return dirs.some((d) => fs.existsSync(path.join(teamsRootDir, d, "config.json")));
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{ label: "team config created" },
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const teamDirs = fs
|
|
158
|
+
.readdirSync(teamsRootDir, { withFileTypes: true })
|
|
159
|
+
.filter((e) => e.isDirectory())
|
|
160
|
+
.map((e) => e.name);
|
|
161
|
+
|
|
162
|
+
const teamIdDir =
|
|
163
|
+
teamDirs.find((d) => fs.existsSync(path.join(teamsRootDir, d, "config.json"))) ?? teamDirs.sort()[0];
|
|
164
|
+
|
|
165
|
+
teamDir = path.join(teamsRootDir, teamIdDir);
|
|
166
|
+
cfgPath = path.join(teamDir, "config.json");
|
|
167
|
+
|
|
168
|
+
await waitFor(
|
|
169
|
+
async () => {
|
|
170
|
+
try {
|
|
171
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
172
|
+
return Array.isArray(cfg.members) && cfg.members.some((m) => m.name === "alice" && m.status === "online");
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{ label: "team config member alice online" },
|
|
178
|
+
);
|
|
179
|
+
console.log("OK: alice online in config.json");
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------
|
|
182
|
+
// Teammate session naming (leader-driven)
|
|
183
|
+
// ---------------------------------------------------------------------
|
|
184
|
+
await waitFor(
|
|
185
|
+
async () => {
|
|
186
|
+
try {
|
|
187
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
188
|
+
const alice = cfg.members?.find((m) => m.name === "alice");
|
|
189
|
+
return alice?.meta?.sessionName === "pi agent teams - comrade alice";
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
{ label: "alice sessionName recorded in config" },
|
|
195
|
+
);
|
|
196
|
+
console.log("OK: alice sessionName recorded in config");
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------
|
|
199
|
+
// Graceful teammate shutdown (mailbox handshake)
|
|
200
|
+
// ---------------------------------------------------------------------
|
|
201
|
+
await send({ type: "prompt", message: "/team shutdown alice e2e" });
|
|
202
|
+
|
|
203
|
+
await waitFor(
|
|
204
|
+
async () => {
|
|
205
|
+
try {
|
|
206
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
207
|
+
return Array.isArray(cfg.members) && cfg.members.some((m) => m.name === "alice" && m.status === "offline");
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
{ label: "alice offline after /team shutdown alice", timeoutMs: 60_000, pollMs: 500 },
|
|
213
|
+
);
|
|
214
|
+
console.log("OK: alice offline after /team shutdown alice");
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------
|
|
217
|
+
// Shutdown
|
|
218
|
+
// ---------------------------------------------------------------------
|
|
219
|
+
await send({ type: "prompt", message: "/team shutdown" });
|
|
220
|
+
|
|
221
|
+
// RPC mode nuance: prompt is fire-and-forget. Extension commands run async,
|
|
222
|
+
// but rpc-mode only checks the shutdownRequested flag after handling *another*
|
|
223
|
+
// input line. Keep sending cheap commands until the process exits.
|
|
224
|
+
const kickTimer = setInterval(() => {
|
|
225
|
+
try {
|
|
226
|
+
proc.stdin.write(JSON.stringify({ type: "get_state", id: `kick-${Date.now()}` }) + "\n");
|
|
227
|
+
} catch {
|
|
228
|
+
// ignore
|
|
229
|
+
}
|
|
230
|
+
}, 250);
|
|
231
|
+
|
|
232
|
+
await new Promise((resolve, reject) => {
|
|
233
|
+
const timeout = setTimeout(() => {
|
|
234
|
+
reject(new Error(`Timeout waiting for pi to exit. stderr=${stderr}`));
|
|
235
|
+
}, 30_000);
|
|
236
|
+
|
|
237
|
+
proc.on("close", (code) => {
|
|
238
|
+
clearInterval(kickTimer);
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
if (code === 0) resolve();
|
|
241
|
+
else reject(new Error(`pi exited with code ${code}. stderr=${stderr}`));
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Verify alice offline after shutdown
|
|
246
|
+
try {
|
|
247
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
248
|
+
const alice = cfg.members?.find((m) => m.name === "alice");
|
|
249
|
+
if (alice) console.log("alice final status:", alice.status);
|
|
250
|
+
} catch {
|
|
251
|
+
// ignore
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
console.log("OK: e2e rpc test passed");
|
|
255
|
+
} finally {
|
|
256
|
+
try {
|
|
257
|
+
rl.close();
|
|
258
|
+
} catch {
|
|
259
|
+
// ignore
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
proc.kill("SIGTERM");
|
|
263
|
+
} catch {
|
|
264
|
+
// ignore
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
fs.rmSync(teamsRootDir, { recursive: true, force: true });
|
|
268
|
+
} catch {
|
|
269
|
+
// ignore
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
run().catch((e) => {
|
|
275
|
+
console.error(e);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: spawn N real Pi worker processes (PI_TEAMS_WORKER=1) and
|
|
3
|
+
* verify that unassigned tasks are auto-claimed + completed.
|
|
4
|
+
*
|
|
5
|
+
* This is intentionally "dumb": tasks are trivial and should complete quickly.
|
|
6
|
+
* It exists to validate the end-to-end loop (task-store -> claim -> agent_end -> completeTask).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx tsx scripts/integration-claim-test.mts
|
|
10
|
+
* npx tsx scripts/integration-claim-test.mts --agents 2 --tasks 3 --timeoutSec 90
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import type { ChildProcess } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
import { ensureTeamConfig } from "../extensions/teams/team-config.js";
|
|
20
|
+
import { getTeamDir } from "../extensions/teams/paths.js";
|
|
21
|
+
import { createTask, listTasks, type TeamTask } from "../extensions/teams/task-store.js";
|
|
22
|
+
|
|
23
|
+
import { sleep, spawnTeamsWorkerRpc, terminateAll } from "./lib/pi-workers.js";
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv: readonly string[]): { agents: number; tasks: number; timeoutSec: number } {
|
|
26
|
+
let agents = 2;
|
|
27
|
+
let tasks = 3;
|
|
28
|
+
let timeoutSec = 60;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === "--agents") {
|
|
33
|
+
const v = argv[i + 1];
|
|
34
|
+
if (v) agents = Number.parseInt(v, 10);
|
|
35
|
+
i += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (a === "--tasks") {
|
|
39
|
+
const v = argv[i + 1];
|
|
40
|
+
if (v) tasks = Number.parseInt(v, 10);
|
|
41
|
+
i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (a === "--timeoutSec") {
|
|
45
|
+
const v = argv[i + 1];
|
|
46
|
+
if (v) timeoutSec = Number.parseInt(v, 10);
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!Number.isFinite(agents) || agents < 1) agents = 2;
|
|
53
|
+
if (!Number.isFinite(tasks) || tasks < 1) tasks = 3;
|
|
54
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec < 10) timeoutSec = 60;
|
|
55
|
+
|
|
56
|
+
return { agents, tasks, timeoutSec };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function allCompleted(ts: TeamTask[]): boolean {
|
|
60
|
+
return ts.length > 0 && ts.every((t) => t.status === "completed");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { agents, tasks, timeoutSec } = parseArgs(process.argv.slice(2));
|
|
64
|
+
|
|
65
|
+
if (agents < 2 || tasks < 3) {
|
|
66
|
+
console.error("This test expects at least --agents 2 and --tasks 3.");
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const teamId = randomUUID();
|
|
71
|
+
const teamDir = getTeamDir(teamId);
|
|
72
|
+
const sessionsDir = path.join(os.homedir(), ".pi", "agent", "teams", teamId, "sessions");
|
|
73
|
+
const logsDir = path.join(os.homedir(), ".pi", "agent", "teams", teamId, "logs");
|
|
74
|
+
|
|
75
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
76
|
+
const repoRoot = path.resolve(scriptDir, "..");
|
|
77
|
+
const entryPath = path.join(repoRoot, "extensions", "teams", "index.ts");
|
|
78
|
+
|
|
79
|
+
const systemAppend = [
|
|
80
|
+
"You are a teammate in an automated integration test.",
|
|
81
|
+
"Keep replies extremely short.",
|
|
82
|
+
"If you are assigned or auto-claim a task that says 'Reply with: okX', respond with exactly 'okX' and nothing else.",
|
|
83
|
+
].join(" ");
|
|
84
|
+
|
|
85
|
+
console.log(`TeamId: ${teamId}`);
|
|
86
|
+
console.log(`TeamDir: ${teamDir}`);
|
|
87
|
+
console.log(`Spawning ${agents} worker(s), creating ${tasks} task(s)`);
|
|
88
|
+
|
|
89
|
+
await ensureTeamConfig(teamDir, { teamId, taskListId: teamId, leadName: "team-lead", style: "normal" });
|
|
90
|
+
|
|
91
|
+
// Create unowned tasks so at least one must be auto-claimed.
|
|
92
|
+
for (let i = 1; i <= tasks; i += 1) {
|
|
93
|
+
await createTask(teamDir, teamId, {
|
|
94
|
+
subject: `T${i}: reply ok${i}`,
|
|
95
|
+
description: `Reply with: ok${i}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const children: ChildProcess[] = [];
|
|
100
|
+
let cleaningUp = false;
|
|
101
|
+
const cleanup = async (): Promise<void> => {
|
|
102
|
+
if (cleaningUp) return;
|
|
103
|
+
cleaningUp = true;
|
|
104
|
+
await terminateAll(children);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
process.on("SIGINT", () => {
|
|
108
|
+
void cleanup().finally(() => process.exit(130));
|
|
109
|
+
});
|
|
110
|
+
process.on("SIGTERM", () => {
|
|
111
|
+
void cleanup().finally(() => process.exit(143));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
for (let i = 1; i <= agents; i += 1) {
|
|
116
|
+
children.push(
|
|
117
|
+
spawnTeamsWorkerRpc({
|
|
118
|
+
cwd: repoRoot,
|
|
119
|
+
entryPath,
|
|
120
|
+
sessionsDir,
|
|
121
|
+
teamId,
|
|
122
|
+
taskListId: teamId,
|
|
123
|
+
agentName: `agent${i}`,
|
|
124
|
+
leadName: "team-lead",
|
|
125
|
+
style: "normal",
|
|
126
|
+
autoClaim: true,
|
|
127
|
+
planRequired: false,
|
|
128
|
+
systemAppend,
|
|
129
|
+
logDir: logsDir,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
const ts = await listTasks(teamDir, teamId);
|
|
137
|
+
const done = ts.filter((t) => t.status === "completed").length;
|
|
138
|
+
const inProgress = ts.filter((t) => t.status === "in_progress").length;
|
|
139
|
+
const pending = ts.filter((t) => t.status === "pending").length;
|
|
140
|
+
console.log(`tasks: completed=${done} in_progress=${inProgress} pending=${pending}`);
|
|
141
|
+
if (allCompleted(ts)) {
|
|
142
|
+
console.log("PASS: all tasks completed");
|
|
143
|
+
const owners = ts.map((t) => `${t.id}:${t.owner ?? "-"}`);
|
|
144
|
+
console.log(`owners: ${owners.join(" ")}`);
|
|
145
|
+
process.exitCode = 0;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
await sleep(1000);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (process.exitCode !== 0) {
|
|
152
|
+
console.error(`FAIL: timeout after ${timeoutSec}s (inspect logs under ${logsDir})`);
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
await cleanup();
|
|
157
|
+
}
|