feat(kanban): validate dispatch capabilities before claim - #4
Merged
Conversation
…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.
…rnContext seam; AST-identical body)
…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
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
This was referenced Jul 30, 2026
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>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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 dispatchpython3 -m py_compile hermes_cli/kanban_preflight.py hermes_cli/kanban_db.py tests/hermes_cli/test_kanban_preflight.pyDeployment 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.