Skip to content

Close the plugin's task-queue vocabulary drift, then gate it (vikunja#558, #543) - #10

Merged
TadMSTR merged 3 commits into
mainfrom
fix/plugin-vocabulary-drift
Aug 29, 2026
Merged

TadMSTR merged 3 commits into
mainfrom
fix/plugin-vocabulary-drift

Conversation

@TadMSTR

@TadMSTR TadMSTR commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Build agent-workflow-interop-2026-08 Phase 2. Closes vikunja#558, folding in #543 — same four files, same defect class.

The drift

task-queue-mcp made routing-failed a first-class non-terminal status and counts it in active on GET /queue/summary. This plugin carried four partial hand-written copies of a vocabulary it does not own, and none of them were updated:

Site Effect
panels/task-list.ts STATUS_ORDER no entry, fell through ?? 9 — a retrying, failing task sorted below cancelled, dead last
panels/task-list.ts NON_TERMINAL_STATUSES the status filter never offered the value; the status-change control could not move a task into it
panels/task-detail.ts DETAIL_NON_TERMINAL_STATUSES second copy of the same list, stale the same way
panels/styles.ts statusColor no case, so default: muted — identical to parked and cancelled

The status most in need of an operator was the one that sorted last and looked least urgent. Nothing detected it: a missing key in a Record<string, …> is not a type error, and a switch with a default has no missing case.

What changed

routing-failed is a known status everywhere. Sorts above in-progress — first in its group — renders error rather than muted, is offered by the status filter, and is a valid destination in the operator status-change control. Setting it by hand is not a dead end: the dispatcher's process_routing_failed pass picks up any such task whose retry window has passed, and is_eligible returns true when next_retry_at is absent — so it reads as "re-route now, skipping re-approval", which is a thing an operator wants.

A routing-failed task explains itself. The detail view gains a banner naming what the dispatcher is doing, retries used, next attempt, and that the end of the budget is a dead letter.

