Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1125,16 +1125,18 @@ provided (non-null) in the body are updated.
"mergeTimeoutMinutes": 60,
"minModelScore": 70,
"requiredCapabilities": ["sensitive"],
"dependsOn": ["<id-or-externalId>", "..."]
"dependsOn": ["<id-or-externalId>", "..."],
"agentClassId": "optional agent class id"
}
```

* Returns `200 OK` with the updated work item record.
* Returns `409 Conflict` for non-`dependsOn` fields when the item is not in `Queued` state (in-flight items are read-only). `dependsOn` is allowed on any **non-terminal** state and returns `409` only on terminal items.
* Returns `409 Conflict` for non-`dependsOn` fields when the item is not in `Queued` state (in-flight items are read-only). `dependsOn`, the audit-budget fields, and `agentClassId` are allowed on any **non-terminal** state and return `409` only on terminal items. `agentClassId` additionally returns `409` while a worker holds the item, so the edit cannot race the dispatch path.
* Validation rules for `title`, `prompt`, and `agent` are identical to `POST /workitems`.
* `workTimeoutMinutes` is clamped to `[1, 480]`, `mergeTimeoutMinutes` to `[1, 240]`, `minModelScore` to `[0, 200]` — out-of-range values pin to the boundary rather than 400, matching the creation surface. This lets an operator bulk-PATCH the queue after a defaults bump without special-casing stray inputs.
* `requiredCapabilities` is the explicit clearance/trust gate (see [agent-classes.md](../concepts/agent-classes.md#capability-gate)). Tags are trimmed, de-duplicated case-insensitively, and validated for length (≤64 chars) and count (≤16 entries). Sending the field replaces the existing list; omit it to leave the list unchanged.
* `dependsOn` is replace-set semantics: the array overwrites the item's full dependency list. Each entry is a GUID, a namespaced `ns:value` externalId, or a bare externalId (must be unambiguous within the project). Capped at 100 entries; the create-time validation (existence, self-loop, cycle) re-runs against the proposed graph and `400`s on rejection. Pass `[]` to clear all deps. Persisted via a partial UPDATE that does not stomp `state` / `startedAt`, and a `work_item.dependencies_changed` audit-log entry records the pre/post sets.
* `agentClassId` moves the item to a different agent class without re-running completed phases — e.g. a `WorkComplete` item parked behind an auditor class whose members are all unavailable. The id must name a class in the live router catalog (unknown ids `400`); the value is persisted via a terminal-guarded partial UPDATE alongside a `work_item.agent_class_changed` audit-log entry recording the old and new class.
* Priority is not editable here — use `PATCH /workitems/{id}/priority` (works on any non-terminal state, uses a TOCTOU-safe partial UPDATE).

### `PATCH /workitems/{id}/priority`
Expand Down
116 changes: 112 additions & 4 deletions src/CodeyBox.Api/WorkItemEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1396,11 +1396,17 @@ private static async Task<IResult> RecoverAsync(
/// Partially update a work item's editable fields. Most fields (title,
/// prompt, agent, work/merge timeouts, min model score, required
/// capabilities) are Queued-only — they affect a running pipeline so the
/// endpoint rejects 409 once dispatch starts. <see cref="PatchWorkItemRequest.DependsOn"/>
/// and the audit-budget fields are the exceptions: they are allowed on any
/// non-terminal state (Queued / Working / Auditing / …), persisted via
/// endpoint rejects 409 once dispatch starts. <see cref="PatchWorkItemRequest.DependsOn"/>,
/// the audit-budget fields, and <see cref="PatchWorkItemRequest.AgentClassId"/>
/// are the exceptions: they are allowed on any non-terminal state
/// (Queued / Working / Auditing / WorkComplete / …), persisted via
/// partial UPDATEs that do not stomp <c>state</c> and friends.
///
/// AgentClassId is refused with 409 while a worker holds the item (the
/// dispatch path may be resolving the class concurrently) and with 409 on
/// terminal items; unknown class ids are rejected with 400 against the
/// live router catalog.
///
/// Timeout / score fields are clamped using the same bounds as creation —
/// out-of-range values do not error, they pin to the boundary so an
/// operator-led bulk-PATCH of a queue after a defaults bump never 400s.
Expand All @@ -1418,6 +1424,8 @@ private static async Task<IResult> PatchWorkItemAsync(
IProjectRepository projects,
IAgentRegistry agents,
IKnobRegistry knobs,
IWorkerRegistry registry,
AgentClassRouter router,
CancellationToken ct)
{
var (item, err) = await ResolveWorkItemAsync(id, store, ct);
Expand All @@ -1443,6 +1451,7 @@ body.Title is not null
|| body.RequiredCapabilities is not null;
var auditBudgetPatch = body.AuditMaxIterations is not null
|| body.AuditComplexity is not null;
var agentClassPatch = body.AgentClassId is not null;

// ── State pre-checks: surface 409 before any write ────────────────────
// DependsOn is allowed on any non-terminal state — adding a dependency
Expand All @@ -1458,6 +1467,16 @@ body.Title is not null
{
error = $"cannot edit audit budget of work item in terminal state '{item.State}'",
});
// AgentClassId is allowed on any non-terminal state — the motivating
// case is a WorkComplete item parked behind an auditor class whose
// members are all unavailable (a Queued-only restriction would not
// solve it). Terminal items are closed; worker-held items are refused
// below rather than racing the dispatch path.
if (agentClassPatch && WorkItemDependencies.TerminalStates.Contains(item!.State))
return Results.Conflict(new
{
error = $"cannot edit agent class of work item in terminal state '{item.State}'",
});
if (queuedOnlyPatch && item!.State != WorkItemState.Queued)
return Results.Conflict(new
{
Expand All @@ -1482,6 +1501,57 @@ body.Title is not null
normalisedPatchKnobs = normalisedKnobs!;
}

// ── AgentClassId validation (no writes yet, may 400) ─────────────────
// Same bounds as the create path (≤200 chars), plus an existence check
// against the live router catalog: an unknown class would otherwise
// fall through to direct agent pick at dispatch and silently strand
// the item outside the class the operator intended.
string? newAgentClassId = null;
string? oldAgentClassId = item!.AgentClassId;
if (agentClassPatch)
{
var trimmed = body.AgentClassId!.Trim();
if (trimmed.Length == 0)
return Results.BadRequest(new { error = "agentClassId must not be empty" });
if (trimmed.Length > 200)
return Results.BadRequest(new { error = "agentClassId must be <= 200 chars" });
var knownClasses = router.ClassIds;
if (!knownClasses.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
return Results.BadRequest(new
{
error = $"unknown agent class '{trimmed}'",
available = knownClasses.OrderBy(c => c, StringComparer.OrdinalIgnoreCase),
});
newAgentClassId = trimmed;

// Refuse while a worker holds the item rather than racing the
// dispatch path, which may be resolving the class concurrently.
// Checked last (closest to the write) to minimise the check/write
// gap; the store's terminal guard below still fails closed on a
// concurrent transition.
try
{
var idStr = item.Id.ToString();
var workers = await registry.ListAsync(ct);
foreach (var worker in workers)
{
if (string.Equals(worker.CurrentWorkItemId, idStr, StringComparison.OrdinalIgnoreCase))
return Results.Conflict(new
{
error = $"cannot change agent class while worker '{worker.WorkerId}' holds work item '{id}'",
});
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
return Results.Conflict(new
{
error = $"cannot change agent class of work item '{id}': failed to inspect worker bindings: {ex.Message}",
});
}
}

var updated = item!;
var now = DateTimeOffset.UtcNow;
var queuedUpdateExpectedUpdatedAt = item!.UpdatedAt;
Expand Down Expand Up @@ -1578,6 +1648,9 @@ body.Title is not null
updated = updated with { AuditComplexity = normalised, UpdatedAt = now };
}

if (newAgentClassId is not null)
updated = updated with { AgentClassId = newAgentClassId, UpdatedAt = now };

IReadOnlyList<WorkItemId> oldDependsOn = updated.DependsOn;
if (depsPatch)
updated = updated with { DependsOn = newDependsOn!, UpdatedAt = now };
Expand Down Expand Up @@ -1646,6 +1719,33 @@ body.Title is not null
break;
}
}
// AgentClassId on a Queued item with other queued edits rides the
// guarded row UPDATE above (its SQL carries agent_class_id); every
// other case — notably non-Queued items, where the guarded write is
// unavailable — goes through the terminal-guarded partial UPDATE so
// pipeline-owned columns are never stomped.
if (agentClassPatch && !needsQueuedRowUpdate)
{
var classResult = await store.UpdateAgentClassAsync(
updated.Id,
newAgentClassId,
now,
ct);
switch (classResult.Outcome)
{
case AgentClassUpdateOutcome.NotFound:
return Results.NotFound(new { error = $"work item '{id}' no longer exists" });
case AgentClassUpdateOutcome.TerminalState:
return Results.Conflict(new
{
error = $"work item transitioned to terminal state '{classResult.Item!.State}' before agent class could be updated",
});
case AgentClassUpdateOutcome.Updated:
oldAgentClassId = classResult.OldAgentClassId ?? oldAgentClassId;
updated = classResult.Item ?? updated with { AgentClassId = newAgentClassId, UpdatedAt = now };
break;
}
}
if (depsPatch && !queuedOnlyPatch)
{
var depResult = await store.UpdateDependsOnAsync(updated.Id, newDependsOn!, now, ct);
Expand Down Expand Up @@ -1679,6 +1779,8 @@ body.Title is not null
}
if (depsPatch)
AuditLog.WorkItemDependenciesChanged(updated.Id, oldDependsOn, newDependsOn!);
if (agentClassPatch)
AuditLog.WorkItemAgentClassChanged(updated.Id, oldAgentClassId, newAgentClassId);

var statesById = new Dictionary<WorkItemId, WorkItemState>();
var depExternalIds = new Dictionary<WorkItemId, string?>();
Expand Down Expand Up @@ -3113,7 +3215,13 @@ public sealed record PatchWorkItemRequest(
// Replace-set knob edit (queued-only, like Title/Agent). Sending a non-null
// map replaces the entire stored map. Unknown keys and invalid values are
// rejected with 400. Send an empty map to clear all per-item overrides.
IReadOnlyDictionary<string, string>? Knobs = null);
IReadOnlyDictionary<string, string>? Knobs = null,
// Agent-class reassignment. Allowed on any non-terminal item with no
// worker bound to it (409 while a worker holds the item or the item is
// terminal). The id must name a class in the live router catalog —
// unknown ids are rejected with 400, mirroring the create-time bounds.
// A work_item.agent_class_changed audit entry records the old/new values.
string? AgentClassId = null);

public sealed record PatchPriorityRequest(int Priority);

Expand Down
19 changes: 19 additions & 0 deletions src/CodeyBox.Core/AuditLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,25 @@ public static void WorkItemDependenciesChanged(
string.Join(",", oldDependsOn.Select(d => d.ToString())),
string.Join(",", newDependsOn.Select(d => d.ToString())));

/// <summary>
/// Distinct audit event for post-hoc agent-class edits via PATCH /workitems/{id}.
/// Records the previous and new class ids explicitly so the audit trail
/// captures the routing mutation; <see cref="WorkItemPatched"/>'s flags-only
/// shape would otherwise erase it. Null on either side means the item
/// carries no class (direct agent routing): the empty-string sentinel is
/// used because Serilog drops null properties, and readers rely on the
/// property being present to distinguish "no class" from "schema changed".
/// </summary>
public static void WorkItemAgentClassChanged(
WorkItemId id,
string? oldAgentClassId,
string? newAgentClassId) =>
Audit("work_item.agent_class_changed")
.Information("Work item {WorkItemId} agent class changed: {OldAgentClassId} → {NewAgentClassId}",
id.ToString(),
oldAgentClassId ?? "",
newAgentClassId ?? "");

public static void WorkItemReordered(int count) =>
Audit("work_item.reordered")
.Information("Queue reordered: {Count} items repositioned", count);
Expand Down
50 changes: 50 additions & 0 deletions src/CodeyBox.Core/IWorkItemStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,32 @@ public enum AuditBudgetUpdateOutcome
/// </summary>
public readonly record struct AuditBudgetUpdateResult(AuditBudgetUpdateOutcome Outcome, WorkItem? Item);

/// <summary>
/// Outcome of <see cref="IWorkItemStore.UpdateAgentClassAsync"/>.
/// </summary>
public enum AgentClassUpdateOutcome
{
/// <summary>The row was updated and the new agent class id is persisted.</summary>
Updated,
/// <summary>The row no longer exists.</summary>
NotFound,
/// <summary>The row exists but is in a terminal state; no write was issued.</summary>
TerminalState,
}

/// <summary>
/// Result returned by <see cref="IWorkItemStore.UpdateAgentClassAsync"/>.
/// <see cref="Item"/> is populated on <see cref="AgentClassUpdateOutcome.Updated"/>
/// and on <see cref="AgentClassUpdateOutcome.TerminalState"/> so callers can
/// return the current state to the client; null on <see cref="AgentClassUpdateOutcome.NotFound"/>.
/// <see cref="OldAgentClassId"/> is the pre-update class id, captured so the
/// caller can emit a meaningful audit-log entry without re-reading the row.
/// </summary>
public readonly record struct AgentClassUpdateResult(
AgentClassUpdateOutcome Outcome,
WorkItem? Item,
string? OldAgentClassId);

/// <summary>
/// Snapshot of a single dispatched iteration. <see cref="PromptRevisionAtDispatch"/>
/// is the value of <see cref="WorkItem.PromptRevision"/> at the moment the iteration
Expand Down Expand Up @@ -239,6 +265,30 @@ Task<AuditBudgetUpdateResult> UpdateAuditBudgetAsync(
DateTimeOffset updatedAt,
CancellationToken ct = default);

/// <summary>
/// Partial UPDATE that touches only the <c>agent_class_id</c> column and
/// <c>updated_at</c> for the row identified by <paramref name="id"/>.
/// Used by PATCH /workitems/{id} when an operator moves an item to a
/// different agent class — e.g. a <c>WorkComplete</c> item parked behind
/// an auditor class whose members are all unavailable. The full-row
/// <see cref="UpdateAsync"/> would otherwise stomp <c>state</c>,
/// <c>started_at</c>, and friends when applied to an in-flight item.
///
/// Returns <see cref="AgentClassUpdateOutcome.TerminalState"/> when the row
/// is in a terminal state — class edits cannot affect closed work.
///
/// The default implementation throws; persistent stores must override it
/// with a terminal-guarded partial UPDATE like
/// <see cref="UpdateAuditBudgetAsync"/>.
/// </summary>
Task<AgentClassUpdateResult> UpdateAgentClassAsync(
WorkItemId id,
string? agentClassId,
DateTimeOffset updatedAt,
CancellationToken ct = default)
=> throw new NotSupportedException(
"This work item store must implement guarded agent-class replacement before it can accept agent class edits.");

/// <summary>
/// Partial UPDATE that touches only the per-item knob map and
/// <c>updated_at</c>, guarded by both persisted state and the exact
Expand Down
55 changes: 55 additions & 0 deletions src/CodeyBox.Orchestrator/SqliteWorkItemStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2218,6 +2218,61 @@ UPDATE work_items SET
}
}

public async Task<AgentClassUpdateResult> UpdateAgentClassAsync(
WorkItemId id,
string? agentClassId,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
await _writeLock.WaitAsync(ct);
try
{
WorkItem? current;
using (var read = _conn.CreateCommand())
{
read.CommandText = "SELECT * FROM work_items WHERE id = $id;";
read.Parameters.AddWithValue("$id", id.ToString());
using var reader = await read.ExecuteReaderAsync(ct);
current = await reader.ReadAsync(ct) ? Read(reader) : null;
}

if (current is null)
return new AgentClassUpdateResult(AgentClassUpdateOutcome.NotFound, null, null);

current = current with { ExternalIds = await LoadExternalIdsForAsync(current.Id, _conn, ct) };

if (WorkItemDependencies.TerminalStates.Contains(current.State))
return new AgentClassUpdateResult(AgentClassUpdateOutcome.TerminalState, current, current.AgentClassId);

using var cmd = _conn.CreateCommand();
cmd.CommandText = """
UPDATE work_items SET
agent_class_id = $agent_class_id,
updated_at = $updated_at
WHERE id = $id;
""";
cmd.Parameters.AddWithValue("$agent_class_id", (object?)agentClassId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$updated_at", updatedAt.ToString("O"));
cmd.Parameters.AddWithValue("$id", id.ToString());
await cmd.ExecuteNonQueryAsync(ct);

var updated = current with
{
AgentClassId = agentClassId,
UpdatedAt = updatedAt,
};
return new AgentClassUpdateResult(AgentClassUpdateOutcome.Updated, updated, current.AgentClassId);
}
catch (SqliteException sqlex) when (sqlex.SqliteErrorCode == SQLITE_FULL)
{
throw HandleDiskFull("UpdateAgentClassAsync", sqlex);
}
finally
{
_writeLock.Release();
}
}

public async Task<bool> TryReplaceKnobsIfStateAndUpdatedAtAsync(
WorkItemId id,
IReadOnlyDictionary<string, string> knobs,
Expand Down
Loading
Loading