Skip to content

feat(kanban): validate dispatch capabilities before claim - #4

Merged
SSC-ENG merged 143 commits into
mainfrom
fix/hel-3114-pre-dispatch-validation
Jul 30, 2026
Merged

feat(kanban): validate dispatch capabilities before claim#4
SSC-ENG merged 143 commits into
mainfrom
fix/hel-3114-pre-dispatch-validation

Conversation

@SSC-ENG

@SSC-ENG SSC-ENG commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a fail-closed pre-dispatch capability gate before task claim
  • validate profile certification and skill packages, credentials, runtime, workspace, and node boundary
  • emit one actionable durable failure event and block invalid candidates without retry loops

Linear

HEL-3114

Tests

  • /Users/danieldezago/.hermes/hermes-agent/venv/bin/python -m pytest -q tests/hermes_cli/test_kanban_preflight.py tests/hermes_cli/test_kanban_db.py -k dispatch
  • 10 passed
  • python3 -m py_compile hermes_cli/kanban_preflight.py hermes_cli/kanban_db.py tests/hermes_cli/test_kanban_preflight.py

Deployment impact

Dispatcher-only behavior. Invalid candidates are blocked before claim; no worker run or workspace is created.

Rollback

Revert this PR commit to restore prior dispatch behavior.

teknium1 and others added 30 commits July 29, 2026 12:18
…nmocked network in compressor tests, stale-SDK feishu pin guard, quadratic redact regexes

- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files:
  it prepended the tests/ dir itself to sys.path, so 'import agent' /
  'import hermes_cli' resolved to the test packages and collection died
  with ModuleNotFoundError depending on import order (2 files failed in
  every full-suite run; 9 more were latent).
- Patch call_llm in 5 context-compressor tests that called compress()
  unmocked: each burned ~50s attempting live LLM traffic through the
  relay before falling back (572s file — the slowest in the suite, and
  flaky under the 300s per-file timeout). File now runs in ~5s.
- agent/redact.py: fix two catastrophically-backtracking regexes hit by
  the compressor's redaction pass on large payloads —
  _STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme
  prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms;
  output-equivalence fuzz-verified on 20k random strings), and the
  _CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword
  pre-gate so secret-free text skips the quadratic pattern entirely.
- tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK
  signature check; the repo pins lark-oapi==1.6.8 but stale local
  installs (1.5.3) fail the assertion — skip below the pin.
- tests/tools/test_managed_browserbase_and_modal.py: stub
  agent.redact + agent.credential_persistence in the fake agent package
  (empty __path__ blocks all real agent.* imports added since the fake
  was written).
- tests/gateway/test_startup_restart_race.py: raise wait_for timeouts
  2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the
  baseline run (passes instantly when the box is quiet).
…t functions

Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
…ite wall 315s → 294s

Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
# Conflicts:
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/run_agent/test_codex_app_server_integration.py
#	tests/test_tui_gateway_server.py
#	tests/tools/test_computer_use_delivery_ladder.py
#	tests/tools/test_zombie_process_cleanup.py
# Conflicts:
#	tests/agent/test_context_compressor.py
#	tests/gateway/test_startup_restart_race.py
#	tests/hermes_cli/test_voice_wrapper.py
# Conflicts:
#	tests/run_agent/test_conversation_fallback_state.py
…'s 6s status poll

