@tmustier/pi-agent-teams 0.2.0 → 0.3.1
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 +15 -3
- package/docs/claude-parity.md +7 -5
- package/docs/smoke-test-plan.md +20 -4
- package/eslint.config.js +74 -0
- package/extensions/teams/activity-tracker.ts +2 -2
- package/extensions/teams/leader-lifecycle-commands.ts +151 -14
- package/extensions/teams/leader-messaging-commands.ts +0 -1
- package/extensions/teams/leader-plan-commands.ts +1 -1
- package/extensions/teams/leader-spawn-command.ts +3 -19
- package/extensions/teams/leader-task-commands.ts +11 -7
- package/extensions/teams/leader-team-command.ts +329 -0
- package/extensions/teams/leader-teams-tool.ts +5 -16
- package/extensions/teams/leader.ts +34 -310
- package/extensions/teams/protocol.ts +20 -0
- package/extensions/teams/spawn-types.ts +21 -0
- package/extensions/teams/task-store.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +26 -2
- package/extensions/teams/teams-panel.ts +41 -95
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +15 -68
- package/extensions/teams/worker.ts +1 -1
- package/package.json +9 -4
- package/scripts/integration-claim-test.mts +16 -86
- package/scripts/integration-todo-test.mts +14 -65
- package/scripts/lib/pi-workers.ts +105 -0
- package/skills/agent-teams/SKILL.md +8 -4
- package/.github/workflows/ci.yml +0 -32
- package/scripts/smoke-test.mjs +0 -199
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import {
|
|
20
|
+
import { spawnSync, type ChildProcess } from "node:child_process";
|
|
21
21
|
import { randomUUID } from "node:crypto";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
|
|
@@ -31,9 +31,7 @@ import {
|
|
|
31
31
|
type TeamTask,
|
|
32
32
|
} from "../extensions/teams/task-store.js";
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
36
|
-
}
|
|
34
|
+
import { sleep, spawnTeamsWorkerRpc, terminateAll } from "./lib/pi-workers.js";
|
|
37
35
|
|
|
38
36
|
function parseArgs(argv: readonly string[]): { timeoutSec: number; pollMs: number } {
|
|
39
37
|
let timeoutSec = 15 * 60;
|
|
@@ -60,30 +58,6 @@ function parseArgs(argv: readonly string[]): { timeoutSec: number; pollMs: numbe
|
|
|
60
58
|
return { timeoutSec, pollMs };
|
|
61
59
|
}
|
|
62
60
|
|
|
63
|
-
async function terminateAll(children: ChildProcess[]): Promise<void> {
|
|
64
|
-
for (const c of children) {
|
|
65
|
-
try {
|
|
66
|
-
c.kill("SIGTERM");
|
|
67
|
-
} catch {
|
|
68
|
-
// ignore
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const deadline = Date.now() + 10_000;
|
|
73
|
-
for (const c of children) {
|
|
74
|
-
while (c.exitCode === null && Date.now() < deadline) {
|
|
75
|
-
await sleep(100);
|
|
76
|
-
}
|
|
77
|
-
if (c.exitCode === null) {
|
|
78
|
-
try {
|
|
79
|
-
c.kill("SIGKILL");
|
|
80
|
-
} catch {
|
|
81
|
-
// ignore
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
61
|
function spawnWorker(opts: {
|
|
88
62
|
cwd: string;
|
|
89
63
|
repoRoot: string;
|
|
@@ -93,17 +67,7 @@ function spawnWorker(opts: {
|
|
|
93
67
|
agentName: string;
|
|
94
68
|
logDir: string;
|
|
95
69
|
}): ChildProcess {
|
|
96
|
-
const { cwd,
|
|
97
|
-
|
|
98
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
99
|
-
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
100
|
-
|
|
101
|
-
const sessionFile = path.join(sessionsDir, `${agentName}.jsonl`);
|
|
102
|
-
fs.closeSync(fs.openSync(sessionFile, "a"));
|
|
103
|
-
|
|
104
|
-
const logPath = path.join(logDir, `${agentName}.log`);
|
|
105
|
-
const out = fs.openSync(logPath, "a");
|
|
106
|
-
const err = fs.openSync(logPath, "a");
|
|
70
|
+
const { cwd, entryPath, sessionsDir, teamId, agentName, logDir } = opts;
|
|
107
71
|
|
|
108
72
|
const systemAppend = [
|
|
109
73
|
"You are a teammate in an automated integration test.",
|
|
@@ -112,34 +76,19 @@ function spawnWorker(opts: {
|
|
|
112
76
|
"Always end with: ACCEPTED: <one-line acceptance confirmation>.",
|
|
113
77
|
].join(" ");
|
|
114
78
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"rpc",
|
|
118
|
-
"--session",
|
|
119
|
-
sessionFile,
|
|
120
|
-
"--session-dir",
|
|
121
|
-
sessionsDir,
|
|
122
|
-
"--no-extensions",
|
|
123
|
-
"-e",
|
|
79
|
+
return spawnTeamsWorkerRpc({
|
|
80
|
+
cwd,
|
|
124
81
|
entryPath,
|
|
125
|
-
|
|
82
|
+
sessionsDir,
|
|
83
|
+
teamId,
|
|
84
|
+
taskListId: teamId,
|
|
85
|
+
agentName,
|
|
86
|
+
leadName: "team-lead",
|
|
87
|
+
style: "normal",
|
|
88
|
+
autoClaim: true,
|
|
89
|
+
planRequired: false,
|
|
126
90
|
systemAppend,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return spawn("pi", args, {
|
|
130
|
-
cwd,
|
|
131
|
-
env: {
|
|
132
|
-
...process.env,
|
|
133
|
-
PI_TEAMS_WORKER: "1",
|
|
134
|
-
PI_TEAMS_TEAM_ID: teamId,
|
|
135
|
-
PI_TEAMS_TASK_LIST_ID: teamId,
|
|
136
|
-
PI_TEAMS_AGENT_NAME: agentName,
|
|
137
|
-
PI_TEAMS_LEAD_NAME: "team-lead",
|
|
138
|
-
PI_TEAMS_STYLE: "normal",
|
|
139
|
-
PI_TEAMS_AUTO_CLAIM: "1",
|
|
140
|
-
PI_TEAMS_PLAN_REQUIRED: "0",
|
|
141
|
-
},
|
|
142
|
-
stdio: ["ignore", out, err],
|
|
91
|
+
logDir,
|
|
143
92
|
});
|
|
144
93
|
}
|
|
145
94
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export function sleep(ms: number): Promise<void> {
|
|
6
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function terminateAll(children: readonly ChildProcess[]): Promise<void> {
|
|
10
|
+
for (const c of children) {
|
|
11
|
+
try {
|
|
12
|
+
c.kill("SIGTERM");
|
|
13
|
+
} catch {
|
|
14
|
+
// ignore
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Give them a moment to flush + exit.
|
|
19
|
+
const deadline = Date.now() + 10_000;
|
|
20
|
+
for (const c of children) {
|
|
21
|
+
while (c.exitCode === null && Date.now() < deadline) {
|
|
22
|
+
await sleep(100);
|
|
23
|
+
}
|
|
24
|
+
if (c.exitCode === null) {
|
|
25
|
+
try {
|
|
26
|
+
c.kill("SIGKILL");
|
|
27
|
+
} catch {
|
|
28
|
+
// ignore
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function spawnTeamsWorkerRpc(opts: {
|
|
35
|
+
cwd: string;
|
|
36
|
+
entryPath: string;
|
|
37
|
+
sessionsDir: string;
|
|
38
|
+
teamId: string;
|
|
39
|
+
taskListId: string;
|
|
40
|
+
agentName: string;
|
|
41
|
+
leadName: string;
|
|
42
|
+
style: "normal" | "soviet";
|
|
43
|
+
autoClaim: boolean;
|
|
44
|
+
planRequired: boolean;
|
|
45
|
+
systemAppend: string;
|
|
46
|
+
logDir: string;
|
|
47
|
+
extraEnv?: Record<string, string>;
|
|
48
|
+
}): ChildProcess {
|
|
49
|
+
const {
|
|
50
|
+
cwd,
|
|
51
|
+
entryPath,
|
|
52
|
+
sessionsDir,
|
|
53
|
+
teamId,
|
|
54
|
+
taskListId,
|
|
55
|
+
agentName,
|
|
56
|
+
leadName,
|
|
57
|
+
style,
|
|
58
|
+
autoClaim,
|
|
59
|
+
planRequired,
|
|
60
|
+
systemAppend,
|
|
61
|
+
logDir,
|
|
62
|
+
extraEnv,
|
|
63
|
+
} = opts;
|
|
64
|
+
|
|
65
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
66
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
67
|
+
|
|
68
|
+
const sessionFile = path.join(sessionsDir, `${agentName}.jsonl`);
|
|
69
|
+
fs.closeSync(fs.openSync(sessionFile, "a"));
|
|
70
|
+
|
|
71
|
+
const logPath = path.join(logDir, `${agentName}.log`);
|
|
72
|
+
const out = fs.openSync(logPath, "a");
|
|
73
|
+
const err = fs.openSync(logPath, "a");
|
|
74
|
+
|
|
75
|
+
const args = [
|
|
76
|
+
"--mode",
|
|
77
|
+
"rpc",
|
|
78
|
+
"--session",
|
|
79
|
+
sessionFile,
|
|
80
|
+
"--session-dir",
|
|
81
|
+
sessionsDir,
|
|
82
|
+
"--no-extensions",
|
|
83
|
+
"-e",
|
|
84
|
+
entryPath,
|
|
85
|
+
"--append-system-prompt",
|
|
86
|
+
systemAppend,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
return spawn("pi", args, {
|
|
90
|
+
cwd,
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
PI_TEAMS_WORKER: "1",
|
|
94
|
+
PI_TEAMS_TEAM_ID: teamId,
|
|
95
|
+
PI_TEAMS_TASK_LIST_ID: taskListId,
|
|
96
|
+
PI_TEAMS_AGENT_NAME: agentName,
|
|
97
|
+
PI_TEAMS_LEAD_NAME: leadName,
|
|
98
|
+
PI_TEAMS_STYLE: style,
|
|
99
|
+
PI_TEAMS_AUTO_CLAIM: autoClaim ? "1" : "0",
|
|
100
|
+
PI_TEAMS_PLAN_REQUIRED: planRequired ? "1" : "0",
|
|
101
|
+
...(extraEnv ?? {}),
|
|
102
|
+
},
|
|
103
|
+
stdio: ["ignore", out, err],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -95,12 +95,14 @@ Spawning with `plan` restricts the teammate to read-only tools. After producing
|
|
|
95
95
|
```
|
|
96
96
|
/team panel # interactive overlay with teammate details
|
|
97
97
|
/team list # show teammates and their state
|
|
98
|
+
/team shutdown # stop all teammates (RPC + best-effort manual) (leader session remains active)
|
|
98
99
|
/team shutdown <name> # graceful shutdown (teammate can reject if busy)
|
|
99
|
-
/team
|
|
100
|
+
/team prune [--all] # hide stale manual teammates (mark offline in config)
|
|
101
|
+
/team kill <name> # force-terminate one RPC teammate
|
|
100
102
|
/team cleanup [--force] # delete team directory after all teammates stopped
|
|
101
103
|
```
|
|
102
104
|
|
|
103
|
-
Teammates reject shutdown requests when they have an active task. Use `/team kill
|
|
105
|
+
Teammates reject shutdown requests when they have an active task. Use `/team kill <name>` to force.
|
|
104
106
|
|
|
105
107
|
## Other commands
|
|
106
108
|
|
|
@@ -111,13 +113,15 @@ Teammates reject shutdown requests when they have an active task. Use `/team kil
|
|
|
111
113
|
|
|
112
114
|
## Shared task list across sessions
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
`PI_TEAMS_TASK_LIST_ID` is primarily **worker-side** (use it when you start a teammate manually).
|
|
117
|
+
|
|
118
|
+
The leader switches task lists via:
|
|
115
119
|
|
|
116
120
|
```
|
|
117
121
|
/team task use my-persistent-list
|
|
118
122
|
```
|
|
119
123
|
|
|
120
|
-
Teammates spawned after the switch inherit the new task list ID.
|
|
124
|
+
The chosen task list ID is persisted in `config.json`. Teammates spawned after the switch inherit the new task list ID; existing teammates need a restart to pick up changes.
|
|
121
125
|
|
|
122
126
|
## Message protocol
|
|
123
127
|
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
typecheck-and-smoke:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
timeout-minutes: 15
|
|
12
|
-
|
|
13
|
-
steps:
|
|
14
|
-
- name: Checkout
|
|
15
|
-
uses: actions/checkout@v4
|
|
16
|
-
|
|
17
|
-
- name: Setup Node
|
|
18
|
-
uses: actions/setup-node@v4
|
|
19
|
-
with:
|
|
20
|
-
node-version: "20.x"
|
|
21
|
-
|
|
22
|
-
- name: Install dependencies
|
|
23
|
-
run: npm install
|
|
24
|
-
|
|
25
|
-
- name: Typecheck (strict)
|
|
26
|
-
run: npm run typecheck
|
|
27
|
-
|
|
28
|
-
- name: Smoke test
|
|
29
|
-
run: npm run smoke-test
|
|
30
|
-
|
|
31
|
-
- name: Package (dry run)
|
|
32
|
-
run: npm pack --dry-run
|
package/scripts/smoke-test.mjs
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import * as assert from "node:assert/strict";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as os from "node:os";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
|
-
|
|
7
|
-
const require = createRequire(import.meta.url);
|
|
8
|
-
const { createJiti } = require("/opt/homebrew/lib/node_modules/@mariozechner/pi-coding-agent/node_modules/@mariozechner/jiti");
|
|
9
|
-
|
|
10
|
-
const jiti = createJiti(import.meta.url, {
|
|
11
|
-
interopDefault: true,
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const root = process.cwd();
|
|
15
|
-
const teamConfig = jiti(path.join(root, "extensions/teams/team-config.ts"));
|
|
16
|
-
const mailbox = jiti(path.join(root, "extensions/teams/mailbox.ts"));
|
|
17
|
-
const taskStore = jiti(path.join(root, "extensions/teams/task-store.ts"));
|
|
18
|
-
const cleanup = jiti(path.join(root, "extensions/teams/cleanup.ts"));
|
|
19
|
-
|
|
20
|
-
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-teams-smoke-"));
|
|
21
|
-
const teamId = "TEAM123";
|
|
22
|
-
const taskListId = teamId;
|
|
23
|
-
const teamDir = path.join(tmpRoot, "teams", teamId);
|
|
24
|
-
|
|
25
|
-
console.log("tmpRoot:", tmpRoot);
|
|
26
|
-
|
|
27
|
-
// -----------------------------------------------------------------------------
|
|
28
|
-
// Team config
|
|
29
|
-
// -----------------------------------------------------------------------------
|
|
30
|
-
{
|
|
31
|
-
const cfg = await teamConfig.ensureTeamConfig(teamDir, { teamId, taskListId, leadName: "team-lead", style: "normal" });
|
|
32
|
-
assert.equal(cfg.teamId, teamId);
|
|
33
|
-
assert.equal(cfg.taskListId, taskListId);
|
|
34
|
-
assert.equal(cfg.leadName, "team-lead");
|
|
35
|
-
assert.ok(cfg.members.find((m) => m.name === "team-lead" && m.role === "lead"));
|
|
36
|
-
|
|
37
|
-
await teamConfig.upsertMember(teamDir, { name: "alice", role: "worker", status: "online" });
|
|
38
|
-
const cfg2 = await teamConfig.loadTeamConfig(teamDir);
|
|
39
|
-
assert.ok(cfg2);
|
|
40
|
-
assert.ok(cfg2.members.find((m) => m.name === "alice" && m.status === "online"));
|
|
41
|
-
|
|
42
|
-
await teamConfig.setMemberStatus(teamDir, "alice", "offline", { meta: { reason: "test" } });
|
|
43
|
-
const cfg3 = await teamConfig.loadTeamConfig(teamDir);
|
|
44
|
-
assert.ok(cfg3);
|
|
45
|
-
assert.ok(cfg3.members.find((m) => m.name === "alice" && m.status === "offline"));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// -----------------------------------------------------------------------------
|
|
49
|
-
// Mailbox
|
|
50
|
-
// -----------------------------------------------------------------------------
|
|
51
|
-
{
|
|
52
|
-
await mailbox.writeToMailbox(teamDir, "team", "team-lead", {
|
|
53
|
-
from: "alice",
|
|
54
|
-
text: JSON.stringify({ type: "idle_notification", from: "alice", timestamp: new Date().toISOString() }),
|
|
55
|
-
timestamp: new Date().toISOString(),
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const msgs = await mailbox.popUnreadMessages(teamDir, "team", "team-lead");
|
|
59
|
-
assert.equal(msgs.length, 1);
|
|
60
|
-
assert.equal(msgs[0].from, "alice");
|
|
61
|
-
|
|
62
|
-
const msgs2 = await mailbox.popUnreadMessages(teamDir, "team", "team-lead");
|
|
63
|
-
assert.equal(msgs2.length, 0);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// -----------------------------------------------------------------------------
|
|
67
|
-
// Task store
|
|
68
|
-
// -----------------------------------------------------------------------------
|
|
69
|
-
{
|
|
70
|
-
const t1 = await taskStore.createTask(teamDir, taskListId, { subject: "Smoke task 1", description: "Do thing" });
|
|
71
|
-
assert.equal(t1.id, "1");
|
|
72
|
-
|
|
73
|
-
const t2 = await taskStore.createTask(teamDir, taskListId, { subject: "Smoke task 2", description: "Do other" });
|
|
74
|
-
assert.equal(t2.id, "2");
|
|
75
|
-
|
|
76
|
-
const t3 = await taskStore.createTask(teamDir, taskListId, { subject: "Smoke task 3", description: "Do third" });
|
|
77
|
-
assert.equal(t3.id, "3");
|
|
78
|
-
|
|
79
|
-
let tasks = await taskStore.listTasks(teamDir, taskListId);
|
|
80
|
-
assert.equal(tasks.length, 3);
|
|
81
|
-
|
|
82
|
-
// Dependencies: #2 depends on #1 (so #2 is blocked until #1 is completed)
|
|
83
|
-
const depRes = await taskStore.addTaskDependency(teamDir, taskListId, "2", "1");
|
|
84
|
-
assert.ok(depRes.ok);
|
|
85
|
-
|
|
86
|
-
const t1AfterDep = await taskStore.getTask(teamDir, taskListId, "1");
|
|
87
|
-
const t2AfterDep = await taskStore.getTask(teamDir, taskListId, "2");
|
|
88
|
-
assert.ok(t1AfterDep);
|
|
89
|
-
assert.ok(t2AfterDep);
|
|
90
|
-
assert.ok(t1AfterDep.blocks.includes("2"));
|
|
91
|
-
assert.ok(t2AfterDep.blockedBy.includes("1"));
|
|
92
|
-
assert.equal(await taskStore.isTaskBlocked(teamDir, taskListId, t2AfterDep), true);
|
|
93
|
-
|
|
94
|
-
const claimed = await taskStore.claimNextAvailableTask(teamDir, taskListId, "alice", { checkAgentBusy: true });
|
|
95
|
-
assert.ok(claimed);
|
|
96
|
-
assert.equal(claimed.id, "1");
|
|
97
|
-
assert.equal(claimed.owner, "alice");
|
|
98
|
-
assert.equal(claimed.status, "in_progress");
|
|
99
|
-
|
|
100
|
-
// Blocked self-claim: bob should skip #2 (blocked) and claim #3
|
|
101
|
-
const claimedBob = await taskStore.claimNextAvailableTask(teamDir, taskListId, "bob", { checkAgentBusy: true });
|
|
102
|
-
assert.ok(claimedBob);
|
|
103
|
-
assert.equal(claimedBob.id, "3");
|
|
104
|
-
assert.equal(claimedBob.owner, "bob");
|
|
105
|
-
|
|
106
|
-
// Busy check: should refuse a second claim while alice has in_progress
|
|
107
|
-
const claimed2 = await taskStore.claimNextAvailableTask(teamDir, taskListId, "alice", { checkAgentBusy: true });
|
|
108
|
-
assert.equal(claimed2, null);
|
|
109
|
-
|
|
110
|
-
await taskStore.completeTask(teamDir, taskListId, claimed.id, "alice", "done");
|
|
111
|
-
|
|
112
|
-
const t2AfterDepDone = await taskStore.getTask(teamDir, taskListId, "2");
|
|
113
|
-
assert.ok(t2AfterDepDone);
|
|
114
|
-
assert.equal(await taskStore.isTaskBlocked(teamDir, taskListId, t2AfterDepDone), false);
|
|
115
|
-
|
|
116
|
-
// Now alice can claim #2
|
|
117
|
-
const claimed3 = await taskStore.claimNextAvailableTask(teamDir, taskListId, "alice", { checkAgentBusy: true });
|
|
118
|
-
assert.ok(claimed3);
|
|
119
|
-
assert.equal(claimed3.id, "2");
|
|
120
|
-
|
|
121
|
-
// Create an additional pending task owned by alice (simulates assigned-but-not-started)
|
|
122
|
-
const t4 = await taskStore.createTask(teamDir, taskListId, {
|
|
123
|
-
subject: "Smoke task 4",
|
|
124
|
-
description: "Do fourth",
|
|
125
|
-
owner: "alice",
|
|
126
|
-
});
|
|
127
|
-
assert.equal(t4.id, "4");
|
|
128
|
-
assert.equal(t4.owner, "alice");
|
|
129
|
-
assert.equal(t4.status, "pending");
|
|
130
|
-
|
|
131
|
-
// Unassign a single task (should only unassign task 2, leaving task 4 assigned)
|
|
132
|
-
await taskStore.unassignTask(teamDir, taskListId, claimed3.id, "alice", "test abort", { abortedBy: "alice" });
|
|
133
|
-
|
|
134
|
-
tasks = await taskStore.listTasks(teamDir, taskListId);
|
|
135
|
-
const t2After = tasks.find((t) => t.id === "2");
|
|
136
|
-
assert.ok(t2After);
|
|
137
|
-
assert.equal(t2After.owner, undefined);
|
|
138
|
-
assert.equal(t2After.status, "pending");
|
|
139
|
-
|
|
140
|
-
const t4After = tasks.find((t) => t.id === "4");
|
|
141
|
-
assert.ok(t4After);
|
|
142
|
-
assert.equal(t4After.owner, "alice");
|
|
143
|
-
assert.equal(t4After.status, "pending");
|
|
144
|
-
|
|
145
|
-
// Unassign remaining non-completed tasks for alice (should unassign task 4)
|
|
146
|
-
const unassignedCount = await taskStore.unassignTasksForAgent(teamDir, taskListId, "alice", "test unassign");
|
|
147
|
-
assert.equal(unassignedCount, 1);
|
|
148
|
-
|
|
149
|
-
tasks = await taskStore.listTasks(teamDir, taskListId);
|
|
150
|
-
const t4After2 = tasks.find((t) => t.id === "4");
|
|
151
|
-
assert.ok(t4After2);
|
|
152
|
-
assert.equal(t4After2.owner, undefined);
|
|
153
|
-
assert.equal(t4After2.status, "pending");
|
|
154
|
-
|
|
155
|
-
// Complete bob's task so clear(completed) deletes both #1 and #3
|
|
156
|
-
await taskStore.completeTask(teamDir, taskListId, claimedBob.id, "bob", "done");
|
|
157
|
-
|
|
158
|
-
// Clear completed tasks (should delete tasks 1 + 3)
|
|
159
|
-
const clearedCompleted = await taskStore.clearTasks(teamDir, taskListId, "completed");
|
|
160
|
-
assert.equal(clearedCompleted.errors.length, 0);
|
|
161
|
-
assert.deepEqual([...clearedCompleted.deletedTaskIds].sort(), ["1", "3"]);
|
|
162
|
-
|
|
163
|
-
tasks = await taskStore.listTasks(teamDir, taskListId);
|
|
164
|
-
assert.equal(tasks.length, 2);
|
|
165
|
-
assert.deepEqual(
|
|
166
|
-
tasks.map((t) => t.id).sort(),
|
|
167
|
-
["2", "4"],
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
// Clear all remaining tasks
|
|
171
|
-
const clearedAll = await taskStore.clearTasks(teamDir, taskListId, "all");
|
|
172
|
-
assert.equal(clearedAll.errors.length, 0);
|
|
173
|
-
|
|
174
|
-
tasks = await taskStore.listTasks(teamDir, taskListId);
|
|
175
|
-
assert.equal(tasks.length, 0);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// -----------------------------------------------------------------------------
|
|
179
|
-
// Cleanup
|
|
180
|
-
// -----------------------------------------------------------------------------
|
|
181
|
-
{
|
|
182
|
-
const teamsRootDir = path.join(tmpRoot, "teams");
|
|
183
|
-
|
|
184
|
-
// Put a sentinel file in the team dir, then delete it.
|
|
185
|
-
fs.mkdirSync(teamDir, { recursive: true });
|
|
186
|
-
fs.writeFileSync(path.join(teamDir, "sentinel.txt"), "sentinel");
|
|
187
|
-
|
|
188
|
-
await cleanup.cleanupTeamDir(teamsRootDir, teamDir);
|
|
189
|
-
assert.equal(fs.existsSync(teamDir), false);
|
|
190
|
-
|
|
191
|
-
// Refuse unsafe paths
|
|
192
|
-
await assert.rejects(
|
|
193
|
-
() => cleanup.cleanupTeamDir(teamsRootDir, path.join(tmpRoot, "outside", "TEAM123")),
|
|
194
|
-
/Refusing to operate on path outside teams root/,
|
|
195
|
-
);
|
|
196
|
-
await assert.rejects(() => cleanup.cleanupTeamDir(teamsRootDir, teamsRootDir), /Refusing to operate on path outside teams root/);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
console.log("OK: smoke test passed");
|