feat(coding-agent-task): implemented agent-centric flat task structure

- Renamed task wire fields, replacing `assignment` and `description` with `task` and `name` while removing `role` references.
- Implemented automated task UI label generation using a tiny model to replace manual role descriptions.
- Updated task execution and rendering logic to support per-item agent resolution and dynamic badge display.
- Migrated schemas, prompts, and test suites to enforce the new flat task structure and agent-centric policy.
This commit is contained in:
can1357
2026-07-11 13:44:04 +02:00
parent 8e006a5c81
commit cb2153e9a5
26 changed files with 837 additions and 647 deletions
@@ -8,7 +8,7 @@
* bodies: with concurrency 1 the second body does not start until the
* first releases.
*
* Param validation (missing agent / missing assignment) is covered by
* Param validation (missing agent / missing task) is covered by
* test/task/task-schema.test.ts.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
@@ -121,9 +121,8 @@ describe("task spawn routing", () => {
const result = await tool.execute("tc-spawn", {
agent: "task",
id: "Spawnling",
description: "background work",
assignment: "Do the thing.",
name: "Spawnling",
task: "Do the thing.",
} as TaskParams);
// Tool returned while the job body is still gated on the deferred.
@@ -165,8 +164,8 @@ describe("task spawn routing", () => {
const manager = createManager();
const tool = await TaskTool.create(createSession({ manager, settings: { "task.maxConcurrency": 1 } }));
const first = await tool.execute("tc-1", { agent: "task", id: "First", assignment: "Work A." } as TaskParams);
const second = await tool.execute("tc-2", { agent: "task", id: "Second", assignment: "Work B." } as TaskParams);
const first = await tool.execute("tc-1", { agent: "task", name: "First", task: "Work A." } as TaskParams);
const second = await tool.execute("tc-2", { agent: "task", name: "Second", task: "Work B." } as TaskParams);
const firstJob = manager.getJob(first.details!.async!.jobId)!;
const secondJob = manager.getJob(second.details!.async!.jobId)!;
@@ -207,8 +206,8 @@ describe("task spawn routing", () => {
const manager = createManager();
const tool = await TaskTool.create(createSession({ manager, settings: { "task.maxConcurrency": 1 } }));
const first = await tool.execute("tc-1", { agent: "task", id: "First", assignment: "Work A." } as TaskParams);
const second = await tool.execute("tc-2", { agent: "task", id: "Second", assignment: "Work B." } as TaskParams);
const first = await tool.execute("tc-1", { agent: "task", name: "First", task: "Work A." } as TaskParams);
const second = await tool.execute("tc-2", { agent: "task", name: "Second", task: "Work B." } as TaskParams);
const firstJob = manager.getJob(first.details!.async!.jobId)!;
const secondJob = manager.getJob(second.details!.async!.jobId)!;
@@ -251,13 +250,13 @@ describe("task spawn routing", () => {
const tool = await TaskTool.create(createSession({ manager, settings: { "task.maxConcurrency": 1 } }));
// A holds the only permit, gated inside the executor.
const first = await tool.execute("tc-1", { agent: "task", id: "First", assignment: "Work A." } as TaskParams);
const first = await tool.execute("tc-1", { agent: "task", name: "First", task: "Work A." } as TaskParams);
const firstJob = manager.getJob(first.details!.async!.jobId)!;
await pollUntil(() => started.length === 1);
// B parks at the semaphore, then is cancelled while queued. Its
// teardown must NOT release a permit it never acquired.
const second = await tool.execute("tc-2", { agent: "task", id: "Second", assignment: "Work B." } as TaskParams);
const second = await tool.execute("tc-2", { agent: "task", name: "Second", task: "Work B." } as TaskParams);
const secondJob = manager.getJob(second.details!.async!.jobId)!;
expect(secondJob.queued).toBe(true);
expect(manager.cancel(secondJob.id)).toBe(true);
@@ -266,7 +265,7 @@ describe("task spawn routing", () => {
// C must stay parked while A still holds the cap. A phantom release
// from B's cancellation would admit C here, running 2 bodies at cap 1.
const third = await tool.execute("tc-3", { agent: "task", id: "Third", assignment: "Work C." } as TaskParams);
const third = await tool.execute("tc-3", { agent: "task", name: "Third", task: "Work C." } as TaskParams);
const thirdJob = manager.getJob(third.details!.async!.jobId)!;
await Bun.sleep(50);
expect(started).toEqual(["First"]);
@@ -280,7 +279,7 @@ describe("task spawn routing", () => {
// D queued behind running C stays serialized: if B's teardown had
// double-released, two permits would be free and D would start now.
const fourth = await tool.execute("tc-4", { agent: "task", id: "Fourth", assignment: "Work D." } as TaskParams);
const fourth = await tool.execute("tc-4", { agent: "task", name: "Fourth", task: "Work D." } as TaskParams);
const fourthJob = manager.getJob(fourth.details!.async!.jobId)!;
await Bun.sleep(50);
expect(started).toEqual(["First", "Third"]);
@@ -320,13 +319,9 @@ describe("task spawn routing", () => {
createSession({ manager, settings: { "task.maxConcurrency": maxConcurrency } }),
);
const first = await tool.execute("tc-1", { agent: "task", id: "First", assignment: "Work A." } as TaskParams);
const second = await tool.execute("tc-2", {
agent: "task",
id: "Second",
assignment: "Work B.",
} as TaskParams);
const third = await tool.execute("tc-3", { agent: "task", id: "Third", assignment: "Work C." } as TaskParams);
const first = await tool.execute("tc-1", { agent: "task", name: "First", task: "Work A." } as TaskParams);
const second = await tool.execute("tc-2", { agent: "task", name: "Second", task: "Work B." } as TaskParams);
const third = await tool.execute("tc-3", { agent: "task", name: "Third", task: "Work C." } as TaskParams);
// All three job bodies clear the spawn semaphore in parallel — none stays queued.
await pollUntil(() => started.length === 3);
@@ -369,12 +364,12 @@ describe("task spawn routing", () => {
} as unknown as ToolSession);
// Prime the semaphore at the initial high cap.
const first = await tool.execute("tc-1", { agent: "task", id: "First", assignment: "Work A." } as TaskParams);
const first = await tool.execute("tc-1", { agent: "task", name: "First", task: "Work A." } as TaskParams);
await pollUntil(() => started.length === 1);
// Tighten the cap mid-session. The next spawn MUST see the new ceiling.
settings.override("task.maxConcurrency", 1);
const second = await tool.execute("tc-2", { agent: "task", id: "Second", assignment: "Work B." } as TaskParams);
const second = await tool.execute("tc-2", { agent: "task", name: "Second", task: "Work B." } as TaskParams);
const secondJob = manager.getJob(second.details!.async!.jobId)!;
// First is still running (and holding the only slot under the new cap),
@@ -421,7 +416,7 @@ describe("task spawn routing", () => {
const jobs: AsyncJob[] = [];
for (const id of ["First", "Second", "Third", "Fourth", "Fifth"]) {
const result = await tool.execute(`tc-${id}`, { agent: "task", id, assignment: `Work ${id}.` } as TaskParams);
const result = await tool.execute(`tc-${id}`, { agent: "task", name: id, task: `Work ${id}.` } as TaskParams);
jobs.push(manager.getJob(result.details!.async!.jobId)!);
}
const fifthJob = jobs[4]!;