A queued manual-then-auto survives the Start button (#543). toWorkflowMode collapsed it to semi-auto, which re-pins every task the session spawns back to semi-auto — the exact failure vikunja#533 added the mode to fix (four security→steward return tasks sat unactioned for over a week). review on such a task now passes manual-then-auto through; run-steward.sh already accepts it and does the downgrade. An explicit auto Start still overrides, and the launch note says which it did. This was a live behavioural bug, not just a missing badge.

src/vocabulary.ts now holds one copy of the vocabulary plus the UI maps keyed by it. STATUS_ORDER and STATUS_COLOR are Record<Status, …>, so adding a status without a sort position and a colour is a tsc error. That is the half the old code lacked — Record<string, number> accepts anything and covers nothing.

Also: a Mode filter and a mode badge (rows badge auto and manual-then-auto, stay quiet about semi-auto, which is 98% of the queue); a task with no recorded workflow_mode renders as unknown rather than as the queue's default, because older records predate the field and inventing a value the queue never wrote is how this class of bug starts.

The gate

npm run gate:vocabulary fetches task-queue-mcp's main and asserts the sets match. Ported from task-dispatcher/tests/test_task_queue_vocabulary.py (vikunja#324) keeping both properties that make that one work:

  • No skip-on-no-network path. It exits non-zero if it cannot read the upstream. A check that quietly passes when it could not reach its source of truth is indistinguishable from one that verified something, and that shape is how #324 stayed open for months. It likewise exits non-zero if it parses zero set literals rather than reporting a vacuous pass over an empty comparison.
  • Tracks main, not a pin. A vocabulary change merged upstream turns this repo red on the next push. That is the alarm, not a malfunction. TASK_QUEUE_MCP_REF exists only for a paired pre-merge change from a shell; CI does not set it, and the ref is charset-constrained so it cannot traverse to another repo's path on raw.githubusercontent.com.

It is its own CI step, after npm test. A red vocabulary means "go edit src/vocabulary.ts" — a different instruction from any unit failure — and folded into one command either could hide the other.

Nothing fetched is executed and no Python runs: a narrow tokenizer (src/gates/python-sets.ts, split out so it is testable) accepts a brace-delimited list of plain string literals and refuses everything else, including the two upstream constructs that must not parse as sets — the derived NON_TERMINAL_STATUSES = VALID_STATUSES - TERMINAL_STATUSES and the annotated VALID_TRANSITIONS: dict[...].

Proof it fires

A gate nobody has seen fail is not yet a gate. All four outcomes verified before this was opened:

Condition Expected Actual
in sync with main exit 0 exit 0
TASK_QUEUE_MCP_REF=v0.8.2 (a real upstream ref predating manual-then-auto) exit 1, names the difference exit 1, VALID_WORKFLOW_MODES — plugin-only: [manual-then-auto]
no network reachable exit 2, hard fail exit 2
TASK_QUEUE_MCP_REF=../../evil/main refused exit 2, refused by name

The new assertions were each verified red against the defect they cover, by mutating the fix back: routing-failed sorting last (2 fail), rendering muted (1 fail), omitted from NON_TERMINAL_STATUSES (2 fail), toWorkflowMode flattening manual-then-auto (2 fail), the parser accepting a derived set (1 fail), and the parser matching an annotated assignment (1 fail). Baseline green in every case after restore.

Also

Relative imports now use .ts, not .js. node --test runs the sources directly under type stripping and does not rewrite a .js specifier to the .ts file beside it — the panels all used .js and were simply never imported by a test; the first one that needed to be, was not.

Verification

  • npm run build (tsc + esbuild) clean
  • npm test — 110 pass, 0 fail (was 85)
  • npm run gate:vocabulary — in sync

The UI half (sort position, colour, banner, filter, badge) needs eyes in CloudCLI after ./deploy.sh && pm2 restart cloudcli — pm2 is sysadmin's, so that goes out as a deploy task.

🤖 Generated with Claude Code

developer-agent added 3 commits August 29, 2026 08:20
…#558, #543)

The plugin carried four partial hand-written copies of a vocabulary it does
not own -- task-queue-mcp's src/tools/queue.py. None of them learned about
`routing-failed` when the MCP made it a first-class non-terminal status, so
the status most in need of an operator sorted below `cancelled`, rendered
the same grey as `parked`, and was not offered by the status filter.
`manual-then-auto` was the same omission one field over.

Fixed
- routing-failed sorts above in-progress, renders in `error`, is filterable,
  and is a valid destination in the status-change control. Setting it by hand
  is not a dead end: the dispatcher's routing-failed pass picks up any such
  task whose retry window has passed, and a record with no next_retry_at is
  eligible immediately -- so it reads as "re-route now, skipping re-approval".
- The detail view gains a banner saying what routing-failed means, how many
  retries are used, when the next attempt is due, and that the end of the
  budget is a dead letter.
- A queued `manual-then-auto` now survives the Start button. toWorkflowMode
  collapsed it to `semi-auto`, re-pinning every task the session spawns back
  to `semi-auto` -- the exact failure vikunja#533 added the mode to fix.

Added
- src/vocabulary.ts: one copy of the vocabulary plus the UI maps keyed BY it.
  STATUS_ORDER and STATUS_COLOR are Record<Status, ...>, so adding a status
  without a sort position and a colour is a tsc error rather than a silent
  fallthrough to `?? 9` and `muted`. That is the half the old code lacked --
  Record<string, ...> accepts anything and covers nothing.
- npm run gate:vocabulary, as its own CI step. Ported from task-dispatcher's
  test_task_queue_vocabulary.py (vikunja#324) keeping both properties that
  make it work: no skip-on-no-network path, and it tracks `main` not a pin.
  Proven to fire before shipping -- exit 0 in sync, 1 on real drift against
  an older upstream ref, 2 with no network, 2 on a traversal-shaped ref.
- A Mode filter and a mode badge. A task with no recorded workflow_mode
  renders as unknown, not as the queue's default.

Changed
- The two NON_TERMINAL_STATUSES literals are one derived constant.
- Relative imports use .ts, not .js: node --test runs the sources directly
  and does not rewrite a .js specifier to the .ts beside it, so the panels
  were not importable by a test.

110 tests pass; each new assertion verified red against the defect it covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

agent-id: developer
Caught by the pre-audit baseline. The routing-failed banner interpolated
`new Date(next_retry_at).toLocaleString()` directly, so a malformed value
in the queue YAML rendered the literal string "Invalid Date" at the
operator. It reaches textContent, not innerHTML, so this was never an
injection -- it is the same failure as the launch-log birthtime case: a
date the code could not read, presented as though it could.

Now the banner names it as unreadable and keeps the rest of the message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

agent-id: developer
agent-workflow-interop-2026-08-phase2 audit: 0 findings, 2 Info, both no
action. INFO 1 was the deliberate absence of `redirect: "manual"` on the
vocabulary gate's fetch, self-disclosed before the audit ran and confirmed
acceptable. Marked SECURITY[accepted] where the decision lives, with the
row in host-forge-knowledge-base/security/accepted-risks.md.

Also adds the in-build "Invalid Date" fix (91acf66) to the 0.8.0 changelog
entry, which it was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

agent-id: developer
@TadMSTR
TadMSTR merged commit c4f1f41 into main Aug 29, 2026
2 checks passed
@TadMSTR
TadMSTR deleted the fix/plugin-vocabulary-drift branch August 29, 2026 12:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant