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
15 changes: 12 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A CloudCLI tab plugin providing a browser UI for [task-queue-mcp](https://github

## What it does

Renders a task list and detail view inside CloudCLI, and offers lifecycle actions (approve, cancel, park/unpark, amend, status change) plus session launch. It is a front end only — the queue itself lives in `task-queue-mcp`.
Renders a task list and detail view inside CloudCLI, and offers lifecycle actions (approve, cancel, park/unpark, amend, status change, requeue) plus session launch. It is a front end only — the queue itself lives in `task-queue-mcp`.

## Module boundaries

Expand All @@ -22,6 +22,10 @@ src/
ws-guard.ts The WebSocket upgrade decision, as a pure function of
(peer address, Origin, allowlist). Extracted for the
same reason: server.ts listens at import time.
dead-letters.ts Shaping and reason-grouping for dead-lettered records, as
pure functions. Extracted for the same reason: the rule that
turns N identical failures into ONE problem deserves tests,
not a loop inside a renderer.
path-guard.ts The realpath-then-prefix filesystem check, in one place.
Used by the context-ref preview AND the headless-run
log reader. Not pure — resolving symlinks is the point.
Expand All @@ -35,6 +39,7 @@ src/
task-list.ts List view, filters, grouping, per-row actions
task-detail.ts Detail view, history timeline, amendments, previews
headless-runs.ts Headless-run list and log viewer, below the task list
dead-letters.ts Collapsed dead-letter section, grouped by failure reason
styles.ts Theme colours (read live from CloudCLI CSS vars), helpers
ws-client.ts Reconnecting WebSocket client
tests/ node --test
Expand All @@ -43,14 +48,18 @@ src/
## Invariants

- **The plugin never writes queue YAML directly.** Reads are direct (fast, watchable); every mutation goes through `control-api.ts` to the MCP control API, inheriting its transition validation, `fcntl` locking, and atomic writes. A new mutation means a new control-API action, never an `fs.writeFile`.
- **`ControlAction` must match the MCP's route set.** The union type in `control-api.ts`, the route regex in `server.ts`, and the MCP's custom routes are three copies of one contract. Change one, change all three.
- **`ControlAction` must match the MCP's route set.** The union type in `control-api.ts`, the route regex in `server.ts`, and the MCP's custom routes are three copies of one contract. Change one, change all three. The two copies that live in *this* repo are now pinned to each other by a source-level test in `control-api.test.ts` — nothing detected the drift before, because adding an action to the union alone compiles and the failure mode is a button that 404s against the plugin's own backend. The third copy is in another repo and still needs a human.
- **The plugin acts as `operator`, never as an agent.** Every proxied mutation sends `actor: 'operator'`. This is what makes the MCP's `amend_task` authorization accept it; the plugin must never assert an agent's identity.
- **Mutations fail closed without the secret.** `callControlApi` returns 500 and never attempts the fetch when `TASK_QUEUE_API_SECRET` is empty, and logs why. Do not add a fallback that proceeds without it.
- **The version comes from `package.json`.** `server.ts` reads it at startup rather than hardcoding a copy — a hardcoded constant silently drifted and reported a stale version on `/health` for two releases. `package.json` and `manifest.json` must also agree.
- **There is exactly one agent roster, and it is not in this repo.** `~/scripts/agent-launch.yml` (override: `AGENT_LAUNCH_POLICY`) is read by this plugin *and* by `task-dispatcher.py`. A hardcoded `AGENT_PROJECTS` map used to live in `server.ts`; it drifted, lost an agent, and that drift is vikunja#523. Do not add a literal roster back — extend the file.
- **A run-as agent goes through its launcher, always.** An entry with `run_as_user` is spawned as `sudo -n -u <user> <launcher> …`, never as `claude`. Spawning `claude` directly for such an agent bypasses the launcher's identity guard and yields a session that appears as that agent in every log while holding none of its credentials. A missing or non-executable launcher is refused by name — there is no fallback path, deliberately.
- **The launch policy fails closed, loudly.** A missing or malformed policy file disables Start with a named error. It must never degrade to an empty policy: an empty policy makes `run_as_user` absent for every agent, which is precisely the impersonation above.
- **Path guards resolve symlinks, and there is one guard.** `resolveAllowedPath` in `path-guard.ts` calls `fs.realpathSync` *before* the prefix compare; `path.resolve` alone normalises `..` but follows nothing, so a symlink inside an allowed prefix would escape it. The trailing `path.sep` on each prefix is load-bearing — without it `/comms-other` matches `/comms`. Both the context-ref preview and the headless-run reader call this one function. Do not write a second check; a subtly different copy is how this property gets lost.
- **The dead-letters section is read-only, and its count is not conditional on being open.** `GET /dead-letters` reads `dead-letters/*.yml` directly like every other read here; the one mutation (requeue) goes through `control-api.ts` like every other mutation. The count loads on every refresh rather than on expand — a number that appears only after the operator opens the section is a number nobody sees, which is the failure this whole surface exists to end. The healthy value is zero and the heading renders that too.
- **Dead letters are grouped by `failed_reason`, never listed flat.** Seventeen records carrying one identical reason are one bug that fired seventeen times; seventeen sibling rows read as seventeen unrelated problems, which is roughly how they were treated for three months (vikunja#557). The grouping is a pure function in `dead-letters.ts` with tests, not a loop in the panel.
- **A dead-letter record with no `id` is skipped, not rendered.** A row that cannot be addressed is a row whose Requeue button could not work, and offering one would be a lie. Likewise an unparseable failure timestamp renders as unknown — `ago()` maps an invalid date to "just now", which on this surface would claim a three-month-old drop happened seconds ago.
- **The queue watcher does not see `dead-letters/`.** `fs.watch` is not recursive on Linux and the watch is on the queue root, so a task arriving in the subdirectory fires no event. That is deliberate: the section is collapsed, a dead letter is not live work, and the dispatcher unlinks the original from the root as it dead-letters — which does fire. Do not add a recursive watch to stream a surface nobody is watching live.
- **The headless-runs section reads launch logs and never writes them.** Both routes are `GET`, resolve through `resolveAllowedPath`, and never treat a route id as a path: `:id` is validated as `<agent>-<task8>` and the filename is then *rebuilt* via `launchLogName`, so the caller's string cannot reach the filesystem even before the realpath guard runs. The guard applies to the **list** route as well as the detail route — the list head-reads every file, so without it a symlink planted in the log directory puts the first line of its target into a row. That was verified against a real `~/.secrets/forge.env` symlink, which leaked with the guard removed and was refused with it present.
- **The launch-log filename parser is the inverse of `launchLogName`, and is pinned to it by a round-trip test.** `launch-policy.ts` owns the name shape and two producers write it (this plugin and `task-dispatcher.py`). A hardcoded regex here would not error when it drifted — the section would simply list nothing. Unparseable names are **skipped**, never rendered with a guessed or empty agent.
- **A run's status comes from the queue, never from the log.** A log proves a session ran; it does not prove its task closed. `steward-f42d3aeb` completed on 2026-08-23 against a task that sat at `approved` for four days. Render that disagreement — do not infer status from the log's prose.
Expand All @@ -67,7 +76,7 @@ npm run build # tsc --noEmit is the typecheck gate; esbuild bundles after it p
npm test
```

Tests cover `control-api.ts` (the secret gate, task-id validation, header and body shape per action, transport-failure mapping, pass-through of the MCP's authorization rejections), `ws-guard.ts` (all three upgrade cases, including the loopback-with-no-Origin one that v0.4.0 broke), `launch-policy.ts` (every closed-set rejection, whole-document rejection, and both argv shapes), `path-guard.ts` (a **real** symlink escape, traversal, the `/comms-other` sibling case — with real files in a tmpdir, because a mocked `fs` cannot demonstrate that realpath runs first), `launch-log.ts` (round-trip against the real `launchLogName`, the live bare-UUID orphans, path-shaped route ids, fence extraction including the dropped unterminated case, and the birthtime-after-mtime fallback), and the reconnect schedule. The UI panels are not unit-tested — verify them in CloudCLI after `./deploy.sh && pm2 restart cloudcli`.
Tests cover `control-api.ts` (the secret gate, task-id validation, header and body shape per action, transport-failure mapping, pass-through of the MCP's authorization rejections, and the union/route-regex drift gate), `dead-letters.ts` (the real dispatcher record shape including the `Date`-valued `created` js-yaml hands back, the id-less and reason-less fallbacks, and the grouping rule against the live seventeen-identical-reasons case), `ws-guard.ts` (all three upgrade cases, including the loopback-with-no-Origin one that v0.4.0 broke), `launch-policy.ts` (every closed-set rejection, whole-document rejection, and both argv shapes), `path-guard.ts` (a **real** symlink escape, traversal, the `/comms-other` sibling case — with real files in a tmpdir, because a mocked `fs` cannot demonstrate that realpath runs first), `launch-log.ts` (round-trip against the real `launchLogName`, the live bare-UUID orphans, path-shaped route ids, fence extraction including the dropped unterminated case, and the birthtime-after-mtime fallback), and the reconnect schedule. The UI panels are not unit-tested — verify them in CloudCLI after `./deploy.sh && pm2 restart cloudcli`.

Two build/test gotchas worth knowing before you touch either script:

Expand Down
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,66 @@

All notable changes to this project will be documented in this file.

## [0.7.0] - 2026-08-29

Adds a collapsed **Dead letters** section below the task list, and a Requeue button.
Build `agent-workflow-interop-2026-08` Phase 1; vikunja#557. Pairs with
`task-queue-mcp` v0.10.0, which is where the queue-side half lives.

`~/.claude/task-queue/dead-letters/` is written by `task-dispatcher` when a task exhausts
its routing retries. Nothing could show it: the MCP's `get_task` searched the queue root
then `archive/` and answered `not found`, and this plugin globbed the queue root only.
Seventeen tasks accumulated there between 2026-05-29 and 2026-07-25 — every one a security
audit request, all seventeen carrying the identical `failed_reason` (`Invalid or missing
build_name in payload: 'unknown'`) — and the only notice any of them ever got was a single
Matrix message at the moment it was dropped.

This does not fix the bug that produced them; that is vikunja#63/#169.

### Added
- `GET /dead-letters` — read-only, reading `dead-letters/*.yml` directly like every other
read here. No new `manifest.json` permission and no new env var.
- `panels/dead-letters.ts` — a **collapsed** section, not a tab: the healthy count is zero,
so a tab would be permanently empty furniture. The heading renders whatever the count is,
including `none`, and turns red the moment it is not. The count loads on every refresh
rather than on expand — a number that appears only after the operator opens the section
is a number nobody sees.
- `dead-letters.ts` — record shaping and reason-grouping as pure functions, unit-tested.
**Grouped by `failed_reason`**: seventeen records with one identical reason are one bug
that fired seventeen times, and seventeen sibling rows read as seventeen unrelated
problems — roughly how they were treated for three months. Largest group first; newest
failure first within a group.
- **Requeue** button, wired to the new `requeue` control action → `POST /tasks/:id/requeue`
on the MCP control API (operator-only there). Confirmed before firing, and the
confirmation says the part that matters: requeueing does **not** fix why the task was
dropped, so if the cause is still live it will come back.
- A source-level **drift gate** pinning the `ControlAction` union to `server.ts`'s mutation
route regex. The AGENTS.md invariant said these were "three copies of one contract" and
nothing checked it: adding an action to the union alone compiles, and the failure mode is
a button that 404s against the plugin's own backend. Two of the three copies live in this
repo and are now pinned to each other. Verified to fail in both directions.

### Security
- Audited 2026-08-29 (`agent-workflow-interop-2026-08-phase1`): no findings in this repo.
The auditor independently confirmed structurally — not just by test — that
`toDeadLetter()` builds its response from an explicit field allowlist and never
references `payload`, so `GET /dead-letters` is a strictly narrower surface than the
existing `GET /tasks`; and that `renderRow()` escapes every interpolated value while
routing `summary` and `reason` through `textContent`.

### Notes
- A record with no `id` is **skipped**, never rendered — a row that cannot be addressed is
a row whose Requeue button could not work.
- An unparseable failure timestamp renders as `—`, not as an age. `ago()` maps an invalid
date to "just now", which on this surface would claim a three-month-old drop happened
seconds ago.
- `created` arrives from js-yaml as a `Date` for every real record (unquoted YAML
timestamp), and is serialised rather than string-coerced.
- The queue watcher still watches the queue root only. `fs.watch` is not recursive on
Linux, so a task arriving in `dead-letters/` fires no event — deliberate: the section is
collapsed, a dead letter is not live work, and the dispatcher unlinks the original from
the root as it dead-letters, which does fire.

## [0.6.0] - 2026-08-27

Adds a **Headless runs** section below the task list: a read-only view of agent sessions
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,40 @@ Live updates arrive over WebSocket: the backend watches the queue directory and
- **Park / Unpark** — pause a task without losing sight of it. A parked task stays in the list, renders muted with an `Unpark` button, is exempt from TTL expiry, and won't be picked up until you unpark it. Unparking returns it to the status it was parked from.
- **Amend** — append a correction to a queued task. The original description is never rewritten; amendments render below it, highlighted, so a reader can't act on stale instructions by mistake.
- **Status change** — advance a task an agent missed (audited operator override)
- **Requeue** — return a dead-lettered task to the queue at `submitted` (see below)
- Live connection indicator and a manual refresh button

## Dead letters

A collapsed section below the task list, showing every record in
`~/.claude/task-queue/dead-letters/` — tasks `task-dispatcher` gave up routing after
exhausting its retries. Nothing picks one up and no agent can transition it.

It exists because nothing could show them. `get_task` searched the queue root then
`archive/` and answered `not found`; this plugin globbed the queue root only. Seventeen
tasks accumulated in that directory between 2026-05-29 and 2026-07-25 — every one a
security audit request, all seventeen carrying the identical `failed_reason` — and the
only notice any of them ever got was a single Matrix message at the moment it was dropped.
A known bug quietly ate seventeen security audits over three months (vikunja#557).

Three decisions worth stating:

- **Collapsed, and a section rather than a tab.** The healthy count here is zero, so a tab
would be permanently empty furniture. What it must never be is absent — the heading
renders whatever the count is, including `none`, and turns red the moment it is not.
- **Grouped by failure reason.** Seventeen records with one identical reason are one bug
that fired seventeen times. Rendering them as seventeen sibling rows reproduces exactly
the reading that let them sit. Largest group first; newest failure first within a group.
- **The count loads on every refresh, not on expand.** A count that only appears once the
operator opens the section is a count nobody sees.

**Requeue** sends a record back to the queue at `submitted` with its `failed_reason`
cleared and its retry count reset, via `POST /tasks/:id/requeue` on the control API — an
operator-only route in `task-queue-mcp` v0.10.0+. It is confirmed before firing, and the
confirmation says the thing that matters: requeueing does **not** fix why the task was
dropped. All seventeen of the records this shipped against would dead-letter again for the
same reason, which is vikunja#63/#169 and a separate piece of work.

## Headless runs

Below the task list, a read-only section lists agent sessions launched with no operator
Expand Down Expand Up @@ -81,6 +113,9 @@ or env var — see [Backend API](#backend-api).

- **Not a queue schema owner.** Statuses, transitions, and validation belong to `task-queue-mcp`. This plugin renders what that server permits and surfaces its rejections verbatim.
- **Not an agent runner.** It can spawn a session for a task; it does not supervise, monitor, or manage agents after launch.
- **Not a dead-letter fixer.** The Dead letters section shows what was dropped and offers
to put it back. It does not and cannot repair the reason it was dropped — that lives in
the dispatcher and in whatever wrote the task.
- **Not a general task tracker.** It is scoped to one queue directory of agent-coordination tasks — not a replacement for an issue tracker.

## Installation
Expand Down Expand Up @@ -133,6 +168,8 @@ The backend exposes a small HTTP API consumed by the UI via `api.rpc()`.
| `POST` | `/tasks/:id/park` | Park; body `{note?}` — proxied |
| `POST` | `/tasks/:id/unpark` | Unpark; body `{note?, status?}` — proxied |
| `POST` | `/tasks/:id/amend` | Append an amendment; body `{amendment, reason?}` — proxied |
| `POST` | `/tasks/:id/requeue` | Requeue a dead-lettered task; body `{note?}` — proxied |
| `GET` | `/dead-letters` | List dead-lettered tasks. Read-only |
| `GET` | `/headless-runs` | List headless agent runs; query param `agent`. Read-only |
| `GET` | `/headless-runs/:id` | One run's full log text plus scraped commands; `:id` is `<agent>-<task8>`. Read-only |

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "task-queue",
"displayName": "Task Queue",
"version": "0.6.0",
"version": "0.7.0",
"description": "Task queue dashboard — view, filter, and launch agent tasks.",
"author": "TadMSTR",
"icon": "icon.svg",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cloudcli-plugin-task-queue",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading