Skip to content

fix(directive): clamp a stop tool's reason and tag every marker-less refusal - #8640

Merged
iamwhatever merged 1 commit into
mainfrom
fix/autonudge-stop-reason-cap-8635
Sep 5, 2026
Merged

fix(directive): clamp a stop tool's reason and tag every marker-less refusal#8640
iamwhatever merged 1 commit into
mainfrom
fix/autonudge-stop-reason-cap-8635

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

autonudge_stop's reason was a hard-capped 500-char field, and the cap was enforced where the handler could never see it. mcp_core._call_tool validates arguments in the dispatch wrapper (call_tool_with_logging(name, raw_args, _validate_args, ...)), and _validate_args looks the tool up in MCP_CORE_SCHEMAS -- which registers autonudge_stop. So an over-long reason raised there, _call_tool_inner was never entered, _emit_directive never ran, and the tool returned a bare string with no directive marker and no out-of-band record:

Error: reason: exceeds max length 500 (got 1391, trim 891 chars)

Two harms, from one marker-less frame:

  1. The stop did not happen. The agent asked to stop its own loop and the loop kept waking. On the reporting host one agent was rejected twice in a row (1391 chars, then 649 on retry) and its loop survived both attempts.
  2. A bug-only diagnostic became noise. The consumer had already authenticated the call as a directive tool via _meta.kiro, so the marker-less final frame landed in the lost-marker branch and logged session-directive decode FAILED ... effect dropped -- roughly 10 times a day, all autonudge_stop, out_len 57-64. That WARNING exists to catch a rawOutput-envelope escaping regression, and its own comment says so.

The asymmetry that pointed at the fix: the oversized-directive path was already handled well (encode tags its refusal with _REFUSAL_SENTINEL, and the consumer reports session-directive REFUSED at INFO). The oversized-argument path had no equivalent, so a decline was indistinguishable from a dropped effect.

Why it matters

Both harms are quiet, and each undermines the recovery the other needs. An unattended loop keeps burning cycles against a goal its own agent already declared finished, while the operator loses the one line that would tell them a directive was genuinely lost in transport -- so the next real transport regression arrives in a log the operator has been trained to scroll past.

What changed (motivation -> approach -> change)

Symptom 1: a stop defeated by its own explanation. reason is not a control input. _autonudge_stop only interpolates it into the outcome text and the persisted stop record, and monitor_stop's reason is the same shape -- neither selects any behaviour. Rejecting the whole call over an argument like that trades a real effect for a few words of narrative. So the field opts into a new FieldSpec.clamp_to_max: over the cap, truncate instead of raise. Declared on the FIELD rather than fixed in the handler, because the handler is downstream of the validation that rejects -- and because that is the only place both validation calls (the wrapper's and the handler's own defence-in-depth call) agree.

Not silent: clamp_to_max_len stamps the value with [... truncated, dropped N chars] -- the count of what THIS cut dropped, including the note's own cost, rather than a claim about the caller's original length, which the clamp cannot know because sanitize_string has already run. So the applied outcome the model reads back, the SEL audit row and the persisted stop reason all state the cut, with no layer having to plumb a second return channel. Opt-in per field, and documented as never for a field a handler acts on -- a truncated control input is a wrong control input, and rejecting is the only safe answer there. monitor_stop gets the same flag; fixing one of the two stop tools would leave the other to be rediscovered.

Symptom 2: a decline read as a lost marker. The narrow version of this -- tag the schema rejection -- would still leave the WARNING firing, because a schema rejection is not the only marker-less thing a directive tool returns. monitor_start: message must not be empty. and every "only works from within a dashboard, Slack, or Discord session" refusal are marker-less too, and land in the same branch. So the invariant is enforced instead: a directive tool's result either carries the marker, or it is a tagged refusal. session_directive.refuse_if_markerless stamps the second case, called once from _call_tool -- the outermost return, and therefore the only point that sees a rejection raised ahead of the handler. encode's existing refusal now routes through the same tag_refusal helper.

Three handler paths did not RETURN their declines but raised them, which escapes that return point entirely (the JSON-RPC layer turns the exception into the same Error: ... text, but past the tag and past the wrapper's own audit): ask_question's nested question validation, and the two callers of parse_github_pull_request_target -- monitor_watch and monitor_update. All three now return, and the parser is reached through one guarded seam (_parsed_pull_request_target) rather than a try per site, because guarding the two sites independently is exactly how the second one came to ship unguarded. The model-facing text is byte-identical; what changes is that the call is audited with its args as failed and the consumer reads it as a refusal.

That rule is ENFORCED rather than left to convention, which is the difference between an invariant and a claim: a parametrized test drives every name in DIRECTIVE_TOOLS with a hostile call and requires a marker or a tagged refusal, and its companion asserts the table covers the frozenset -- so a new directive tool fails the suite until it is added.

Tagging is diagnostic only. It keys on the tool NAME alone, so it is inert for every other tool, and the token carries no payload and grants no effect -- a model emitting the literal bytes can change how a line is logged, never what is applied. The two consumers' INFO wording is generalized accordingly (it claimed the delivery-limit cause, which is now one refusal reason among several), and the spec in docs/architecture/mcp.md states the invariant, the clamp, and the resulting rule for a tool author.

Tests

test/test_directive_refusal_not_lost_marker_8635.py (12 tests):

  • an over-long autonudge_stop reason now emits a directive whose payload carries the clamped, self-describing reason (pinned to the 1391-char length from the issue's own evidence), and the same for monitor_stop
  • a reason within the cap is passed through untouched
  • clamping is opt-in: an ordinary capped FieldSpec still raises
  • the note's count equals what was really dropped at four sizes, the result never exceeds the cap (so a clamped value cannot round-trip into something the same schema rejects), and a cap too small to hold the note degrades to a plain cut
  • the ratchet: every tool in DIRECTIVE_TOOLS driven with a hostile call returns a marker or a tagged refusal and lets no exception escape _call_tool, plus a completeness guard so a new directive tool fails until it has a row
  • forgery: four shapes of a marker-bearing argument NAME (the two that really decoded, plus the two that did not) decode to None, are tagged refusals, and publish nothing; the defanged marker stays visible to a reader
  • the elision count reconciles exactly against what was retained, including the note's own footprint
  • an over-long rejection (a 9,000-character argument name) stays under the transport bound, keeps its refusal tag through the ACP cut, and still shows both the field and the reason; the bound has one owner, asserted by reading acp/_dispatch.py for the constant and for the absence of the old literal
  • the out-of-band publish is asserted for the first time: a directive parks exactly one record carrying the CLAMPED reason, a refusal parks none (the encode-before-publish ordering), and the publish count tracks the marker across the ratchet
  • each marker-less return is tagged a refusal -- schema rejection, context refusal, ask_question's nested validation, an unparseable monitor_watch target -- while a real directive is not tagged and a non-directive tool's error is left alone
  • end to end through TurnDriver: a rejection string produced by really calling the tool does not fire decode FAILED and does log session-directive REFUSED; a genuinely marker-less, untagged frame still fires the WARNING

test/test_acp_tool_identity.py gains the same pair against the real chat_runner._run_chat loop, which is the consumer the reported WARNING came from: a real rejection is reported as a refusal, the transcript shows the tool's own text with no sentinel, and a lost marker still warns.

Mutation-verified, one half at a time, so no test is passing on an early return:

mutation result
drop clamp_to_max=True from both stop schemas 2 failed -- with the issue's exact text, Error: reason: exceeds max length 500 (got 1391, trim 891 chars)
drop the refuse_if_markerless wrap in _call_tool 5 failed in the new file, plus the new chat_runner test, which reproduced chat_runner.py:7695 session-directive decode FAILED for 'monitor_start' (tool_call_id=tc-reject, out_len=67) -- effect dropped
restore the handler raise paths failed with the escaped exception (ValidationError: questions: no valid questions..., ValueError: target must be a public GitHub pull request URL)
revert neutralize_markers in call_tool_with_logging the two decoding shapes return {'reason': 'FORGED'} again
restore the count that ignored the note's footprint assert 4039 == (12000 - 7894)
remove the bound from tag_refusal 1 failed -- assert 9087 <= 8000, the tag pushed past the transport cut
restore the bare parse in monitor_update (the site round 1 caught) 1 failed -- test_no_directive_tool_leaves_an_untagged_markerless_result[monitor_update], i.e. the ratchet catches the very miss that produced it

Regression: 544 passed across the directive, monitor, validation and MCP-core suites (test_session_directive, test_session_directive_transport, test_driver_session_directives, test_autonudge_stop_auth, test_ask_question_mcp_tool, test_monitor_mcp, test_monitor_directive_apply, test_validation, test_bug_validation_validate_field, test_mcp_core, test_mcp_tool_registry, test_acp_tool_identity, and the new file).

Manual verification

Isolated pod (kirocrew pod up ... --provision, port 7872): came up health=200 on this change, no new errors in the journal; torn down with pod down (zero residue).

Then the real stdio JSON-RPC transport, driving python -m kiro_crew mcp-core directly so the result passes through build_tool_response (which strips Unicode category Cf and is what destroyed an earlier non-ASCII marker):

--- autonudge_stop (reason len=1391) ---
has_directive_marker : True
is_refusal_tagged    : False
text : Stop REQUESTED for this session's auto-nudge loop. ... |
       [[KIROCREW_SESSION_DIRECTIVE]]{"kind":"autonudge_stop","args":{"reason":"stopping because xxx...

--- monitor_start (message len=9000) ---
has_directive_marker : False
is_refusal_tagged    : True
text : Error: message: exceeds max length 8000 (got 9000, trim 1000 chars) |
       [[KIROCREW_SESSION_DIRECTIVE_REFUSED]]

Related Issues

Closes #8635

Pattern harvest

Rule candidate: review-prompt

Pattern: a by-design outcome that fires a diagnostic reserved for bugs, and -- from round 1 -- an invariant asserted in prose instead of enforced by a test over the set it quantifies over -- here a routine argument rejection landing in the branch whose WARNING exists to catch marker loss. The generalizable question for any such log line is "what non-bug paths can reach this?", and the answer has to be enforced at the producer, because the consumer sees only the absence. The narrower spelling worth carrying forward: when argument validation runs in a dispatch WRAPPER, a handler cannot see, shape, or tag its own rejection, so anything a handler is expected to guarantee about its result has to be established at the outermost return or declared on the schema. Round 1 added the corollary: a claim of the form "every X does Y" over a named set (here DIRECTIVE_TOOLS) should ship as a parametrized test over that set plus a completeness guard, because the review round that catches the missing member is the round a test would have replaced.

Other notes for the reviewer

  • Scope was split, then the cut was moved by one layer. This PR is issue autonudge_stop silently fails when reason exceeds 500 chars, and trips the lost-marker warning #8635 -- the clamp, the
    refusal tag, the ratchet, the test-isolation fix -- PLUS neutralize_markers, which closes an
    inherited forgery hole (a rejection echoing a model-chosen argument NAME decoding as an authenticated
    directive; reproducible on main). That layer moved back in because it is independent of everything
    else here, because GPT blocks this PR on that hole and it is real rather than overridable, and because
    the refusal tag added here is what a reviewer would otherwise read as a provenance guarantee.
  • The STRUCTURAL hardening stays in fix(directive): honour a marker on provenance, not on appearance #8696, stacked on this branch: positive provenance (vouch), which
    makes forgery fail by construction instead of by remembering to defang each error site, plus
    _emit_directive's structural discriminator. That is a new mechanism and deserves its own review.
  • One cost, accepted deliberately: the refusal sentinel is now appended to common declines, so the model sees it in the tool result. That is the mechanism the repo already chose for encode's refusal, and a stdio MCP server has no side channel to the consumer other than the result string.
  • An earlier revision of this description listed a second accepted cost -- that a marker-less return longer than the ACP cut would lose its tag, on the grounds that "no directive tool returns anything near that". That was wrong: the length is not the tool's to choose, because a rejection echoes the model-supplied argument NAME. Measured at 9,087 chars, the tag was cut and the decline read as a lost marker. tag_refusal now elides against MAX_TOOL_RESULT_CHARS, so it is no longer a residual.
  • No CHANGELOG entry: the file is curated per release and currently has no Unreleased section. Say the word if a 0.5.1 entry is wanted and I will add one.
  • Adjacent and NOT touched: Dashboard AutoNudge loop can be stranded active-but-unarmed, with no reconciler and no fire logging #8636 (a loop stranded active-but-unarmed) is a different failure of the same subsystem -- this PR does not make a stranded loop stoppable, it makes a stop request survive its own reason.

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 5, 2026 03:32
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 6dacb0451cae67226f2281627eb36e71f1faf351 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound invariant-driven design, but a directive-forgery fix rides in this diff while the description claims the security hardening moved to #8696.

Watch

  • Undisclosed security hunks vs. the stated scope split. The description says "the marker-provenance hardening found while probing it — an inherited forgery hole reproducible on main — moved to fix(directive): honour a marker on provenance, not on appearance #8696," yet this diff ships neutralize_markers (its docstring: a forged sentinel in an echoed argument name "makes the rejection string decode as a REAL directive… Confirmed reachable, and reproducible on main") plus preserve_tail_marker at the ACP cut. Mechanism → consequence: the human who ordered the split reviews this PR as diagnostics and fix(directive): honour a marker on provenance, not on appearance #8696 as the security change, so the forgery defang's completeness (are the three Error: sites the only model-echo points?) gets neither review. It is load-bearing here — refuse_if_markerless short-circuits on has_marker, so an un-defanged forged sentinel would skip tagging entirely — so keep it, but the description must own it as part of this PR's contract, not disclaim it.

[DESIGN-REVIEWED] 6dacb04

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 6dacb0451cae67226f2281627eb36e71f1faf351 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 6dacb04

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 6dacb0451cae67226f2281627eb36e71f1faf351: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 6dacb0451cae67226f2281627eb36e71f1faf351 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All counted claims check out: DIRECTIVE_TOOLS has exactly the 9 members the hostile-call table covers, and parse_github_pull_request_target has exactly the 2 directive-tool callers the new seam guards (the third caller, dashboard/handlers/autonudge.py:208, is an HTTP handler outside the directive return path). Here is the review.

First-Principles-Verdict: PASS

Every item traces to the one reported defect (#8635) or to a boundary hole the fix itself exposed; each new surface has counted consumers and the invariant is ratcheted, not asserted.

What this change ships

Intent: a loop-stop must not be defeated by the length of its own explanation, and a tool's decline must stop impersonating a transport bug. FIX.

  1. Over-long autonudge_stop reason now truncated (with a visible note), stop still applies — justified
  2. monitor_stop reason clamped identically — justified sibling fix
  3. New opt-in FieldSpec.clamp_to_max + clamp_to_max_len — 2 consumers, justified
  4. Marker-less directive declines tagged; consumer logs INFO REFUSED instead of the lost-marker WARNING — justified (the fix)
  5. Three handler declines returned instead of raised (ask_question, monitor_watch, monitor_update) via one guarded seam — cause-level; 2 of 2 in-scope parse_github_pull_request_target callers covered
  6. Marker bytes echoed in a rejected argument name can no longer forge a directive — rides along, derived (untrusted-model-input boundary; the tag is defeated without it)
  7. Over-long refusals middle-elided so the tag survives the transport cut — justified (length is model-reachable)
  8. [:8000] literal replaced by owned MAX_TOOL_RESULT_CHARS; tail marker re-attached after redaction growth — mechanism-level, mirrors the existing App-marker re-injection at the same seam
  9. Both consumers' log wording names both remaining causes — rides along with 4, justified
  10. Ratchet test over all 9 DIRECTIVE_TOOLS + spec section in docs/architecture/mcp.md — mandated (same-commit spec rule)

[FIRST-PRINCIPLES-REVIEWED] 6dacb04

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 6dacb0451cae67226f2281627eb36e71f1faf351 — this comment is updated in place on each push.

Review details

All the security-critical claims verify independently:

  • The forgery vector (line 480 echoing the model-controlled argument key into ValidationError, __str__ = f"{field}: {message}") is caught in call_tool_with_logging and defanged via neutralize_markers before return.
  • The other f"Error: {exc}" paths (parse_github_pull_request_target, validate_ask_user_question) raise only static messages — no model-controlled interpolation, so no sentinel can be smuggled.
  • clamp_to_max_len, tag_refusal, and preserve_tail_marker all provably return <= MAX_TOOL_RESULT_CHARS, including note-footprint accounting; preserve_tail_marker's room < idx relation excludes the partial marker, so no double-marker.
  • output = event.tool_output or "" guarantees len(output) and len(_out or "") never see None.
  • refuse_if_markerless is idempotent with tag_refusal and inert outside DIRECTIVE_TOOLS; the invariant totality holds.

No candidate survived falsification, and no new grounded defect emerged in Step 2.

No findings.

[OPUS-REVIEWED] 6dacb04

Verdict parsed from the review's SHA-scoped output markers for commit 6dacb0451cae67226f2281627eb36e71f1faf351.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 6dacb0451cae67226f2281627eb36e71f1faf351: <one-sentence reason>

@chenmingwei23
chenmingwei23 force-pushed the fix/autonudge-stop-reason-cap-8635 branch from 3079b58 to 909ead9 Compare September 5, 2026 03:48
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 1 findings, both dispositioned on head 909ead931. First Principles was right and the PR description was wrong -- I have fixed the code and corrected the body.

1. monitor_update still raised past the tag -- FIXED, and the class closed

First Principles (and GPT's first finding) named a real miss of my own claim. parse_github_pull_request_target has two callers among the directive handlers, and I guarded only one:

  • monitor_watch -- guarded
  • monitor_update -- bare, raising ValueError on a bad target

call_tool_with_logging catches only ValidationError, so that exception escaped refuse_if_markerless and still fired the WARNING this PR exists to silence. The description's "Two handler paths did not RETURN their declines" was therefore false as written, which by itself makes the omission a description-code mismatch rather than a scope judgment.

I did not want to fix this as a third try block, because guarding the sites independently is precisely how the second one came to ship unguarded. Two changes instead:

  1. One guarded seam. _parsed_pull_request_target(raw) -> (url, error_text) is now the only way either handler reaches that parser, so the next caller is correct by construction rather than by remembering.
  2. The invariant is now enforced, not asserted. This is First Principles' own point ("the invariant is total" was a claim, not a mechanism) and Design's suggestion, and they converge: a parametrized test drives EVERY name in DIRECTIVE_TOOLS with a hostile call and requires the result to be a marker or a tagged refusal, and a companion test asserts the table covers the frozenset -- so a new directive tool fails the suite until someone gives it a row.

Mutation-verified against the actual defect: restoring the bare parse in monitor_update reddens exactly test_no_directive_tool_leaves_an_untagged_markerless_result[monitor_update] with ValueError: target must be a public GitHub pull request URL. That is the finding reproduced by the ratchet that would have caught it, which is the outcome I would rather have than a patched line.

2. The clamp note's "original" length -- FIXED at cause, but not by the prescribed remedy

GPT is right that the number was ambiguous. validate_field runs sanitize_string(value) before the cap check, so the length the clamp sees is already post-sanitization, and a note reading truncated from N chars invites the reader to treat N as what they typed.

I did not implement the suggested fix (thread the pre-sanitization length into the note), because it would introduce the inaccuracy it is trying to remove: the clamp is not the sanitizer, and one number cannot honestly stand for two different removals. Attributing the sanitizer's dropped characters to the truncation note would make the note wrong in a second way while looking more precise.

Instead the note now reports what this cut actually dropped, and stops claiming to report an original at all:

[... truncated, dropped N chars]

The count includes the note's own cost, so dropped plus the surviving text always reconciles to the string the clamp received. Three tests pin it: the count equals the real delta at four sizes, the result never exceeds the cap (so a clamped value cannot round-trip into something the same schema would reject), and a cap too small to hold the note degrades to a plain cut rather than returning a value that is only a note.

Not changed

Design Review's suggestion was the ratchet above, so it is implemented rather than deferred. No other finding was outstanding: Opus and GPT report no blocking issues, and GPT's two findings are both addressed above.

Tests: 472 passed across the directive, monitor, validation and MCP-core suites on this head. Spec updated in the same commit (docs/architecture/mcp.md) to state the enforced rule and the single seam.

@chenmingwei23
chenmingwei23 force-pushed the fix/autonudge-stop-reason-cap-8635 branch from 909ead9 to bc7d5ac Compare September 5, 2026 03:56
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 2, head bc7d5acd3. Both of Design Review's items actioned; the first was a defect I introduced in round 1.

1. Spec claimed a semantic the code refuses to claim -- FIXED

Design caught that docs/architecture/mcp.md still documented the note as [... truncated from N chars] while the code now stamps [... truncated, dropped N chars]. That is worse than a stale doc: the spec asserted exactly the "original length" reading that the code comment explains it cannot honestly report, so the two halves of the same commit disagreed about the meaning of the number. My round-1 fix reworded the code and left the spec behind.

The spec now carries the wording AND the reason: N counts what that cut dropped, including the note's own cost, and deliberately not the caller's original length, because the field is sanitized before the cap is checked and one number cannot stand for both removals.

2. A handler crash was misattributed by the WARNING's own text -- FIXED

The Watch item is fair and I have taken it rather than dispositioning it. Now that every by-design decline is tagged, exactly two things reach the lost-marker branch: a marker mangled in transport, and a handler that raised past its own return (the JSON-RPC layer turns that into text, which never passes refuse_if_markerless). Design's point is that "a crash stays loud" is coherent, but a line whose text asserts a rawOutput-envelope escaping regression sends an operator hunting a bug that is not there -- which is the same failure mode this PR exists to fix, one level up.

So both consumers now name both causes:

session-directive decode FAILED for 'monitor_start' (tool_call_id=..., out_len=64)
- effect dropped. Either the marker was lost in transport (a rawOutput-envelope
escaping regression) or the tool raised past its own return, so its decline was
never tagged a refusal

No behaviour change -- the WARNING still fires for exactly the same frames, and the decode FAILED prefix the tests assert on is unchanged. It is the same class of fix as dropping the now-false "over the delivery limit" cause from the REFUSED line: a log line whose wording outlived its truth.

I did not try to tag crashes themselves. Catching a non-ValidationError exception at _call_tool to tag it would swallow the SEL failed audit that mcp_shared writes for an escaped exception, and a crash in a directive handler SHOULD stay at WARNING -- it is a bug, unlike the declines this PR moved to INFO. Design's "the ratchet only proves the one hostile input per tool" is also accurate and accepted: the ratchet bounds the by-design decline paths, not arbitrary crash inputs, and a fuzzer over nine handlers is not what this PR is.

Tests: 446 passed across the directive, monitor, validation and consumer suites on this head. The spec and the code now state the same note semantics, checked by grep in the same commit.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/autonudge-stop-reason-cap-8635 branch from bc7d5ac to 1a995ce Compare September 5, 2026 04:55
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 3, head 1a995ce1e. Two real failures, both fixed. GPT's blocking finding was correct and I could reproduce it -- with one correction to the adjudication's harm claim, and one thing that turned out to be worse than either of them said.

1. GPT BLOCKING: the tests reached a live gateway -- FIXED

Verified rather than taken on faith, and the mechanism is exactly as GPT traced it: _call_tool -> autonudge_stop -> _emit_directive -> unstubbed mcp_core._post -> _send -> a real request to http://127.0.0.1:{_api_port()}.

Correction to the adjudication: under pytest the POST is REFUSED, so it does not mutate any SEL audit. The conftest KIROCREW_HOME pin makes the client read a different instance credential, and the gateway answers this client authenticated against the wrong Kiro Crew instance. The harm rung is therefore lower than "cross-context mutation" for the suite as it stands.

But the finding is worse than "it might mutate", not better, and this is why I fixed it rather than rebutting the harm framing. Running the same call from a plain script against the same tree -- no conftest, so no credential mismatch -- returned:

{'ok': True, 'id': '54a8d6a92e904287b534af40b2344aa8'}

That is a real directive record parked in a live gateway's queue, under a real-looking slot key, waiting for a turn to claim it. A parked autonudge_stop claimed by an actual session stops that session's loop. So the suite is protected only by a guard that belongs to a different subsystem and that these tests are not entitled to rely on. Fixed regardless of the current harm rung.

The fix, and why it is a recorder. mcp_core._post is now stubbed in the fixture, and dashboard_session depends on it, so no test using that fixture can reach the network even if it starts emitting a directive it did not emit before. The stub RECORDS rather than discards, which turns a muzzle into coverage: the out-of-band publish is half the directive contract (marker + parked record) and was unasserted anywhere in these tests. Three assertions now use it -- a directive parks exactly one record carrying the CLAMPED reason (not the raw argument), a refusal parks nothing (the encode-before-publish ordering, whose whole purpose is to never tell the model "nothing was applied" while a record waits to apply it), and across the ratchet the publish count tracks the marker.

Measurement, and a note on how not to measure it. A spy that RAISES proves nothing here, because _emit_directive swallows every exception -- my first attempt "passed" against deliberately unstubbed code, which is exactly the silence that hid this. With a recorder instead (autouse fixture appending every _send call to a file, positive-control verified against unstubbed code): zero outbound calls from either file this PR touches.

The same recorder found 17 outbound POSTs from three pre-existing test files (test_autonudge_stop_auth.py, test_monitor_mcp.py, test_ask_question_mcp_tool.py). Those are deliberately NOT in this diff -- widening a converged review is how a fix PR turns into a refactor -- so they are filed as #8652 with the measurement and a suggested conftest-level fix.

2. Black gate -- FIXED

test/test_directive_refusal_not_lost_marker_8635.py was not formatted for --target-version py310. My local check omitted that flag, so it reported clean; the repo's own gate is the authority and I now run it directly (python3 scripts/check_black_formatting.py), which passes with zero new offenders. test_acp_tool_identity.py remains untouched by the formatter -- it is baselined at line 389, and reformatting it would put ~40 unrelated lines in this diff.

Tests: 382 passed across the directive, monitor, validation and consumer suites on this head, plus the 49 in the two touched files under the network recorder.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/autonudge-stop-reason-cap-8635 branch from 1a995ce to 9a8ba0d Compare September 5, 2026 05:39
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 4, head 9a8ba0daf. GPT's blocking finding is REAL and now fixed. One important correction: it is not a defect this PR introduced -- I reproduced it on untouched main -- but it is fixed here, and the fix is stronger than the one suggested.

The finding is real, and I reproduced it

The vector is exactly as traced. validate_tool_args reports an unknown field by echoing the argument KEY, and the model chooses that key. Driving the real _call_tool with a key built from the sentinel plus a JSON payload:

argument name shape decode(out, "autonudge_stop") before the fix
<SENTINEL>{payload} None (the message's trailing text keeps the line from parsing)
<SENTINEL>{payload}\n {'reason': 'FORGED'}
\n<SENTINEL>{payload}\n {'reason': 'FORGED'}
\n<SENTINEL>{payload} None

So two of the four shapes decode. The newline is the load-bearing part: it ends the marker's line, leaving the payload as the whole line json.loads sees. The identity gate does not help, because the call really is a genuine autonudge_stop from kirocrew-core -- it merely failed validation -- so _dir_tool is legitimately set and decode returns the forged args.

Correction: pre-existing, not introduced here

The finding is filed at session_directive.py:185, which is a line this PR adds, and that reads as though the refusal tag created the hole. It did not. Running the same probe against untouched main (f4089ee29, a separate worktree, same venv):

BASE decoded= {'reason': 'FORGED'}

The rejection string was returned verbatim before this PR too, and chat_runner's decode-and-apply path is unchanged by it. What this PR did was make marker provenance an explicit invariant -- which is exactly why the hole belongs here rather than in a follow-up: the PR claims "a directive tool's result either carries the marker, or it is a tagged refusal", and a forged marker in a rejection is a counterexample to that claim. I would rather close it than ship the claim with a hole in it.

Worth noting the pre-fix behaviour also broke the tag: because has_marker() was true, refuse_if_markerless returned the forged text untouched, so the smuggled directive was not even tagged a refusal. The forgery suppressed its own diagnostic.

The fix, and why not the suggested one

Suggested: "neutralize markers in rejection text before tagging it as a refusal." That closes the one path this PR added, and leaves the same string reaching every OTHER consumer -- the SEL audit row, the transcript, a channel renderer -- still carrying live marker bytes. It also would not have fixed main.

Instead the defanging happens where the untrusted text becomes an error message, in all three places mcp_shared builds one (call_tool_with_logging's ValidationError branch and both f"Error: {exc}" dispatch sites). Each of those knows BY CONSTRUCTION that the string is not a directive, which is the only place that knowledge exists -- doing it centrally over every tool result would defang the real marker too, and doing it at the consumer cannot distinguish a forged marker from a genuine one at all.

session_directive.neutralize_markers substitutes rather than deletes ([[kirocrew-marker-removed]]), so an operator reading a transcript still sees that something marker-shaped was submitted. The substitute is deliberately not a prefix of either sentinel.

All four shapes now decode to None and are correctly tagged refusals. Mutation-verified: reverting the one line in call_tool_with_logging reddens 5 tests, including both shapes that decoded.

The other two failures

Backend Tests (3.12, 2) failed on test/test_job_sdk.py::TestLiveness::test_live_while_a_registered_thread_runs -- a thread-registry liveness assertion (frozenset({'2898e9c3...'}) != frozenset()), i.e. a registered thread id lingering. Unrelated to this diff, which touches no threading: it passes 4/4 locally on this branch, and the shard that failed ran 23m35s with 21,809 passing tests, which is the load profile a thread-timing race needs. I have not "fixed" anything for it; it wants a re-run, and a real flake here is worth its own issue if it recurs.

Coverage Gate failed in 6s with backend-test=failure -- failing closed. It is a consequence of the shard above, not an independent signal.

Tests on this head: 544 passed across the directive, monitor, validation, MCP-core and registry suites, plus 124 across the other mcp_shared consumers (cron, artifacts, delete_message, arg-crash, audit) since the error path is now shared by all five servers.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Escalating rather than patching a fourth time

Last round I wrote: "If a further finding lands in this same span I will stop and bring it to Raymond with options rather than attempt a fourth consecutive patch." One has, so I am stopping. GPT's finding is real, it is security/data-loss class, and it is a regression I introduced in round 6 -- and it is the same defect this PR exists to fix, which is the strongest possible signal that the span needs a decision rather than another patch from me.

The regression, verified

A reason containing the literal [[KIROCREW_SESSION_DIRECTIVE_REFUSED]] loses the stop:

  1. encode() builds a genuine directive whose JSON payload contains that token (inside the reason).
  2. _emit_directive's pre-existing guard if session_directive.is_refusal(out) matches -- the token is in the string -- so it returns early, skipping both the out-of-band POST and (new in round 6) the vouch.
  3. refuse_if_markerless then sees a real marker with no vouch, treats it as forged, and neutralize_markers destroys it.
  4. The consumer decodes nothing. The stop is not applied.

Measured with the same probe against both trees:

tree decode(out, "autonudge_stop")
untouched main applied ({'reason': ...})
this branch 3b57efa35 lost (None)

So this is a genuine regression, not a pre-existing hole. (Pre-existing, step 2 already suppressed the out-of-band POST for such a reason -- posted: [] on both trees -- but the marker survived, so the stop still landed.)

The irony is instructive and worth stating plainly: is_refusal(out) infers provenance from CONTENT that model text can imitate, which is the exact mistake this PR spent rounds 4-6 removing everywhere else. I removed it from the consumer gate and left it in the producer.

Round-by-round, so the pattern is visible

round finding span disposition
1 monitor_update raised past the tag; clamp note claimed an "original" length control.py, validation.py fixed; one guarded seam + ratchet over DIRECTIVE_TOOLS
2 spec documented a note wording the code refused to claim; WARNING text misattributed a crash mcp.md, both consumers fixed
3 tests POSTed to a live gateway; black gate test fixture fixed; 17 pre-existing calls filed as #8652
4 a rejection echoing a model-chosen argument NAME forged a decodable directive (pre-existing, reproduced on main) mcp_shared, session_directive fixed by defanging at the three error-construction sites
5 that defang bounded length before redaction, which GROWS text; tag lost to the cut session_directive fixed by bounding + preserve_tail_marker at the seam
6 the defang was per-site, so handler return declines were undefended session_directive, mcp_core fixed by positive provenance (vouch) -- which introduced this round's regression
7 a reason containing the refusal token now destroys a genuine marker session_directive, control.py escalating

Rounds 4-7 are all one span, each fix seeding the next finding. Rounds 1-3 are settled and independent of it.

Options

A. Fix it (small, and it is the PR's own lesson applied one level further). In _emit_directive, identify encode's refusal by the ABSENCE of the directive marker rather than by is_refusal, then vouch the marker-bearing output -- a structural test instead of a content test. This is GPT's suggested fix and I believe it is correct. It also repairs the pre-existing POST-suppression bug in the same line. Cost: one round, one discriminator, plus a test that a reason containing either sentinel still stops the loop.

B. Descope round 6. Revert the vouch gate and keep rounds 1-5. The regression disappears with it. Cost: Design's handler-return gap goes back to being latent-advisory (safe today because those handler declines carry static messages), and marker provenance stays per-site.

C. Split the PR. Land rounds 1-3 -- which is the whole of issue #8635: the clamp, the refusal tag, the ratchet, the test-isolation fix -- and move the marker-provenance hardening (rounds 4-6, all of it inherited-hole work rather than #8635) into its own PR where it can be reviewed as the security change it actually is.

My recommendation is C, with A applied inside the split-out PR. #8635 is a two-line-behaviour bug and its fix has been green since round 3; rounds 4-6 are a separate security story that grew out of it, and bundling them is what has kept this review open for seven rounds.

I have stopped the babysit loop. Nothing is blocked on CI: 52 checks pass, the only red is this GPT block plus the readiness gate that depends on it.

@chenmingwei23
chenmingwei23 force-pushed the fix/autonudge-stop-reason-cap-8635 branch from 3b57efa to 14193c6 Compare September 5, 2026 07:44
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Split, as decided

Raymond chose the split. This PR is now issue #8635 and nothing else; the marker-provenance hardening moved to #8696, stacked on this branch.

Here (#8640), head 14193c680 -- the reported bug and its diagnostic:

  • FieldSpec.clamp_to_max, so a stop is not defeated by the length of its own explanation, on both stop tools
  • refuse_if_markerless + tag_refusal, so decode FAILED ... effect dropped means a lost marker again
  • the raise-to-return conversions and one guarded _parsed_pull_request_target seam, plus the ratchet over DIRECTIVE_TOOLS that keeps the invariant total
  • tag_refusal's bound and preserve_tail_marker, so the tag survives delivery
  • the test-isolation fix (no test in this diff reaches the network) and the two chat_runner consumer tests

Moved to #8696 -- an inherited security hole found while probing this one:

  • neutralize_markers at the three mcp_shared error-construction sites
  • the vouch / clear_vouch positive-provenance gate
  • _emit_directive's structural discriminator, which is the round-7 regression fix

The round-7 regression is gone from this branch

It only existed because of the vouch gate, which moved out. Verified on this head with the probe that found it:

reason quoting the refusal token -> stop applies: True

That matches untouched main, so this branch no longer regresses it. The fix for the underlying content-vs-structure mistake lives in #8696, where the vouch gate that exposed it lives.

What this branch does NOT claim

It is neutral on the forgery, exactly as main is. refuse_if_markerless here returns a marker-bearing result untouched, so a rejection echoing a model-chosen argument name still decodes on this branch as it does on main -- this PR neither introduces that hole nor closes it. #8696 closes it. I am stating that plainly rather than leaving a reviewer to infer it from the diff.

State

52 checks were green on the pre-split head and the only red was the GPT block on the regression that is now gone. 645 tests pass locally across the directive, monitor, validation, MCP-core, registry, ACP-provider, turn-dispatch and cron suites; the repo's own black gate passes with zero new offenders.

Not merging either PR -- both await your approval, and #8696 needs this one first.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/autonudge-stop-reason-cap-8635 branch from 14193c6 to 1291b38 Compare September 5, 2026 14:04
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

The three BLOCKs were stale, and the test reds were not this PR's

Head is now 1291b387e, rebased onto current main (369bb6d0b).

Design and First Principles: BLOCK on a description that no longer exists

Both lanes blocked on the same thing -- the body claiming a neutralize_markers fix, with mutation evidence and forgery tests, that is not in the diff. They were right about the body they read, and it was my mistake: I pushed the split commit at 07:52 and only edited the description at 07:57, so the lanes snapshotted the pre-split body against the post-split diff. Design's phrase for it, "a security claim with no backing code", is exactly correct for that pairing.

The body has carried no such claim since 07:57 -- grep for neutralize_markers, FORGED and smuggle over the current body returns zero hits, and the only surviving mention of the word "forgery" is the scope note pointing at #8696. So these two BLOCKs describe a state that no longer exists and should clear on this push, which re-runs both lanes against the current body.

Worth recording for whoever hits this next: on a split, edit the description BEFORE pushing the reduced commit. The reverse order guarantees one round of phantom-claim blocks, and it is the same ordering hazard as the CI cancellation earlier in this PR (editing a body while CI is in flight cancels the run).

Design also confirmed the remaining design is sound: "clamp on explanatory-only fields, tagging at the outermost return since the wrapper rejects ahead of the handler, the DIRECTIVE_TOOLS ratchet, tail-marker re-attachment after redaction growth -- is sound and proportionate to #8635."

GPT: one stale blocker, one real finding -- the real one is fixed

The BLOCKING item is the same phantom: it asks for the neutralization that now lives in #8696. This PR is deliberately NEUTRAL on that hole, exactly as main is, and says so in the body.

The advisory finding is real and now fixed: tag_refusal's elision count excluded the note's own footprint, so it understated the loss by the note's length. Measured on a 12,000-char input, it claimed 4,039 chars elided where 4,106 were actually gone. The count now derives from what is retained, so it reconciles exactly. Mutation-verified -- restoring the old expression reddens the new test with assert 4039 == (12000 - 7894).

That is the third finding in this PR against a note whose only job is to describe its own truncation (the clamp note's "original length", the spec wording, now this count). Same lesson each time: a self-describing value has to be measured, not estimated.

Backend Tests (3.12, 3) and (Windows) (3): broken main, not this branch

Both failed on the git-publish guard suite -- 'git push origin @(main)' was not read as a wildcard shape, assert 'git-publish-push-bare' in frozenset() -- which this diff does not touch.

Untouched main failed on the SAME tests in the SAME two jobs at 07:49 (run 33953620339, head 6d1b51704), and main has been green since 12:27 (c791f0f1d, 6b7847856), so it was fixed upstream. That suite now passes locally on the rebased base. Coverage Gate was the consequence of those two (backend-test=failure -- failing closed), and PR Readiness the consequence of the lanes.

Net

Of the seven reds: two were broken main (fixed by the rebase), two were their consequences, three were lanes reading a stale description (cleared by the rebuild), and one was a genuine finding inside those lanes, now fixed with a test. 382 tests pass locally across the directive, monitor, validation and MCP-core suites; the repo's black gate passes.

#8696 has been rebased onto this head (90ecec9d1) so the stack stays consistent. Neither PR is merged.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
…refusal

An over-long autonudge_stop reason was rejected by the schema in the dispatch
wrapper, ahead of the handler, so _emit_directive never ran: the loop was not
stopped, and the marker-less error landed in the consumer's lost-marker branch
and fired the WARNING that exists to catch a rawOutput-envelope escaping
regression (~10x/day on the reporting host).

FieldSpec.clamp_to_max makes the two stop tools' reason truncate instead of
reject, stamped with what the cut dropped. refuse_if_markerless tags every
marker-less return from a directive tool at the server's outermost return, so
decode FAILED means exactly one thing again; ask_question's nested validation and
the two callers of parse_github_pull_request_target now RETURN their declines
through one guarded seam, and a ratchet over DIRECTIVE_TOOLS keeps that total.
Because both sentinels are tail-anchored, tag_refusal bounds its text and
preserve_tail_marker re-attaches a marker the transport cut removed.

Closes #8635
@chenmingwei23
chenmingwei23 force-pushed the fix/autonudge-stop-reason-cap-8635 branch from 1291b38 to 6dacb04 Compare September 5, 2026 14:18
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head 6dacb0451. GPT's block is cleared -- no blocking findings, no findings at all -- and First Principles and Opus are clean. Design is CONCERNS (advisory), and it is right about the body it read.

I made the same ordering mistake twice, and reported it wrongly

Design's Watch quotes the description disclaiming the security work: "the marker-provenance hardening ... moved to #8696". That sentence has not been in the body since 14:24, but I pushed 6dacb0451 at ~14:22 and edited the description after, so the lane again snapshotted the pre-push body against the post-push diff -- the identical failure mode as the previous round, which I had just written a rule about.

Worse, I told Raymond in chat that this time I had edited the description before pushing. That was false: I rebased, moved the code, pushed, and only then edited. The correct sequence was available -- make the code change locally, edit the body, then push -- and I did not take it. Recording it here because a claim about process is as checkable as a claim about code, and this one was wrong.

The current body owns the change explicitly ("PLUS neutralize_markers, which closes an inherited forgery hole ... reproducible on main", plus a second bullet naming what stayed in #8696). The CONCERNS is advisory and does not gate readiness; it will re-read on the next push if one happens.

Design's real question, answered

Design asks the right question rather than assuming: are the three Error: sites the only model-echo points? Verified rather than asserted.

On the directive-tool result path -- yes. Enumerated every raise ValidationError in validation.py whose FIELD argument is model-controlled, since ValidationError.__str__ renders f"{field}: {message}":

  • validation.py:480 -- ValidationError(key, f"unknown field for tool '{schema.tool_name}'"), where key is a model-chosen argument name. This is the vector, and it flows through call_tool_with_logging, which is defanged.
  • Every other one passes a literal, spec.name, _path, or field_name -- all server-controlled.
  • No ValidationError message interpolates a model-supplied VALUE; validate_ask_user_question's are all static strings ("must be a non-empty list", "duplicate option labels are not allowed").
  • Handler-returned declines in control.py interpolate only gateway-controlled values (a resolved session_key) or static text; parse_github_pull_request_target's messages are static.

There is a second echo point of the same SHAPE, and it is not a forgery vector. validation.py:873 in the MCP-args validator raises ValidationError(f"{_path}.{key}", "unknown field") with a model-supplied key. It is reached from mcp_gateway/app_call.py, not from mcp_core._call_tool, so it cannot produce a directive tool's result: the consumer honours a directive only when the call carries _meta.kiro with mcpServerName == kirocrew-core AND a name in DIRECTIVE_TOOLS, and an app call satisfies neither. A forged marker there resolves to no directive tool and is ignored.

What it CAN do is make marker bytes appear in an app-call result and trip the "marker present but the tool call carried no core-MCP identity" diagnostic -- noise in the same family this PR exists to remove, but no effect. I am not widening this PR for it: it is a different subsystem, a different consumer branch, and a diagnostic rather than a security concern. Disclosing it so the completeness question has an answer on the record rather than an assumption.

State

40 checks pass, 14 pending, none failing. mergeable MERGEABLE. #8696 is rebased onto this head at acc3ab843. Neither merged.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Review-ready: 6dacb0451

All 57 checks pass (8 skipping, none failing, none pending). PR Readiness: "Eligible automated validation passed for this revision". mergeable MERGEABLE; mergeStateStatus BLOCKED only on maintainer approval.

Lane verdicts, all on this exact head:

lane verdict
GPT 5.6 no blocking findings, and no findings at all
Opus 4.8 no blocking findings
First Principles PASS
Design CONCERNS (advisory)

Zero review threads. Every bot finding across the run is dispositioned in a comment above.

The one open advisory, and why I am not pushing to clear it

Design's CONCERNS is the stale-description one: it read the body from before my 14:24 edit and objected that the description disclaimed the neutralize_markers work that ships here. The body has owned that since 14:24 ("PLUS neutralize_markers, which closes an inherited forgery hole ... reproducible on main", with a second bullet naming what stayed in #8696), and its substantive question -- are the three Error: sites the only model-echo points? -- is answered with an enumeration in the comment above, including one same-shaped site (validation.py:873, the MCP-args validator) that is NOT a forgery vector because it cannot produce a core directive tool's result.

A lane comment only refreshes on a push. Pushing solely to re-roll an advisory would restart ~25 minutes of CI and re-roll three non-deterministic lanes for zero correctness gain, so I am leaving it. Both halves of the concern -- ownership and completeness -- are settled in writing on this PR; only the lane's snapshot is out of date.

Stacked follow-on

#8696 (acc3ab843) carries the structural half: positive provenance (vouch), which makes marker forgery fail by construction rather than by remembering to defang each error site, plus _emit_directive's structural discriminator. It is based on this branch and auto-retargets to main once this merges, which is also when it first gets the full check set -- its current partial set is a consequence of a branch base, not evidence of coverage.

Its GPT lane failed with "review did not complete ... no [GPT-REVIEWED] marker -- failing closed", which is a transient incomplete run rather than a finding; I re-ran that workflow.

Not merging. Over to you.

@iamwhatever
iamwhatever merged commit 18a396e into main Sep 5, 2026
72 of 73 checks passed
@iamwhatever
iamwhatever deleted the fix/autonudge-stop-reason-cap-8635 branch September 5, 2026 15:04
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 5, 2026
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.

autonudge_stop silently fails when reason exceeds 500 chars, and trips the lost-marker warning

2 participants