The change watcher (NousResearch#73673) missed one always-on-while-mounted timer: the
Messaging page polled /api/messaging/platforms every 6s for connection
status. The gateway already persists platform connect/disconnect/health to
gateway_state.json, so watch that file's mtime and broadcast
platforms.changed (floored to 5s — the gateway also rewrites the file for
in-flight-count bookkeeping), route it through live-sync like its
siblings, and refresh the page on the tick. Older backends keep the
legacy visible-tab poll verbatim.

Finishes the always-on poll sweep for NousResearch#73618.
hermes-setup.exe bakes its build-time commit into the binary
(BUILD_PIN_COMMIT) and passes it as -Commit on every install-mode run,
including the retry the desktop's "Update didn't finish" screen kicks
off. The repository stage checked that SHA out unconditionally, so an
installer built months earlier rewound a current managed checkout to its
build commit -- 9,160 commits in the reported case -- leaving ancient
source against a current venv. npm then failed on workspaces that did not
exist yet at that commit, and every later update ran against the wrong
tree.

Skip the pin when its target is already an ancestor of HEAD. Fresh clones
have no such ancestry so reproducible/CI pinning is unchanged, and
--force-commit / -ForceCommit still rolls back on purpose.
…e with main's readonly sweep)

Main's 243c918/a16fd675df/7142dc4580 added load_config_readonly
sibling stubs across 38 files; our pruned versions of 11 of those files
kept only the load_config stubs. Re-applied the pairing at every
surviving site (26 patch()/setattr sites) — same return_value/
side_effect as the adjacent load_config stub. 494 tests green across
the 11 files.
Three surfaces start updates against one checkout: a terminal
"hermes update", the dashboard's Update button (which spawns that same
command detached), and the desktop's, which hands off to the Tauri
updater. Only the Tauri updater published the in-progress marker, and
only Electron read it -- to gate backend startup, not to stop a second
updater. So a dashboard-spawned update and an installer-driven git
checkout could mutate the same tree concurrently, rewriting source under
a live interpreter.

Claim the same marker from cmd_update rather than adding a second
mechanism: same path, same pid+started_at payload the Rust and Electron
readers already parse. A marker only counts as live when its pid is alive
and it is inside the shared age ceiling, so a crashed updater self-heals
instead of wedging every future update. Release only removes a marker we
still own, leaving a handoff partner's claim intact.

Refusing exits 2, matching the existing concurrent-instance contract the
Tauri updater already recognizes.
UpdateMarkerGuard::acquire overwrote the in-progress marker
unconditionally, so a Tauri update launched while a dashboard-spawned
"hermes update" was mid-flight simply took the marker and ran a second
updater over the same checkout. That is the race behind the reported
Windows failure: install-mode bootstrap rewound the tree while the
dashboard's updater was still running npm install against it.

acquire now returns Result and refuses when a live foreign owner holds
the marker, and Drop no longer deletes a marker this process does not
own. Liveness matches the Python and Electron readers of the same file:
dead pid or past the shared age ceiling means stale and reclaimable, so
a crashed updater cannot wedge future updates.

Adds a cfg(unix) libc dependency for the signal-0 liveness probe; the
Windows path uses OpenProcess/GetExitCodeProcess.
…ow-value

test: prune low-value tests suite-wide — 58% fewer tests, half the wall time, zero flakes
The updater keeps a Tauri/Cocoa event loop alive while it relaunches the
desktop, and that loop can outlive app.exit(0). Relying on Drop alone
left a *successful* update looking active -- a live pid holding a fresh
marker -- which blocked desktop startup and, now that the marker is also
the cross-process update lock, every subsequent updater until the age
ceiling expired.

Release explicitly once all install-tree mutations are done, before the
relaunch. complete() is idempotent so Drop still covers the failure and
panic paths. Arms a process-exit fallback so a wedged event loop cannot
leave a finished updater lingering as a live pid.

Co-authored-by: nateEc <nateEc@users.noreply.github.com>
approve_code()'s success path never cleared _failures:{platform}. The
counter is incremented on every non-matching code, persisted in
_rate_limits.json, and only ever reset to 0 when it reaches
MAX_FAILED_ATTEMPTS (firing the lockout). So it counts failures over the
gateway's entire lifetime, not consecutive ones.

An owner who mistypes a pairing code on a handful of separate occasions
— each time immediately retyping it correctly and successfully pairing —
accumulates those isolated typos. A later single fresh typo then hits
MAX_FAILED_ATTEMPTS and locks the whole platform out for an hour, at
which point _is_locked_out gates approve_code and even the *correct*
code is rejected.

Reset the counter on a successful approval, matching standard
brute-force-guard semantics (the counter tracks consecutive failures).
This does not weaken protection: an attacker cannot produce a success
without a valid code, and 5 consecutive wrong attempts still lock out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up hardening on the request-id grant path.

approve_request took the same lockout treatment as approve_code: gated by
it, and recording a miss toward it. But the two paths defend different
things. The lockout exists to stop guessing at the 8-char code space over a
messaging channel; a request id is only ever obtained by an admin already
authenticated to the store, so a miss means the row they clicked went stale.
Counting those let a handful of clicks on a stale list lock the operator out
of `hermes pairing approve` for an hour — the GUI DoSing the CLI.

Also drops the `code`/`code_hash_prefix` compat fields from list_pending.
The hash prefix is what admin surfaces mistook for an approvable code in the
first place, and re-exporting the request id under the old `code` key just
preserves the ambiguity; both consumers in the tree read `request_id` now.
The 16-hex sniffing that had been copy-pasted into the CLI and the endpoint
(where a chained conditional consulted it against the wrong field) moves to
one owner, PairingStore.looks_like_request_id.

The endpoint no longer reports a 429 on the request-id path, where lockout
can't apply — a stale id surfaced as a bogus "locked out" while the platform
sat locked for something else entirely.
The Windows-footgun linter caught a real bug in the new update lock. On
Windows os.kill(pid, 0) is not a no-op: CPython routes sig=0 to
GenerateConsoleCtrlEvent, which sends Ctrl+C to the target's entire
console process group (bpo-14484). The liveness probe would have killed
the very updater it was asking about -- and any sibling sharing that
console.

Delegate to gateway.status._pid_exists, the project's existing no-kill
probe, which uses psutil (OpenProcess/GetExitCodeProcess on Windows) and
also reports zombies as dead. Any pid we cannot evaluate still counts as
dead so a corrupt marker cannot wedge the lock.
…e at :root

The thread's bottom clearance is composer + status-stack + 2rem, and both
inputs are measured by JS onto the owning [data-chat-surface]. The surface-var
helpers fell back to document.documentElement when they couldn't resolve one —
but :root is where every surface's DEFAULTS live, so a single stale write there
becomes a global floor under every thread's clearance until reload.

An unowned publisher has nowhere to publish to, so it now publishes nowhere.
…arance

fix(desktop): stop an unowned publisher poisoning the thread clearance at :root
…prove-entry

fix(pairing): make the listed pending request approvable
fix(update): one updater at a time, and never roll an install backwards
newSessionOpensTab answers a question that isn't specific to the sidebar
"+" — is there a conversation on main that must not be discarded — and the
palette needs the same answer. Fold it into open-session as
mainChatOccupied so both callers share one definition.
An unused draft tab is the one a user would have typed into, so give the
tile store a way to name it (blankDraftTile) and hand it to another
session in place (reuseBlankDraftTile). A blank-but-busy tab has its
first turn in flight and an unbound tile is unknown rather than empty, so
neither is a candidate.
Both surfaces passed the sidebar's in-place intent, which means "load it
into main when it isn't already on screen" — right for a row you clicked
in a list you were looking at, wrong for a chat opened from outside the
workspace. Neither had a surface of its own, so they took the one you
were using.

Add a stack intent for that case. It focuses the session when it's
already open, spends an unused draft tab when there is one, and only
falls back to main while main is itself a blank draft. Modifiers still
force a tab or window.
⌘1…⌘9, ⌃Tab, and the ⌘W / ⌘T family all resolved the last-interacted
zone, so switching tabs in a second pane meant clicking into it first.
They now resolve the HOVERED zone when the pointer is in one, falling
back to the focused zone otherwise — hover pane 1, ⌘2; hover pane 2, ⌘2,
and each lands in its own strip.

tabTargetGroupId() is the single resolver, keeping the number keys and
the tab verbs from disagreeing about which zone is "the" zone the way
they already couldn't. pointerover fires per boundary crossing rather
than per mouse move, and leaving the document clears the override so a
parked pointer never strands the keys on a stale zone.
…modules (wave 2; route-table equality verified)

