feat(cogos-harness): threads registry + warn-tier hook (v0.3.0) - #23
feat(cogos-harness): threads registry + warn-tier hook (v0.3.0)#23chazmaniandinkle wants to merge 14 commits into
Conversation
Adds a small threads registry (bin/threads add|list|check|close,
~/.cog/status/threads.json) and its UserPromptSubmit warn hook, so a
seat's stated open-ended wait ("I'll hold until the verdict lands")
survives compaction and session end instead of dying silently in
conversation context.
A thread is a resolution predicate, not a completion signal: a bounded
shell command whose exit code answers "is the condition true", not
"did the process finish" -- the distinction a 2026-08-07 incident
proved matters (watching a CI run reported "done" before the actual
review verdict had landed). Declared fields are the only ones a caller
ever writes (id/what/why/predicate/opened_at/expected_by/owner/
closed_at/closed_reason); resolved/orphaned/overdue/age are always
derived by re-running the predicate, never stored or hand-set.
threads-warn.py is silent by default and only speaks for orphaned
(unresolved + past expected_by) threads, bounded by a hard per-
predicate timeout plus an overall wall-clock budget, fail-open on any
missing/corrupt state per the plugin's per-turn-hook contract.
Also ships, disabled by default: threads-gate-pr.py, a PreToolUse gate
that can require an open thread before `gh pr create` runs (armed via
enforce_pr_create_thread in ~/.cog/status/threads-config.json -- not
set anywhere in this change).
37 self-tests (tests/test_threads.py, stdlib unittest) cover the CLI
round trip, the shared library's state/predicate/derive primitives,
and -- byte-exact against real stdout -- the warn hook's silence
contract for every failure mode: missing/corrupt/empty state file, a
timing-out predicate, an erroring predicate, and unresolved-but-not-
overdue threads.
Adversarial review of #23 reproduced four fail-safety hazards on this per-turn hook and its CLI: - run_predicate captured stdout/stderr (capture_output=True) but nothing ever read them -- an unbounded-output predicate (cat /dev/zero) blew 10.7s / 7.68GB into the per-turn hook, past both its own timeout and hooks.json's declared budget. Now discards all predicate I/O via DEVNULL; the same fix incidentally stops a non-UTF-8-but-exit-0 predicate from being misreported as unresolved by a decode error, and stops predicate output from ever leaking onto the hook's JSON stdout. Reproduced fixed: cat /dev/zero predicate now finishes in ~3s (budget- bounded) at ~17MB RSS instead of 10.7s / 7.68GB. - `threads add`/`close` did an unguarded read-modify-write (load -> mutate -> atomic_write); atomic_write's tempfile+os.replace only makes the final rename atomic, not the read-modify-write around it, so concurrent invocations silently clobbered each other while each printed "registered" and exited 0. Added lib.locked_state(), an flock- guarded context manager around the whole load/mutate/write span; add and close now use it. Reproduced fixed: 8 concurrent `add`s that previously dropped to as few as 3 survivors now consistently register all 8. - An empty or missing `predicate` made a thread self-report RESOLVED (`/bin/sh -c ''` exits 0) -- exactly the "all-clear from nothing" failure this registry's derived-not-declared design exists to prevent. `threads add` now rejects a blank/whitespace `--predicate` outright; derive_status() additionally treats a missing/blank predicate as an explicit unresolved result (defense in depth against a hand-edited or partially-written state file), never as resolved. - derive_status() always ran the predicate before checking `overdue`, even though `orphaned` requires overdue and the per-turn warn hook only ever speaks for orphaned threads -- so a thread with a multi-hour/day expected_by paid full predicate latency (and, for the documented gh-based predicates, a network round trip) every single turn for its entire lifetime, for a result that was always discarded. Added `skip_predicate_if_not_due` (used only by the warn hook, never by `threads check`, which always wants a real predicate run): skips invoking the predicate when the thread isn't past its deadline, since the exit code can't change the answer in that case. Bounds: a thread becomes exactly as checkable as before the instant it goes overdue (skip only applies while not-yet-due); `threads check` is unaffected at either bound, since it never sets the flag. 14 new tests cover all four fixes directly (bounded-output timing, concurrent-add survival, empty/missing-predicate rejection at both the CLI and library layers, and skip-vs-still-runs-when-overdue for the per-turn hook), plus the existing 34 all still pass unmodified: 48/48. Also corrects derive_status()'s docstring, which previously declined to justify skipping owner-liveness by network-call cost while shelling out to arbitrary (possibly network-calling) predicate shell in the very next statement -- now states plainly that predicates may make network calls and that skip_predicate_if_not_due bounds how often, not whether.
|
Pushed a follow-up commit (f054faa) closing 4 blocking hazards an adversarial review found in the original design:
14 new tests cover all four directly; existing 34 pass unmodified. Full suite: 48/48 ( |
🕶 Independent blind review —
|
…y as overdue-eligible
F6 in the 2026-08-07 independent review: an unparseable opened_at falls
back to now, which makes a duration expected_by ("1d") recompute to
"now + 1d" fresh on every derive_status() call -- a deadline that
recedes forever and can never be reached, leaving the thread invisible
to the warn hook for its whole life. Same defense-in-depth class as the
existing blank-predicate handling: treat the thread as already
overdue-eligible instead of trusting the receding deadline.
F7 in the 2026-08-07 independent review: SCHEMA_VERSION was written on every save but never read back on load, so a future schema bump would parse silently under old-schema assumptions instead of failing loudly. This is a single-user machine, not a service with compatibility obligations to a fleet of other readers -- a loud, immediate refusal to load a newer-than-supported state file is the correct failure mode here.
F8 in the 2026-08-07 independent review: subprocess.run(timeout=...) only signals the direct /bin/sh child. A predicate that backgrounds or forks something the shell doesn't wait on (a stray `&`, a curl/gh call left running) survived its parent's death, reparented and still running -- one leaked process per turn per overdue slow/hanging thread. run_predicate now runs /bin/sh in its own session (start_new_session=True) and kills the whole process group via os.killpg on timeout, reaching every descendant in one signal.
… scan order F1 (HIGH) in the 2026-08-07 independent review: budget exhaustion could silently suppress the entire orphan report when a genuine orphan sat behind enough non-orphaned-but-slow predicates in registry order to exhaust the wall-clock budget -- reproduced 5/5 in review, because scan order was plain registry order every turn, so the same thread starved identically every time. Two fixes: - The budget note now renders unconditionally (`_render_block`), even when zero orphans were found among the threads actually checked -- silence previously meant either "all healthy" or "ran out of time," indistinguishably. - Scan order rotates across invocations (`_rotation_offset`, a small persisted counter next to the state file) so a thread skipped for budget this turn is checked earlier on a later one instead of starving forever. Covered by a deterministic, clock-driven reproduction of the exact starvation scenario (no reliance on real predicate/OS timing, which is inherently a race) plus a test confirming rotation eventually surfaces the orphan. Also corrects skills/threads/SKILL.md and README.md (F4), which still described the warn hook as checking every open thread's predicate every turn and didn't mention the budget-notice behavior above.
…eal config F5 in the 2026-08-07 independent review: TestGatePrScaffoldDisabledByDefault only set COGOS_THREADS_STATE per test, not COGOS_THREADS_CONFIG. Any test that doesn't explicitly arm the gate with its own config fixture inherits the unset env var, which resolves to the operator's real ~/.cog/status/threads-config.json -- if that file happens to have enforce_pr_create_thread=true set on the machine running the tests, the "disabled by default" assertions fail against live state instead of a fixture. Every test in the class now gets its own nonexistent-by-default config fixture path via setUp.
…re substring F2 in the 2026-08-07 independent review: the PR-create gate matched a bare substring of the whole command, so `git commit -m "docs: ... gh pr create ..."` and `grep -rn "gh pr create" .` both tripped a false deny -- editing this feature's own docs would trip the gate. Also: any stale unrelated open thread satisfied the "at least one open thread" check. Replaced with a tokenizer (_split_commands / _looks_like_gh_pr_create) that splits the command line on shell separators (; && || | $( newline), respecting quotes, and checks whether any segment's argv actually starts with `gh pr create` rather than merely containing the words somewhere. Variable-substitution evasion (`C=create; gh pr $C`) is explicitly out of scope and documented as such in the tokenizer's docstring -- a warn-tier gate must never itself run a shell to catch that, and this gate's only job is to catch a literal invocation. Updates the README test count and adds a summary of all fixes from this review pass, now that the full suite (82 tests) is in place.
🕶 Independent blind review — delta pass —
|
| finding | verdict | evidence |
|---|---|---|
| F1 (HIGH) | FIXED — CONFIRMED | Identical repro, 5 turns: before = 5×[SILENT]; after = budget notice turn 1, orphan surfaced turns 2/3/5 via rotation. TestWarnHookBudgetFairness drives the exact scenario with an injectable clock. |
| F2 | FIXED — CONFIRMED | Full table re-run: both false positives now ALLOW, all seven true positives still DENY. Tokenizer survived adversarial sweep (unbalanced quotes, nested $( ), 200 KB command, 50k ;) — no raise, no hang, ≤0.57 s. |
| F3 | FIXED — CONFIRMED | Body's 82/82 matches measured; f054faa hazards disclosed; skip-if-not-due described. Nit: "eight findings, all fixed" section enumerates seven — F3 itself absent (self-evidencing). |
| F4 | FIXED — CONFIRMED | All three doc sites checked against actual behavior. |
| F5 | FIXED — CONFIRMED | Gate tests hermetic via fixture COGOS_THREADS_CONFIG. |
| F6 | FIXED (detection) — CONFIRMED | Correct overdue-eligibility; see NEW-2 for the rendering side effect. |
| F7 | FIXED — CONFIRMED | version: 99 → warn hook silent (fail-open surface), threads list loud, exit 1. Residual: isinstance(int) guard lets string "2" load. |
| F8 | FIXED — CONFIRMED | Same probe both trees: OLD leaked the backgrounded descendant, NEW did not. killpg-on-zombie race safe by construction. |
Regression check: silence contract re-verified directly (missing/empty/corrupt state, no threads, future schema, healthy not-yet-due → all SILENT rc=0). Rotation counter survives every hostile state constructed (non-integer, negative, directory, unwritable parent) → offset-0 fallback, all orphans still reported.
New findings (all LOW/INFO, fail-open)
- NEW-1 — residual gate false negatives:
GH_TOKEN=x gh pr create(leading env assignment — the old regex caught this; ordinary usage, not evasion), single¬ a separator,(subshell)paren fusing, backticks, backslash-in-single-quotes. Fix: strip leadingNAME=valuetokens; add&to separators. - NEW-2 — F6 threads render self-contradicting evidence:
(overdue)besideage 0s, expected_by <tomorrow>becauseopenedfalls back tonow. Suggest an explicit reason token andage ?rendering. - NEW-3 — INFO: sidecar files
.threads.json.rotation/.threads.json.lockin~/.cog/status/absent from the README file reference. - NEW-4 — PLAUSIBLE: killpg reap (
proc.wait(timeout=2)) can overshoot the wall-clock budget by 2 s per timing-out predicate; safe at shipped defaults (~5.2 s vs 8 s hooks.json timeout), reachable only by env misconfiguration.
Delta-pass residual on F7 (2026-08-07 independent review): the version guard was `isinstance(version, int) and version > SCHEMA_VERSION`, which lets a non-int version (a hand-edited "2", a float, `true`) slide through un-checked -- isinstance(int) is simply False for it, so the newer-than-supported branch never fires. Now any present-but-non-int version raises CorruptStateError the same way a too-new one does. bool is explicitly excluded despite being a Python int subclass -- "version": true is not a version number.
…ound NEW-4 (2026-08-07 independent review, delta pass): plausible-but-cosmetic residual, no behavior change. The proc.wait(timeout=2) reap after killpg on a timed-out predicate can itself add up to 2s of wall-clock overshoot. One-line comment records that it's bounded and that shipped defaults (THREADS_PREDICATE_TIMEOUT 3s, THREADS_TOTAL_BUDGET 4s) keep the warn hook's worst case well under hooks.json's 8s timeout for it.
…l evidence NEW-2 (2026-08-07 independent review, delta pass): an F6 thread (unparseable opened_at + duration expected_by) rendered self- contradicting evidence on the triage surface -- "(overdue)" right next to "age 0s, expected_by <tomorrow>", both synthesized from `now` standing in for the corrupt real timestamp. derive_status() now flags ThreadStatus.age_unknown (set whenever opened_at didn't parse, regardless of expected_by's shape) and expected_by_unknown (set only when expected_by_ts was itself derived from the guessed opened_at), and adds an explicit "unparseable_opened_at" reason. Both the warn hook's rendered line and `threads check`'s output now show "age ?" / "expected_by ?" instead of the synthesized values. `threads list` already computed age independently and needed no change.
…sidecars NEW-3 (2026-08-07 independent review, delta pass): .threads.json.lock (locked_state()'s flock-guarded CLI write lock) and .threads.json.rotation (the warn hook's F1 scan-order counter) were undocumented in the README's env/file reference. Both are named from COGOS_THREADS_STATE, not independently configurable, and are safe to delete by hand -- every read failure on either degrades to a default rather than an error.
… in PR-create gate NEW-1 (2026-08-07 independent review, delta pass): the F2 tokenizer regressed one case the OLD bare-substring match caught for free -- `GH_TOKEN=x gh pr create` -- because a leading env-assignment token shifts argv, so `argv[:3] == ["gh","pr","create"]` no longer matched. Ordinary usage, not evasion. Fixed by stripping leading `NAME=value` tokens and a subshell `(` fused to the first token before the argv compare (_strip_leading_noise()). Also added a single `&` to the separator set -- `gh pr create & sleep 1` was previously one un-splittable segment. Backtick command substitution and a backslash-escaped quote inside a single-quoted string remain documented, accepted limits (added to the in-code limits comment alongside the existing variable-substitution- evasion one).
NEW-5 (2026-08-07 independent review, second delta pass): a trailing
`)` fused to the last of the first three tokens defeated the subshell
strip in the no-flags case -- `(gh pr create)`, `FOO=$(gh pr create)`,
and `(GH_TOKEN=x gh pr create)` all tokenized their final token as
`create)`, which _strip_leading_noise() (opening-paren only) never
touched, so all three ALLOWed instead of DENYing.
Fixed with a per-token rstrip(")") on the first three tokens right
before the argv[:3] == ["gh","pr","create"] compare in
_looks_like_gh_pr_create(), alongside the existing leading-noise strip.
Documented in both the function's own docstring and the closing-paren
note added to _split_commands()'s limits list.
🕶 Independent blind review — final pass —
|
Summary
bin/threads add|list|check|close, state at~/.cog/status/threads.json) so a seat's stated open-ended wait ("I'll hold until the verdict lands") survives compaction and session end instead of dying silently in conversation context — the failure this fixes happened on 2026-08-07 (a session watched a CI run and reported "done" when the process exited, before the actual review verdict had propagated).id/what/why/predicate/opened_at/expected_by/owner/closed_at/closed_reason) —resolved/orphaned/overdue/ageare always derived by re-running the predicate, never stored or hand-set.hooks/threads-warn.py(UserPromptSubmit): silent-by-default, per-turn check. It only actually runs a thread's predicate once that thread is past itsexpected_by(skip_predicate_if_not_due) — a thread with a distant deadline costs nothing turn after turn until it's actually due. Speaks for orphaned threads (unresolved AND pastexpected_by), and, distinctly, whenever its overall wall-clock budget (COGOS_THREADS_TOTAL_BUDGET) runs out before every open thread could be checked — that case renders an explicit "N not checked this turn — over budget" note rather than staying silent, with scan order rotated across invocations so the same thread never starves the budget check forever. Fail-open on any missing/corrupt state.hooks/threads-gate-pr.py(PreToolUse/Bash): scaffolded but disabled by default — can require an open thread beforegh pr createruns, armed only viaenforce_pr_create_thread: truein~/.cog/status/threads-config.json. Nothing in this PR sets that key. Command matching tokenizes the shell command line (splitting on;/&&/||/|/$(/newline, respecting quotes) and checks whether any segment's argv starts withgh pr create, rather than a bare substring match — so the phrase appearing inside a quotedgit commit -m/grepargument doesn't trip the gate. Documented, in-code limit: shell variable-substitution evasion (C=create; gh pr $C) is out of scope for a warn-tier gate that must never itself run a shell.skills/threads/SKILL.md: the procedure, including a worked good-vs-bad predicate example, theskip_predicate_if_not_duebehavior, and the budget-notice behavior above — and a real footgun caught empirically while building this (gh ... --jq '.x == "y"'printsfalsebut still exits 0; predicates need[ "$(...)" = "..." ]to be unambiguous).plugin.json+marketplace.jsonbumped to 0.3.0 (cascaded together, both carried duplicate version/description/keywords for this plugin).Follow-up commit (f054faa): four blocking hazards closed
An adversarial review of the initial design found four issues, all fixed in a follow-up commit before this PR was reviewed further:
cat /dev/zerocost 10.7s / 7.68GB against a hook with a 4s budget. Fixed by discarding predicate stdin/stdout/stderr (DEVNULL) instead of capturing output nothing reads.threads addsilently lost registrations — an unguarded read-modify-write dropped entries under concurrent CLI invocations. Fixed withlib.locked_state(), an flock-guarded context manager used byadd/close.predicateself-reported RESOLVED —/bin/sh -c ''exits 0.threads addnow rejects a blank--predicate;derive_status()additionally treats a missing/blank predicate as explicit-unresolved as defense in depth for hand-edited state.orphanedrequiresoverdue, soderive_statusalways ran the predicate first even for a not-yet-due thread, discarding the result every time. Addedskip_predicate_if_not_due(warn-hook only, neverthreads check).First review pass: eight findings from an independent blind review, all fixed
A second independent review (2026-08-07) returned
REQUEST_CHANGESwith eight findings — F1 through F8. All eight are addressed on this branch:git commit -m "docs: ... gh pr create ..."andgrep -rn "gh pr create" .both tripped a false deny. Replaced with a tokenizer that splits the command line on shell separators (respecting quotes) and checks whether any segment's argv actually starts withgh pr create. Variable-substitution evasion is explicitly out of scope and documented as such — a warn-tier gate must never run a shell to catch it.skills/threads/SKILL.mdandREADME.mdcorrected to describe actual behavior: predicates only run for due/overdue threads, and the budget-notice behavior from F1 is documented.COGOS_THREADS_CONFIGto a fixture path unconditionally, so they never resolve to the operator's real~/.cog/status/threads-config.json.opened_atpaired with a durationexpected_byused to recomputenow + durationfresh every call, a deadline that recedes forever and can never be reached. Same defense-in-depth class as the blank-predicate case: treated as already overdue-eligible instead.SCHEMA_VERSIONis now enforced on load: a state file with a newer version than this code supports fails loudly (CorruptStateError) rather than parsing silently under the wrong assumptions.start_new_session=True+os.killpg), not just the immediate/bin/shchild — a predicate that backgrounds or forks something the shell doesn't wait on no longer leaks a reparented process per turn per overdue slow/hanging thread.Delta pass: independent re-review returned APPROVE, four new residuals closed
The same independent instance re-verified the fixes above with its own reproductions and returned
APPROVE(all eight findings CONFIRMED fixed, F1's starvation scenario 5/5 → 0/5 silent, F2 adversarially swept, F8's leak reproduced dead on both trees). It surfaced four new LOW/INFO residuals in the new code, plus a lingering type-confusion gap in F7's fix — all closed in this same pass:GH_TOKEN=x gh pr create(a leading env-assignment token shifts argv, soargv[:3] == ["gh","pr","create"]no longer matched). Fixed by stripping leadingNAME=valuetokens and a fused subshell(before the compare; also added single&to the separator set (gh pr create & sleep 1was previously one un-splittable segment). Backtick substitution and a backslash-escaped quote inside single quotes remain documented, accepted limits. Second-pass residual (NEW-5): the mirror case, a trailing)fused tocreatewith no flags after it ((gh pr create),FOO=$(gh pr create),(GH_TOKEN=x gh pr create)), also defeated the match — fixed with a per-tokenrstrip(")")on the first three tokens before the compare.opened_at+ durationexpected_by) rendered self-contradicting evidence on the triage surface:(overdue)next toage 0s, expected_by <tomorrow>, both synthesized fromnowstanding in for the real timestamp.derive_status()now flagsage_unknown/expected_by_unknownand adds an explicitunparseable_opened_atreason; both the warn hook's rendered line andthreads check's output now showage ?/expected_by ?instead of the synthesized values..threads.json.lock(flock-guarded CLI write lock) and.threads.json.rotation(F1's scan-order counter) sidecar files in the README's env/file reference; previously undocumented.proc.wait(timeout=2)afterkillpg) noting the up-to-2s wall-clock overshoot it can add and that shipped defaults keep the warn hook's worst case underhooks.json's 8s timeout for it.isinstance(version, int), which lets a non-intversion(a hand-edited"2", a float,true) slide through unchecked, since the newer-than-supported comparison simply never fires for it. Now rejected the same way as a too-new version:CorruptStateErrorfor any present-but-non-intversion(bool explicitly excluded from counting as int, despite being a Pythonintsubclass).Test plan
python3 tests/test_threads.py -v— 109/109 passing (37 at initial PR open → 48 after the four-hazard follow-up → 82 after the first review's eight-finding fixes → 102 after the delta pass's four-residual + F7-residual fixes → 109 after the second delta pass's NEW-5 trailing-paren fix)&/subshell-paren cases with DENY-expected armed-gate assertions), F5's hermetic gate-test config, F6's unparseable-opened_at defense-in-depth plus NEW-2'sage_unknown/expected_by_unknownrendering (both the warn hook line andthreads check's output), F7's schema-version enforcement plus its non-int-version residual, F8's process-group-kill (spawns a real backgrounded descendant, confirms viapgrepthat it doesn't survive the timeout)hooks.jsonand both.claude-plugin/marketplace.jsonmanifests still parse as valid JSONsettings.json/settings.local.jsontouched