fix(work): single-claim semantics — max 1 in_progress claim per agent, next auto-claim only after review/done, server-side double-claim guard (TSK-0237)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f5680c6982
commit
9dfbf3f657
@ -21,6 +21,8 @@ interface Listed {
|
|||||||
taskId?: string;
|
taskId?: string;
|
||||||
toAgent?: string;
|
toAgent?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
|
claimedBy?: string;
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Announce presence (best-effort) and print the joined line. */
|
/** Announce presence (best-effort) and print the joined line. */
|
||||||
@ -58,17 +60,33 @@ export async function listReviewTasks(ctx: AgentContext): Promise<Listed[]> {
|
|||||||
: svcListTasks(ctx.projectCwd, { status: 'review' });
|
: svcListTasks(ctx.projectCwd, { status: 'review' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRIORITY_RANK: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the open task addressed to this agent. "Addressed" = task title starts
|
* Find the ONE open task this agent should claim next. "Addressed" = task
|
||||||
* with "<agent>:" (delegation convention), OR a handoff for the task has
|
* title starts with "<agent>:" (delegation convention), OR a handoff for the
|
||||||
* toAgent === <agent>, OR the task is already assignedTo this agent (a reopened
|
* task has toAgent === <agent>, OR the task is already assignedTo this agent
|
||||||
* task that came back for rework). Returns the task + the handoff list (so the
|
* (a reopened task that came back for rework).
|
||||||
* caller can print the matching handoff without re-fetching).
|
*
|
||||||
|
* Single-claim semantics (TSK-0237): while the agent already holds an
|
||||||
|
* in_progress task, NOTHING is addressed — the next auto-claim happens only
|
||||||
|
* after that task reaches review/done (or the claim is taken back via
|
||||||
|
* reopen). Among several addressed candidates exactly one wins: highest
|
||||||
|
* priority, oldest createdAt breaks ties. The rest stays open/assigned.
|
||||||
|
* Returns the task + the handoff list (so the caller can print the matching
|
||||||
|
* handoff without re-fetching).
|
||||||
*/
|
*/
|
||||||
export async function findAddressedOpenTask(
|
export async function findAddressedOpenTask(
|
||||||
ctx: AgentContext,
|
ctx: AgentContext,
|
||||||
): Promise<{ task: Listed; handoffs: Listed[] } | undefined> {
|
): Promise<{ task: Listed; handoffs: Listed[] } | undefined> {
|
||||||
const a = ctx.agent.toLowerCase();
|
const a = ctx.agent.toLowerCase();
|
||||||
|
|
||||||
|
const inProgress = ctx.serverUrl
|
||||||
|
? await remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' })
|
||||||
|
: svcListTasks(ctx.projectCwd, { status: 'in_progress' });
|
||||||
|
const busy = inProgress.some((t) => String(t.claimedBy ?? t.assignedTo ?? '').toLowerCase() === a);
|
||||||
|
if (busy) return undefined;
|
||||||
|
|
||||||
const tasks = await listOpenRoleTasks(ctx);
|
const tasks = await listOpenRoleTasks(ctx);
|
||||||
const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd);
|
const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd);
|
||||||
|
|
||||||
@ -85,7 +103,26 @@ export async function findAddressedOpenTask(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mine.length === 0) return undefined;
|
if (mine.length === 0) return undefined;
|
||||||
return { task: mine[0], handoffs };
|
if (mine.length === 1) return { task: mine[0], handoffs };
|
||||||
|
|
||||||
|
// Priority is not part of the task index — fetch it per candidate (only the
|
||||||
|
// few addressed ones), rank critical > high > medium > low, oldest first.
|
||||||
|
const ranked = await Promise.all(
|
||||||
|
mine.map(async (t) => {
|
||||||
|
let rank = PRIORITY_RANK.medium;
|
||||||
|
try {
|
||||||
|
// Both the remote client and the local service return { task, body }.
|
||||||
|
const detail = ctx.serverUrl ? await remoteClient.getTask(ctx.serverUrl, t.id) : svcGetTask(ctx.projectCwd, t.id);
|
||||||
|
const full = (detail as { task?: { priority?: string } }).task;
|
||||||
|
rank = PRIORITY_RANK[full?.priority ?? 'medium'] ?? PRIORITY_RANK.medium;
|
||||||
|
} catch {
|
||||||
|
/* missing priority ⇒ medium */
|
||||||
|
}
|
||||||
|
return { t, rank };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ranked.sort((x, y) => y.rank - x.rank || String(x.t.createdAt ?? '').localeCompare(String(y.t.createdAt ?? '')));
|
||||||
|
return { task: ranked[0].t, handoffs };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Claim the task and print its body + handoff + the review-gate next step. */
|
/** Claim the task and print its body + handoff + the review-gate next step. */
|
||||||
|
|||||||
@ -82,6 +82,32 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
|
|||||||
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
|
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Single-claim guard (TSK-0237): an agent holds at most ONE in_progress task.
|
||||||
|
// Second line of defense behind the work loop's own selection — a direct
|
||||||
|
// claim (CLI/MCP/board drag) must not bypass it either. The architect is
|
||||||
|
// exempt (coordinates several threads); there is no --force for agents.
|
||||||
|
// An uninitialized project (no config, e.g. bare service-level usage) simply
|
||||||
|
// has no architect exemption — the guard still applies.
|
||||||
|
let architect: string | undefined;
|
||||||
|
try {
|
||||||
|
architect = loadConfig(cwd).roles.architect?.preferredAgent;
|
||||||
|
} catch {
|
||||||
|
architect = undefined;
|
||||||
|
}
|
||||||
|
if (agentName !== architect) {
|
||||||
|
const held = listTasks(cwd).find(
|
||||||
|
(t) =>
|
||||||
|
t.status === 'in_progress' &&
|
||||||
|
t.id !== id &&
|
||||||
|
(t.claimedBy === agentName || (!t.claimedBy && t.assignedTo === agentName)),
|
||||||
|
);
|
||||||
|
if (held) {
|
||||||
|
throw new Error(
|
||||||
|
`${agentName} already holds an in_progress claim on ${held.id} — one task at a time: ` +
|
||||||
|
`submit ${held.id} for review (or wait for a reopen) before claiming ${id}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
|
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -227,17 +227,15 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
var status = byStatus(t.status);
|
var status = byStatus(t.status);
|
||||||
var isOpen = openConsoles[t.id] ? true : false;
|
var isOpen = openConsoles[t.id] ? true : false;
|
||||||
var live = status === 'in_progress';
|
var live = status === 'in_progress';
|
||||||
// Console lives inside the card while a task is worked (in_progress = live)
|
// Console toggle lives in the card; the console itself opens as a
|
||||||
// and stays available in review so you can see what the agent did.
|
// dropdown OVERLAY on <body> (see openConsole) — it spans the full
|
||||||
|
// column width instead of being squeezed into the card grid cell.
|
||||||
var console = (live || status === 'review')
|
var console = (live || status === 'review')
|
||||||
? '<div class="card-console-wrap">' +
|
? '<div class="card-console-wrap">' +
|
||||||
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
|
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
|
||||||
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
|
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
|
||||||
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
|
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
|
|
||||||
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
// Progress ring on in-progress (work time) AND review cards (wait time) —
|
// Progress ring on in-progress (work time) AND review cards (wait time) —
|
||||||
@ -461,13 +459,74 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
|
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
|
||||||
body.scrollTop = body.scrollHeight;
|
body.scrollTop = body.scrollHeight;
|
||||||
}
|
}
|
||||||
|
// ── Console dropdown overlays ────────────────────────────────────────────
|
||||||
|
// The console is NOT rendered inside the card anymore (the card grid cell
|
||||||
|
// is far too narrow for log lines, and .card{overflow:hidden} would clip
|
||||||
|
// any absolute panel). Instead each open console gets ONE overlay panel on
|
||||||
|
// <body>, position:fixed, anchored under its card and spanning the full
|
||||||
|
// column width — readable logs regardless of how many cards share a row.
|
||||||
|
function consoleOverlay(id) {
|
||||||
|
return document.querySelector('.console-overlay[data-console-for="' + id + '"]');
|
||||||
|
}
|
||||||
|
function ensureConsoleOverlay(id) {
|
||||||
|
var ov = consoleOverlay(id);
|
||||||
|
if (ov) return ov;
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.className = 'console-overlay';
|
||||||
|
ov.setAttribute('data-console-for', id);
|
||||||
|
ov.innerHTML =
|
||||||
|
'<div class="console-overlay-head">' +
|
||||||
|
'<span class="console-overlay-title">' + esc(id) + ' · console</span>' +
|
||||||
|
'<button class="console-overlay-close" type="button" data-console-close="' + esc(id) + '" aria-label="Close console">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card-console-body" data-console-body="' + esc(id) + '"><div class="cc-empty">waiting for output…</div></div>';
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
return ov;
|
||||||
|
}
|
||||||
|
function positionConsoleOverlay(id) {
|
||||||
|
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
|
||||||
|
var ov = consoleOverlay(id);
|
||||||
|
if (!tog || !ov) return;
|
||||||
|
var card = tog.closest('.card');
|
||||||
|
var column = tog.closest('.column');
|
||||||
|
if (!card || !column) return;
|
||||||
|
var inset = 13; // column padding (12) + border (1)
|
||||||
|
var colRect = column.getBoundingClientRect();
|
||||||
|
var cardRect = card.getBoundingClientRect();
|
||||||
|
ov.style.left = Math.round(colRect.left + inset) + 'px';
|
||||||
|
ov.style.width = Math.round(colRect.width - inset * 2) + 'px';
|
||||||
|
ov.style.top = Math.round(cardRect.bottom + 6) + 'px';
|
||||||
|
}
|
||||||
|
function openConsole(id) {
|
||||||
|
ensureConsoleOverlay(id);
|
||||||
|
positionConsoleOverlay(id);
|
||||||
|
loadConsole(id);
|
||||||
|
}
|
||||||
|
function closeConsole(id) {
|
||||||
|
var ov = consoleOverlay(id);
|
||||||
|
if (ov) ov.remove();
|
||||||
|
}
|
||||||
|
// After every board re-render / scroll / resize: keep the open overlays
|
||||||
|
// glued to their (possibly moved) cards; close orphans whose card is gone.
|
||||||
|
function repositionConsoles() {
|
||||||
|
Object.keys(openConsoles).forEach(function(id) {
|
||||||
|
if (!openConsoles[id]) return;
|
||||||
|
if (!document.querySelector('[data-console-toggle="' + id + '"]')) {
|
||||||
|
openConsoles[id] = false;
|
||||||
|
closeConsole(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureConsoleOverlay(id);
|
||||||
|
positionConsoleOverlay(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Re-open any consoles that were expanded before a re-render (cards rebuild
|
// Re-open any consoles that were expanded before a re-render (cards rebuild
|
||||||
// their innerHTML, so the panel state must be re-applied + reloaded).
|
// their innerHTML, so the panel state must be re-applied + reloaded).
|
||||||
function reapplyConsoles() {
|
function reapplyConsoles() {
|
||||||
|
repositionConsoles();
|
||||||
Object.keys(openConsoles).forEach(function(id) {
|
Object.keys(openConsoles).forEach(function(id) {
|
||||||
if (!openConsoles[id]) return;
|
if (openConsoles[id]) loadConsole(id);
|
||||||
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
|
||||||
if (panel) { panel.hidden = false; loadConsole(id); }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function setConn(state, label) {
|
function setConn(state, label) {
|
||||||
@ -809,9 +868,9 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
}).then(function(ok) { if (ok) deleteTask(id); });
|
}).then(function(ok) { if (ok) deleteTask(id); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicks/selection inside the console body must not navigate to the task page.
|
// Clicks/selection inside the console overlay must not trigger card behavior.
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
|
if (e.target.closest && e.target.closest('.console-overlay')) e.preventDefault();
|
||||||
});
|
});
|
||||||
// Live-console toggle lives inside the card <a> — stop the navigation.
|
// Live-console toggle lives inside the card <a> — stop the navigation.
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
@ -823,10 +882,40 @@ export function columnsJs(opts: ColumnsJsOpts): string {
|
|||||||
openConsoles[id] = willOpen;
|
openConsoles[id] = willOpen;
|
||||||
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||||
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
|
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
|
||||||
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
if (willOpen) openConsole(id);
|
||||||
if (panel) panel.hidden = !willOpen;
|
else closeConsole(id);
|
||||||
if (willOpen) loadConsole(id);
|
|
||||||
});
|
});
|
||||||
|
// The overlay's own close button.
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest && e.target.closest('[data-console-close]');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
var id = btn.getAttribute('data-console-close');
|
||||||
|
openConsoles[id] = false;
|
||||||
|
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
|
||||||
|
if (tog) {
|
||||||
|
tog.setAttribute('aria-expanded', 'false');
|
||||||
|
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
|
||||||
|
}
|
||||||
|
closeConsole(id);
|
||||||
|
});
|
||||||
|
// Dropdown behavior: a pointerdown OUTSIDE overlay + toggle closes it.
|
||||||
|
document.addEventListener('pointerdown', function(e) {
|
||||||
|
if (e.target.closest && (e.target.closest('.console-overlay') || e.target.closest('[data-console-toggle]'))) return;
|
||||||
|
Object.keys(openConsoles).forEach(function(id) {
|
||||||
|
if (!openConsoles[id]) return;
|
||||||
|
openConsoles[id] = false;
|
||||||
|
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
|
||||||
|
if (tog) {
|
||||||
|
tog.setAttribute('aria-expanded', 'false');
|
||||||
|
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
|
||||||
|
}
|
||||||
|
closeConsole(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Keep overlays glued to their cards on scroll (any container) + resize.
|
||||||
|
document.addEventListener('scroll', repositionConsoles, true);
|
||||||
|
window.addEventListener('resize', repositionConsoles);
|
||||||
|
|
||||||
// ── Task-detail modal ──────────────────────────────────────────────────
|
// ── Task-detail modal ──────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@ -215,9 +215,8 @@ export function boardV2Css(): string {
|
|||||||
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
|
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
|
||||||
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
|
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
|
||||||
|
|
||||||
/* ── In-card live console (ported) ────────────────────────────────── */
|
/* ── Console dropdown overlay (full column width, floats above cards) ── */
|
||||||
.card-console-wrap { margin-top: 9px; }
|
.card-console-wrap { margin-top: 9px; }
|
||||||
.card:has(.card-console:not([hidden])) { min-height: 230px; }
|
|
||||||
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
|
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
|
||||||
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
|
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
|
||||||
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
|
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
|
||||||
@ -228,14 +227,27 @@ export function boardV2Css(): string {
|
|||||||
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
|
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
|
||||||
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
|
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
|
||||||
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||||
.card-console { margin-top: 7px; }
|
/* The panel itself lives on <body> (position:fixed, JS-anchored under its
|
||||||
.card-console[hidden] { display: none; }
|
card) so it escapes .card{overflow:hidden} and the narrow grid cell. */
|
||||||
.card-console-body { max-height: 180px; overflow-y: auto; background: rgba(0,0,0,.28);
|
.console-overlay { position: fixed; z-index: 70; background: var(--b2-raised);
|
||||||
|
border: 1px solid var(--b2-border); border-radius: 10px; padding: 8px 10px 10px;
|
||||||
|
box-shadow: 0 18px 44px rgba(2,6,18,.55); animation: consoleDrop 160ms ease-out; }
|
||||||
|
@keyframes consoleDrop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
||||||
|
.console-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||||
|
margin-bottom: 6px; font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--b2-muted); }
|
||||||
|
.console-overlay-close { border: 1px solid var(--b2-border); background: transparent; color: var(--b2-muted);
|
||||||
|
width: 18px; height: 18px; border-radius: 50%; font: 12px/1 var(--mono); cursor: pointer; display: inline-grid; place-items: center; }
|
||||||
|
.console-overlay-close:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
.card-console-body { max-height: 260px; overflow-y: auto; background: rgba(0,0,0,.28);
|
||||||
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
|
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
|
||||||
font: 11px/1.55 var(--mono); color: #cbd5e1; }
|
font: 11px/1.55 var(--mono); color: #cbd5e1; }
|
||||||
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
|
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
|
||||||
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
|
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
|
||||||
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
|
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
|
||||||
|
/* flex:1 + min-width:0 — the text must claim the line's remaining width and
|
||||||
|
wrap inside it. Without this, overflow-wrap:anywhere collapses the flex
|
||||||
|
item to its min-content width (~1 char) and the log renders vertically. */
|
||||||
|
.cc-line .cc-text { flex: 1 1 auto; min-width: 0; }
|
||||||
.cc-line.level-error .cc-text { color: #fca5a5; }
|
.cc-line.level-error .cc-text { color: #fca5a5; }
|
||||||
.cc-line.level-warn .cc-text { color: #fcd34d; }
|
.cc-line.level-warn .cc-text { color: #fcd34d; }
|
||||||
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
|
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
|
||||||
|
|||||||
@ -190,7 +190,7 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
|
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
|
||||||
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
|
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
|
||||||
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
|
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
|
||||||
.log-text { color:var(--text);min-width:0; }
|
.log-text { color:var(--text);flex:1 1 auto;min-width:0; }
|
||||||
.log-line[data-level="status"] .log-text { color:var(--status-review); }
|
.log-line[data-level="status"] .log-text { color:var(--status-review); }
|
||||||
.log-line[data-level="warn"] .log-text { color:var(--status-review); }
|
.log-line[data-level="warn"] .log-text { color:var(--status-review); }
|
||||||
.log-line[data-level="error"] .log-text { color:#F85149; }
|
.log-line[data-level="error"] .log-text { color:#F85149; }
|
||||||
|
|||||||
166
tests/singleClaim.test.ts
Normal file
166
tests/singleClaim.test.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for TSK-0237: single-claim semantics in the work loop.
|
||||||
|
*
|
||||||
|
* Bug (24.07. ~01:54): after the TSK-0230 auto-wake, an agent's waiting work
|
||||||
|
* loop claimed EVERY task assigned to it in parallel (codex held TSK-0222 +
|
||||||
|
* TSK-0235 at once) — the board lied about who works on what.
|
||||||
|
*
|
||||||
|
* Soll:
|
||||||
|
* (a) several assigned tasks ⇒ the loop claims exactly ONE (highest
|
||||||
|
* priority, oldest createdAt breaks ties); the rest stays open.
|
||||||
|
* (b) the next auto-claim happens only after the active task reaches
|
||||||
|
* review/done (or is reopened back).
|
||||||
|
* (c) server-side guard: a claim while holding an in_progress task → error
|
||||||
|
* (architect exempt, no --force for agents).
|
||||||
|
* (d) the TSK-0230 auto-wake suite keeps passing.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { init } from '../src/cli/commands/init.js';
|
||||||
|
import { workAgent } from '../src/cli/commands/work.js';
|
||||||
|
import { startServer } from '../src/server/index.js';
|
||||||
|
import { createTask, claimTask, getTask } from '../src/core/services/taskService.js';
|
||||||
|
import type { Task } from '../src/core/schema.js';
|
||||||
|
|
||||||
|
const AGENT = 'kimi';
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndAssign(serverUrl: string, title: string, priority: string): Promise<string> {
|
||||||
|
const res = await fetch(`${serverUrl}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, role: 'implementer', priority }),
|
||||||
|
});
|
||||||
|
const task = (await res.json()) as Task;
|
||||||
|
await fetch(`${serverUrl}/tasks/${task.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ assignedTo: AGENT }),
|
||||||
|
});
|
||||||
|
return task.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function taskViaApi(serverUrl: string, id: string): Promise<Task> {
|
||||||
|
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
|
||||||
|
return res.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimViaApi(serverUrl: string, id: string, agent: string): Promise<Response> {
|
||||||
|
return fetch(`${serverUrl}/tasks/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'in_progress', assignedTo: agent }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('single-claim semantics (TSK-0237)', () => {
|
||||||
|
let cwd: string;
|
||||||
|
let server: Awaited<ReturnType<typeof startServer>>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
cwd = mkdtempSync(join(tmpdir(), 'ah-single-claim-'));
|
||||||
|
init(cwd, { projectName: 'single-claim-test', yes: true });
|
||||||
|
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.app.close().catch(() => undefined);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(a) claims exactly ONE of several assigned tasks — highest priority first', async () => {
|
||||||
|
const low = await createAndAssign(server.url, 'kimi: low prio', 'low');
|
||||||
|
await sleep(10);
|
||||||
|
const high = await createAndAssign(server.url, 'kimi: high prio', 'high');
|
||||||
|
|
||||||
|
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
||||||
|
|
||||||
|
const highTask = await taskViaApi(server.url, high);
|
||||||
|
expect(highTask.status).toBe('in_progress');
|
||||||
|
expect(highTask.assignedTo).toBe(AGENT);
|
||||||
|
|
||||||
|
// The other task must stay open — no parallel claim.
|
||||||
|
const lowTask = await taskViaApi(server.url, low);
|
||||||
|
expect(lowTask.status).toBe('open');
|
||||||
|
expect(lowTask.assignedTo).toBe(AGENT);
|
||||||
|
|
||||||
|
// A second work run while busy claims nothing (single-claim), it just waits.
|
||||||
|
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 1 });
|
||||||
|
expect((await taskViaApi(server.url, low)).status).toBe('open');
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it('(a2) breaks priority ties by oldest createdAt', async () => {
|
||||||
|
const older = await createAndAssign(server.url, 'kimi: older medium', 'medium');
|
||||||
|
await sleep(10);
|
||||||
|
const newer = await createAndAssign(server.url, 'kimi: newer medium', 'medium');
|
||||||
|
|
||||||
|
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
||||||
|
|
||||||
|
expect((await taskViaApi(server.url, older)).status).toBe('in_progress');
|
||||||
|
expect((await taskViaApi(server.url, newer)).status).toBe('open');
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it('(b) auto-claims the next task only after the active one reaches review', async () => {
|
||||||
|
const first = await createAndAssign(server.url, 'kimi: first', 'high');
|
||||||
|
await sleep(10);
|
||||||
|
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
|
||||||
|
|
||||||
|
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
||||||
|
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
|
||||||
|
expect((await taskViaApi(server.url, second)).status).toBe('open');
|
||||||
|
|
||||||
|
// Submit the active task for review → the agent is free for the next one.
|
||||||
|
await fetch(`${server.url}/tasks/${first}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'review' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
||||||
|
expect((await taskViaApi(server.url, second)).status).toBe('in_progress');
|
||||||
|
}, 12_000);
|
||||||
|
|
||||||
|
it('(c) server guard: claim while holding an in_progress task → 400, architect exempt', async () => {
|
||||||
|
const t1 = await createAndAssign(server.url, 'kimi: held', 'medium');
|
||||||
|
const t2 = await createAndAssign(server.url, 'kimi: blocked', 'medium');
|
||||||
|
|
||||||
|
const ok = await claimViaApi(server.url, t1, AGENT);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
|
||||||
|
const blocked = await claimViaApi(server.url, t2, AGENT);
|
||||||
|
expect(blocked.status).toBe(400);
|
||||||
|
const body = (await blocked.json()) as { error?: string; message?: string };
|
||||||
|
expect(JSON.stringify(body)).toContain(t1);
|
||||||
|
// The blocked task stays open.
|
||||||
|
expect((await taskViaApi(server.url, t2)).status).toBe('open');
|
||||||
|
|
||||||
|
// Architect (default config: claude) may hold several threads.
|
||||||
|
const t3 = await createAndAssign(server.url, 'claude: thread one', 'medium');
|
||||||
|
const t4 = await createAndAssign(server.url, 'claude: thread two', 'medium');
|
||||||
|
expect((await claimViaApi(server.url, t3, 'claude')).status).toBe(200);
|
||||||
|
expect((await claimViaApi(server.url, t4, 'claude')).status).toBe(200);
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it('(c2) claimTask unit: guard throws, same-task re-claim stays idempotent', () => {
|
||||||
|
const a = createTask(cwd, { title: 'kimi: a', role: 'implementer' });
|
||||||
|
const b = createTask(cwd, { title: 'kimi: b', role: 'implementer' });
|
||||||
|
|
||||||
|
claimTask(cwd, a.id, AGENT);
|
||||||
|
expect(() => claimTask(cwd, b.id, AGENT)).toThrowError(new RegExp(a.id));
|
||||||
|
// Idempotent re-claim of the SAME task is still a no-op.
|
||||||
|
expect(() => claimTask(cwd, a.id, AGENT)).not.toThrow();
|
||||||
|
// Architect exempt.
|
||||||
|
const c = createTask(cwd, { title: 'claude: x', role: 'architect' });
|
||||||
|
const d = createTask(cwd, { title: 'claude: y', role: 'architect' });
|
||||||
|
claimTask(cwd, c.id, 'claude');
|
||||||
|
expect(() => claimTask(cwd, d.id, 'claude')).not.toThrow();
|
||||||
|
|
||||||
|
const { task } = getTask(cwd, b.id);
|
||||||
|
expect(task.status).toBe('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user