- hermes_cli/web_routers/sessions.py: 14 routes across 3 routers
  (list_router, search_router, manage_router) mounted at the three original
  registration points so global route order is preserved exactly.
- hermes_cli/web_routers/mcp.py: 11 routes; OAuth flow registry
  (_mcp_oauth_flows/lock/cap) stays in web_server, reached via new
  web_deps.LateState live proxies so tests mutating web_server._mcp_oauth_flows
  keep working.
- hermes_cli/web_routers/skills.py: 12 routes across hub_router + router
  (two original registration points straddle the profiles router include).
- hermes_cli/web_routers/tools.py: 12 routes; toolset/terminal catalogs stay
  in web_server (some are defined after the mount point), reached via LateState.
- web_deps.py: add LateState — operation-time proxy for web_server-owned
  module state (getattr/item/iter/len/contains/context-manager/comparisons).
- Handler bodies byte-identical; legacy re-exports keep
  web_server.<handler> importable for tests.
- Verified: ordered route table (method, path) identical to pre-refactor app
  (291 routes); import smoke; ruff; windows-footguns clean.
- test_web_server_sessiondb_eventloop.py: structural AST scan now reads both
  web_server.py and web_routers/sessions.py (handlers moved; helpers stayed).
…on-tab

Stop ⌘K and notification clicks stealing the main tab
…slots

Tab verbs follow the pane under the pointer
OutThisLife and others added 20 commits July 30, 2026 00:06
Two rules every hotkey-opened, search-driven overlay needs, in one place
so each picker doesn't reinvent them:

usePointerQuiet — a mouse that is merely PRESENT is not a mouse in use.
A list that opens under a parked cursor, or re-flows under one as its
filter narrows, fires pointerenter on whatever row slides beneath; menus
that select on hover take that as intent and steal the row you typed
toward. The pointer stays inert until it actually moves (or scrolls),
then hover works for the rest of the overlay's life.

releaseTypingFocus — dismissing the overlay ends its claim on the
keyboard. Handlers subscribe once, so the primitive stays ignorant of
what typing means on any given surface.
Committing a model with Enter left focus on the pill (Radix restores it
to the trigger), so the next thing typed went nowhere instead of into
the message being written. And with the cursor parked over the list,
rows re-flowing under it as the query narrowed hover-stole the selection
mid-type — Enter then committed whatever the mouse happened to be over.

Both surfaces adopt the shared primitives: the model menu and every cmdk
list (palette, model dialog, session picker, searchable selects) go
pointer-inert until the mouse moves, and the model menu plus the command
palette hand typing focus back on close. The release defers a frame and
yields when something editable already claimed focus, so a palette
action that opens a dialog or navigates keeps its own focus.

Replaces the model menu's focus/blur highlight gate — pointer intent is
the real signal, so the keyboard highlight no longer flickers off when
Radix moves DOM focus onto a hovered row.
… tool, model context

Reactions live in the existing messages.display_metadata JSON column (no new
table), with iOS Tapback semantics enforced DB-side: one reaction per author
per message, re-tap retracts, different emoji replaces. The desktop catches up
to the reaction contract five platform adapters already ship.

- SessionDB: set/get_message_reaction, latest_message_row_id (role + offset +
  require_text so invisible tool-call-only rows are never targeted),
  take_unseen_reactions (announce-exactly-once), get_message_role
- message.react RPC: accepts row_id or newest_role for live messages that
  haven't learned their durable id yet
- react_to_message tool: desktop-gated (check_fn), defaults to the user's
  latest visible message, messages_back for retroactive reactions
- Model context rides run_message only (beside the speech-interrupted note):
  the persisted prompt stays clean, so no [The user reacted …] scaffolding in
  transcripts, and no cached prefix ever changes
- Resume projection forwards row_id + reactions; _row_id is stripped from
  outgoing API copies next to display_metadata
…live paint

One slot, Slack-style: on assistant rows the picker trigger and the landed
reaction are the same far-right element, so reacting never shifts layout
(empty → ☺ following the action bar's hover fade; reacted → the emoji, always
visible, always full-strength). User bubbles react via right-click and show
the badge beneath, in the checkpoint row's register.

- Clicks paint instantly from a local nanostores overlay — no round-trip in
  the loop; the RPC persists behind it and rolls back visibly on rejection
- Agent reactions land via the message.reaction event into an overlay keyed
  by DURABLE row id, so the end-of-turn resume (which regenerates renderer
  ids and rebuilds from in-memory history) can't clobber the paint
- Full picker is frimousse behind the six-emoji quick row, fed from bundled
  emojibase-data served at ./emojibase by a small vite plugin (offline, no
  CDN), with Slack-style alternating cell tints keyed off the codepoint
- Reaction picker opts out of the shared popover glass: solid surface so
  15%-alpha hover tints stay readable
- react_to_message tool blocks are suppressed in the transcript (like todo):
  the reaction appearing IS the UI; failures still render
- rowId reaches rehydrated messages from both transcript shapes (gateway
  row_id, REST numeric id)
A third trigger kind beside @ and / — same detection, same popover, same
commit path. :jo opens 😂/🤣/… fed by the bundled emojibase shortcode data
(search hits shortcodes first, then tags and labels); picking inserts the
emoji character as plain inline text, not a chip. Boundary-anchored with a
two-char minimum so localhost:8080, timestamps, and :D never trigger it.
Wired in the main composer and the edit composer's duplicated trigger loop.
CI caught ACP session restore seeing an unexpected _row_id in restored
history — get_messages_as_conversation feeds more than the desktop, and
changing the default shape broke the strictest consumer. Row ids are now
include_row_ids=True, requested only by the gateway's resume/display
projections; ACP restore, export, and inspection get the transcript in its
historical shape.
…by default

One lever, every surface. The renderer toggle persists locally and mirrors
into display.message_reactions; the backend gates the agent's
react_to_message tool (check_fn) and the model-context annotation on the same
key, and the ':' composer trigger reads the store at detection time. Off
means off everywhere: no ☺ slot, no right-click picker, no :shortcode:
popover, no agent reactions, and the model hears nothing — while reactions
already persisted keep rendering so history doesn't lose data. Also fixes the
import-order lint error CI flagged in composer/index.tsx.
Slice 1 fell over on eight DB fakes with frozen get_messages_as_conversation
signatures — the new opt-in kwarg is part of the method's contract now, so
the fakes accept **_kwargs like the real SessionDB. Also opts the child-watch
resume projection into row ids: it feeds the same _history_to_messages as the
desktop resume, so reactions on a watched child session address rows the same
way.
…other platforms

npm install on macOS dropped all 26 @esbuild/* cross-platform entries (443
lines) — that breaks Linux/Windows installs. Restored main's lockfile and
merged in only the three genuinely new entries.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
codicon.css styles glyphs through `.codicon[class*='codicon-']`, a
two-class selector that outranks Tailwind's single-class `hidden`. The
indicator stacks a check and a dash and hides one per state, so neither
was ever hidden and the box rendered both glyphs side by side, spilling
past its 16px bounds in every state including unchecked.

Use the important modifier on both display utilities so state, not
stylesheet order, decides which glyph shows.
The micro-action pills, the status stack, and the underside strip all
rendered inside the composer root. The pop-out drag region is an
`absolute inset-0` child of that root, so everything alongside it was
inside the grab area by construction — hovering a pill hatched the
composer and a press between two badges started a peel-out drag.

That was being patched at the gesture instead of the structure: a
`composer-no-drag` exclusion in gestureTargetOk, a matching guard on the
double-click toggle, and a strip that juggled z-index and pointer-events
to climb back over a region painting on top of it.

Introduce a dock column that owns the composer's position and stacks its
children in flow, bottom-anchored:

    composer-dock
    ├── micro-action strip
    ├── status stack
    ├── composer            <- drag region lives in here, and only here
    └── underside strip

The strips are siblings of the composer rather than children, so landing
in the grab area is impossible rather than excluded. gestureTargetOk and
the double-click handler go back to what they were, and the shared strip
constant drops to a bare flex row.

Two things fall out of the move:

Alignment stops needing a magic number. The strips sat in different
containing blocks — one in the stack's absolute lane resolving against
the root's padding box, one in the root's content box — and the root
carries `padding-inline: 5px` for the grab margin, which is the 5px the
pills hung left by. One parent and a shared `px-[5px]` line both strips
up with the surface directly.

The stack stops measuring itself. It published
--status-stack-measured-height only because it was out of flow and the
composer's measurement couldn't see it. As a dock child it's covered by
the dock's own height, so the var, the ResizeObserver effect, and the
detached-node cleanup it needed all go, and thread clearance reads one
number instead of summing two.
…tent

Keyboard-first pickers: give typing focus back, ignore a parked cursor
…tions

feat: iMessage-style emoji reactions on desktop — opt-in, two-way, persistent, model-aware
…trips-outside

Move the composer strips out of the pop-out drag region
…ndeterminate

fix(desktop): stop the checkbox painting the check and dash at once
@SSC-ENG SSC-ENG added the ci-reviewed Maintainer reviewed CI-sensitive / MCP catalog changes label Jul 30, 2026
@SSC-ENG
SSC-ENG merged commit e482975 into main Jul 30, 2026
@SSC-ENG
SSC-ENG deleted the fix/hel-3114-pre-dispatch-validation branch July 30, 2026 06:28
SSC-ENG added a commit that referenced this pull request Jul 30, 2026
Re-lands the governed HEL-3110 implementation (originally PR #3, merged
as ad29d8d, reverted by remediation PR #10 / 8684c5b because the
merge bypassed the mandatory pre-merge TRC gate) on post-removal main.

- work_intent_id correlation key on task_events (additive column +
  fresh-schema DDL), work_intent_id = task_id.
- Typed lifecycle events emitted at existing dispatcher mutation
  boundaries in hermes_cli/kanban_db.py: task_claimed,
  worker_spawn_requested, worker_spawned, worker_started, heartbeat,
  worker_exited. gateway/kanban_watchers.py remains caller-only.
- Append-only envelope per HELIOS-THROUGHPUT-TRC-FINAL-SCOPE \u00a73.1;
  idempotency_key dedupe so replaying a source event yields exactly one
  logical transition; no second telemetry DB; current_step_key untouched.
- Payload safety: no secrets, raw HAA text, or local filesystem paths.

Content is the symmetric reverse of PR #10's removal (mechanically the
same lines PR #3 added), rebased over #4/#6 dispatcher changes.

Linear: HEL-3110 (parent HEL-3103)
SSC-ENG added a commit that referenced this pull request Jul 30, 2026
…ion, and backfill gaps

AGA rejection issuecomment-5128947822 reproduced five durable-contract
bypasses at head 3e14c97d4. This commit closes all five behaviorally:

P1 #1 replay safety: persist_review no longer uses INSERT OR REPLACE
(delete+reinsert re-fired the ledger-promotion trigger and nulled
disposition/verification on replay). Both telemetry tables now use true
ON CONFLICT ... DO UPDATE upserts; the findings upsert preserves
MIN(first_observed_at)/MAX(last_observed_at). The promotion trigger
gains a NOT EXISTS replay guard. Regression test replays a
dispositioned+verified telemetry source and proves the full ledger row,
finding-event cardinality, and orphan set are unchanged.

P1 #2 governed attestation: verify_finding only accepts a typed
VerifiedEvidence produced by a governed adapter. fetch_linear_issue_evidence
performs a real Linear GraphQL existence/state lookup (hermetic transport
injection for tests) and binds canonical UUID, observed state, and
verification time; fetch_decision_record_evidence refuses decision refs
never recorded via record_finding_decision. Unknown sources, free-form
strings, mismatched issues, and syntactically-valid-but-nonexistent ids
are rejected with negative tests. The CLI verify path routes through the
same adapters; no free-form evidence flags remain.

P1 #3 bound-field immutability: trg_finding_disposition_bound_immutable
freezes disposition, linear_issue_id, decision_record_ref, and
dispositioned_at once disposition is set; trg_finding_verification_immutable
freezes verified_at, verification_evidence_ref, verification_source, and
verification_observed_state once verified. Direct-SQL tests cover every
bound field; governed FROM-NULL updates still pass.

P1 #4 purge retention: delete_archived_task and delete_task rehome
findings whose telemetry source is retained to the durable rescue
work-intent (finding_rehomed event) instead of orphaning the source;
the rescue container itself cannot be deleted. Tests cover archive-purge
and hard-delete paths with and without retained sources.

P1 #5 migration: init_db DROP+recreates the finding triggers (legacy
boards kept stale narrow bodies under CREATE TRIGGER IF NOT EXISTS),
ALTERs verification_observed_state onto legacy findings tables before
trigger recreation, and idempotently backfills retained
telemetry_review_findings into the ledger - valid tasks promote in
place, absent tasks rehome to the rescue work-intent with a
finding_rehomed event.

Linear: HEL-3112 (parent HEL-3104).
SSC-ENG added a commit that referenced this pull request Jul 30, 2026
* feat(HEL-3112): gate accepted findings into owned queue

* fix(HEL-3112): close finding gate bypasses

* fix(HEL-3112): close AGA P1 replay, attestation, immutability, retention, and backfill gaps

AGA rejection issuecomment-5128947822 reproduced five durable-contract
bypasses at head 3e14c97d4. This commit closes all five behaviorally:

P1 #1 replay safety: persist_review no longer uses INSERT OR REPLACE
(delete+reinsert re-fired the ledger-promotion trigger and nulled
disposition/verification on replay). Both telemetry tables now use true
ON CONFLICT ... DO UPDATE upserts; the findings upsert preserves
MIN(first_observed_at)/MAX(last_observed_at). The promotion trigger
gains a NOT EXISTS replay guard. Regression test replays a
dispositioned+verified telemetry source and proves the full ledger row,
finding-event cardinality, and orphan set are unchanged.

P1 #2 governed attestation: verify_finding only accepts a typed
VerifiedEvidence produced by a governed adapter. fetch_linear_issue_evidence
performs a real Linear GraphQL existence/state lookup (hermetic transport
injection for tests) and binds canonical UUID, observed state, and
verification time; fetch_decision_record_evidence refuses decision refs
never recorded via record_finding_decision. Unknown sources, free-form
strings, mismatched issues, and syntactically-valid-but-nonexistent ids
are rejected with negative tests. The CLI verify path routes through the
same adapters; no free-form evidence flags remain.

P1 #3 bound-field immutability: trg_finding_disposition_bound_immutable
freezes disposition, linear_issue_id, decision_record_ref, and
dispositioned_at once disposition is set; trg_finding_verification_immutable
freezes verified_at, verification_evidence_ref, verification_source, and
verification_observed_state once verified. Direct-SQL tests cover every
bound field; governed FROM-NULL updates still pass.

P1 #4 purge retention: delete_archived_task and delete_task rehome
findings whose telemetry source is retained to the durable rescue
work-intent (finding_rehomed event) instead of orphaning the source;
the rescue container itself cannot be deleted. Tests cover archive-purge
and hard-delete paths with and without retained sources.

P1 #5 migration: init_db DROP+recreates the finding triggers (legacy
boards kept stale narrow bodies under CREATE TRIGGER IF NOT EXISTS),
ALTERs verification_observed_state onto legacy findings tables before
trigger recreation, and idempotently backfills retained
telemetry_review_findings into the ledger - valid tasks promote in
place, absent tasks rehome to the rescue work-intent with a
finding_rehomed event.

Linear: HEL-3112 (parent HEL-3104).

---------

Co-authored-by: SSC-ENG <225143396+SSC-ENG@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-reviewed Maintainer reviewed CI-sensitive / MCP catalog changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.